Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .sonarlint/sonar-local.globalconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
is_global = true

# Rule severities for the local SonarCloud reproduction (see sonar-local.props).
# Applied only when building with
# dotnet build -p:CustomAfterMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.props

# CI's SonarCloud quality profile reports these, but the SonarAnalyzer NuGet package ships them
# disabled by default. Raise them so a local run sees what CI sees.
#
# S3267 and S2699 are here because CI reported them on PR #87 - S3267 as new issues on
# DocComment.Validate, S2699 against the older test files. The rest are carried over from the
# equivalent config in ktsu.Semantics, whose quality profile is the closest available reference.
dotnet_diagnostic.S107.severity = warning
dotnet_diagnostic.S1075.severity = warning
dotnet_diagnostic.S1172.severity = warning
dotnet_diagnostic.S1192.severity = warning
dotnet_diagnostic.S1871.severity = warning
dotnet_diagnostic.S2583.severity = warning
dotnet_diagnostic.S2699.severity = warning
dotnet_diagnostic.S3267.severity = warning
dotnet_diagnostic.S3358.severity = warning
dotnet_diagnostic.S3458.severity = warning
dotnet_diagnostic.S3776.severity = warning
dotnet_diagnostic.S6444.severity = warning

# Enabled by default in the analyzer package. Left enabled here: unlike ktsu.Semantics, this
# repository has not been shown to have a profile that excludes it, so a false positive is
# cheaper than a missed finding.
# dotnet_diagnostic.S1481.severity = none

# KNOWN GAP: SonarCloud reported one new issue on PR #87 that this configuration does not
# reproduce. The rule behind it is either absent from the analyzer package or shipped disabled
# and not listed above. If you have dashboard access and can identify it, add it here - the
# calibration is only as good as the rules it names.
32 changes: 32 additions & 0 deletions .sonarlint/sonar-local.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project>

<!--
Local SonarCloud reproduction.

CI analyses this repository with the SonarCloud scanner, which injects the Sonar analyzers
into the compilation. A plain `dotnet build` does not run them, so Sonar findings are
invisible locally and only surface after a push - and the bot comment links to a dashboard
rather than naming them.

This file wires the same analyzers into a local build. It is NOT imported automatically -
point MSBuild at it explicitly:

dotnet build -p:CustomAfterMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.props

Note "After", not "Before". Every project here declares its SDK with <Sdk Name="..." />
elements rather than the <Project Sdk="..."> attribute, and CustomBeforeMicrosoftCommonProps
does not reach that form. CustomAfterMicrosoftCommonProps does, and is still early enough for
restore to pick up the PackageReference below.

sonar-local.globalconfig (next to this file) sets the rule severities so the local warning set
approximates CI's.

Nothing in the repository imports this, so normal builds, the CI pipeline, and packaging are
all unaffected.
-->
<ItemGroup>
<PackageReference Include="SonarAnalyzer.CSharp" VersionOverride="10.18.0.131500" PrivateAssets="all" />
<EditorConfigFiles Include="$(MSBuildThisFileDirectory)sonar-local.globalconfig" />
</ItemGroup>

