From 4785841c6ef8f4e688a4b9df6fac46d0c02c6764 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:17:46 +0000 Subject: [PATCH 1/2] feat: default to LF line endings [major] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeBlocker writes through IndentedTextWriter, which terminates lines with Environment.NewLine. That made the same calls produce different bytes on different machines, and generated code is almost always committed, diffed, or compared against a golden file — all of which want reproducibility more than they want the local convention. Callers had to remember to pass NewLines.Lf, and forgetting was silent. Flip the default: CodeBlocker.DefaultNewLineString is NewLines.Lf, used by every constructor and factory overload that does not take a terminator, and substituted for a null one. NewLines.Host is still there as the opt-in for callers who genuinely want the platform terminator. This is a breaking change for anyone on Windows relying on the old default, hence [major]. Tests asserted against Environment.NewLine throughout, which passes on Linux for the wrong reason and would have hidden the Windows break entirely. They now assert against CodeBlocker.DefaultNewLineString, and NewLineTests covers both that the default is LF regardless of host and that the host terminator is still reachable by asking for it. Closes #89 Co-Authored-By: Claude --- CodeBlocker.Test/CodeBlockerTests.cs | 32 ++--- CodeBlocker.Test/IntegrationTests.cs | 116 +++++++++--------- CodeBlocker.Test/NewLineTests.cs | 23 +++- CodeBlocker.Test/ScopeTests.cs | 28 ++--- .../ScopeWithTrailingSemicolonTests.cs | 38 +++--- CodeBlocker.Test/TextWriterTests.cs | 4 +- CodeBlocker/CodeBlocker.cs | 36 ++++-- CodeBlocker/NewLines.cs | 4 +- README.md | 55 +++++---- 9 files changed, 185 insertions(+), 151 deletions(-) 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` | From 233a1b792ab6f47d9ce5cc8799ac70048e12c1d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:17:57 +0000 Subject: [PATCH 2/2] chore: add a local SonarCloud reproduction [patch] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 findings are invisible locally and only surface after a push — and the bot comment links to a dashboard rather than naming them. That bit #87: the gate passed but reported three new issues with no way to see what they were. Add an opt-in .sonarlint/ that nothing imports automatically: 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 reason the equivalent setup in ktsu.Semantics only ever reached one project. This is what identified #87's three findings (two S3267, one S3878) and let them be fixed before the next push. Known gap, recorded in the globalconfig and CLAUDE.md: SonarCloud reported one new issue on #87 that this configuration does not reproduce. sonarcloud.io is not reachable from the agent sandbox, so the rule behind it could not be identified and the calibration criterion in #88 is not met. CLAUDE.md also picks up the architecture refresh the merged work left it owing — the TextWriter constructors, the other scopes, the preamble helpers, the template object model, and the line-ending rule for tests. Co-Authored-By: Claude --- .sonarlint/sonar-local.globalconfig | 34 +++++++++++++++++ .sonarlint/sonar-local.props | 32 ++++++++++++++++ CLAUDE.md | 58 ++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 .sonarlint/sonar-local.globalconfig create mode 100644 .sonarlint/sonar-local.props 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: