diff --git a/.sonarlint/sonar-local.globalconfig b/.sonarlint/sonar-local.globalconfig new file mode 100644 index 0000000..b52b70b --- /dev/null +++ b/.sonarlint/sonar-local.globalconfig @@ -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. diff --git a/.sonarlint/sonar-local.props b/.sonarlint/sonar-local.props new file mode 100644 index 0000000..311f56c --- /dev/null +++ b/.sonarlint/sonar-local.props @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/CLAUDE.md b/CLAUDE.md index 80aae20..f53305a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,36 @@ 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 `` +elements rather than the `` 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 @@ -25,14 +55,24 @@ dotnet test --logger "console;verbosity=detailed" ## 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 `}` @@ -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: diff --git a/CodeBlocker.Test/CodeBlockerTests.cs b/CodeBlocker.Test/CodeBlockerTests.cs index eecc4bf..6ae4b06 100644 --- a/CodeBlocker.Test/CodeBlockerTests.cs +++ b/CodeBlocker.Test/CodeBlockerTests.cs @@ -51,7 +51,7 @@ public void WriteLineShouldAddLineWithIndentation() // Assert - Assert.AreEqual("test line" + Environment.NewLine, result); + Assert.AreEqual("test line" + CodeBlocker.DefaultNewLineString, result); } [TestMethod] @@ -68,7 +68,7 @@ public void NewLineShouldAddEmptyLine() // Assert - Assert.AreEqual(Environment.NewLine, result); + Assert.AreEqual(CodeBlocker.DefaultNewLineString, result); } [TestMethod] @@ -86,7 +86,7 @@ public void WriteLineWithIndentationShouldRespectIndentLevel() // Assert - Assert.AreEqual("\tindented line" + Environment.NewLine, result); + Assert.AreEqual("\tindented line" + CodeBlocker.DefaultNewLineString, result); } [TestMethod] @@ -107,7 +107,7 @@ public void MultipleLinesShouldMaintainProperIndentation() // 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); } @@ -154,7 +154,7 @@ public void CreateWithCustomIndentStringShouldUseSpecifiedIndent() // Assert - Assert.AreEqual(" test line" + Environment.NewLine, result); + Assert.AreEqual(" test line" + CodeBlocker.DefaultNewLineString, result); Assert.AreEqual(customIndent, codeBlocker.IndentString); } @@ -175,7 +175,7 @@ public void ConstructorWithCustomIndentStringShouldWork() // Assert - Assert.AreEqual(" indented content" + Environment.NewLine, result); + Assert.AreEqual(" indented content" + CodeBlocker.DefaultNewLineString, result); Assert.AreEqual(customIndent, codeBlocker.IndentString); } @@ -211,7 +211,7 @@ public void CustomIndentStringWithMultipleIndentLevels() // 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); } @@ -232,7 +232,7 @@ public void WriteLineWithoutParametersShouldAddEmptyLineWithIndentation() // Assert - Assert.AreEqual("\t" + Environment.NewLine, result); + Assert.AreEqual("\t" + CodeBlocker.DefaultNewLineString, result); } [TestMethod] @@ -287,7 +287,7 @@ public void CurrentIndentSetterShouldUpdateIndentationLevel() // 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] @@ -308,7 +308,7 @@ public void CurrentIndentSetterWithZeroShouldRemoveIndentation() // Assert Assert.AreEqual(0, codeBlocker.CurrentIndent); - Assert.AreEqual("no indent" + Environment.NewLine, result); + Assert.AreEqual("no indent" + CodeBlocker.DefaultNewLineString, result); } [TestMethod] @@ -340,7 +340,7 @@ public void CreateWithNullIndentStringShouldWork() // 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] @@ -425,7 +425,7 @@ public void MixedWriteAndWriteLineShouldFormatCorrectly() // 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); } @@ -450,7 +450,7 @@ public void DeepIndentationStressTest() // 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] @@ -469,7 +469,7 @@ public void LargeStringContentShouldBeHandledCorrectly() // 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] @@ -485,7 +485,7 @@ public void EmptyIndentStringShouldWork() // 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 } @@ -506,6 +506,6 @@ public void VeryLongIndentStringShouldWork() // Assert Assert.AreEqual(longIndent, codeBlocker.IndentString); - Assert.AreEqual(longIndent + "test" + Environment.NewLine, result); + Assert.AreEqual(longIndent + "test" + CodeBlocker.DefaultNewLineString, result); } } diff --git a/CodeBlocker.Test/IntegrationTests.cs b/CodeBlocker.Test/IntegrationTests.cs index d03a954..115436e 100644 --- a/CodeBlocker.Test/IntegrationTests.cs +++ b/CodeBlocker.Test/IntegrationTests.cs @@ -39,22 +39,22 @@ public void ComplexCodeGenerationShouldFormatCorrectly() // Assert string result = codeBlocker.ToString(); - string expected = "public class TestClass" + Environment.NewLine + - "{" + Environment.NewLine + - "\tpublic void Method1()" + Environment.NewLine + - "\t{" + Environment.NewLine + - "\t\tvar x = 1;" + Environment.NewLine + - "\t\tConsole.WriteLine(x);" + Environment.NewLine + - "\t}" + Environment.NewLine + - Environment.NewLine + - "\tpublic void Method2()" + Environment.NewLine + - "\t{" + Environment.NewLine + - "\t\tif (true)" + Environment.NewLine + - "\t\t{" + Environment.NewLine + - "\t\t\treturn;" + Environment.NewLine + - "\t\t}" + Environment.NewLine + - "\t}" + Environment.NewLine + - "}" + Environment.NewLine; + string expected = "public class TestClass" + CodeBlocker.DefaultNewLineString + + "{" + CodeBlocker.DefaultNewLineString + + "\tpublic void Method1()" + CodeBlocker.DefaultNewLineString + + "\t{" + CodeBlocker.DefaultNewLineString + + "\t\tvar x = 1;" + CodeBlocker.DefaultNewLineString + + "\t\tConsole.WriteLine(x);" + CodeBlocker.DefaultNewLineString + + "\t}" + CodeBlocker.DefaultNewLineString + + CodeBlocker.DefaultNewLineString + + "\tpublic void Method2()" + CodeBlocker.DefaultNewLineString + + "\t{" + CodeBlocker.DefaultNewLineString + + "\t\tif (true)" + CodeBlocker.DefaultNewLineString + + "\t\t{" + CodeBlocker.DefaultNewLineString + + "\t\t\treturn;" + CodeBlocker.DefaultNewLineString + + "\t\t}" + CodeBlocker.DefaultNewLineString + + "\t}" + CodeBlocker.DefaultNewLineString + + "}" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -85,10 +85,10 @@ public void DeepNestingShouldMaintainCorrectIndentation() // Verify it contains the expected structure - Assert.IsTrue(result.Contains("start" + Environment.NewLine, StringComparison.Ordinal), "Result should contain 'start' line"); - Assert.IsTrue(result.Contains("level 1" + Environment.NewLine, StringComparison.Ordinal), "Result should contain 'level 1' line"); - Assert.IsTrue(result.Contains("level 5" + Environment.NewLine, StringComparison.Ordinal), "Result should contain 'level 5' line"); - Assert.IsTrue(result.Contains("end" + Environment.NewLine, StringComparison.Ordinal), "Result should contain 'end' line"); + Assert.IsTrue(result.Contains("start" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain 'start' line"); + Assert.IsTrue(result.Contains("level 1" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain 'level 1' line"); + Assert.IsTrue(result.Contains("level 5" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain 'level 5' line"); + Assert.IsTrue(result.Contains("end" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain 'end' line"); // Count opening and closing braces to ensure they match @@ -134,14 +134,14 @@ public void MixedContentTypesShouldFormatCorrectly() // Verify structure - Assert.IsTrue(result.StartsWith("// Header comment" + Environment.NewLine, StringComparison.Ordinal), "Result should start with header comment"); - Assert.IsTrue(result.Contains("namespace TestNamespace" + Environment.NewLine, StringComparison.Ordinal), "Result should contain namespace declaration"); - Assert.IsTrue(result.Contains("\tusing System;" + Environment.NewLine, StringComparison.Ordinal), "Result should contain indented using directive"); - Assert.IsTrue(result.Contains("\tpublic interface ITest" + Environment.NewLine, StringComparison.Ordinal), "Result should contain interface declaration"); - Assert.IsTrue(result.Contains("\t\tvoid DoSomething();" + Environment.NewLine, StringComparison.Ordinal), "Result should contain interface method with double indentation"); - Assert.IsTrue(result.Contains("\tpublic class Test : ITest" + Environment.NewLine, StringComparison.Ordinal), "Result should contain class declaration"); - Assert.IsTrue(result.Contains("\t\tpublic void DoSomething()" + Environment.NewLine, StringComparison.Ordinal), "Result should contain class method with double indentation"); - Assert.IsTrue(result.Contains("\t\t\t// Implementation" + Environment.NewLine, StringComparison.Ordinal), "Result should contain implementation comment with triple indentation"); + Assert.IsTrue(result.StartsWith("// Header comment" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should start with header comment"); + Assert.IsTrue(result.Contains("namespace TestNamespace" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain namespace declaration"); + Assert.IsTrue(result.Contains("\tusing System;" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain indented using directive"); + Assert.IsTrue(result.Contains("\tpublic interface ITest" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain interface declaration"); + Assert.IsTrue(result.Contains("\t\tvoid DoSomething();" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain interface method with double indentation"); + Assert.IsTrue(result.Contains("\tpublic class Test : ITest" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain class declaration"); + Assert.IsTrue(result.Contains("\t\tpublic void DoSomething()" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain class method with double indentation"); + Assert.IsTrue(result.Contains("\t\t\t// Implementation" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain implementation comment with triple indentation"); } [TestMethod] @@ -166,7 +166,7 @@ public void EmptyScopesShouldNotAffectOtherContent() // Assert string result = codeBlocker.ToString(); - string expected = "before" + Environment.NewLine + "{" + Environment.NewLine + "}" + Environment.NewLine + "after" + Environment.NewLine; + string expected = "before" + CodeBlocker.DefaultNewLineString + "{" + CodeBlocker.DefaultNewLineString + "}" + CodeBlocker.DefaultNewLineString + "after" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -197,8 +197,8 @@ public void MultipleCodeBlockersShouldBeIndependent() string result1 = codeBlocker1.ToString(); string result2 = codeBlocker2.ToString(); - string expected1 = "codeBlocker1 content" + Environment.NewLine + "{" + Environment.NewLine + "\tinside scope1" + Environment.NewLine + "}" + Environment.NewLine; - string expected2 = "codeBlocker2 content" + Environment.NewLine + "{" + Environment.NewLine + "\tinside scope2" + Environment.NewLine + "}" + Environment.NewLine; + string expected1 = "codeBlocker1 content" + CodeBlocker.DefaultNewLineString + "{" + CodeBlocker.DefaultNewLineString + "\tinside scope1" + CodeBlocker.DefaultNewLineString + "}" + CodeBlocker.DefaultNewLineString; + string expected2 = "codeBlocker2 content" + CodeBlocker.DefaultNewLineString + "{" + CodeBlocker.DefaultNewLineString + "\tinside scope2" + CodeBlocker.DefaultNewLineString + "}" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected1, result1); Assert.AreEqual(expected2, result2); @@ -254,13 +254,13 @@ public void ComplexTemplateGenerationWithMultipleIndentTypesShouldWork() // Verify HTML uses 2-space indentation - Assert.IsTrue(htmlResult.Contains(" " + Environment.NewLine, StringComparison.Ordinal), "HTML result should contain head tag with 2-space indentation"); - Assert.IsTrue(htmlResult.Contains(" Test Page" + Environment.NewLine, StringComparison.Ordinal), "HTML result should contain title tag with 4-space indentation"); + Assert.IsTrue(htmlResult.Contains(" " + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "HTML result should contain head tag with 2-space indentation"); + Assert.IsTrue(htmlResult.Contains(" Test Page" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "HTML result should contain title tag with 4-space indentation"); // Verify JS uses tab indentation - Assert.IsTrue(jsResult.Contains("\tconst content = document.getElementById('content');" + Environment.NewLine, StringComparison.Ordinal), "JS result should contain const declaration with tab indentation"); - Assert.IsTrue(jsResult.Contains("\t\tcontent.addEventListener('click', handleClick);" + Environment.NewLine, StringComparison.Ordinal), "JS result should contain addEventListener with double tab indentation"); + Assert.IsTrue(jsResult.Contains("\tconst content = document.getElementById('content');" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "JS result should contain const declaration with tab indentation"); + Assert.IsTrue(jsResult.Contains("\t\tcontent.addEventListener('click', handleClick);" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "JS result should contain addEventListener with double tab indentation"); } [TestMethod] @@ -296,15 +296,15 @@ public void MixedWriteOperationsWithComplexIndentationShouldFormatCorrectly() string result = codeBlocker.ToString(); string expected = - "public class MyClass : BaseClass" + Environment.NewLine + - "{" + Environment.NewLine + - "\tprivate readonly string _field;" + Environment.NewLine + - Environment.NewLine + - "\tpublic MyClass(string field)" + Environment.NewLine + - "\t{" + Environment.NewLine + - "\t\t_field = field ?? throw new ArgumentNullException(nameof(field));" + Environment.NewLine + - "\t}" + Environment.NewLine + - "}" + Environment.NewLine; + "public class MyClass : BaseClass" + CodeBlocker.DefaultNewLineString + + "{" + CodeBlocker.DefaultNewLineString + + "\tprivate readonly string _field;" + CodeBlocker.DefaultNewLineString + + CodeBlocker.DefaultNewLineString + + "\tpublic MyClass(string field)" + CodeBlocker.DefaultNewLineString + + "\t{" + CodeBlocker.DefaultNewLineString + + "\t\t_field = field ?? throw new ArgumentNullException(nameof(field));" + CodeBlocker.DefaultNewLineString + + "\t}" + CodeBlocker.DefaultNewLineString + + "}" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -355,11 +355,11 @@ public void LargeScaleCodeGenerationShouldPerformReasonably() // Verify structure exists - Assert.IsTrue(result.Contains("namespace LargeTest" + Environment.NewLine, StringComparison.Ordinal), "Result should contain namespace declaration"); - Assert.IsTrue(result.Contains("public class Class0" + Environment.NewLine, StringComparison.Ordinal), "Result should contain first class declaration"); - Assert.IsTrue(result.Contains($"public class Class{classCount - 1}{Environment.NewLine}", StringComparison.Ordinal), "Result should contain last class declaration"); - Assert.IsTrue(result.Contains("public void Method0()" + Environment.NewLine, StringComparison.Ordinal), "Result should contain first method declaration"); - Assert.IsTrue(result.Contains($"public void Method{methodsPerClass - 1}(){Environment.NewLine}", StringComparison.Ordinal), "Result should contain last method declaration"); + Assert.IsTrue(result.Contains("namespace LargeTest" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain namespace declaration"); + Assert.IsTrue(result.Contains("public class Class0" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain first class declaration"); + Assert.IsTrue(result.Contains($"public class Class{classCount - 1}{CodeBlocker.DefaultNewLineString}", StringComparison.Ordinal), "Result should contain last class declaration"); + Assert.IsTrue(result.Contains("public void Method0()" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain first method declaration"); + Assert.IsTrue(result.Contains($"public void Method{methodsPerClass - 1}(){CodeBlocker.DefaultNewLineString}", StringComparison.Ordinal), "Result should contain last method declaration"); // Verify performance (should complete in reasonable time) @@ -406,11 +406,11 @@ public void SharedStringWriterBetweenCodeBlockersShouldWork() // Verify both CodeBlockers wrote to the same StringWriter - Assert.IsTrue(result.Contains("// First CodeBlocker" + Environment.NewLine, StringComparison.Ordinal), "Result should contain first CodeBlocker comment"); - Assert.IsTrue(result.Contains("\tcontent from first" + Environment.NewLine, StringComparison.Ordinal), "Result should contain first CodeBlocker content with tab indent"); + Assert.IsTrue(result.Contains("// First CodeBlocker" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain first CodeBlocker comment"); + Assert.IsTrue(result.Contains("\tcontent from first" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain first CodeBlocker content with tab indent"); - Assert.IsTrue(result.Contains("// Second CodeBlocker with different indent" + Environment.NewLine, StringComparison.Ordinal), "Result should contain second CodeBlocker comment"); - Assert.IsTrue(result.Contains(" content from second" + Environment.NewLine, StringComparison.Ordinal), "Result should contain second CodeBlocker content with 2-space indent"); + Assert.IsTrue(result.Contains("// Second CodeBlocker with different indent" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain second CodeBlocker comment"); + Assert.IsTrue(result.Contains(" content from second" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain second CodeBlocker content with 2-space indent"); } @@ -446,8 +446,8 @@ public void ErrorRecoveryAfterExceptionShouldNotAffectFutureOperations() } string result = codeBlocker.ToString(); - Assert.IsTrue(result.Contains("recovered content" + Environment.NewLine, StringComparison.Ordinal), "Result should contain recovered content after error"); - Assert.IsTrue(result.Contains("scope content" + Environment.NewLine, StringComparison.Ordinal), "Result should contain scope content after recovery"); + Assert.IsTrue(result.Contains("recovered content" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain recovered content after error"); + Assert.IsTrue(result.Contains("scope content" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain scope content after recovery"); } [TestMethod] @@ -471,9 +471,9 @@ public void UnicodeAndSpecialCharactersShouldBeHandledCorrectly() string result = codeBlocker.ToString(); - Assert.IsTrue(result.Contains("// Unicode test: αβγδε 中文 🚀" + Environment.NewLine, StringComparison.Ordinal), "Result should contain Unicode comment with Greek, Chinese, and emoji characters"); - Assert.IsTrue(result.Contains("→→string text = \"Hello 世界!\";" + Environment.NewLine, StringComparison.Ordinal), "Result should contain string with Chinese characters and Unicode arrow indent"); - Assert.IsTrue(result.Contains("→→char symbol = '€';" + Environment.NewLine, StringComparison.Ordinal), "Result should contain Euro symbol with Unicode arrow indent"); + Assert.IsTrue(result.Contains("// Unicode test: αβγδε 中文 🚀" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain Unicode comment with Greek, Chinese, and emoji characters"); + Assert.IsTrue(result.Contains("→→string text = \"Hello 世界!\";" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain string with Chinese characters and Unicode arrow indent"); + Assert.IsTrue(result.Contains("→→char symbol = '€';" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain Euro symbol with Unicode arrow indent"); Assert.IsTrue(result.Contains("→→// Special chars: \t\r\n\\\"", StringComparison.Ordinal), "Result should contain special characters with Unicode arrow indent"); } } diff --git a/CodeBlocker.Test/NewLineTests.cs b/CodeBlocker.Test/NewLineTests.cs index a9e0184..9f68d14 100644 --- a/CodeBlocker.Test/NewLineTests.cs +++ b/CodeBlocker.Test/NewLineTests.cs @@ -19,12 +19,25 @@ namespace CodeBlocker.Tests; public sealed class NewLineTests { [TestMethod] - public void DefaultNewLineStringIsTheHostTerminator() + public void TheDefaultTerminatorIsLineFeedRatherThanTheHostTerminator() { + // The point of the default: the same calls give the same bytes on every platform. This + // assertion is only meaningful on a host whose terminator is not LF, so it is written to + // fail loudly there rather than to pass vacuously everywhere. using CodeBlocker codeBlocker = CodeBlocker.Create(); - Assert.AreEqual(NewLines.Host, codeBlocker.NewLineString); - Assert.AreEqual(Environment.NewLine, codeBlocker.NewLineString); + Assert.AreEqual(NewLines.Lf, codeBlocker.NewLineString); + Assert.AreEqual(CodeBlocker.DefaultNewLineString, codeBlocker.NewLineString); + } + + [TestMethod] + public void TheHostTerminatorIsStillAvailableByAskingForIt() + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Host); + + codeBlocker.WriteLine("a"); + + Assert.AreEqual($"a{Environment.NewLine}", codeBlocker.ToString()); } [TestMethod] @@ -64,11 +77,11 @@ public void NewLineStringIsReportedBackVerbatim() } [TestMethod] - public void NullNewLineStringFallsBackToTheHostTerminator() + public void NullNewLineStringFallsBackToTheDefault() { using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, null!); - Assert.AreEqual(NewLines.Host, codeBlocker.NewLineString); + Assert.AreEqual(CodeBlocker.DefaultNewLineString, codeBlocker.NewLineString); } [TestMethod] diff --git a/CodeBlocker.Test/ScopeTests.cs b/CodeBlocker.Test/ScopeTests.cs index 52c7db6..3592340 100644 --- a/CodeBlocker.Test/ScopeTests.cs +++ b/CodeBlocker.Test/ScopeTests.cs @@ -24,7 +24,7 @@ public void ConstructorShouldOpenBraceAndIncreaseIndentation() Assert.AreEqual(initialIndent + 1, codeBlocker.CurrentIndent); string result = codeBlocker.ToString(); - Assert.IsTrue(result.Contains("{" + Environment.NewLine, StringComparison.Ordinal), "Result should contain opening brace with newline"); + Assert.IsTrue(result.Contains("{" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain opening brace with newline"); } [TestMethod] @@ -44,7 +44,7 @@ public void DisposeShouldCloseBraceAndDecreaseIndentation() Assert.AreEqual(initialIndent, codeBlocker.CurrentIndent); string result = codeBlocker.ToString(); - Assert.IsTrue(result.EndsWith("}" + Environment.NewLine, StringComparison.Ordinal), "Result should end with closing brace and newline"); + Assert.IsTrue(result.EndsWith("}" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should end with closing brace and newline"); } [TestMethod] @@ -64,7 +64,7 @@ public void UsingStatementShouldProperlyOpenAndCloseScope() // Assert string result = codeBlocker.ToString(); - string expected = "{" + Environment.NewLine + "\tcontent inside scope" + Environment.NewLine + "}" + Environment.NewLine; + string expected = "{" + CodeBlocker.DefaultNewLineString + "\tcontent inside scope" + CodeBlocker.DefaultNewLineString + "}" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -90,7 +90,7 @@ public void NestedScopesShouldMaintainProperIndentation() // Assert string result = codeBlocker.ToString(); - string expected = "{" + Environment.NewLine + "\tlevel 1" + Environment.NewLine + "\t{" + Environment.NewLine + "\t\tlevel 2" + Environment.NewLine + "\t}" + Environment.NewLine + "\tback to level 1" + Environment.NewLine + "}" + Environment.NewLine; + string expected = "{" + CodeBlocker.DefaultNewLineString + "\tlevel 1" + CodeBlocker.DefaultNewLineString + "\t{" + CodeBlocker.DefaultNewLineString + "\t\tlevel 2" + CodeBlocker.DefaultNewLineString + "\t}" + CodeBlocker.DefaultNewLineString + "\tback to level 1" + CodeBlocker.DefaultNewLineString + "}" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -126,7 +126,7 @@ public void ScopeWithoutContentShouldStillFormatCorrectly() // Assert string result = codeBlocker.ToString(); - string expected = "{" + Environment.NewLine + "}" + Environment.NewLine; + string expected = "{" + CodeBlocker.DefaultNewLineString + "}" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -152,7 +152,7 @@ public void MultipleSequentialScopesShouldFormatCorrectly() // Assert string result = codeBlocker.ToString(); - string expected = "{" + Environment.NewLine + "\tscope 1 content" + Environment.NewLine + "}" + Environment.NewLine + "{" + Environment.NewLine + "\tscope 2 content" + Environment.NewLine + "}" + Environment.NewLine; + string expected = "{" + CodeBlocker.DefaultNewLineString + "\tscope 1 content" + CodeBlocker.DefaultNewLineString + "}" + CodeBlocker.DefaultNewLineString + "{" + CodeBlocker.DefaultNewLineString + "\tscope 2 content" + CodeBlocker.DefaultNewLineString + "}" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -175,7 +175,7 @@ public void ScopeWithCustomIndentStringShouldWork() // Assert string result = codeBlocker.ToString(); - string expected = "{" + Environment.NewLine + " custom indented content" + Environment.NewLine + "}" + Environment.NewLine; + string expected = "{" + CodeBlocker.DefaultNewLineString + " custom indented content" + CodeBlocker.DefaultNewLineString + "}" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); Assert.AreEqual(customIndent, codeBlocker.IndentString); } @@ -228,8 +228,8 @@ public void ScopeWithVeryDeepNestingShouldWork() // Assert string result = codeBlocker.ToString(); - Assert.IsTrue(result.Contains("level 0" + Environment.NewLine, StringComparison.Ordinal), "Result should contain first nesting level"); - Assert.IsTrue(result.Contains($"level {nestingLevels - 1}{Environment.NewLine}", StringComparison.Ordinal), "Result should contain last nesting level"); + Assert.IsTrue(result.Contains("level 0" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain first nesting level"); + Assert.IsTrue(result.Contains($"level {nestingLevels - 1}{CodeBlocker.DefaultNewLineString}", StringComparison.Ordinal), "Result should contain last nesting level"); // Count braces to ensure they match @@ -266,7 +266,7 @@ public void ScopeWithMixedManualIndentAndScopeIndentShouldWork() // Note: After manual Outdent within scope, the closing }; will be at the current indent level // The scope ends at whatever the current indent is when Dispose() is called - string expected = "\t{" + Environment.NewLine + "\t\tdouble indented" + Environment.NewLine + "\tsingle indented" + Environment.NewLine + "}" + Environment.NewLine + "back to manual indent" + Environment.NewLine; + string expected = "\t{" + CodeBlocker.DefaultNewLineString + "\t\tdouble indented" + CodeBlocker.DefaultNewLineString + "\tsingle indented" + CodeBlocker.DefaultNewLineString + "}" + CodeBlocker.DefaultNewLineString + "back to manual indent" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -292,10 +292,10 @@ public void ScopeWithCurrentIndentSetterShouldWork() Assert.IsGreaterThanOrEqualTo(0, codeBlocker.CurrentIndent); // Should be reasonable value string result = codeBlocker.ToString(); - Assert.IsTrue(result.Contains("{" + Environment.NewLine, StringComparison.Ordinal), "Result should contain opening brace with newline"); - Assert.IsTrue(result.Contains("\t\t\t\t\tlevel 5 content" + Environment.NewLine, StringComparison.Ordinal), "Result should contain content with 5 tabs indentation"); + Assert.IsTrue(result.Contains("{" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain opening brace with newline"); + Assert.IsTrue(result.Contains("\t\t\t\t\tlevel 5 content" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain content with 5 tabs indentation"); - Assert.IsTrue(result.EndsWith("}" + Environment.NewLine, StringComparison.Ordinal), "Result should end with closing brace and newline"); + Assert.IsTrue(result.EndsWith("}" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should end with closing brace and newline"); } [TestMethod] @@ -345,7 +345,7 @@ public void ScopeWithEmptyCustomIndentStringShouldWork() // Assert string result = codeBlocker.ToString(); - string expected = "{" + Environment.NewLine + "no indent" + Environment.NewLine + "}" + Environment.NewLine; + string expected = "{" + CodeBlocker.DefaultNewLineString + "no indent" + CodeBlocker.DefaultNewLineString + "}" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } diff --git a/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs b/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs index 08fe5f8..ed31592 100644 --- a/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs +++ b/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs @@ -24,7 +24,7 @@ public void ConstructorShouldOpenBraceAndIncreaseIndentation() Assert.AreEqual(initialIndent + 1, codeBlocker.CurrentIndent); string result = codeBlocker.ToString(); - Assert.IsTrue(result.Contains("{" + Environment.NewLine, StringComparison.Ordinal), "Result should contain opening brace with newline"); + Assert.IsTrue(result.Contains("{" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should contain opening brace with newline"); } [TestMethod] @@ -44,7 +44,7 @@ public void DisposeShouldCloseBraceWithSemicolonAndDecreaseIndentation() Assert.AreEqual(initialIndent, codeBlocker.CurrentIndent); string result = codeBlocker.ToString(); - Assert.IsTrue(result.EndsWith("};" + Environment.NewLine, StringComparison.Ordinal), "Result should end with closing brace, semicolon, and newline"); + Assert.IsTrue(result.EndsWith("};" + CodeBlocker.DefaultNewLineString, StringComparison.Ordinal), "Result should end with closing brace, semicolon, and newline"); } [TestMethod] @@ -64,7 +64,7 @@ public void UsingStatementShouldProperlyOpenAndCloseScope() // Assert string result = codeBlocker.ToString(); - string expected = "{" + Environment.NewLine + "\tcontent inside scope" + Environment.NewLine + "};" + Environment.NewLine; + string expected = "{" + CodeBlocker.DefaultNewLineString + "\tcontent inside scope" + CodeBlocker.DefaultNewLineString + "};" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -90,7 +90,7 @@ public void NestedScopesShouldMaintainProperIndentation() // Assert string result = codeBlocker.ToString(); - string expected = "{" + Environment.NewLine + "\tlevel 1" + Environment.NewLine + "\t{" + Environment.NewLine + "\t\tlevel 2" + Environment.NewLine + "\t};" + Environment.NewLine + "\tback to level 1" + Environment.NewLine + "};" + Environment.NewLine; + string expected = "{" + CodeBlocker.DefaultNewLineString + "\tlevel 1" + CodeBlocker.DefaultNewLineString + "\t{" + CodeBlocker.DefaultNewLineString + "\t\tlevel 2" + CodeBlocker.DefaultNewLineString + "\t};" + CodeBlocker.DefaultNewLineString + "\tback to level 1" + CodeBlocker.DefaultNewLineString + "};" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -125,7 +125,7 @@ public void ScopeWithoutContentShouldStillFormatCorrectly() // Assert string result = codeBlocker.ToString(); - string expected = "{" + Environment.NewLine + "};" + Environment.NewLine; + string expected = "{" + CodeBlocker.DefaultNewLineString + "};" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -148,7 +148,7 @@ public void ScopeWithCustomIndentStringShouldWork() // Assert string result = codeBlocker.ToString(); - string expected = "{" + Environment.NewLine + " custom indented content" + Environment.NewLine + "};" + Environment.NewLine; + string expected = "{" + CodeBlocker.DefaultNewLineString + " custom indented content" + CodeBlocker.DefaultNewLineString + "};" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); Assert.AreEqual(customIndent, codeBlocker.IndentString); } @@ -203,18 +203,18 @@ public void MixedWithRegularScopeShouldWork() string result = codeBlocker.ToString(); string expected = - "namespace Test" + Environment.NewLine + - "{" + Environment.NewLine + - "\tpublic class Example" + Environment.NewLine + - "\t{" + Environment.NewLine + - "\t\tpublic enum Color" + Environment.NewLine + - "\t\t{" + Environment.NewLine + - "\t\t\tRed," + Environment.NewLine + - "\t\t\tGreen," + Environment.NewLine + - "\t\t\tBlue" + Environment.NewLine + - "\t\t};" + Environment.NewLine + - "\t}" + Environment.NewLine + - "}" + Environment.NewLine; + "namespace Test" + CodeBlocker.DefaultNewLineString + + "{" + CodeBlocker.DefaultNewLineString + + "\tpublic class Example" + CodeBlocker.DefaultNewLineString + + "\t{" + CodeBlocker.DefaultNewLineString + + "\t\tpublic enum Color" + CodeBlocker.DefaultNewLineString + + "\t\t{" + CodeBlocker.DefaultNewLineString + + "\t\t\tRed," + CodeBlocker.DefaultNewLineString + + "\t\t\tGreen," + CodeBlocker.DefaultNewLineString + + "\t\t\tBlue" + CodeBlocker.DefaultNewLineString + + "\t\t};" + CodeBlocker.DefaultNewLineString + + "\t}" + CodeBlocker.DefaultNewLineString + + "}" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } @@ -236,7 +236,7 @@ public void ScopeWithEmptyCustomIndentStringShouldWork() // Assert string result = codeBlocker.ToString(); - string expected = "{" + Environment.NewLine + "no indent" + Environment.NewLine + "};" + Environment.NewLine; + string expected = "{" + CodeBlocker.DefaultNewLineString + "no indent" + CodeBlocker.DefaultNewLineString + "};" + CodeBlocker.DefaultNewLineString; Assert.AreEqual(expected, result); } } diff --git a/CodeBlocker.Test/TextWriterTests.cs b/CodeBlocker.Test/TextWriterTests.cs index 1f356fc..7dc99d2 100644 --- a/CodeBlocker.Test/TextWriterTests.cs +++ b/CodeBlocker.Test/TextWriterTests.cs @@ -89,7 +89,7 @@ public void AWriterCreateOwnsIsDisposed() // Writing after disposal would throw if the StringWriter were still open; instead the // already-buffered text is all that remains readable. - Assert.AreEqual($"a{Environment.NewLine}", codeBlocker.ToString()); + Assert.AreEqual($"a{CodeBlocker.DefaultNewLineString}", codeBlocker.ToString()); } [TestMethod] @@ -123,7 +123,7 @@ public void ToStringReturnsTheTypeNameWhenThereIsNothingBuffered() // Documented behaviour: no copy is kept, and ToString does not throw, so debuggers and // diagnostics stay safe. The generated code is read from the writer instead. Assert.AreEqual(typeof(CodeBlocker).ToString(), codeBlocker.ToString()); - Assert.AreEqual("a" + Environment.NewLine, target.ToString()); + Assert.AreEqual("a" + CodeBlocker.DefaultNewLineString, target.ToString()); } [TestMethod] diff --git a/CodeBlocker/CodeBlocker.cs b/CodeBlocker/CodeBlocker.cs index eb62d3b..d27313c 100644 --- a/CodeBlocker/CodeBlocker.cs +++ b/CodeBlocker/CodeBlocker.cs @@ -13,6 +13,18 @@ public class CodeBlocker : IDisposable /// The indent string used when none is specified: a single tab. public const string DefaultIndentString = "\t"; + /// + /// The line terminator used when none is specified: a line feed. + /// + /// + /// LF rather than the host's terminator, so the same calls produce the same bytes wherever they + /// run. That is what generated code almost always needs: it gets written to a file, committed, + /// diffed, or compared against a golden file, and every one of those wants reproducibility more + /// than it wants the local convention. Pass explicitly for the old + /// behaviour. + /// + public const string DefaultNewLineString = NewLines.Lf; + private readonly TextWriter writer; private bool disposedValue; @@ -29,10 +41,8 @@ public class CodeBlocker : IDisposable /// Get the line terminator written at the end of every line. /// /// - /// Defaults to , which makes output depend on the operating system it - /// was produced on. Generators whose output is committed to a repository should pass an explicit - /// terminator — or — so the same input - /// always produces the same bytes. + /// Defaults to . Pass to follow the + /// operating system's convention instead, at the cost of output that differs by platform. /// public string NewLineString { get; } @@ -60,7 +70,7 @@ public class CodeBlocker : IDisposable /// /// The to write to. public CodeBlocker(StringWriter stringWriter) - : this((TextWriter)stringWriter, DefaultIndentString, NewLines.Host) + : this((TextWriter)stringWriter, DefaultIndentString, DefaultNewLineString) { } @@ -70,7 +80,7 @@ public CodeBlocker(StringWriter stringWriter) /// The to write to. /// The string to use for indentation. public CodeBlocker(StringWriter stringWriter, string indentString) - : this((TextWriter)stringWriter, indentString, NewLines.Host) + : this((TextWriter)stringWriter, indentString, DefaultNewLineString) { } @@ -81,7 +91,7 @@ public CodeBlocker(StringWriter stringWriter, string indentString) /// The string to use for indentation. /// /// The line terminator to write at the end of every line. selects - /// . + /// . /// public CodeBlocker(StringWriter stringWriter, string indentString, string newLineString) : this((TextWriter)stringWriter, indentString, newLineString) @@ -97,7 +107,7 @@ public CodeBlocker(StringWriter stringWriter, string indentString, string newLin /// that makes for itself is disposed here. /// public CodeBlocker(TextWriter writer) - : this(writer, DefaultIndentString, NewLines.Host) + : this(writer, DefaultIndentString, DefaultNewLineString) { } @@ -108,7 +118,7 @@ public CodeBlocker(TextWriter writer) /// The to write to. /// The string to use for indentation. public CodeBlocker(TextWriter writer, string indentString) - : this(writer, indentString, NewLines.Host) + : this(writer, indentString, DefaultNewLineString) { } @@ -120,7 +130,7 @@ public CodeBlocker(TextWriter writer, string indentString) /// The string to use for indentation. /// /// The line terminator to write at the end of every line. selects - /// . + /// . /// /// is . public CodeBlocker(TextWriter writer, string indentString, string newLineString) @@ -129,7 +139,7 @@ public CodeBlocker(TextWriter writer, string indentString, string newLineString) // indentString is deliberately not null-checked: a null indent has always meant "no // indentation" here, and CreateWithNullIndentStringShouldWork pins that behaviour. - newLineString ??= NewLines.Host; + newLineString ??= DefaultNewLineString; this.writer = writer; IndentString = indentString; @@ -150,14 +160,14 @@ public CodeBlocker(TextWriter writer, string indentString, string newLineString) /// Create a new instance of . /// /// A new instance of . - public static CodeBlocker Create() => Create(DefaultIndentString, NewLines.Host); + public static CodeBlocker Create() => Create(DefaultIndentString, DefaultNewLineString); /// /// Create a new instance of with a custom indent string. /// /// The string to use for indentation. /// A new instance of . - public static CodeBlocker Create(string indentString) => Create(indentString, NewLines.Host); + public static CodeBlocker Create(string indentString) => Create(indentString, DefaultNewLineString); /// /// Create a new instance of with a custom indent string and line terminator. diff --git a/CodeBlocker/NewLines.cs b/CodeBlocker/NewLines.cs index 8834a24..295d94e 100644 --- a/CodeBlocker/NewLines.cs +++ b/CodeBlocker/NewLines.cs @@ -21,8 +21,8 @@ public static class NewLines /// /// The host operating system's line terminator. This is what a - /// uses by default, and therefore what falls back to when no line - /// terminator is specified — which makes the output depend on where it was produced. + /// uses by default; deliberately does not, because it makes output + /// depend on where it was produced. Pass it explicitly when that is what you want. /// public static string Host => System.Environment.NewLine; } diff --git a/README.md b/README.md index b327b62..999786d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ CodeBlocker is a specialized utility built on top of `IndentedTextWriter` that s - **Automatic Indentation**: Properly manages indentation levels as you create nested code blocks - **Configurable Indentation**: Support for custom indent strings (tabs, spaces, or any custom pattern) -- **Configurable Line Endings**: Pin the line terminator so the same calls produce byte-identical output on every platform +- **Deterministic Line Endings**: Lines end with LF by default, so the same calls produce byte-identical output on every platform; pin CRLF or the host terminator when you want them - **Scope Management**: Uses C# `using` statements for clean, readable scope creation with automatic brace handling powered by `ktsu.ScopedAction`, with optional trailing semicolons via `ScopeWithTrailingSemicolon` - **More Than Braces**: Parenthesis, bracket, bare-indent, `#region`, `#if` and `#pragma warning` scopes, each balanced by disposal - **Preamble Helpers**: One call each for the auto-generated marker, the nullable context, the namespace declaration, and the using directives @@ -200,7 +200,7 @@ internal class TemplateExample file.Classes.Add(money); - using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + using CodeBlocker codeBlocker = CodeBlocker.Create(); codeBlocker.AddSourceFile(file); return codeBlocker.ToString(); } @@ -297,7 +297,7 @@ internal class ScopesExample { public static string GenerateCode() { - using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + using CodeBlocker codeBlocker = CodeBlocker.Create(); codeBlocker.WriteLine("public class Example"); using (new Scope(codeBlocker)) @@ -349,7 +349,7 @@ public class Example The lines at the top of a generated file are the same every time, so they get one call each. Every helper that conventionally has a blank line after it writes that blank line, which keeps the spacing consistent no matter which parts a given generator emits. ```csharp -using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); +using CodeBlocker codeBlocker = CodeBlocker.Create(); codeBlocker .WriteAutoGeneratedHeader("Copyright (c) 2023-2026 ktsu-dev contributors") @@ -388,7 +388,7 @@ internal class FileExample public static void GenerateToFile(string path) { using StreamWriter file = new(path); - using CodeBlocker codeBlocker = new(file, CodeBlocker.DefaultIndentString, NewLines.Lf); + using CodeBlocker codeBlocker = new(file); codeBlocker.WriteLine("public class Example"); using (new Scope(codeBlocker)) @@ -406,9 +406,7 @@ Two things to know: ### Line Endings -`CodeBlocker` writes through `IndentedTextWriter`, which terminates lines with `Environment.NewLine`. That makes output depend on the machine that produced it — the same calls give you CRLF on Windows and LF everywhere else. - -If your generated code is committed to a repository, or compared against a golden file, pin the terminator instead: +`CodeBlocker` terminates lines with a line feed unless you say otherwise. Generated code is nearly always committed to a repository or compared against a golden file, and both of those want the same bytes out of every machine — so the default is the deterministic terminator rather than the local convention: ```csharp namespace CodeBlockerExample; @@ -419,8 +417,8 @@ internal class DeterministicExample { public static string GenerateCode() { - // Byte-identical on every platform. - using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + // Byte-identical on every platform: LF, on Windows too. + using CodeBlocker codeBlocker = CodeBlocker.Create(); codeBlocker.WriteLine("public class Example"); using (new Scope(codeBlocker)) @@ -433,15 +431,21 @@ internal class DeterministicExample } ``` -The `NewLines` class names the usual choices: +This is the one place `CodeBlocker` departs from the `IndentedTextWriter` it writes through, which uses `Environment.NewLine`. + +The `NewLines` class names the usual choices, and any other string works too — the terminator is written verbatim: | Name | Value | Notes | |------|-------|-------| -| `NewLines.Lf` | `"\n"` | The conventional choice for reproducible output | +| `NewLines.Lf` | `"\n"` | The default, exposed as `CodeBlocker.DefaultNewLineString` | | `NewLines.CrLf` | `"\r\n"` | Use when the target repository stores `.cs` files with CRLF | -| `NewLines.Host` | `Environment.NewLine` | The default, and the one that varies by platform | +| `NewLines.Host` | `Environment.NewLine` | Opt in when the output is for this machine rather than for a repository | -Any other string works too — the terminator is written verbatim. +To follow the host convention, ask for it: + +```csharp +using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Host); +``` ### Advanced Usage @@ -497,15 +501,22 @@ string result = codeBlocker.ToString(); The main class for building indented code blocks. +#### Constants + +| Name | Value | Description | +|------|-------|-------------| +| `DefaultIndentString` | `"\t"` | The indent written per level when none is specified | +| `DefaultNewLineString` | `NewLines.Lf` | The line terminator used when none is specified | + #### Constructors | Name | Description | |------|-------------| -| `CodeBlocker(StringWriter stringWriter)` | Creates a new CodeBlocker with the specified StringWriter using tab indentation | -| `CodeBlocker(StringWriter stringWriter, string indentString)` | Creates a new CodeBlocker with the specified StringWriter and custom indent string | +| `CodeBlocker(StringWriter stringWriter)` | Creates a new CodeBlocker with the specified StringWriter using tab indentation and LF line endings | +| `CodeBlocker(StringWriter stringWriter, string indentString)` | Creates a new CodeBlocker with the specified StringWriter and custom indent string, still LF | | `CodeBlocker(StringWriter stringWriter, string indentString, string newLineString)` | As above, and pins the line terminator written at the end of every line | -| `CodeBlocker(TextWriter writer)` | Creates a new CodeBlocker over any TextWriter using tab indentation | -| `CodeBlocker(TextWriter writer, string indentString)` | Creates a new CodeBlocker over any TextWriter with a custom indent string | +| `CodeBlocker(TextWriter writer)` | Creates a new CodeBlocker over any TextWriter using tab indentation and LF line endings | +| `CodeBlocker(TextWriter writer, string indentString)` | Creates a new CodeBlocker over any TextWriter with a custom indent string, still LF | | `CodeBlocker(TextWriter writer, string indentString, string newLineString)` | As above, and pins the line terminator | #### Properties @@ -514,7 +525,7 @@ The main class for building indented code blocks. |------|------|-------------| | `CurrentIndent` | `int` | Gets or sets the current indentation level | | `IndentString` | `string` | Gets the current indent string being used (e.g., "\t", " ", " ") | -| `NewLineString` | `string` | Gets the line terminator written at the end of every line | +| `NewLineString` | `string` | Gets the line terminator written at the end of every line; `DefaultNewLineString` (LF) unless one was passed | | `IsBuffered` | `bool` | Whether `ToString()` can return the generated code, i.e. whether the underlying writer is a `StringWriter` | #### Methods @@ -528,8 +539,8 @@ The main class for building indented code blocks. | `Indent()` | `void` | Increases the indent level | | `Outdent()` | `void` | Decreases the indent level | | `ToString()` | `string` | Returns the generated code as a string | -| `Create()` | `CodeBlocker` | Static factory method to create a new CodeBlocker instance with tab indentation | -| `Create(string indentString)` | `CodeBlocker` | Static factory method to create a new CodeBlocker instance with custom indentation | +| `Create()` | `CodeBlocker` | Static factory method to create a new CodeBlocker instance with tab indentation and LF line endings | +| `Create(string indentString)` | `CodeBlocker` | Static factory method to create a new CodeBlocker instance with custom indentation, still LF | | `Create(string indentString, string newLineString)` | `CodeBlocker` | Static factory method to create a new CodeBlocker instance with custom indentation and a pinned line terminator | | `Dispose()` | `void` | Disposes of the CodeBlocker and underlying resources | @@ -602,7 +613,7 @@ Named line terminators. See [Line Endings](#line-endings). | Name | Value | |------|-------| -| `Lf` | `"\n"` | +| `Lf` | `"\n"` (the default — see `CodeBlocker.DefaultNewLineString`) | | `CrLf` | `"\r\n"` | | `Host` | `Environment.NewLine` |