</Project>
58 changes: 57 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,61 @@ dotnet test --filter "TestMethodName"
dotnet test --logger "console;verbosity=detailed"
```

### Reproducing SonarCloud warnings locally

CI analyses this repository with the SonarCloud scanner, which injects the Sonar analyzers into the
compilation. A plain `dotnet build` does **not** run them, so Sonar findings are invisible locally
and only surface after a push — and the bot comment links to a dashboard rather than naming them.
To run the same analyzers:

```bash
dotnet build -p:CustomAfterMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.props
```

```powershell
dotnet build -p:CustomAfterMicrosoftCommonProps=$PWD\.sonarlint\sonar-local.props
```

Note **`After`**, not `Before`. Every project here declares its SDK with `<Sdk Name="..." />`
elements rather than the `<Project Sdk="...">` attribute, and `CustomBeforeMicrosoftCommonProps`
does not reach that form.

The opt-in lives in `.sonarlint/sonar-local.props` (the analyzer package) and
`.sonarlint/sonar-local.globalconfig` (rule severities — it raises the rules CI reports that the
analyzer package ships disabled). Nothing imports these automatically, so normal builds, the CI
pipeline, and packaging are unaffected.

**Known gap:** SonarCloud reported one new issue on PR #87 that this configuration does not
reproduce, and sonarcloud.io is not reachable from the agent sandbox to identify it. The rule
behind it is either absent from the analyzer package or shipped disabled and not listed in the
globalconfig. If you have dashboard access, add it — the calibration is only as good as the rules
it names.

## Project Structure

- **CodeBlocker/**: Main library - an `IndentedTextWriter` wrapper for generating code blocks with automatic indentation
- **CodeBlocker.Test/**: MSTest-based unit and integration tests

## Architecture

The library consists of three main classes:
The library is built around these types:

1. **`CodeBlocker`** (`CodeBlocker/CodeBlocker.cs`): Wraps `System.CodeDom.Compiler.IndentedTextWriter` to provide simplified code generation with:
- Factory methods (`Create()`, `Create(string indentString)`) that manage `StringWriter` lifecycle
- Constructors over any `TextWriter`, for streaming straight to a file — such a writer stays the
caller's to dispose, and `IsBuffered`/`ToString()` only work over a `StringWriter`
- Indentation control via `Indent()`, `Outdent()`, and `CurrentIndent` property
- Output methods: `Write()`, `WriteLine()`, `NewLine()`
- Implements `IDisposable` with proper resource cleanup

**Line endings.** `IndentedTextWriter` terminates lines with `Environment.NewLine`;
`CodeBlocker` deliberately does not. `DefaultNewLineString` is `NewLines.Lf`, so output is
byte-identical on every platform — generated code is committed, diffed and compared against
golden files, all of which want reproducibility over the local convention. `NewLines.Host` is
the opt-in for the platform terminator. Tests must therefore assert against
`CodeBlocker.DefaultNewLineString`, never `Environment.NewLine`: the latter passes on Linux
for the wrong reason and hides a Windows break.

2. **`Scope`** (`CodeBlocker/Scope.cs`): Extends `ktsu.ScopedAction` to provide automatic brace handling:
- On creation: writes `{` and increases indent
- On disposal: decreases indent and writes `}`
Expand All @@ -43,6 +83,22 @@ The library consists of three main classes:
- On disposal: decreases indent and writes `};`
- Useful for C/C++ enum declarations, struct initializers, etc.

4. **Other scopes** (`CodeBlocker/Scopes.cs`): `DelimiterScope` and its `ParenScope`/`BracketScope`
derivations, plus `IndentScope`, `RegionScope`, `DirectiveScope` and `PragmaScope`.

5. **Preamble helpers** (`CodeBlocker/CodeBlockerExtensions.cs`): one call each for the
auto-generated marker, the nullable context, the file-scoped namespace and the using directives.

6. **Template object model** (`CodeBlocker/Templates/`): `SourceFileTemplate`, `ClassTemplate`,
`MethodTemplate`, `PropertyTemplate`, `OperatorTemplate` and friends describe a source file as
objects and own all the punctuation, spacing and indentation. Rendering lives in the internal
`TemplateRendering`; a `BodyFactory` writes only the body, with no leading separator.

When emitting a multi-line fragment inside a template, route it through
`TemplateRendering.SpliceFragment` rather than `NewLine()`/`WriteLineNoTabs` —
`IndentedTextWriter.WriteLineNoTabs` does not re-arm the pending-tab flag, so the next line
silently lands at column 0.

## SDK and Dependencies

This project uses:
Expand Down
32 changes: 16 additions & 16 deletions CodeBlocker.Test/CodeBlockerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@

// Assert

Assert.AreEqual("test line" + Environment.NewLine, result);
Assert.AreEqual("test line" + CodeBlocker.DefaultNewLineString, result);
}

[TestMethod]
Expand All @@ -68,7 +68,7 @@

// Assert

Assert.AreEqual(Environment.NewLine, result);
Assert.AreEqual(CodeBlocker.DefaultNewLineString, result);
}

[TestMethod]
Expand All @@ -86,7 +86,7 @@

// Assert

Assert.AreEqual("\tindented line" + Environment.NewLine, result);
Assert.AreEqual("\tindented line" + CodeBlocker.DefaultNewLineString, result);
}

[TestMethod]
Expand All @@ -107,12 +107,12 @@

// Assert

string expected = "line 1" + Environment.NewLine + "\tline 2 indented" + Environment.NewLine + "line 3" + Environment.NewLine;
string expected = "line 1" + CodeBlocker.DefaultNewLineString + "\tline 2 indented" + CodeBlocker.DefaultNewLineString + "line 3" + CodeBlocker.DefaultNewLineString;
Assert.AreEqual(expected, result);
}

[TestMethod]
public void DisposeShouldNotThrowException()

Check warning on line 115 in CodeBlocker.Test/CodeBlockerTests.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add at least one assertion to this test case.

Check warning on line 115 in CodeBlocker.Test/CodeBlockerTests.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add at least one assertion to this test case.
{
// Arrange

Expand All @@ -125,7 +125,7 @@
}

[TestMethod]
public void DisposeMultipleCallsShouldNotThrow()

Check warning on line 128 in CodeBlocker.Test/CodeBlockerTests.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add at least one assertion to this test case.

Check warning on line 128 in CodeBlocker.Test/CodeBlockerTests.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add at least one assertion to this test case.
{
// Arrange

Expand Down Expand Up @@ -154,7 +154,7 @@

// Assert

Assert.AreEqual(" test line" + Environment.NewLine, result);
Assert.AreEqual(" test line" + CodeBlocker.DefaultNewLineString, result);
Assert.AreEqual(customIndent, codeBlocker.IndentString);
}

Expand All @@ -175,7 +175,7 @@

// Assert

Assert.AreEqual(" indented content" + Environment.NewLine, result);
Assert.AreEqual(" indented content" + CodeBlocker.DefaultNewLineString, result);
Assert.AreEqual(customIndent, codeBlocker.IndentString);
}

Expand Down Expand Up @@ -211,7 +211,7 @@

// Assert

string expected = "level 0" + Environment.NewLine + ">>level 1" + Environment.NewLine + ">>>>level 2" + Environment.NewLine;
string expected = "level 0" + CodeBlocker.DefaultNewLineString + ">>level 1" + CodeBlocker.DefaultNewLineString + ">>>>level 2" + CodeBlocker.DefaultNewLineString;
Assert.AreEqual(expected, result);
Assert.AreEqual(customIndent, codeBlocker.IndentString);
}
Expand All @@ -232,7 +232,7 @@

// Assert

Assert.AreEqual("\t" + Environment.NewLine, result);
Assert.AreEqual("\t" + CodeBlocker.DefaultNewLineString, result);
}

[TestMethod]
Expand Down Expand Up @@ -287,7 +287,7 @@
// Assert

Assert.AreEqual(3, codeBlocker.CurrentIndent);
Assert.AreEqual("\t\t\ttest line" + Environment.NewLine, result);
Assert.AreEqual("\t\t\ttest line" + CodeBlocker.DefaultNewLineString, result);
}

[TestMethod]
Expand All @@ -308,7 +308,7 @@
// Assert

Assert.AreEqual(0, codeBlocker.CurrentIndent);
Assert.AreEqual("no indent" + Environment.NewLine, result);
Assert.AreEqual("no indent" + CodeBlocker.DefaultNewLineString, result);
}

[TestMethod]
Expand Down Expand Up @@ -340,7 +340,7 @@
// Assert - Should work with null indent string (treated as default)

Assert.IsNotNull(result);
Assert.IsTrue(result.Contains("test" + Environment.NewLine, StringComparison.Ordinal), "Result should contain test line with a line terminator");
Assert.IsTrue(result.Contains("test" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain test line with a line terminator");
}

[TestMethod]
Expand Down Expand Up @@ -395,7 +395,7 @@
}

[TestMethod]
public void DisposeWithStringWriterManagementShouldNotThrowWhenCalledMultipleTimes()

Check warning on line 398 in CodeBlocker.Test/CodeBlockerTests.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add at least one assertion to this test case.

Check warning on line 398 in CodeBlocker.Test/CodeBlockerTests.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Add at least one assertion to this test case.
{
// Arrange

Expand Down Expand Up @@ -425,7 +425,7 @@

// Assert

string expected = "start middle end" + Environment.NewLine + "new line" + Environment.NewLine;
string expected = "start middle end" + CodeBlocker.DefaultNewLineString + "new line" + CodeBlocker.DefaultNewLineString;
Assert.AreEqual(expected, result);
}

Expand All @@ -450,7 +450,7 @@
// Assert

Assert.AreEqual(maxDepth, codeBlocker.CurrentIndent);
Assert.IsTrue(result.StartsWith(new string('\t', maxDepth) + "deeply nested" + Environment.NewLine, StringComparison.Ordinal), "Result should start with deeply nested content prefixed by correct number of tabs");
Assert.IsTrue(result.StartsWith(new string('\t', maxDepth) + "deeply nested" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should start with deeply nested content prefixed by correct number of tabs");
}

[TestMethod]
Expand All @@ -469,7 +469,7 @@
// Assert

Assert.IsTrue(result.Contains(largeString, StringComparison.Ordinal), "Result should contain the large string content");
Assert.IsTrue(result.EndsWith(Environment.NewLine, StringComparison.Ordinal), "Result should end with a line terminator");
Assert.IsTrue(result.EndsWith(CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should end with a line terminator");
}

[TestMethod]
Expand All @@ -485,7 +485,7 @@
// Assert

Assert.AreEqual(string.Empty, codeBlocker.IndentString);
Assert.AreEqual("test" + Environment.NewLine, result); // No indentation with empty string
Assert.AreEqual("test" + CodeBlocker.DefaultNewLineString, result); // No indentation with empty string

}

Expand All @@ -506,6 +506,6 @@
// Assert

Assert.AreEqual(longIndent, codeBlocker.IndentString);
Assert.AreEqual(longIndent + "test" + Environment.NewLine, result);
Assert.AreEqual(longIndent + "test" + CodeBlocker.DefaultNewLineString, result);
}
}
Loading