From aa7546c04926d9941bdac68e62dd65bc38972dbe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 02:38:53 +0000 Subject: [PATCH 1/7] feat: make the line terminator configurable [minor] CodeBlocker writes through IndentedTextWriter, which terminates lines with Environment.NewLine, so the same calls produced CRLF on Windows and LF everywhere else. Generated code that is committed to a repository has to be byte-identical wherever it was produced, so consumers had to post-process the output to undo this. Add a newLineString to the constructor and to Create(), plus a NewLines class naming the usual choices, and report the terminator back as NewLineString. The terminator is set on both the StringWriter and the IndentedTextWriter: the latter forwards to its inner writer on modern targets, but this package also ships for netstandard2.0, where the running framework supplies IndentedTextWriter and that forwarding is not guaranteed. The default is unchanged (Environment.NewLine), so existing callers see the same output as before; determinism is opt-in. The test suite spelled its expectations with CRLF throughout, so 46 of its 67 tests failed on any non-Windows host. They now build their writer through a CRLF-pinned test factory, which makes those expectations true on every platform and keeps the readable CRLF spelling in the multi-line fixtures. The default terminator is covered by the new NewLineTests. Refs #81 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ --- CodeBlocker.Test/CodeBlockerTests.cs | 54 +++--- CodeBlocker.Test/IntegrationTests.cs | 28 +-- CodeBlocker.Test/NewLineTests.cs | 169 ++++++++++++++++++ CodeBlocker.Test/ScopeTests.cs | 30 ++-- .../ScopeWithTrailingSemicolonTests.cs | 20 +-- CodeBlocker.Test/TestCodeBlocker.cs | 34 ++++ CodeBlocker/CodeBlocker.cs | 91 +++++++--- CodeBlocker/NewLines.cs | 28 +++ README.md | 43 +++++ 9 files changed, 411 insertions(+), 86 deletions(-) create mode 100644 CodeBlocker.Test/NewLineTests.cs create mode 100644 CodeBlocker.Test/TestCodeBlocker.cs create mode 100644 CodeBlocker/NewLines.cs diff --git a/CodeBlocker.Test/CodeBlockerTests.cs b/CodeBlocker.Test/CodeBlockerTests.cs index a7d3433..2478fd7 100644 --- a/CodeBlocker.Test/CodeBlockerTests.cs +++ b/CodeBlocker.Test/CodeBlockerTests.cs @@ -13,7 +13,7 @@ public void CreateShouldReturnValidInstance() { // Act - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Assert @@ -26,7 +26,7 @@ public void ToStringEmptyCodeBlockerShouldReturnEmptyString() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -42,7 +42,7 @@ public void WriteLineShouldAddLineWithIndentation() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -59,7 +59,7 @@ public void NewLineShouldAddEmptyLine() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -76,7 +76,7 @@ public void WriteLineWithIndentationShouldRespectIndentLevel() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -94,7 +94,7 @@ public void MultipleLinesShouldMaintainProperIndentation() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -116,7 +116,7 @@ public void DisposeShouldNotThrowException() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act & Assert @@ -129,7 +129,7 @@ public void DisposeMultipleCallsShouldNotThrow() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act & Assert @@ -147,7 +147,7 @@ public void CreateWithCustomIndentStringShouldUseSpecifiedIndent() // Act - using CodeBlocker codeBlocker = CodeBlocker.Create(customIndent); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(customIndent); codeBlocker.Indent(); codeBlocker.WriteLine("test line"); string result = codeBlocker.ToString(); @@ -168,7 +168,7 @@ public void ConstructorWithCustomIndentStringShouldWork() // Act - using CodeBlocker codeBlocker = new(stringWriter, customIndent); + using CodeBlocker codeBlocker = new(stringWriter, customIndent, NewLines.CrLf); codeBlocker.Indent(); codeBlocker.WriteLine("indented content"); string result = codeBlocker.ToString(); @@ -184,7 +184,7 @@ public void DefaultIndentStringShouldBeTab() { // Act - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Assert @@ -198,7 +198,7 @@ public void CustomIndentStringWithMultipleIndentLevels() const string customIndent = ">>"; // Custom string - using CodeBlocker codeBlocker = CodeBlocker.Create(customIndent); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(customIndent); // Act @@ -221,7 +221,7 @@ public void WriteLineWithoutParametersShouldAddEmptyLineWithIndentation() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -240,7 +240,7 @@ public void WriteMethodShouldAddTextWithoutNewline() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -258,7 +258,7 @@ public void WriteMethodWithIndentationShouldRespectIndentLevel() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -276,7 +276,7 @@ public void CurrentIndentSetterShouldUpdateIndentationLevel() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -295,7 +295,7 @@ public void CurrentIndentSetterWithZeroShouldRemoveIndentation() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); codeBlocker.Indent(); codeBlocker.Indent(); @@ -332,7 +332,7 @@ public void CreateWithNullIndentStringShouldWork() { // Arrange & Act - using CodeBlocker codeBlocker = CodeBlocker.Create(null!); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(null!); codeBlocker.Indent(); codeBlocker.WriteLine("test"); string result = codeBlocker.ToString(); @@ -348,7 +348,7 @@ public void WriteLineWithNullParameterShouldWork() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -365,7 +365,7 @@ public void WriteWithNullParameterShouldWork() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -382,7 +382,7 @@ public void OutdentBelowZeroShouldNotThrow() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act & Assert - Should not throw, but may not go below 0 @@ -399,7 +399,7 @@ public void DisposeWithStringWriterManagementShouldNotThrowWhenCalledMultipleTim { // Arrange - CodeBlocker codeBlocker = CodeBlocker.Create(); // This should manage StringWriter disposal + CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // This should manage StringWriter disposal // Act & Assert - Should not throw @@ -413,7 +413,7 @@ public void MixedWriteAndWriteLineShouldFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -434,7 +434,7 @@ public void DeepIndentationStressTest() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); const int maxDepth = 100; // Act @@ -458,7 +458,7 @@ public void LargeStringContentShouldBeHandledCorrectly() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); string largeString = new('x', 10000); // Act @@ -477,7 +477,7 @@ public void EmptyIndentStringShouldWork() { // Arrange & Act - using CodeBlocker codeBlocker = CodeBlocker.Create(string.Empty); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(string.Empty); codeBlocker.Indent(); codeBlocker.WriteLine("test"); string result = codeBlocker.ToString(); @@ -495,7 +495,7 @@ public void VeryLongIndentStringShouldWork() // Arrange const string longIndent = "===================================="; - using CodeBlocker codeBlocker = CodeBlocker.Create(longIndent); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(longIndent); // Act diff --git a/CodeBlocker.Test/IntegrationTests.cs b/CodeBlocker.Test/IntegrationTests.cs index 1c5d953..a024fb4 100644 --- a/CodeBlocker.Test/IntegrationTests.cs +++ b/CodeBlocker.Test/IntegrationTests.cs @@ -13,7 +13,7 @@ public void ComplexCodeGenerationShouldFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act - Simulate generating a class with methods @@ -64,7 +64,7 @@ public void DeepNestingShouldMaintainCorrectIndentation() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); const int nestingLevels = 5; // Act @@ -102,7 +102,7 @@ public void MixedContentTypesShouldFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -149,7 +149,7 @@ public void EmptyScopesShouldNotAffectOtherContent() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -175,8 +175,8 @@ public void MultipleCodeBlockersShouldBeIndependent() { // Arrange - using CodeBlocker codeBlocker1 = CodeBlocker.Create(); - using CodeBlocker codeBlocker2 = CodeBlocker.Create(); + using CodeBlocker codeBlocker1 = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker2 = TestCodeBlocker.CreateCrLf(); // Act @@ -210,9 +210,9 @@ public void ComplexTemplateGenerationWithMultipleIndentTypesShouldWork() { // Arrange - using CodeBlocker htmlBlocker = CodeBlocker.Create(" "); // 2 spaces for HTML + using CodeBlocker htmlBlocker = TestCodeBlocker.CreateCrLf(" "); // 2 spaces for HTML - using CodeBlocker jsBlocker = CodeBlocker.Create("\t"); // Tabs for JS + using CodeBlocker jsBlocker = TestCodeBlocker.CreateCrLf("\t"); // Tabs for JS // Act - Generate HTML structure @@ -268,7 +268,7 @@ public void MixedWriteOperationsWithComplexIndentationShouldFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act - Mix Write and WriteLine operations @@ -314,7 +314,7 @@ public void LargeScaleCodeGenerationShouldPerformReasonably() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); const int classCount = 100; const int methodsPerClass = 10; @@ -386,14 +386,14 @@ public void SharedStringWriterBetweenCodeBlockersShouldWork() // Act - using (CodeBlocker codeBlocker1 = new(sharedWriter)) + using (CodeBlocker codeBlocker1 = new(sharedWriter, CodeBlocker.DefaultIndentString, NewLines.CrLf)) { codeBlocker1.WriteLine("// First CodeBlocker"); using Scope scope1 = new(codeBlocker1); codeBlocker1.WriteLine("content from first"); } - using (CodeBlocker codeBlocker2 = new(sharedWriter, " ")) + using (CodeBlocker codeBlocker2 = new(sharedWriter, " ", NewLines.CrLf)) { codeBlocker2.WriteLine("// Second CodeBlocker with different indent"); using Scope scope2 = new(codeBlocker2); @@ -419,7 +419,7 @@ public void ErrorRecoveryAfterExceptionShouldNotAffectFutureOperations() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act & Assert - Test error recovery #pragma warning disable CA1031 // Do not catch general exception types - This test specifically needs to catch any potential exception @@ -455,7 +455,7 @@ public void UnicodeAndSpecialCharactersShouldBeHandledCorrectly() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create("→→"); // Unicode arrows as indent + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf("→→"); // Unicode arrows as indent // Act diff --git a/CodeBlocker.Test/NewLineTests.cs b/CodeBlocker.Test/NewLineTests.cs new file mode 100644 index 0000000..a9e0184 --- /dev/null +++ b/CodeBlocker.Test/NewLineTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace CodeBlocker.Tests; + +using ktsu.CodeBlocker; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers the configurable line terminator. +/// +/// +/// terminates lines with +/// , so before the terminator became configurable the same calls +/// produced CRLF on Windows and LF elsewhere. Any generator whose output is committed to a +/// repository needs the bytes to be identical wherever it ran, so these tests assert exact bytes +/// rather than comparing against . +/// +[TestClass] +public sealed class NewLineTests +{ + [TestMethod] + public void DefaultNewLineStringIsTheHostTerminator() + { + using CodeBlocker codeBlocker = CodeBlocker.Create(); + + Assert.AreEqual(NewLines.Host, codeBlocker.NewLineString); + Assert.AreEqual(Environment.NewLine, codeBlocker.NewLineString); + } + + [TestMethod] + public void CreateWithLfEmitsLineFeedsOnly() + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + + codeBlocker.WriteLine("a"); + codeBlocker.Indent(); + codeBlocker.WriteLine("b"); + codeBlocker.Outdent(); + + Assert.AreEqual("a\n\tb\n", codeBlocker.ToString()); + } + + [TestMethod] + public void CreateWithCrLfEmitsCarriageReturnLineFeeds() + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.CrLf); + + codeBlocker.WriteLine("a"); + codeBlocker.Indent(); + codeBlocker.WriteLine("b"); + codeBlocker.Outdent(); + + Assert.AreEqual("a\r\n\tb\r\n", codeBlocker.ToString()); + } + + [TestMethod] + public void NewLineStringIsReportedBackVerbatim() + { + using CodeBlocker lf = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + using CodeBlocker crlf = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.CrLf); + + Assert.AreEqual("\n", lf.NewLineString); + Assert.AreEqual("\r\n", crlf.NewLineString); + } + + [TestMethod] + public void NullNewLineStringFallsBackToTheHostTerminator() + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, null!); + + Assert.AreEqual(NewLines.Host, codeBlocker.NewLineString); + } + + [TestMethod] + public void ParameterlessWriteLineUsesTheConfiguredTerminator() + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + + codeBlocker.WriteLine(); + + Assert.AreEqual("\n", codeBlocker.ToString()); + } + + [TestMethod] + public void BlankLineUsesTheConfiguredTerminator() + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + + codeBlocker.Indent(); + codeBlocker.NewLine(); + + // NewLine() writes without tabs, so the configured terminator is the entire output. + Assert.AreEqual("\n", codeBlocker.ToString()); + } + + [TestMethod] + public void ScopesUseTheConfiguredTerminator() + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + + codeBlocker.WriteLine("if (x)"); + using (new Scope(codeBlocker)) + { + codeBlocker.WriteLine("y();"); + } + + Assert.AreEqual("if (x)\n{\n\ty();\n}\n", codeBlocker.ToString()); + } + + [TestMethod] + public void TrailingSemicolonScopesUseTheConfiguredTerminator() + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + + codeBlocker.WriteLine("enum E"); + using (new ScopeWithTrailingSemicolon(codeBlocker)) + { + codeBlocker.WriteLine("A,"); + } + + Assert.AreEqual("enum E\n{\n\tA,\n};\n", codeBlocker.ToString()); + } + + [TestMethod] + public void ATerminatorThatIsNeitherLfNorCrLfIsHonoured() + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, ""); + + codeBlocker.WriteLine("a"); + + Assert.AreEqual("a", codeBlocker.ToString()); + } + + [TestMethod] + public void SameCallsWithTheSameTerminatorProduceIdenticalBytes() + { + // The point of the option: output is a function of the calls and the configuration, never + // of the machine it ran on. + static string Render(string newLineString) + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, newLineString); + codeBlocker.WriteLine("class C"); + using (new Scope(codeBlocker)) + { + codeBlocker.WriteLine("void M()"); + using (new Scope(codeBlocker)) + { + codeBlocker.WriteLine("return;"); + } + } + + return codeBlocker.ToString(); + } + + Assert.AreEqual("class C\n{\n\tvoid M()\n\t{\n\t\treturn;\n\t}\n}\n", Render(NewLines.Lf)); + Assert.AreEqual("class C\r\n{\r\n\tvoid M()\r\n\t{\r\n\t\treturn;\r\n\t}\r\n}\r\n", Render(NewLines.CrLf)); + } + + [TestMethod] + public void ConstructorOverloadHonoursTheTerminator() + { + using StringWriter stringWriter = new(); + using CodeBlocker codeBlocker = new(stringWriter, CodeBlocker.DefaultIndentString, NewLines.Lf); + + codeBlocker.WriteLine("a"); + + Assert.AreEqual("a\n", stringWriter.ToString()); + } +} diff --git a/CodeBlocker.Test/ScopeTests.cs b/CodeBlocker.Test/ScopeTests.cs index 7a9a028..436f9ea 100644 --- a/CodeBlocker.Test/ScopeTests.cs +++ b/CodeBlocker.Test/ScopeTests.cs @@ -13,7 +13,7 @@ public void ConstructorShouldOpenBraceAndIncreaseIndentation() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); int initialIndent = codeBlocker.CurrentIndent; // Act @@ -32,7 +32,7 @@ public void DisposeShouldCloseBraceAndDecreaseIndentation() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); int initialIndent = codeBlocker.CurrentIndent; Scope scope = new(codeBlocker); @@ -52,7 +52,7 @@ public void UsingStatementShouldProperlyOpenAndCloseScope() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -73,7 +73,7 @@ public void NestedScopesShouldMaintainProperIndentation() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -99,7 +99,7 @@ public void MultipleDisposeShouldNotThrowException() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); Scope scope = new(codeBlocker); // Act & Assert @@ -114,7 +114,7 @@ public void ScopeWithoutContentShouldStillFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -135,7 +135,7 @@ public void MultipleSequentialScopesShouldFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -163,7 +163,7 @@ public void ScopeWithCustomIndentStringShouldWork() const string customIndent = " "; // Two spaces - using CodeBlocker codeBlocker = CodeBlocker.Create(customIndent); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(customIndent); // Act @@ -193,7 +193,7 @@ public void ScopeWithDisposedCodeBlockerShouldThrowException() { // Arrange - CodeBlocker codeBlocker = CodeBlocker.Create(); + CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); codeBlocker.Dispose(); // Act & Assert - Should throw when trying to use disposed CodeBlocker @@ -206,7 +206,7 @@ public void ScopeWithVeryDeepNestingShouldWork() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); const int nestingLevels = 50; List scopes = []; @@ -244,7 +244,7 @@ public void ScopeWithMixedManualIndentAndScopeIndentShouldWork() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -275,7 +275,7 @@ public void ScopeWithCurrentIndentSetterShouldWork() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -303,7 +303,7 @@ public void ScopeDisposalOrderShouldNotMatterForCorrectness() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -333,7 +333,7 @@ public void ScopeWithEmptyCustomIndentStringShouldWork() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(string.Empty); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(string.Empty); // Act @@ -354,7 +354,7 @@ public void ScopeAfterManualDisposeOfCodeBlockerShouldThrowException() { // Arrange - CodeBlocker codeBlocker = CodeBlocker.Create(); + CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act - Dispose the CodeBlocker while scope is still active diff --git a/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs b/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs index 343f854..4984895 100644 --- a/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs +++ b/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs @@ -13,7 +13,7 @@ public void ConstructorShouldOpenBraceAndIncreaseIndentation() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); int initialIndent = codeBlocker.CurrentIndent; // Act @@ -32,7 +32,7 @@ public void DisposeShouldCloseBraceWithSemicolonAndDecreaseIndentation() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); int initialIndent = codeBlocker.CurrentIndent; ScopeWithTrailingSemicolon scope = new(codeBlocker); @@ -52,7 +52,7 @@ public void UsingStatementShouldProperlyOpenAndCloseScope() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -73,7 +73,7 @@ public void NestedScopesShouldMaintainProperIndentation() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -99,7 +99,7 @@ public void MultipleDisposeShouldNotThrowException() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); ScopeWithTrailingSemicolon scope = new(codeBlocker); // Act & Assert @@ -113,7 +113,7 @@ public void ScopeWithoutContentShouldStillFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act @@ -136,7 +136,7 @@ public void ScopeWithCustomIndentStringShouldWork() const string customIndent = " "; // Two spaces - using CodeBlocker codeBlocker = CodeBlocker.Create(customIndent); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(customIndent); // Act @@ -166,7 +166,7 @@ public void ScopeWithDisposedCodeBlockerShouldThrowException() { // Arrange - CodeBlocker codeBlocker = CodeBlocker.Create(); + CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); codeBlocker.Dispose(); // Act & Assert - Should throw when trying to use disposed CodeBlocker @@ -179,7 +179,7 @@ public void MixedWithRegularScopeShouldWork() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // Act - Mix Scope and ScopeWithTrailingSemicolon @@ -224,7 +224,7 @@ public void ScopeWithEmptyCustomIndentStringShouldWork() { // Arrange - using CodeBlocker codeBlocker = CodeBlocker.Create(string.Empty); + using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(string.Empty); // Act diff --git a/CodeBlocker.Test/TestCodeBlocker.cs b/CodeBlocker.Test/TestCodeBlocker.cs new file mode 100644 index 0000000..944e215 --- /dev/null +++ b/CodeBlocker.Test/TestCodeBlocker.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace CodeBlocker.Tests; + +using ktsu.CodeBlocker; + +/// +/// Factory used by the test suite in place of . +/// +/// +/// The assertions throughout these tests spell their expected output with CRLF line endings, which +/// only matched the writer's behaviour while it inherited from the +/// host — so the suite passed on Windows and failed on every other platform. Pinning the terminator +/// here makes those expectations true everywhere, and keeps the CRLF spelling in the expectations +/// (which is far more readable for the multi-line fixtures) rather than splicing +/// into every literal. +/// +/// The default terminator is covered separately by NewLineTests, which is the only place that +/// should call directly. +/// +/// +internal static class TestCodeBlocker +{ + /// Creates a CRLF-terminated with the default indent string. + /// A new . + internal static CodeBlocker CreateCrLf() => + CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.CrLf); + + /// Creates a CRLF-terminated with a custom indent string. + /// The string to use for indentation. + /// A new . + internal static CodeBlocker CreateCrLf(string indentString) => + CodeBlocker.Create(indentString, NewLines.CrLf); +} diff --git a/CodeBlocker/CodeBlocker.cs b/CodeBlocker/CodeBlocker.cs index 1602039..e554eb4 100644 --- a/CodeBlocker/CodeBlocker.cs +++ b/CodeBlocker/CodeBlocker.cs @@ -7,57 +7,108 @@ namespace ktsu.CodeBlocker; /// /// Class to create indented code blocks wrapped in braces. /// -/// -/// Create a new instance of . -/// -/// The to write to. -public class CodeBlocker(StringWriter stringWriter) : IDisposable +public class CodeBlocker : IDisposable { + /// The indent string used when none is specified: a single tab. + public const string DefaultIndentString = "\t"; + + private readonly StringWriter stringWriter; private bool disposedValue; private bool shouldDisposeStringWriter; - private IndentedTextWriter IndentedTextWriter { get; set; } = new(stringWriter, "\t"); + private IndentedTextWriter IndentedTextWriter { get; } /// /// Get the current indent string being used. /// - public string IndentString { get; private set; } = "\t"; + public string IndentString { get; } + + /// + /// 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. + /// + public string NewLineString { get; } + + /// + /// Create a new instance of . + /// + /// The to write to. + public CodeBlocker(StringWriter stringWriter) + : this(stringWriter, DefaultIndentString, NewLines.Host) + { + } /// /// Create a new instance of with a custom indent string. /// /// The to write to. /// The string to use for indentation. - public CodeBlocker(StringWriter stringWriter, string indentString) : this(stringWriter) + public CodeBlocker(StringWriter stringWriter, string indentString) + : this(stringWriter, indentString, NewLines.Host) { - IndentString = indentString; - IndentedTextWriter.Dispose(); // Dispose the default one - IndentedTextWriter = new IndentedTextWriter(stringWriter, indentString); } /// - /// Create a new instance of . + /// Create a new instance of with a custom indent string and line terminator. /// - /// A new instance of . - public static CodeBlocker Create() + /// The to write to. + /// The string to use for indentation. + /// + /// The line terminator to write at the end of every line. selects + /// . + /// + /// is . + public CodeBlocker(StringWriter stringWriter, string indentString, string newLineString) { -#pragma warning disable CA2000 // Dispose objects before losing scope - StringWriter will be disposed by CodeBlocker when shouldDisposeStringWriter is true - return new(new()) + ArgumentNullException.ThrowIfNull(stringWriter); + + // indentString is deliberately not null-checked: a null indent has always meant "no + // indentation" here, and CreateWithNullIndentStringShouldWork pins that behaviour. + newLineString ??= NewLines.Host; + + this.stringWriter = stringWriter; + IndentString = indentString; + NewLineString = newLineString; + + // The terminator is set on both writers rather than on IndentedTextWriter alone: its NewLine + // property forwards to the inner writer on modern targets, but CodeBlocker also ships for + // netstandard2.0, where the running framework supplies IndentedTextWriter and that forwarding + // is not guaranteed. Setting both is cheap and makes the behaviour identical everywhere. + stringWriter.NewLine = newLineString; + IndentedTextWriter = new IndentedTextWriter(stringWriter, indentString) { - shouldDisposeStringWriter = true + NewLine = newLineString }; -#pragma warning restore CA2000 // Dispose objects before losing scope } + /// + /// Create a new instance of . + /// + /// A new instance of . + public static CodeBlocker Create() => Create(DefaultIndentString, NewLines.Host); + /// /// 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) + public static CodeBlocker Create(string indentString) => Create(indentString, NewLines.Host); + + /// + /// Create a new instance of with a custom indent string and line terminator. + /// + /// The string to use for indentation. + /// The line terminator to write at the end of every line. + /// A new instance of . + public static CodeBlocker Create(string indentString, string newLineString) { #pragma warning disable CA2000 // Dispose objects before losing scope - StringWriter will be disposed by CodeBlocker when shouldDisposeStringWriter is true - return new(new(), indentString) + return new(new(), indentString, newLineString) { shouldDisposeStringWriter = true }; diff --git a/CodeBlocker/NewLines.cs b/CodeBlocker/NewLines.cs new file mode 100644 index 0000000..8834a24 --- /dev/null +++ b/CodeBlocker/NewLines.cs @@ -0,0 +1,28 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker; + +/// +/// The line terminators understands, named so that call sites read as +/// intent rather than as escape sequences. +/// +/// +/// Generated code that is committed to a repository has to be byte-identical no matter which +/// operating system produced it, so a generator should pick one of these explicitly rather than +/// inheriting the host's . +/// +public static class NewLines +{ + /// A single line feed, "\n". The conventional choice for reproducible output. + public const string Lf = "\n"; + + /// A carriage return followed by a line feed, "\r\n". + public const string CrLf = "\r\n"; + + /// + /// 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. + /// + public static string Host => System.Environment.NewLine; +} diff --git a/README.md b/README.md index 9d75c0a..1a11c4b 100644 --- a/README.md +++ b/README.md @@ -18,6 +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 - **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` - **Flexible API**: Write individual lines or entire code blocks with proper formatting - **Standard Output Support**: Works with StringWriter for flexible output handling @@ -151,6 +152,45 @@ using (new Scope(scopeCodeBlocker)) } ``` +### 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: + +```csharp +namespace CodeBlockerExample; + +using ktsu.CodeBlocker; + +internal class DeterministicExample +{ + public static string GenerateCode() + { + // Byte-identical on every platform. + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + + codeBlocker.WriteLine("public class Example"); + using (new Scope(codeBlocker)) + { + codeBlocker.WriteLine("public int Value { get; set; }"); + } + + return codeBlocker.ToString(); + } +} +``` + +The `NewLines` class names the usual choices: + +| Name | Value | Notes | +|------|-------|-------| +| `NewLines.Lf` | `"\n"` | The conventional choice for reproducible output | +| `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 | + +Any other string works too — the terminator is written verbatim. + ### Advanced Usage ```csharp @@ -211,6 +251,7 @@ The main class for building indented code blocks. |------|-------------| | `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, string indentString, string newLineString)` | As above, and pins the line terminator written at the end of every line | #### Properties @@ -218,6 +259,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 | #### Methods @@ -232,6 +274,7 @@ The main class for building indented code blocks. | `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(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 | ### `Scope` Class From 6b5e44cd7c2168e8c908d354c0f84c28f1befbf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 02:41:25 +0000 Subject: [PATCH 2/7] feat: accept any TextWriter, not just StringWriter [minor] IndentedTextWriter takes any TextWriter, so restricting CodeBlocker to StringWriter was self-imposed. It ruled out generating straight to a file, to a writer supplied by a build task, or to a test double - which matters for a generator emitting many files, where buffering each one in memory as a string is wasteful. The restriction existed because ToString() reads back from the captured StringWriter. ToString() now returns the buffered code when the writer is a StringWriter and the type name otherwise, rather than throwing: debuggers and diagnostics call ToString() freely, so throwing there would be worse than the fallback. IsBuffered tells callers which case they are in. The existing StringWriter constructors stay and delegate, so no caller has to change. Disposal is unchanged in substance: only a writer Create() made for itself is disposed, never a caller-supplied one - disposing the IndentedTextWriter does not reach the writer it wraps. Refs #82 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ --- CodeBlocker.Test/TextWriterTests.cs | 146 ++++++++++++++++++++++++++++ CodeBlocker/CodeBlocker.cs | 104 ++++++++++++++++---- README.md | 36 ++++++- 3 files changed, 268 insertions(+), 18 deletions(-) create mode 100644 CodeBlocker.Test/TextWriterTests.cs diff --git a/CodeBlocker.Test/TextWriterTests.cs b/CodeBlocker.Test/TextWriterTests.cs new file mode 100644 index 0000000..1f356fc --- /dev/null +++ b/CodeBlocker.Test/TextWriterTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace CodeBlocker.Tests; + +using System.Text; +using ktsu.CodeBlocker; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers building a over an arbitrary rather +/// than only over a . +/// +[TestClass] +public sealed class TextWriterTests +{ + /// + /// A that is not a , so it exercises the + /// unbuffered path, and that records whether it was disposed. + /// + private sealed class RecordingWriter : TextWriter + { + private readonly StringBuilder builder = new(); + + public bool Disposed { get; private set; } + + public override Encoding Encoding => Encoding.UTF8; + + public override void Write(char value) => builder.Append(value); + + public override string ToString() => builder.ToString(); + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } + + [TestMethod] + public void WritesIndentedOutputToAnArbitraryTextWriter() + { + using RecordingWriter target = new(); + + using (CodeBlocker codeBlocker = new(target, CodeBlocker.DefaultIndentString, NewLines.Lf)) + { + codeBlocker.WriteLine("class C"); + using Scope scope = new(codeBlocker); + codeBlocker.WriteLine("int x;"); + } + + Assert.AreEqual("class C\n{\n\tint x;\n}\n", target.ToString()); + } + + [TestMethod] + public void ACallerSuppliedWriterIsNotDisposed() + { + using RecordingWriter target = new(); + + using (CodeBlocker codeBlocker = new(target)) + { + codeBlocker.WriteLine("a"); + } + + Assert.IsFalse(target.Disposed, "CodeBlocker must not dispose a writer it did not create."); + } + + [TestMethod] + public void ACallerSuppliedStringWriterIsNotDisposed() + { + StringWriter target = new(); + + using (CodeBlocker codeBlocker = new(target)) + { + codeBlocker.WriteLine("a"); + } + + // A disposed StringWriter throws from Write; reaching this line means it is still usable. + target.Write("still open"); + Assert.IsTrue(target.ToString().EndsWith("still open", StringComparison.Ordinal)); + target.Dispose(); + } + + [TestMethod] + public void AWriterCreateOwnsIsDisposed() + { + CodeBlocker codeBlocker = CodeBlocker.Create(); + codeBlocker.WriteLine("a"); + codeBlocker.Dispose(); + + // 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()); + } + + [TestMethod] + public void IsBufferedIsTrueForStringWriterBackedInstances() + { + using CodeBlocker created = CodeBlocker.Create(); + using StringWriter stringWriter = new(); + using CodeBlocker overStringWriter = new(stringWriter); + + Assert.IsTrue(created.IsBuffered); + Assert.IsTrue(overStringWriter.IsBuffered); + } + + [TestMethod] + public void IsBufferedIsFalseForOtherWriters() + { + using RecordingWriter target = new(); + using CodeBlocker codeBlocker = new(target); + + Assert.IsFalse(codeBlocker.IsBuffered); + } + + [TestMethod] + public void ToStringReturnsTheTypeNameWhenThereIsNothingBuffered() + { + using RecordingWriter target = new(); + using CodeBlocker codeBlocker = new(target); + + codeBlocker.WriteLine("a"); + + // 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()); + } + + [TestMethod] + public void NullTextWriterThrows() => + Assert.ThrowsExactly(() => new CodeBlocker((TextWriter)null!)); + + [TestMethod] + public void IndentAndTerminatorApplyToAnArbitraryWriter() + { + using RecordingWriter target = new(); + using CodeBlocker codeBlocker = new(target, " ", NewLines.CrLf); + + codeBlocker.Indent(); + codeBlocker.WriteLine("x"); + + Assert.AreEqual(" x\r\n", target.ToString()); + Assert.AreEqual(" ", codeBlocker.IndentString); + Assert.AreEqual(NewLines.CrLf, codeBlocker.NewLineString); + } +} diff --git a/CodeBlocker/CodeBlocker.cs b/CodeBlocker/CodeBlocker.cs index e554eb4..a3c6265 100644 --- a/CodeBlocker/CodeBlocker.cs +++ b/CodeBlocker/CodeBlocker.cs @@ -12,9 +12,10 @@ public class CodeBlocker : IDisposable /// The indent string used when none is specified: a single tab. public const string DefaultIndentString = "\t"; - private readonly StringWriter stringWriter; + private readonly TextWriter writer; + private bool disposedValue; - private bool shouldDisposeStringWriter; + private bool shouldDisposeWriter; private IndentedTextWriter IndentedTextWriter { get; } @@ -34,12 +35,31 @@ public class CodeBlocker : IDisposable /// public string NewLineString { get; } + /// + /// Gets a value indicating whether can return the generated code. + /// + /// + /// True when this instance writes to a — which is always the case for + /// instances from . A built over some other + /// streams straight through to it and keeps no copy, so the generated + /// code has to be read from that writer instead. + /// + public bool IsBuffered => writer is StringWriter; + + /// + /// The writer as a when it is one, otherwise . + /// Only a buffers what was written, so this is what lets + /// hand the generated code back. Kept as a property rather than a field + /// so there is exactly one writer reference to own and dispose. + /// + private StringWriter? BufferedWriter => writer as StringWriter; + /// /// Create a new instance of . /// /// The to write to. public CodeBlocker(StringWriter stringWriter) - : this(stringWriter, DefaultIndentString, NewLines.Host) + : this((TextWriter)stringWriter, DefaultIndentString, NewLines.Host) { } @@ -49,7 +69,7 @@ public CodeBlocker(StringWriter stringWriter) /// The to write to. /// The string to use for indentation. public CodeBlocker(StringWriter stringWriter, string indentString) - : this(stringWriter, indentString, NewLines.Host) + : this((TextWriter)stringWriter, indentString, NewLines.Host) { } @@ -62,16 +82,55 @@ public CodeBlocker(StringWriter stringWriter, string indentString) /// The line terminator to write at the end of every line. selects /// . /// - /// is . public CodeBlocker(StringWriter stringWriter, string indentString, string newLineString) + : this((TextWriter)stringWriter, indentString, newLineString) + { + } + + /// + /// Create a new instance of over any . + /// + /// The to write to. + /// + /// The writer is not disposed by — whoever created it owns it. Only the + /// that makes for itself is disposed here. + /// + public CodeBlocker(TextWriter writer) + : this(writer, DefaultIndentString, NewLines.Host) + { + } + + /// + /// Create a new instance of over any with a + /// custom indent string. + /// + /// The to write to. + /// The string to use for indentation. + public CodeBlocker(TextWriter writer, string indentString) + : this(writer, indentString, NewLines.Host) { - ArgumentNullException.ThrowIfNull(stringWriter); + } + + /// + /// Create a new instance of over any with a + /// custom indent string and line terminator. + /// + /// The to write to. + /// 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) + { + ArgumentNullException.ThrowIfNull(writer); // indentString is deliberately not null-checked: a null indent has always meant "no // indentation" here, and CreateWithNullIndentStringShouldWork pins that behaviour. newLineString ??= NewLines.Host; - this.stringWriter = stringWriter; + this.writer = writer; IndentString = indentString; NewLineString = newLineString; @@ -79,8 +138,8 @@ public CodeBlocker(StringWriter stringWriter, string indentString, string newLin // property forwards to the inner writer on modern targets, but CodeBlocker also ships for // netstandard2.0, where the running framework supplies IndentedTextWriter and that forwarding // is not guaranteed. Setting both is cheap and makes the behaviour identical everywhere. - stringWriter.NewLine = newLineString; - IndentedTextWriter = new IndentedTextWriter(stringWriter, indentString) + writer.NewLine = newLineString; + IndentedTextWriter = new IndentedTextWriter(writer, indentString) { NewLine = newLineString }; @@ -107,10 +166,10 @@ public CodeBlocker(StringWriter stringWriter, string indentString, string newLin /// A new instance of . public static CodeBlocker Create(string indentString, string newLineString) { -#pragma warning disable CA2000 // Dispose objects before losing scope - StringWriter will be disposed by CodeBlocker when shouldDisposeStringWriter is true - return new(new(), indentString, newLineString) +#pragma warning disable CA2000 // Dispose objects before losing scope - the StringWriter is disposed by CodeBlocker because shouldDisposeWriter is true + return new(new StringWriter(), indentString, newLineString) { - shouldDisposeStringWriter = true + shouldDisposeWriter = true }; #pragma warning restore CA2000 // Dispose objects before losing scope } @@ -118,8 +177,17 @@ public static CodeBlocker Create(string indentString, string newLineString) /// /// Get the string representation of the code. /// - /// The string representation of the code. - public override string ToString() => stringWriter.ToString(); + /// + /// The code written so far when this instance is buffered — see — and + /// otherwise the type name, as would give. + /// + /// + /// A over a non-buffering (a file, a network + /// stream) keeps no copy of what it wrote, so there is nothing to hand back; read the generated + /// code from that writer instead. This returns the type name rather than throwing so that + /// diagnostics and debuggers, which call freely, stay safe. + /// + public override string ToString() => BufferedWriter?.ToString() ?? base.ToString() ?? nameof(CodeBlocker); /// /// Write a line of code without indentation. @@ -172,12 +240,14 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - if (shouldDisposeStringWriter) + if (shouldDisposeWriter) { - stringWriter.Dispose(); - shouldDisposeStringWriter = false; + writer.Dispose(); + shouldDisposeWriter = false; } + // Disposing the IndentedTextWriter does not dispose the writer it wraps, so a + // caller-supplied writer survives this and stays theirs to dispose. IndentedTextWriter.Dispose(); } diff --git a/README.md b/README.md index 1a11c4b..ed28def 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ CodeBlocker is a specialized utility built on top of `IndentedTextWriter` that s - **Configurable Line Endings**: Pin the line terminator so the same calls produce byte-identical output on every platform - **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` - **Flexible API**: Write individual lines or entire code blocks with proper formatting -- **Standard Output Support**: Works with StringWriter for flexible output handling +- **Any TextWriter**: Buffer into a `StringWriter`, or stream straight to a file or any other `TextWriter` - **Cross-Platform**: Supports .NET 10.0, 9.0, 8.0, 7.0, 6.0, 5.0, .NET Standard 2.0 and 2.1 - **Lightweight**: Minimal dependencies, built on top of `ktsu.ScopedAction` for robust scope management - **Well-Tested**: Includes comprehensive unit and integration tests @@ -152,6 +152,36 @@ using (new Scope(scopeCodeBlocker)) } ``` +### Writing Somewhere Other Than a String + +`CodeBlocker.Create()` buffers into a `StringWriter` it owns, which is what makes `ToString()` able to hand the code back. You can instead give it any `TextWriter` — a file, a `TextWriter` handed to you by a build task, a test double: + +```csharp +namespace CodeBlockerExample; + +using ktsu.CodeBlocker; + +internal class FileExample +{ + public static void GenerateToFile(string path) + { + using StreamWriter file = new(path); + using CodeBlocker codeBlocker = new(file, CodeBlocker.DefaultIndentString, NewLines.Lf); + + codeBlocker.WriteLine("public class Example"); + using (new Scope(codeBlocker)) + { + codeBlocker.WriteLine("public int Value { get; set; }"); + } + } +} +``` + +Two things to know: + +- **A writer you supply stays yours.** `CodeBlocker.Dispose()` disposes only the `StringWriter` that `Create()` made for itself; it never disposes a writer you passed in. +- **`ToString()` only works when buffered.** A `CodeBlocker` over a `StreamWriter` keeps no copy of what it wrote, so `ToString()` returns the type name rather than the code — check `IsBuffered` if you need to know which case you are in. Read the generated code from your own writer instead. + ### 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. @@ -252,6 +282,9 @@ The main class for building indented code blocks. | `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, 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, string indentString, string newLineString)` | As above, and pins the line terminator | #### Properties @@ -260,6 +293,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 | +| `IsBuffered` | `bool` | Whether `ToString()` can return the generated code, i.e. whether the underlying writer is a `StringWriter` | #### Methods From 8ee526e88d3acc9acbd71e685dc737dbedb43ef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 02:45:23 +0000 Subject: [PATCH 3/7] feat: add scope and preprocessor-directive helpers [minor] Scope and ScopeWithTrailingSemicolon covered braces; every generator built on CodeBlocker then re-implemented the same handful of other shapes on top of raw WriteLine, and got to invent its own spacing conventions doing it. Add DelimiterScope as the shared base, ParenScope and BracketScope over it, IndentScope for continuation lines, and RegionScope, DirectiveScope and PragmaScope for the directive pairs. The directive scopes do not indent their body, because a directive does not nest code. Balance is the point for the pragma in particular: a suppression left open leaks into the rest of the file and is tedious to trace back. Add the file-preamble helpers next to them - the auto-generated marker, the nullable context, the file-scoped namespace, and the using directives - each owning the blank line that conventionally follows it, so a preamble is spaced consistently no matter which parts a generator emits. Scope and ScopeWithTrailingSemicolon are deliberately left as they are rather than reparented onto DelimiterScope: their tests pin NullReferenceException for a null CodeBlocker, and the new types validate properly instead. Refs #83 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ --- .../CodeBlockerExtensionsTests.cs | 141 +++++++++++ CodeBlocker.Test/ScopesTests.cs | 226 ++++++++++++++++++ CodeBlocker/CodeBlockerExtensions.cs | 135 +++++++++++ CodeBlocker/Scopes.cs | 187 +++++++++++++++ README.md | 157 ++++++++++++ 5 files changed, 846 insertions(+) create mode 100644 CodeBlocker.Test/CodeBlockerExtensionsTests.cs create mode 100644 CodeBlocker.Test/ScopesTests.cs create mode 100644 CodeBlocker/CodeBlockerExtensions.cs create mode 100644 CodeBlocker/Scopes.cs diff --git a/CodeBlocker.Test/CodeBlockerExtensionsTests.cs b/CodeBlocker.Test/CodeBlockerExtensionsTests.cs new file mode 100644 index 0000000..29a7331 --- /dev/null +++ b/CodeBlocker.Test/CodeBlockerExtensionsTests.cs @@ -0,0 +1,141 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace CodeBlocker.Tests; + +using ktsu.CodeBlocker; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers the file-level preamble helpers. +/// +[TestClass] +public sealed class CodeBlockerExtensionsTests +{ + private static CodeBlocker Create() => CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + + [TestMethod] + public void AutoGeneratedHeaderWithoutCopyrightIsJustTheMarker() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker.WriteAutoGeneratedHeader(); + + Assert.AreEqual("// \n\n", codeBlocker.ToString()); + } + + [TestMethod] + public void AutoGeneratedHeaderPutsTheCopyrightAboveTheMarker() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker.WriteAutoGeneratedHeader("Copyright (c) 2023-2026 ktsu-dev contributors"); + + Assert.AreEqual( + "// Copyright (c) 2023-2026 ktsu-dev contributors\n// \n\n", + codeBlocker.ToString()); + } + + [TestMethod] + public void NullableDirectivesAreWrittenVerbatim() + { + using CodeBlocker enable = Create(); + using CodeBlocker disable = Create(); + + enable.WriteNullableEnable(); + disable.WriteNullableDisable(); + + Assert.AreEqual("#nullable enable\n", enable.ToString()); + Assert.AreEqual("#nullable disable\n", disable.ToString()); + } + + [TestMethod] + public void FileScopedNamespaceIsFollowedByABlankLine() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker.WriteFileScopedNamespace("Contoso.Widgets"); + + Assert.AreEqual("namespace Contoso.Widgets;\n\n", codeBlocker.ToString()); + } + + [TestMethod] + public void AnEmptyNamespaceWritesNothing() + { + using CodeBlocker nullNamespace = Create(); + using CodeBlocker emptyNamespace = Create(); + + nullNamespace.WriteFileScopedNamespace(null); + emptyNamespace.WriteFileScopedNamespace(string.Empty); + + Assert.AreEqual(string.Empty, nullNamespace.ToString()); + Assert.AreEqual(string.Empty, emptyNamespace.ToString()); + } + + [TestMethod] + public void UsingsAreWrittenOnePerLineAndFollowedByABlankLine() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker.WriteUsings("System", "System.Collections.Generic"); + + Assert.AreEqual("using System;\nusing System.Collections.Generic;\n\n", codeBlocker.ToString()); + } + + [TestMethod] + public void UsingsAreWrittenVerbatimSoAliasesAndStaticImportsWork() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker.WriteUsings("static System.Math", "Text = System.Text"); + + Assert.AreEqual("using static System.Math;\nusing Text = System.Text;\n\n", codeBlocker.ToString()); + } + + [TestMethod] + public void NoUsingsWritesNothingIncludingTheBlankLine() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker.WriteUsings([]); + + Assert.AreEqual(string.Empty, codeBlocker.ToString()); + } + + [TestMethod] + public void APreambleComposesWithConsistentSpacing() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker + .WriteAutoGeneratedHeader("Copyright (c) 2023-2026 ktsu-dev contributors") + .WriteNullableEnable() + .WriteFileScopedNamespace("Contoso.Widgets") + .WriteUsings("System"); + + Assert.AreEqual( + """ + // Copyright (c) 2023-2026 ktsu-dev contributors + // + + #nullable enable + namespace Contoso.Widgets; + + using System; + + + """.ReplaceLineEndings("\n"), + codeBlocker.ToString()); + } + + [TestMethod] + public void NullArgumentsThrow() + { + using CodeBlocker codeBlocker = Create(); + + Assert.ThrowsExactly(() => CodeBlockerExtensions.WriteAutoGeneratedHeader(null!)); + Assert.ThrowsExactly(() => CodeBlockerExtensions.WriteNullableEnable(null!)); + Assert.ThrowsExactly(() => CodeBlockerExtensions.WriteNullableDisable(null!)); + Assert.ThrowsExactly(() => CodeBlockerExtensions.WriteFileScopedNamespace(null!, "N")); + Assert.ThrowsExactly(() => codeBlocker.WriteUsings((IEnumerable)null!)); + } +} diff --git a/CodeBlocker.Test/ScopesTests.cs b/CodeBlocker.Test/ScopesTests.cs new file mode 100644 index 0000000..51e9788 --- /dev/null +++ b/CodeBlocker.Test/ScopesTests.cs @@ -0,0 +1,226 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace CodeBlocker.Tests; + +using ktsu.CodeBlocker; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers the scopes added alongside : delimiters, bare indentation, and the +/// preprocessor directive pairs. +/// +/// +/// Every fixture pins the line terminator so the expectations are exact bytes rather than +/// host-dependent ones. +/// +[TestClass] +public sealed class ScopesTests +{ + private static CodeBlocker Create() => CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + + [TestMethod] + public void ParenScopeWritesParenthesesAndIndentsTheBody() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker.WriteLine("Method"); + using (new ParenScope(codeBlocker)) + { + codeBlocker.WriteLine("first,"); + codeBlocker.WriteLine("second"); + } + + Assert.AreEqual("Method\n(\n\tfirst,\n\tsecond\n)\n", codeBlocker.ToString()); + } + + [TestMethod] + public void BracketScopeWritesBracketsAndIndentsTheBody() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker.WriteLine("int[] values ="); + using (new BracketScope(codeBlocker)) + { + codeBlocker.WriteLine("1,"); + codeBlocker.WriteLine("2,"); + } + + Assert.AreEqual("int[] values =\n[\n\t1,\n\t2,\n]\n", codeBlocker.ToString()); + } + + [TestMethod] + public void IndentScopeIndentsWithoutWritingDelimiters() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker.WriteLine("public class Repository"); + using (new IndentScope(codeBlocker)) + { + codeBlocker.WriteLine("where T : class"); + } + + codeBlocker.WriteLine("{ }"); + + Assert.AreEqual("public class Repository\n\twhere T : class\n{ }\n", codeBlocker.ToString()); + } + + [TestMethod] + public void RegionScopeWrapsTheBodyWithoutIndentingIt() + { + using CodeBlocker codeBlocker = Create(); + + using (new RegionScope(codeBlocker, "Generated members")) + { + codeBlocker.WriteLine("int x;"); + } + + Assert.AreEqual("#region Generated members\nint x;\n#endregion\n", codeBlocker.ToString()); + } + + [TestMethod] + public void RegionScopeWithoutANameOmitsTheTrailingSpace() + { + using CodeBlocker codeBlocker = Create(); + + using (new RegionScope(codeBlocker, string.Empty)) + { + codeBlocker.WriteLine("int x;"); + } + + Assert.AreEqual("#region\nint x;\n#endregion\n", codeBlocker.ToString()); + } + + [TestMethod] + public void DirectiveScopeWrapsTheBodyInAConditional() + { + using CodeBlocker codeBlocker = Create(); + + using (new DirectiveScope(codeBlocker, "NET8_0_OR_GREATER")) + { + codeBlocker.WriteLine("Span buffer = stackalloc char[16];"); + } + + Assert.AreEqual( + "#if NET8_0_OR_GREATER\nSpan buffer = stackalloc char[16];\n#endif\n", + codeBlocker.ToString()); + } + + [TestMethod] + public void PragmaScopeDisablesAndRestoresTheSameWarnings() + { + using CodeBlocker codeBlocker = Create(); + + using (new PragmaScope(codeBlocker, "CS1591")) + { + codeBlocker.WriteLine("public int Undocumented;"); + } + + Assert.AreEqual( + "#pragma warning disable CS1591\npublic int Undocumented;\n#pragma warning restore CS1591\n", + codeBlocker.ToString()); + } + + [TestMethod] + public void PragmaScopeJoinsSeveralWarnings() + { + using CodeBlocker codeBlocker = Create(); + + using (new PragmaScope(codeBlocker, ["CS1591", "CA1707"])) + { + codeBlocker.WriteLine("public int Undocumented_Name;"); + } + + Assert.AreEqual( + "#pragma warning disable CS1591, CA1707\npublic int Undocumented_Name;\n#pragma warning restore CS1591, CA1707\n", + codeBlocker.ToString()); + } + + [TestMethod] + public void ScopesOfMixedKindsNestCorrectly() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker.WriteLine("class C"); + using (new Scope(codeBlocker)) + { + using (new RegionScope(codeBlocker, "Ctors")) + { + codeBlocker.WriteLine("public C"); + using (new ParenScope(codeBlocker)) + { + codeBlocker.WriteLine("int a,"); + codeBlocker.WriteLine("int b"); + } + + codeBlocker.WriteLine("{ }"); + } + } + + Assert.AreEqual( + """ + class C + { + #region Ctors + public C + ( + int a, + int b + ) + { } + #endregion + } + + """.ReplaceLineEndings("\n"), + codeBlocker.ToString()); + } + + [TestMethod] + public void TheClosingDelimiterIsWrittenEvenWhenTheBodyThrows() + { + using CodeBlocker codeBlocker = Create(); + + try + { + using (new ParenScope(codeBlocker)) + { + codeBlocker.WriteLine("arg"); + throw new InvalidOperationException("boom"); + } + } + catch (InvalidOperationException) + { + // Expected: the scope's disposal still has to close the delimiter and restore the indent. + } + + Assert.AreEqual("(\n\targ\n)\n", codeBlocker.ToString()); + Assert.AreEqual(0, codeBlocker.CurrentIndent); + } + + [TestMethod] + public void ScopesRestoreTheIndentLevelTheyFound() + { + using CodeBlocker codeBlocker = Create(); + + codeBlocker.Indent(); + codeBlocker.Indent(); + int before = codeBlocker.CurrentIndent; + + using (new BracketScope(codeBlocker)) + { + Assert.AreEqual(before + 1, codeBlocker.CurrentIndent); + } + + Assert.AreEqual(before, codeBlocker.CurrentIndent); + } + + [TestMethod] + public void NullCodeBlockerThrowsArgumentNullException() + { + Assert.ThrowsExactly(() => new ParenScope(null!)); + Assert.ThrowsExactly(() => new BracketScope(null!)); + Assert.ThrowsExactly(() => new IndentScope(null!)); + Assert.ThrowsExactly(() => new RegionScope(null!, "r")); + Assert.ThrowsExactly(() => new DirectiveScope(null!, "C")); + Assert.ThrowsExactly(() => new PragmaScope(null!, "CS1591")); + } +} diff --git a/CodeBlocker/CodeBlockerExtensions.cs b/CodeBlocker/CodeBlockerExtensions.cs new file mode 100644 index 0000000..d13be91 --- /dev/null +++ b/CodeBlocker/CodeBlockerExtensions.cs @@ -0,0 +1,135 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker; + +/// +/// Helpers for the file-level shapes that recur in generated C#: the auto-generated preamble, the +/// nullable context, the namespace declaration, and the using directives. +/// +/// +/// Each helper owns the blank line that conventionally follows it, so a preamble assembled from +/// them is spaced consistently no matter which parts a given generator emits. +/// +public static class CodeBlockerExtensions +{ + /// + /// Writes the preamble that marks a file as generated, followed by a blank line. + /// + /// The to write to. + /// + /// An optional copyright line written above the marker, without its // prefix. Pass the + /// same text the consuming repository's file header template uses. + /// + /// The same , for chaining. + /// is . + public static CodeBlocker WriteAutoGeneratedHeader(this CodeBlocker codeBlocker, string? copyright = null) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + if (!string.IsNullOrEmpty(copyright)) + { + codeBlocker.WriteLine($"// {copyright}"); + } + + codeBlocker.WriteLine("// "); + codeBlocker.NewLine(); + return codeBlocker; + } + + /// + /// Writes #nullable enable. + /// + /// The to write to. + /// The same , for chaining. + /// is . + public static CodeBlocker WriteNullableEnable(this CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + codeBlocker.WriteLine("#nullable enable"); + return codeBlocker; + } + + /// + /// Writes #nullable disable. + /// + /// The to write to. + /// The same , for chaining. + /// is . + public static CodeBlocker WriteNullableDisable(this CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + codeBlocker.WriteLine("#nullable disable"); + return codeBlocker; + } + + /// + /// Writes a file-scoped namespace declaration followed by a blank line, or nothing at all when + /// no namespace is given. + /// + /// The to write to. + /// The namespace, for example Contoso.Widgets. + /// The same , for chaining. + /// is . + public static CodeBlocker WriteFileScopedNamespace(this CodeBlocker codeBlocker, string? namespaceName) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + if (string.IsNullOrEmpty(namespaceName)) + { + return codeBlocker; + } + + codeBlocker.WriteLine($"namespace {namespaceName};"); + codeBlocker.NewLine(); + return codeBlocker; + } + + /// + /// Writes one using directive per entry followed by a blank line, or nothing at all when + /// the sequence is empty. + /// + /// The to write to. + /// + /// The namespaces to import, without the using keyword or the trailing semicolon. An + /// entry may carry a modifier or alias — static System.Math, Alias = System.Text — + /// because it is written verbatim after the keyword. + /// + /// The same , for chaining. + /// + /// or is . + /// + public static CodeBlocker WriteUsings(this CodeBlocker codeBlocker, IEnumerable usings) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + ArgumentNullException.ThrowIfNull(usings); + + bool wroteAny = false; + foreach (string usingDirective in usings) + { + codeBlocker.WriteLine($"using {usingDirective};"); + wroteAny = true; + } + + if (wroteAny) + { + codeBlocker.NewLine(); + } + + return codeBlocker; + } + + /// + /// Writes one using directive per entry followed by a blank line, or nothing at all when + /// no namespaces are given. + /// + /// The to write to. + /// The namespaces to import. + /// The same , for chaining. + /// + /// or is . + /// + public static CodeBlocker WriteUsings(this CodeBlocker codeBlocker, params string[] usings) => + codeBlocker.WriteUsings((IEnumerable)usings); +} diff --git a/CodeBlocker/Scopes.cs b/CodeBlocker/Scopes.cs new file mode 100644 index 0000000..a6f2d5e --- /dev/null +++ b/CodeBlocker/Scopes.cs @@ -0,0 +1,187 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker; + +using ktsu.ScopedAction; + +/// +/// Base class for a scope that writes an opening delimiter, indents the body, and writes a closing +/// delimiter when disposed. +/// +/// +/// Both delimiters are written on their own line, so a scope always reads as a block: +/// +/// ( +/// arg +/// ) +/// +/// +/// The parent . +/// The delimiter written before the body. +/// The delimiter written after the body. +public class DelimiterScope(CodeBlocker codeBlocker, string open, string close) + : ScopedAction(onOpen: () => Begin(codeBlocker, open), onClose: () => End(codeBlocker, close)) +{ + /// + /// Writes the opening delimiter and increases the indent level. + /// + /// The parent . + /// The delimiter to write. + /// is . + protected static void Begin(CodeBlocker codeBlocker, string open) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + codeBlocker.WriteLine(open); + codeBlocker.Indent(); + } + + /// + /// Decreases the indent level and writes the closing delimiter. + /// + /// The parent . + /// The delimiter to write. + /// is . + protected static void End(CodeBlocker codeBlocker, string close) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + codeBlocker.Outdent(); + codeBlocker.WriteLine(close); + } +} + +/// +/// Class to create a parenthesised scope in a code block, for argument lists long enough to break +/// across lines. +/// +/// +/// Create a new instance of . +/// +/// The parent . +public class ParenScope(CodeBlocker codeBlocker) : DelimiterScope(codeBlocker, "(", ")"); + +/// +/// Class to create a bracketed scope in a code block, for collection expressions and array +/// initialisers long enough to break across lines. +/// +/// +/// Create a new instance of . +/// +/// The parent . +public class BracketScope(CodeBlocker codeBlocker) : DelimiterScope(codeBlocker, "[", "]"); + +/// +/// Class to indent a run of lines without writing any delimiters, for continuation lines such as +/// generic constraints or a chained call. +/// +/// +/// Create a new instance of . +/// +/// The parent . +public class IndentScope(CodeBlocker codeBlocker) + : ScopedAction(onOpen: () => Begin(codeBlocker), onClose: () => End(codeBlocker)) +{ + private static void Begin(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + codeBlocker.Indent(); + } + + private static void End(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + codeBlocker.Outdent(); + } +} + +/// +/// Class to wrap a run of lines in #region and #endregion. +/// +/// +/// Create a new instance of . The directives are written at the current +/// indent level, and the body is not indented further — a region does not nest code. +/// +/// The parent . +/// The region name. +public class RegionScope(CodeBlocker codeBlocker, string name) + : ScopedAction(onOpen: () => Begin(codeBlocker, name), onClose: () => End(codeBlocker)) +{ + private static void Begin(CodeBlocker codeBlocker, string name) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + codeBlocker.WriteLine(string.IsNullOrEmpty(name) ? "#region" : $"#region {name}"); + } + + private static void End(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + codeBlocker.WriteLine("#endregion"); + } +} + +/// +/// Class to wrap a run of lines in #if and #endif. +/// +/// +/// Create a new instance of . The directives are written at the current +/// indent level, and the body is not indented further — a conditional compilation directive does not +/// nest code. +/// +/// The parent . +/// The condition expression, for example NET8_0_OR_GREATER. +public class DirectiveScope(CodeBlocker codeBlocker, string condition) + : ScopedAction(onOpen: () => Begin(codeBlocker, condition), onClose: () => End(codeBlocker)) +{ + private static void Begin(CodeBlocker codeBlocker, string condition) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + codeBlocker.WriteLine($"#if {condition}"); + } + + private static void End(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + codeBlocker.WriteLine("#endif"); + } +} + +/// +/// Class to wrap a run of lines in #pragma warning disable and +/// #pragma warning restore for the same warnings. +/// +/// +/// An unbalanced suppression leaks into the rest of the file and is tedious to trace back, so +/// pairing the two directives in a scope is worth more here than the line saving. The directives are +/// written at the current indent level, and the body is not indented further. +/// +/// The parent . +/// +/// The warning identifiers to suppress, written verbatim after the directive — either a single +/// identifier such as CS1591 or a comma-separated list. +/// +public class PragmaScope(CodeBlocker codeBlocker, string warnings) + : ScopedAction(onOpen: () => Begin(codeBlocker, warnings), onClose: () => End(codeBlocker, warnings)) +{ + /// + /// Create a new instance of suppressing several warnings. + /// + /// The parent . + /// The warning identifiers to suppress. + public PragmaScope(CodeBlocker codeBlocker, IEnumerable warnings) + : this(codeBlocker, string.Join(", ", warnings ?? [])) + { + } + + private static void Begin(CodeBlocker codeBlocker, string warnings) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + codeBlocker.WriteLine($"#pragma warning disable {warnings}"); + } + + private static void End(CodeBlocker codeBlocker, string warnings) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + codeBlocker.WriteLine($"#pragma warning restore {warnings}"); + } +} diff --git a/README.md b/README.md index ed28def..21da796 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ CodeBlocker is a specialized utility built on top of `IndentedTextWriter` that s - **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 - **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 - **Flexible API**: Write individual lines or entire code blocks with proper formatting - **Any TextWriter**: Buffer into a `StringWriter`, or stream straight to a file or any other `TextWriter` - **Cross-Platform**: Supports .NET 10.0, 9.0, 8.0, 7.0, 6.0, 5.0, .NET Standard 2.0 and 2.1 @@ -152,6 +154,109 @@ using (new Scope(scopeCodeBlocker)) } ``` +### More Than Braces + +`Scope` and `ScopeWithTrailingSemicolon` cover braces. The same pattern covers the other shapes that recur in generated code, and every one of them is balanced by disposal — so an unbalanced `#pragma warning disable` or a stray `#endregion` is not something you can leave behind. + +| Scope | Opens with | Closes with | Indents the body | +|-------|-----------|-------------|------------------| +| `Scope` | `{` | `}` | Yes | +| `ScopeWithTrailingSemicolon` | `{` | `};` | Yes | +| `ParenScope` | `(` | `)` | Yes | +| `BracketScope` | `[` | `]` | Yes | +| `IndentScope` | — | — | Yes | +| `RegionScope` | `#region name` | `#endregion` | No | +| `DirectiveScope` | `#if condition` | `#endif` | No | +| `PragmaScope` | `#pragma warning disable …` | `#pragma warning restore …` | No | + +The three directive scopes do not indent, because a directive does not nest code. `DelimiterScope` is the shared base if you need a pair of delimiters the library does not name. + +```csharp +namespace CodeBlockerExample; + +using ktsu.CodeBlocker; + +internal class ScopesExample +{ + public static string GenerateCode() + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + + codeBlocker.WriteLine("public class Example"); + using (new Scope(codeBlocker)) + { + using (new RegionScope(codeBlocker, "Constructors")) + { + codeBlocker.WriteLine("public Example"); + using (new ParenScope(codeBlocker)) + { + codeBlocker.WriteLine("int first,"); + codeBlocker.WriteLine("int second"); + } + + codeBlocker.WriteLine("{ }"); + } + + using (new PragmaScope(codeBlocker, "CS1591")) + { + codeBlocker.WriteLine("public int Undocumented;"); + } + } + + return codeBlocker.ToString(); + } +} +``` + +Produces: + +```csharp +public class Example +{ + #region Constructors + public Example + ( + int first, + int second + ) + { } + #endregion + #pragma warning disable CS1591 + public int Undocumented; + #pragma warning restore CS1591 +} +``` + +### File Preambles + +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); + +codeBlocker + .WriteAutoGeneratedHeader("Copyright (c) 2023-2026 ktsu-dev contributors") + .WriteNullableEnable() + .WriteFileScopedNamespace("Contoso.Widgets") + .WriteUsings("System", "System.Collections.Generic"); +``` + +Produces: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors +// + +#nullable enable +namespace Contoso.Widgets; + +using System; +using System.Collections.Generic; + +``` + +`WriteFileScopedNamespace` writes nothing for a null or empty namespace, and `WriteUsings` writes nothing — not even the blank line — for an empty sequence, so a generator can call them unconditionally. + ### Writing Somewhere Other Than a String `CodeBlocker.Create()` buffers into a `StringWriter` it owns, which is what makes `ToString()` able to hand the code back. You can instead give it any `TextWriter` — a file, a `TextWriter` handed to you by a build task, a test double: @@ -334,6 +439,58 @@ Helper class for managing indentation scopes with automatic brace handling. Buil - **Exception Safety**: Guaranteed cleanup even if exceptions occur within the scope - **Resource Management**: Built on `ktsu.ScopedAction` for reliable resource handling +### `CodeBlockerExtensions` Class + +File-level preamble helpers. Each returns the same `CodeBlocker` so calls chain. + +| Name | Description | +|------|-------------| +| `WriteAutoGeneratedHeader(string? copyright = null)` | Writes an optional copyright line, then `// `, then a blank line | +| `WriteNullableEnable()` | Writes `#nullable enable` | +| `WriteNullableDisable()` | Writes `#nullable disable` | +| `WriteFileScopedNamespace(string? namespaceName)` | Writes `namespace X;` and a blank line, or nothing when the namespace is null or empty | +| `WriteUsings(IEnumerable usings)` | Writes one `using X;` per entry and a blank line, or nothing when empty | +| `WriteUsings(params string[] usings)` | As above | + +### `NewLines` Class + +Named line terminators. See [Line Endings](#line-endings). + +| Name | Value | +|------|-------| +| `Lf` | `"\n"` | +| `CrLf` | `"\r\n"` | +| `Host` | `Environment.NewLine` | + +### `DelimiterScope` Class + +Base class for a scope that writes an opening delimiter, indents the body, and writes a closing delimiter on disposal. `ParenScope` and `BracketScope` derive from it; derive your own for a delimiter pair the library does not name. + +#### Constructor + +| Name | Description | +|------|-------------| +| `DelimiterScope(CodeBlocker codeBlocker, string open, string close)` | Creates a scope writing `open` before the body and `close` after it | + +### `ParenScope`, `BracketScope`, `IndentScope` Classes + +| Name | Description | +|------|-------------| +| `ParenScope(CodeBlocker codeBlocker)` | Wraps the body in `(` and `)`, indenting it | +| `BracketScope(CodeBlocker codeBlocker)` | Wraps the body in `[` and `]`, indenting it | +| `IndentScope(CodeBlocker codeBlocker)` | Indents the body without writing any delimiters | + +### `RegionScope`, `DirectiveScope`, `PragmaScope` Classes + +Preprocessor directive pairs. None of them indents the body. + +| Name | Description | +|------|-------------| +| `RegionScope(CodeBlocker codeBlocker, string name)` | Wraps the body in `#region name` and `#endregion`; the name may be empty | +| `DirectiveScope(CodeBlocker codeBlocker, string condition)` | Wraps the body in `#if condition` and `#endif` | +| `PragmaScope(CodeBlocker codeBlocker, string warnings)` | Wraps the body in `#pragma warning disable`/`restore` for the same warnings | +| `PragmaScope(CodeBlocker codeBlocker, IEnumerable warnings)` | As above, joining the identifiers with `, ` | + ### `ScopeWithTrailingSemicolon` Class Variant of `Scope` that appends a semicolon after the closing brace. Useful for code generation scenarios like C/C++ enum or struct declarations where a trailing semicolon is required. From d2d33361c74d2c6c48934e7890e30bf707578ffb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 03:06:52 +0000 Subject: [PATCH 4/7] feat: add the C# template object model [minor] ktsu.Semantics built a declarative C# syntax object model on top of CodeBlocker and it turned out to be the most reusable part of that repository's generator stack: ~500 lines with no reference to physics, quantities or anything else specific to it. It belongs here, where any generator can use it. Ported as ktsu.CodeBlocker.Templates in the same package, made public, documented, and given per-template render tests plus a golden test over a file exercising every template kind - whose expected text has been compiled as C# to confirm it is valid. The port could not ship as it stood. Rendering a representative file showed the model only ever produced valid C# for the narrow shapes Semantics happens to use, so the fixes tracked separately land with it: - Types close with "}" rather than "};", and the kind - class, struct, interface, record, record struct, enum - comes from a TypeKind rather than from free text in Keywords. - A body fragment is spliced line by line and re-indented to where it lands. It used to be written as one string, so only its first line picked up the surrounding indent and everything after it sat flush against the left margin. - CodeBlocker.NewLine() writes through IndentedTextWriter.WriteLineNoTabs, which does not re-arm the writer's pending-tab flag; every place that used it to terminate an open declaration line was silently losing the indent of whatever came next. Those now use WriteLine(). - Methods and constructors no longer implement the parameter list and body twice. That divergence hid a bug: the method's empty-body branch tested a line count that can never be zero, so a method with an empty body rendered with neither body nor semicolon. - Accessors are data - AccessorTemplate with Auto/Expression/Block and an optional modifier - rather than callbacks compared by reference. A caller-supplied body is no longer mistaken for an automatic accessor, and "private set" is expressible. A property with no accessors at all now throws instead of rendering "int Value;", which is a field. - Added: operators and implicit/explicit conversions, generic parameters and constraints on types and methods, enum and interface declarations, positional records, and constructor initialisers chaining to this. Attributes go on their own line; a member carrying several suppressions no longer produces a line long enough to hide the declaration at the end of it. - The broken ClassTemplateExtensions.AddInheritance, which wrote its base list twice and was called by nothing, is not carried over. XML documentation is modelled as data too, rather than as pre-formatted comment lines. DocComment escapes text content by default - a description containing "<" or "&" used to emit malformed XML - orders the tags canonically, prefixes multi-line descriptions correctly, and can validate its param and typeparam entries against the member being documented, so a mismatch is reportable as a build diagnostic rather than surfacing as CS1572 inside generated source. The verbatim Comments list stays as the escape hatch. Refs #84, #85, #86 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ --- CodeBlocker.Test/DocCommentTests.cs | 244 ++++++ CodeBlocker.Test/TemplateGoldenTests.cs | 201 +++++ CodeBlocker.Test/TemplateTests.cs | 762 +++++++++++++++++++ CodeBlocker/Templates/AccessorTemplate.cs | 116 +++ CodeBlocker/Templates/ClassTemplate.cs | 178 +++++ CodeBlocker/Templates/ConstructorTemplate.cs | 61 ++ CodeBlocker/Templates/DocComment.cs | 261 +++++++ CodeBlocker/Templates/EnumMemberTemplate.cs | 31 + CodeBlocker/Templates/FieldTemplate.cs | 24 + CodeBlocker/Templates/MemberTemplate.cs | 47 ++ CodeBlocker/Templates/MethodTemplate.cs | 51 ++ CodeBlocker/Templates/OperatorTemplate.cs | 90 +++ CodeBlocker/Templates/ParameterTemplate.cs | 29 + CodeBlocker/Templates/PropertyTemplate.cs | 131 ++++ CodeBlocker/Templates/SourceFileTemplate.cs | 78 ++ CodeBlocker/Templates/TemplateBase.cs | 235 ++++++ CodeBlocker/Templates/TemplateRendering.cs | 197 +++++ CodeBlocker/Templates/TypeKind.cs | 54 ++ README.md | 144 ++++ 19 files changed, 2934 insertions(+) create mode 100644 CodeBlocker.Test/DocCommentTests.cs create mode 100644 CodeBlocker.Test/TemplateGoldenTests.cs create mode 100644 CodeBlocker.Test/TemplateTests.cs create mode 100644 CodeBlocker/Templates/AccessorTemplate.cs create mode 100644 CodeBlocker/Templates/ClassTemplate.cs create mode 100644 CodeBlocker/Templates/ConstructorTemplate.cs create mode 100644 CodeBlocker/Templates/DocComment.cs create mode 100644 CodeBlocker/Templates/EnumMemberTemplate.cs create mode 100644 CodeBlocker/Templates/FieldTemplate.cs create mode 100644 CodeBlocker/Templates/MemberTemplate.cs create mode 100644 CodeBlocker/Templates/MethodTemplate.cs create mode 100644 CodeBlocker/Templates/OperatorTemplate.cs create mode 100644 CodeBlocker/Templates/ParameterTemplate.cs create mode 100644 CodeBlocker/Templates/PropertyTemplate.cs create mode 100644 CodeBlocker/Templates/SourceFileTemplate.cs create mode 100644 CodeBlocker/Templates/TemplateBase.cs create mode 100644 CodeBlocker/Templates/TemplateRendering.cs create mode 100644 CodeBlocker/Templates/TypeKind.cs diff --git a/CodeBlocker.Test/DocCommentTests.cs b/CodeBlocker.Test/DocCommentTests.cs new file mode 100644 index 0000000..94e704c --- /dev/null +++ b/CodeBlocker.Test/DocCommentTests.cs @@ -0,0 +1,244 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace CodeBlocker.Tests; + +using ktsu.CodeBlocker; +using ktsu.CodeBlocker.Templates; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers XML documentation modelled as data: escaping, tag order, multi-line layout, and +/// validation against the documented member. +/// +[TestClass] +public sealed class DocCommentTests +{ + private static string Render(Action write) + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + write(codeBlocker); + return codeBlocker.ToString(); + } + + private static string Render(DocComment documentation) => Render(documentation.WriteTo); + + [TestMethod] + public void AnEmptyCommentWritesNothing() + { + DocComment documentation = new(); + + Assert.IsTrue(documentation.IsEmpty); + Assert.AreEqual(string.Empty, Render(documentation)); + } + + [TestMethod] + public void ASingleLineSummaryStaysOnOneLine() => + Assert.AreEqual( + "/// How many widgets.\n", + Render(new DocComment { Summary = "How many widgets." })); + + [TestMethod] + public void AMultiLineSummaryPrefixesEveryLine() + { + DocComment documentation = new() + { + Summary = "How many widgets.\nCounted lazily.", + }; + + Assert.AreEqual( + """ + /// + /// How many widgets. + /// Counted lazily. + /// + + """.ReplaceLineEndings("\n"), + Render(documentation)); + } + + [TestMethod] + public void ABlankLineInsideATagIsWrittenWithoutTrailingWhitespace() + { + DocComment documentation = new() { Remarks = "First.\n\nSecond." }; + + Assert.AreEqual( + "/// \n/// First.\n///\n/// Second.\n/// \n", + Render(documentation)); + } + + [TestMethod] + public void EitherLineTerminatorSplitsTheText() + { + Assert.AreEqual( + Render(new DocComment { Summary = "a\nb" }), + Render(new DocComment { Summary = "a\r\nb" })); + } + + [TestMethod] + public void TextContentIsEscapedByDefault() + { + // "values in the range <0, 1>" is an entirely ordinary thing for metadata to say, and it + // used to emit malformed XML. + DocComment documentation = new() { Summary = "Values in the range <0, 1> & beyond." }; + + Assert.AreEqual( + "/// Values in the range <0, 1> & beyond.\n", + Render(documentation)); + } + + [TestMethod] + public void EscapingCanBeTurnedOffForTextThatEmbedsMarkup() + { + DocComment documentation = new() + { + EscapeText = false, + Summary = "Wraps .", + }; + + Assert.AreEqual( + "/// Wraps .\n", + Render(documentation)); + } + + [TestMethod] + public void AttributeValuesAreAlwaysEscaped() + { + DocComment documentation = new() { EscapeText = false }; + documentation.Params.Add(new DocTag { Name = "ax\n", Render(documentation)); + } + + [TestMethod] + public void TagsAreWrittenInCanonicalOrder() + { + DocComment documentation = new() + { + Remarks = "Remarks.", + Returns = "The sum.", + Value = "The value.", + Summary = "Adds.", + }; + documentation.SeeAlso.Add("System.Math"); + documentation.Exceptions.Add(new DocTag { Name = "System.OverflowException", Text = "It overflowed." }); + documentation.Params.Add(new DocTag { Name = "a", Text = "First." }); + documentation.TypeParams.Add(new DocTag { Name = "T", Text = "The type." }); + + Assert.AreEqual( + """ + /// Adds. + /// The type. + /// First. + /// The sum. + /// The value. + /// It overflowed. + /// Remarks. + /// + + """.ReplaceLineEndings("\n"), + Render(documentation)); + } + + [TestMethod] + public void InheritDocIsWrittenFirst() + { + DocComment bare = new() { InheritDoc = true }; + DocComment withCref = new() { InheritDoc = true, InheritDocCref = "IWidget.Count", Summary = "Count." }; + + Assert.AreEqual("/// \n", Render(bare)); + Assert.AreEqual( + "/// \n/// Count.\n", + Render(withCref)); + } + + [TestMethod] + public void ValidationAcceptsAMatchingComment() + { + DocComment documentation = new(); + documentation.Params.Add(new DocTag { Name = "a", Text = "First." }); + documentation.TypeParams.Add(new DocTag { Name = "T", Text = "The type." }); + + Assert.IsEmpty(documentation.Validate(["a"], ["T"])); + } + + [TestMethod] + public void ValidationReportsATagThatNamesNothing() + { + DocComment documentation = new(); + documentation.Params.Add(new DocTag { Name = "typo", Text = "First." }); + + IReadOnlyList issues = documentation.Validate(["a"], []); + + Assert.HasCount(2, issues); + Assert.Contains("typo", issues[0]); + Assert.Contains("'a' has no entry", issues[1]); + } + + [TestMethod] + public void ValidationReportsADuplicateTag() + { + DocComment documentation = new(); + documentation.Params.Add(new DocTag { Name = "a", Text = "First." }); + documentation.Params.Add(new DocTag { Name = "a", Text = "Again." }); + + IReadOnlyList issues = documentation.Validate(["a"], []); + + Assert.HasCount(1, issues); + Assert.Contains("more than once", issues[0]); + } + + [TestMethod] + public void ValidationRejectsNullArguments() + { + DocComment documentation = new(); + + Assert.ThrowsExactly(() => documentation.Validate(null!, [])); + Assert.ThrowsExactly(() => documentation.Validate([], null!)); + } + + [TestMethod] + public void ATemplateWritesItsDocumentationAboveItsAttributes() + { + MethodTemplate method = new() + { + Type = "int", + Name = "Add", + Keywords = { "public" }, + Attributes = { "Pure" }, + Comments = { "// Runs in constant time." }, + Parameters = + { + new ParameterTemplate { Type = "int", Name = "a" }, + new ParameterTemplate { Type = "int", Name = "b" }, + }, + Documentation = new DocComment + { + Summary = "Adds two numbers.", + Returns = "Their sum.", + Params = + { + new DocTag { Name = "a", Text = "The first." }, + new DocTag { Name = "b", Text = "The second." }, + }, + }, + BodyFactory = codeBlocker => codeBlocker.Write("=> a + b;"), + }; + + Assert.AreEqual( + """ + /// Adds two numbers. + /// The first. + /// The second. + /// Their sum. + // Runs in constant time. + [Pure] + public int Add(int a, int b) => a + b; + + """.ReplaceLineEndings("\n"), + Render(method.WriteTo)); + } + + [TestMethod] + public void NullCodeBlockerIsRejected() => + Assert.ThrowsExactly(() => new DocComment().WriteTo(null!)); +} diff --git a/CodeBlocker.Test/TemplateGoldenTests.cs b/CodeBlocker.Test/TemplateGoldenTests.cs new file mode 100644 index 0000000..3033d6f --- /dev/null +++ b/CodeBlocker.Test/TemplateGoldenTests.cs @@ -0,0 +1,201 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace CodeBlocker.Tests; + +using ktsu.CodeBlocker; +using ktsu.CodeBlocker.Templates; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Renders one source file exercising every template kind and pins the result exactly. +/// +/// +/// The expected text below has been compiled as C# — it is valid, and it is what a reviewer would +/// expect a hand-written file to look like. That matters more than any individual assertion here: +/// the model's job is to own the punctuation, spacing and indentation so a generator author does +/// not have to, and a golden file is the only thing that catches a regression in the layout as a +/// whole. +/// +[TestClass] +public sealed class TemplateGoldenTests +{ + [TestMethod] + public void RendersARepresentativeSourceFile() + { + SourceFileTemplate file = new() + { + FileName = "Widget.g.cs", + Namespace = "Contoso.Widgets", + Usings = { "System" }, + Comments = { "// " }, + }; + + ClassTemplate type = new() + { + Kind = TypeKind.Class, + Name = "Widget", + TypeParameters = { "T" }, + Keywords = { "public", "abstract" }, + BaseClass = "WidgetBase", + Interfaces = { "IWidget" }, + Constraints = { "where T : struct" }, + Comments = { "/// A widget." }, + Attributes = { "Serializable" }, + }; + + type.Members.Add(new FieldTemplate { Type = "int", Name = "count", Keywords = { "private" }, DefaultValue = "0" }); + type.Members.Add(new FieldTemplate { Type = "string", Name = "label", Keywords = { "private" }, DefaultValue = "none", DefaultValueIsQuoted = true }); + type.Members.Add(new PropertyTemplate { Type = "int", Name = "Count", Keywords = { "public" }, Getter = AccessorTemplate.Auto(), Setter = AccessorTemplate.Auto() }); + type.Members.Add(new PropertyTemplate { Type = "int", Name = "Seed", Keywords = { "public" }, Getter = AccessorTemplate.Auto(), Setter = new AccessorTemplate { Modifier = "private" }, DefaultValue = "7" }); + type.Members.Add(new PropertyTemplate { Type = "int", Name = "Doubled", Keywords = { "public" }, ExpressionBodyFactory = cb => cb.Write("count * 2") }); + type.Members.Add(new PropertyTemplate + { + Type = "int", + Name = "Checked", + Keywords = { "public" }, + Getter = AccessorTemplate.Block(cb => + { + cb.WriteLine("ArgumentOutOfRangeException.ThrowIfNegative(count);"); + cb.WriteLine("return count;"); + }), + }); + type.Members.Add(new PropertyTemplate { Type = "int", Name = "Abstract", Keywords = { "public", "abstract" }, Getter = AccessorTemplate.Auto() }); + type.Members.Add(new MethodTemplate { Type = "void", Name = "Reset", Keywords = { "public" }, BodyFactory = cb => cb.Write("=> count = 0;") }); + type.Members.Add(new MethodTemplate + { + Type = "int", + Name = "Add", + Keywords = { "public" }, + Parameters = { new ParameterTemplate { Type = "int", Name = "a" }, new ParameterTemplate { Type = "int", Name = "b", DefaultValue = "1" } }, + BodyFactory = cb => + { + using (new Scope(cb)) + { + cb.WriteLine("if (a > b)"); + using (new Scope(cb)) + { + cb.WriteLine("return a;"); + } + + cb.WriteLine("return a + b;"); + } + }, + }); + type.Members.Add(new MethodTemplate + { + Type = "TResult", + Name = "Map", + Keywords = { "public" }, + TypeParameters = { "TResult" }, + Parameters = { new ParameterTemplate { Type = "Func", Name = "selector" } }, + Constraints = { "where TResult : struct" }, + BodyFactory = cb => cb.Write("=> selector(default);"), + }); + type.Members.Add(new MethodTemplate { Type = "void", Name = "Abstracted", Keywords = { "public", "abstract" }, BodyFactory = null }); + type.Members.Add(new ConstructorTemplate { Name = "Widget", Keywords = { "public" }, Parameters = { new ParameterTemplate { Type = "int", Name = "count" } }, BaseParameters = { "count" } }); + type.Members.Add(new OperatorTemplate + { + Type = "Widget", + Keywords = { "public", "static" }, + Symbol = "+", + Parameters = { new ParameterTemplate { Type = "Widget", Name = "left" }, new ParameterTemplate { Type = "Widget", Name = "right" } }, + Attributes = { "System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\", \"CA2225\")" }, + BodyFactory = cb => cb.Write("=> left;"), + }); + type.Members.Add(new OperatorTemplate + { + Kind = OperatorKind.Implicit, + Type = "int", + Keywords = { "public", "static" }, + Parameters = { new ParameterTemplate { Type = "Widget", Name = "widget" } }, + BodyFactory = cb => cb.Write("=> widget.count;"), + }); + type.NestedClasses.Add(new ClassTemplate { Kind = TypeKind.Enum, Name = "Kind", Keywords = { "private" }, Members = { new EnumMemberTemplate { Name = "Small", DefaultValue = "1" }, new EnumMemberTemplate { Name = "Large" } } }); + type.NestedClasses.Add(new ClassTemplate { Kind = TypeKind.Interface, Name = "IHandle", Keywords = { "private" }, Members = { new MethodTemplate { Type = "void", Name = "Handle" } } }); + type.NestedClasses.Add(new ClassTemplate { Kind = TypeKind.RecordStruct, Name = "Pair", TypeParameters = { "TValue" }, Keywords = { "private", "readonly" }, PositionalParameters = { new ParameterTemplate { Type = "TValue", Name = "First" }, new ParameterTemplate { Type = "TValue", Name = "Second" } } }); + + file.Classes.Add(type); + + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + codeBlocker.AddSourceFile(file); + + // AddSourceFile follows each type with a blank line, so the rendered file ends with one. + Assert.AreEqual(Expected.ReplaceLineEndings("\n") + "\n\n", codeBlocker.ToString()); + } + + private const string Expected = + """ + // + namespace Contoso.Widgets; + + using System; + + /// A widget. + [Serializable] + public abstract class Widget : WidgetBase, IWidget + where T : struct + { + private int count = 0; + + private string label = "none"; + + public Widget(int count) + : base(count) { } + + public int Count { get; set; } + + public int Seed + { + get; + private set; + } = 7; + + public int Doubled => count * 2; + + public int Checked + { + get + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + return count; + } + } + + public abstract int Abstract { get; } + + public void Reset() => count = 0; + + public int Add(int a, int b = 1) + { + if (a > b) + { + return a; + } + return a + b; + } + + public TResult Map(Func selector) + where TResult : struct => selector(default); + + public abstract void Abstracted(); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225")] + public static Widget operator +(Widget left, Widget right) => left; + + public static implicit operator int(Widget widget) => widget.count; + + private enum Kind + { + Small = 1, + Large, + } + + private interface IHandle + { + void Handle(); + } + + private readonly record struct Pair(TValue First, TValue Second); + } + """; +} diff --git a/CodeBlocker.Test/TemplateTests.cs b/CodeBlocker.Test/TemplateTests.cs new file mode 100644 index 0000000..63dc3df --- /dev/null +++ b/CodeBlocker.Test/TemplateTests.cs @@ -0,0 +1,762 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace CodeBlocker.Tests; + +using ktsu.CodeBlocker; +using ktsu.CodeBlocker.Templates; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Per-template render tests. Each one pins the exact text a template produces. +/// +[TestClass] +public sealed class TemplateTests +{ + private static string Render(Action write) + { + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + write(codeBlocker); + return codeBlocker.ToString(); + } + + private static string Render(TemplateBase template) => Render(template.WriteTo); + + #region ParameterTemplate + + [TestMethod] + public void ParameterIsTypeThenName() => + Assert.AreEqual("int value", Render(new ParameterTemplate { Type = "int", Name = "value" })); + + [TestMethod] + public void ParameterWritesItsDefaultValue() => + Assert.AreEqual("int value = 1", Render(new ParameterTemplate { Type = "int", Name = "value", DefaultValue = "1" })); + + [TestMethod] + public void ParameterQuotesItsDefaultValueWhenAsked() => + Assert.AreEqual( + "string name = \"none\"", + Render(new ParameterTemplate { Type = "string", Name = "name", DefaultValue = "none", DefaultValueIsQuoted = true })); + + [TestMethod] + public void ParameterKeepsItsAttributesOnTheDeclarationLine() + { + ParameterTemplate parameter = new() { Type = "int", Name = "value", Attributes = { "In" }, Keywords = { "ref" } }; + + Assert.AreEqual("[In] ref int value", Render(parameter)); + } + + #endregion + + #region FieldTemplate + + [TestMethod] + public void FieldIsTerminatedWithASemicolon() => + Assert.AreEqual("private int count;\n", Render(new FieldTemplate { Type = "int", Name = "count", Keywords = { "private" } })); + + [TestMethod] + public void FieldWritesItsInitialiser() => + Assert.AreEqual( + "private const int Max = 10;\n", + Render(new FieldTemplate { Type = "int", Name = "Max", Keywords = { "private", "const" }, DefaultValue = "10" })); + + [TestMethod] + public void FieldCommentsAndAttributesGoOnTheirOwnLines() + { + FieldTemplate field = new() + { + Type = "int", + Name = "count", + Keywords = { "private" }, + Comments = { "/// How many." }, + Attributes = { "Obsolete", "NonSerialized" }, + }; + + Assert.AreEqual( + """ + /// How many. + [Obsolete] + [NonSerialized] + private int count; + + """.ReplaceLineEndings("\n"), + Render(field)); + } + + #endregion + + #region PropertyTemplate + + [TestMethod] + public void AutomaticAccessorsCollapseToOneLine() + { + PropertyTemplate property = new() + { + Type = "int", + Name = "Count", + Keywords = { "public" }, + Getter = AccessorTemplate.Auto(), + Setter = AccessorTemplate.Auto(), + }; + + Assert.AreEqual("public int Count { get; set; }\n", Render(property)); + } + + [TestMethod] + public void AGetOnlyAutomaticPropertyIsWhatAnAbstractPropertyLooksLike() + { + PropertyTemplate property = new() + { + Type = "int", + Name = "Count", + Keywords = { "public", "abstract" }, + Getter = AccessorTemplate.Auto(), + }; + + Assert.AreEqual("public abstract int Count { get; }\n", Render(property)); + } + + [TestMethod] + public void AnInitOnlySetterUsesTheInitKeyword() + { + PropertyTemplate property = new() + { + Type = "int", + Name = "Count", + Keywords = { "public" }, + Getter = AccessorTemplate.Auto(), + Setter = AccessorTemplate.Auto(), + SetterIsInitOnly = true, + }; + + Assert.AreEqual("public int Count { get; init; }\n", Render(property)); + } + + [TestMethod] + public void AShorthandPropertyKeepsItsInitialiser() + { + PropertyTemplate property = new() + { + Type = "int", + Name = "Count", + Keywords = { "public" }, + Getter = AccessorTemplate.Auto(), + Setter = AccessorTemplate.Auto(), + DefaultValue = "7", + }; + + Assert.AreEqual("public int Count { get; set; } = 7;\n", Render(property)); + } + + [TestMethod] + public void AnAccessorModifierForcesTheBracedForm() + { + // The whole point of modelling accessors as data: a modifier is expressible, and it is what + // decides the shape rather than the identity of a callback. + PropertyTemplate property = new() + { + Type = "int", + Name = "Count", + Keywords = { "public" }, + Getter = AccessorTemplate.Auto(), + Setter = new AccessorTemplate { Modifier = "private" }, + }; + + Assert.AreEqual( + """ + public int Count + { + get; + private set; + } + + """.ReplaceLineEndings("\n"), + Render(property)); + } + + [TestMethod] + public void AnExpressionBodiedPropertyStaysOnOneLine() + { + PropertyTemplate property = new() + { + Type = "int", + Name = "Doubled", + Keywords = { "public" }, + ExpressionBodyFactory = codeBlocker => codeBlocker.Write("count * 2"), + }; + + Assert.AreEqual("public int Doubled => count * 2;\n", Render(property)); + } + + [TestMethod] + public void AnExpressionBodiedAccessorIsWrittenInFull() + { + PropertyTemplate property = new() + { + Type = "int", + Name = "Count", + Keywords = { "public" }, + Getter = AccessorTemplate.Expression(codeBlocker => codeBlocker.Write("count")), + Setter = AccessorTemplate.Expression(codeBlocker => codeBlocker.Write("count = value")), + }; + + Assert.AreEqual( + """ + public int Count + { + get => count; + set => count = value; + } + + """.ReplaceLineEndings("\n"), + Render(property)); + } + + [TestMethod] + public void ABlockBodiedAccessorIsBracedAndIndented() + { + PropertyTemplate property = new() + { + Type = "int", + Name = "Count", + Keywords = { "public" }, + Getter = AccessorTemplate.Block(codeBlocker => + { + codeBlocker.WriteLine("Refresh();"); + codeBlocker.WriteLine("return count;"); + }), + }; + + Assert.AreEqual( + """ + public int Count + { + get + { + Refresh(); + return count; + } + } + + """.ReplaceLineEndings("\n"), + Render(property)); + } + + [TestMethod] + public void APropertyWithNoAccessorsAtAllIsRejected() + { + // It used to render as "int Value;" — a field, not a property. + PropertyTemplate property = new() { Type = "int", Name = "Value" }; + + InvalidOperationException exception = + Assert.ThrowsExactly(() => Render(property)); + Assert.Contains("Value", exception.Message); + } + + #endregion + + #region MethodTemplate + + [TestMethod] + public void AMethodWithNoBodyIsTerminatedWithASemicolon() => + Assert.AreEqual( + "public abstract void Run();\n", + Render(new MethodTemplate { Type = "void", Name = "Run", Keywords = { "public", "abstract" }, BodyFactory = null })); + + [TestMethod] + public void AnExpressionBodyIsSeparatedFromTheParameterList() => + Assert.AreEqual( + "public void Reset() => count = 0;\n", + Render(new MethodTemplate + { + Type = "void", + Name = "Reset", + Keywords = { "public" }, + BodyFactory = codeBlocker => codeBlocker.Write("=> count = 0;"), + })); + + [TestMethod] + public void ABodyThatWritesNothingRendersAsAnEmptyBlock() + { + // Methods and constructors used to disagree here: the constructor emitted "{ }" and the + // method emitted nothing at all, leaving a declaration with neither body nor semicolon. + MethodTemplate method = new() + { + Type = "void", + Name = "Run", + Keywords = { "public", "virtual" }, + BodyFactory = _ => { }, + }; + + Assert.AreEqual("public virtual void Run() { }\n", Render(method)); + } + + [TestMethod] + public void AMultiLineBodyIsIndentedToWhereItIsSpliced() + { + MethodTemplate method = new() + { + Type = "int", + Name = "Add", + Keywords = { "public" }, + Parameters = + { + new ParameterTemplate { Type = "int", Name = "a" }, + new ParameterTemplate { Type = "int", Name = "b" }, + }, + BodyFactory = codeBlocker => + { + using Scope scope = new(codeBlocker); + codeBlocker.WriteLine("return a + b;"); + }, + }; + + // Rendered two levels in, to prove every line of the body picks up the surrounding indent + // rather than only the first. + string output = Render(codeBlocker => + { + codeBlocker.Indent(); + codeBlocker.Indent(); + method.WriteTo(codeBlocker); + }); + + Assert.AreEqual( + "\t\tpublic int Add(int a, int b)\n\t\t{\n\t\t\treturn a + b;\n\t\t}\n", + output); + } + + [TestMethod] + public void GenericConstraintsHangOffTheDeclarationAndCarryTheBody() + { + MethodTemplate method = new() + { + Type = "TResult", + Name = "Map", + Keywords = { "public" }, + TypeParameters = { "TResult" }, + Parameters = { new ParameterTemplate { Type = "Func", Name = "selector" } }, + Constraints = { "where TResult : struct" }, + BodyFactory = codeBlocker => codeBlocker.Write("=> selector(0);"), + }; + + Assert.AreEqual( + """ + public TResult Map(Func selector) + where TResult : struct => selector(0); + + """.ReplaceLineEndings("\n"), + Render(method)); + } + + [TestMethod] + public void ConstraintsOnAMethodWithNoBodyCarryTheSemicolon() + { + MethodTemplate method = new() + { + Type = "void", + Name = "Run", + Keywords = { "public", "abstract" }, + TypeParameters = { "T" }, + Constraints = { "where T : class", "new()" }, + BodyFactory = null, + }; + + Assert.AreEqual( + """ + public abstract void Run() + where T : class + new(); + + """.ReplaceLineEndings("\n"), + Render(method)); + } + + #endregion + + #region ConstructorTemplate + + [TestMethod] + public void AConstructorWithNoBaseCallIsAnEmptyBlock() => + Assert.AreEqual( + "public Widget() { }\n", + Render(new ConstructorTemplate { Name = "Widget", Keywords = { "public" } })); + + [TestMethod] + public void AConstructorInitialiserIsIndentedOnItsOwnLine() + { + ConstructorTemplate constructor = new() + { + Name = "Widget", + Keywords = { "public" }, + Parameters = { new ParameterTemplate { Type = "int", Name = "count" } }, + BaseParameters = { "count" }, + }; + + Assert.AreEqual( + """ + public Widget(int count) + : base(count) { } + + """.ReplaceLineEndings("\n"), + Render(constructor)); + } + + [TestMethod] + public void AConstructorCanChainToThis() + { + ConstructorTemplate constructor = new() + { + Name = "Widget", + Keywords = { "public" }, + BaseParameters = { "0" }, + ChainsToThis = true, + }; + + Assert.AreEqual( + """ + public Widget() + : this(0) { } + + """.ReplaceLineEndings("\n"), + Render(constructor)); + } + + #endregion + + #region OperatorTemplate + + [TestMethod] + public void AnOperatorIsNamedByItsSymbol() + { + OperatorTemplate op = new() + { + Type = "Money", + Keywords = { "public", "static" }, + Symbol = "+", + Parameters = + { + new ParameterTemplate { Type = "Money", Name = "left" }, + new ParameterTemplate { Type = "Money", Name = "right" }, + }, + BodyFactory = codeBlocker => codeBlocker.Write("=> new(left.Amount + right.Amount);"), + }; + + Assert.AreEqual( + "public static Money operator +(Money left, Money right) => new(left.Amount + right.Amount);\n", + Render(op)); + } + + [TestMethod] + public void AConversionIsNamedByItsResultType() + { + OperatorTemplate implicitConversion = new() + { + Kind = OperatorKind.Implicit, + Type = "decimal", + Keywords = { "public", "static" }, + Parameters = { new ParameterTemplate { Type = "Money", Name = "money" } }, + BodyFactory = codeBlocker => codeBlocker.Write("=> money.Amount;"), + }; + + OperatorTemplate explicitConversion = new() + { + Kind = OperatorKind.Explicit, + Type = "Money", + Keywords = { "public", "static" }, + Parameters = { new ParameterTemplate { Type = "decimal", Name = "amount" } }, + BodyFactory = codeBlocker => codeBlocker.Write("=> new(amount);"), + }; + + Assert.AreEqual( + "public static implicit operator decimal(Money money) => money.Amount;\n", + Render(implicitConversion)); + Assert.AreEqual( + "public static explicit operator Money(decimal amount) => new(amount);\n", + Render(explicitConversion)); + } + + #endregion + + #region ClassTemplate + + [TestMethod] + public void TheKindSuppliesTheDeclarationKeyword() + { + static string RenderKind(TypeKind kind) => + Render(new ClassTemplate { Kind = kind, Name = "X", Keywords = { "public" } }); + + Assert.AreEqual("public class X\n{\n}\n", RenderKind(TypeKind.Class)); + Assert.AreEqual("public struct X\n{\n}\n", RenderKind(TypeKind.Struct)); + Assert.AreEqual("public interface X\n{\n}\n", RenderKind(TypeKind.Interface)); + Assert.AreEqual("public record X\n{\n}\n", RenderKind(TypeKind.Record)); + Assert.AreEqual("public record struct X\n{\n}\n", RenderKind(TypeKind.RecordStruct)); + Assert.AreEqual("public enum X\n{\n}\n", RenderKind(TypeKind.Enum)); + } + + [TestMethod] + public void ATypeBodyClosesWithoutASemicolon() + { + // Every type used to close with "};" — legal, but not what anyone writes by hand. + string output = Render(new ClassTemplate + { + Name = "Widget", + Keywords = { "public" }, + Members = { new FieldTemplate { Type = "int", Name = "count", Keywords = { "private" } } }, + }); + + Assert.AreEqual( + """ + public class Widget + { + private int count; + } + + """.ReplaceLineEndings("\n"), + output); + } + + [TestMethod] + public void BaseTypeAndInterfacesShareOneClause() + { + ClassTemplate type = new() + { + Name = "Widget", + Keywords = { "public" }, + BaseClass = "WidgetBase", + Interfaces = { "IWidget", "IDisposable" }, + }; + + Assert.AreEqual( + "public class Widget : WidgetBase, IWidget, IDisposable\n{\n}\n", + Render(type)); + } + + [TestMethod] + public void MembersAreGroupedByKindButKeepTheirOrderWithinAKind() + { + ClassTemplate type = new() + { + Name = "Widget", + Keywords = { "public" }, + Members = + { + new MethodTemplate { Type = "void", Name = "Second", Keywords = { "public" }, BodyFactory = _ => { } }, + new FieldTemplate { Type = "int", Name = "b", Keywords = { "private" } }, + new MethodTemplate { Type = "void", Name = "First", Keywords = { "public" }, BodyFactory = _ => { } }, + new FieldTemplate { Type = "int", Name = "a", Keywords = { "private" } }, + }, + }; + + Assert.AreEqual( + """ + public class Widget + { + private int b; + + private int a; + + public void Second() { } + + public void First() { } + } + + """.ReplaceLineEndings("\n"), + Render(type)); + } + + [TestMethod] + public void SortingCanBeTurnedOffToKeepDeclarationOrder() + { + ClassTemplate type = new() + { + Name = "Widget", + Keywords = { "public" }, + SortMembers = false, + Members = + { + new MethodTemplate { Type = "void", Name = "Run", Keywords = { "public" }, BodyFactory = _ => { } }, + new FieldTemplate { Type = "int", Name = "a", Keywords = { "private" } }, + }, + }; + + Assert.AreEqual( + """ + public class Widget + { + public void Run() { } + + private int a; + } + + """.ReplaceLineEndings("\n"), + Render(type)); + } + + [TestMethod] + public void EnumMembersAreListedWithoutBlankLinesBetweenThem() + { + ClassTemplate type = new() + { + Kind = TypeKind.Enum, + Name = "Size", + Keywords = { "public" }, + BaseClass = "byte", + Members = + { + new EnumMemberTemplate { Name = "Small", DefaultValue = "1" }, + new EnumMemberTemplate { Name = "Large" }, + }, + }; + + Assert.AreEqual( + """ + public enum Size : byte + { + Small = 1, + Large, + } + + """.ReplaceLineEndings("\n"), + Render(type)); + } + + [TestMethod] + public void APositionalRecordWithNoBodyIsDeclaredWithASemicolon() + { + ClassTemplate type = new() + { + Kind = TypeKind.RecordStruct, + Name = "Pair", + TypeParameters = { "T" }, + Keywords = { "public", "readonly" }, + PositionalParameters = + { + new ParameterTemplate { Type = "T", Name = "First" }, + new ParameterTemplate { Type = "T", Name = "Second" }, + }, + }; + + Assert.AreEqual("public readonly record struct Pair(T First, T Second);\n", Render(type)); + } + + [TestMethod] + public void APositionalRecordWithMembersStillGetsABody() + { + ClassTemplate type = new() + { + Kind = TypeKind.Record, + Name = "Pair", + Keywords = { "public" }, + PositionalParameters = { new ParameterTemplate { Type = "int", Name = "First" } }, + Members = { new FieldTemplate { Type = "int", Name = "cached", Keywords = { "private" } } }, + }; + + Assert.AreEqual( + """ + public record Pair(int First) + { + private int cached; + } + + """.ReplaceLineEndings("\n"), + Render(type)); + } + + [TestMethod] + public void TypeConstraintsGoOnTheirOwnIndentedLines() + { + ClassTemplate type = new() + { + Name = "Repository", + TypeParameters = { "T" }, + Keywords = { "public" }, + Constraints = { "where T : class, new()" }, + }; + + Assert.AreEqual( + """ + public class Repository + where T : class, new() + { + } + + """.ReplaceLineEndings("\n"), + Render(type)); + } + + [TestMethod] + public void NestedTypesAreIndentedInsideTheirParent() + { + ClassTemplate type = new() + { + Name = "Outer", + Keywords = { "public" }, + NestedClasses = + { + new ClassTemplate + { + Name = "Inner", + Keywords = { "private" }, + Members = { new FieldTemplate { Type = "int", Name = "x", Keywords = { "private" } } }, + }, + }, + }; + + Assert.AreEqual( + """ + public class Outer + { + private class Inner + { + private int x; + } + } + + """.ReplaceLineEndings("\n"), + Render(type)); + } + + #endregion + + #region SourceFileTemplate + + [TestMethod] + public void AnEmptySourceFileWritesNothing() => + Assert.AreEqual(string.Empty, Render(codeBlocker => codeBlocker.AddSourceFile(new SourceFileTemplate()))); + + [TestMethod] + public void ASourceFileWritesItsPreambleThenItsTypes() + { + SourceFileTemplate file = new() + { + FileName = "Widget.g.cs", + Namespace = "Contoso", + Usings = { "System" }, + Comments = { "// " }, + Classes = { new ClassTemplate { Name = "Widget", Keywords = { "public" } } }, + }; + + Assert.AreEqual( + "// \nnamespace Contoso;\n\nusing System;\n\npublic class Widget\n{\n}\n\n", + Render(codeBlocker => codeBlocker.AddSourceFile(file))); + } + + #endregion + + #region Null handling + + [TestMethod] + public void EveryTemplateRejectsANullCodeBlocker() + { + Assert.ThrowsExactly(() => new ParameterTemplate().WriteTo(null!)); + Assert.ThrowsExactly(() => new FieldTemplate().WriteTo(null!)); + Assert.ThrowsExactly(() => new EnumMemberTemplate().WriteTo(null!)); + Assert.ThrowsExactly(() => new PropertyTemplate().WriteTo(null!)); + Assert.ThrowsExactly(() => new MethodTemplate().WriteTo(null!)); + Assert.ThrowsExactly(() => new ConstructorTemplate().WriteTo(null!)); + Assert.ThrowsExactly(() => new OperatorTemplate().WriteTo(null!)); + Assert.ThrowsExactly(() => new ClassTemplate().WriteTo(null!)); + Assert.ThrowsExactly(() => new AccessorTemplate().WriteTo(null!, "get")); + } + + #endregion +} diff --git a/CodeBlocker/Templates/AccessorTemplate.cs b/CodeBlocker/Templates/AccessorTemplate.cs new file mode 100644 index 0000000..18e5337 --- /dev/null +++ b/CodeBlocker/Templates/AccessorTemplate.cs @@ -0,0 +1,116 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +/// +/// How a property accessor is written. +/// +public enum AccessorKind +{ + /// An automatic accessor: get;. + Auto, + + /// An expression-bodied accessor: get => expression;. + Expression, + + /// A block-bodied accessor: get followed by a braced body. + Block, +} + +/// +/// Describes one accessor of a property. +/// +/// +/// The accessor's shape is data — — rather than something the model infers by +/// comparing callback instances, so a caller-supplied body is never mistaken for an automatic +/// accessor and an accessor can carry its own accessibility modifier. +/// +public class AccessorTemplate +{ + /// Gets or sets how the accessor is written. Defaults to . + public AccessorKind Kind { get; set; } = AccessorKind.Auto; + + /// + /// Gets or sets the accessor's own accessibility modifier, for example private. Empty + /// writes none, which is the usual case. + /// + public string Modifier { get; set; } = string.Empty; + + /// + /// Gets or sets the callback that writes the accessor body. + /// + /// + /// For the callback writes only the expression: the model + /// supplies the => and the terminating semicolon. For + /// it writes the statements only: the model supplies the + /// braces. It is ignored for . + /// + public Action? BodyFactory { get; set; } + + /// + /// Creates an automatic accessor. + /// + /// A new . + public static AccessorTemplate Auto() => new() { Kind = AccessorKind.Auto }; + + /// + /// Creates an expression-bodied accessor. + /// + /// Writes the expression, without => or a semicolon. + /// A new . + public static AccessorTemplate Expression(Action expression) => + new() { Kind = AccessorKind.Expression, BodyFactory = expression }; + + /// + /// Creates a block-bodied accessor. + /// + /// Writes the statements, without the enclosing braces. + /// A new . + public static AccessorTemplate Block(Action body) => + new() { Kind = AccessorKind.Block, BodyFactory = body }; + + /// + /// Writes the accessor. + /// + /// The to write to. + /// The accessor keyword: get, set or init. + /// is . + public void WriteTo(CodeBlocker codeBlocker, string keyword) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + string prefix = string.IsNullOrEmpty(Modifier) ? keyword : $"{Modifier} {keyword}"; + + switch (Kind) + { + case AccessorKind.Auto: + codeBlocker.WriteLine($"{prefix};"); + break; + + case AccessorKind.Expression: + codeBlocker.Write(prefix); + codeBlocker.Write(" => "); + codeBlocker.Write(TemplateRendering.RenderFragment(codeBlocker, BodyFactory)); + codeBlocker.WriteLine(";"); + break; + + case AccessorKind.Block: + codeBlocker.WriteLine(prefix); + using (new Scope(codeBlocker)) + { + TemplateRendering.SpliceFragment(codeBlocker, TemplateRendering.RenderFragment(codeBlocker, BodyFactory)); + } + + break; + + default: + throw new InvalidOperationException($"Unknown accessor kind '{Kind}'."); + } + } + + /// + /// Gets a value indicating whether this accessor can appear in the one-line + /// { get; set; } shorthand. + /// + internal bool IsShorthandEligible => Kind == AccessorKind.Auto && string.IsNullOrEmpty(Modifier); +} diff --git a/CodeBlocker/Templates/ClassTemplate.cs b/CodeBlocker/Templates/ClassTemplate.cs new file mode 100644 index 0000000..649f19a --- /dev/null +++ b/CodeBlocker/Templates/ClassTemplate.cs @@ -0,0 +1,178 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +using System.Collections.ObjectModel; + +/// +/// Describes a type declaration and everything inside it. +/// +/// +/// supplies the declaration keyword, so +/// carries only the modifiers, and +/// carries the generic parameters, so +/// is the bare name. +/// +public class ClassTemplate : TemplateBase +{ + /// Gets or sets the kind of type declared. Defaults to . + public TypeKind Kind { get; set; } = TypeKind.Class; + + /// Gets the type's generic parameters, written as <T, TResult>. + public Collection TypeParameters { get; } = []; + + /// + /// Gets the positional parameters of a record declaration, written as a parameter list + /// immediately after the name. Empty declares no positional parameters. + /// + public Collection PositionalParameters { get; } = []; + + /// + /// Gets or sets the base type, or for an enum its underlying type. Empty declares none. + /// + public string BaseClass { get; set; } = string.Empty; + + /// Gets the interfaces the type implements. + public Collection Interfaces { get; } = []; + + /// + /// Gets the generic constraint clauses, each written verbatim on its own indented line — for + /// example where T : struct. + /// + public Collection Constraints { get; } = []; + + /// Gets the members declared in the type. + public Collection Members { get; } = []; + + /// Gets the types nested inside this one. + public Collection NestedClasses { get; } = []; + + /// + /// Gets or sets a value indicating whether members are grouped by kind — see + /// — before being written. Defaults to + /// ; clear it to write them in the order they were added. + /// + public bool SortMembers { get; set; } = true; + + /// + /// Writes the declaration, its base type and interfaces, its constraints, and its body. + /// + /// The to write to. + /// is . + public override void WriteTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + base.WriteTo(codeBlocker); + + codeBlocker.Write($"{TypeKindKeywords.For(Kind)} {Name}"); + TemplateRendering.WriteTypeParameterList(codeBlocker, TypeParameters); + + if (PositionalParameters.Count > 0) + { + TemplateRendering.WriteParameterList(codeBlocker, PositionalParameters); + } + + WriteBaseClassAndInterfacesTo(codeBlocker); + TemplateRendering.WriteConstraints(codeBlocker, Constraints); + + // A positional record with nothing in it needs no body at all. + if (IsBodylessPositionalRecord) + { + codeBlocker.WriteLine(";"); + return; + } + + // Terminate the declaration line, whether it ended with the name, the base list, or the + // last constraint clause. + codeBlocker.WriteLine(); + WriteBodyTo(codeBlocker); + } + + /// + /// Gets a value indicating whether this is a record whose positional parameters are its whole + /// definition, which is declared with a semicolon instead of an empty body. + /// + private bool IsBodylessPositionalRecord => + PositionalParameters.Count > 0 + && Members.Count == 0 + && NestedClasses.Count == 0 + && Kind is TypeKind.Record or TypeKind.RecordStruct; + + private void WriteBaseClassAndInterfacesTo(CodeBlocker codeBlocker) + { + List baseAndInterfaces = []; + if (!string.IsNullOrEmpty(BaseClass)) + { + baseAndInterfaces.Add(BaseClass); + } + + baseAndInterfaces.AddRange(Interfaces); + + if (baseAndInterfaces.Count == 0) + { + return; + } + + codeBlocker.Write($" : {string.Join(", ", baseAndInterfaces)}"); + } + + private void WriteBodyTo(CodeBlocker codeBlocker) + { + using Scope scope = new(codeBlocker); + + IEnumerable members = SortMembers + ? Members.OrderBy(MemberTemplate.MemberSortOrder) + : Members; + + // Enum members read as a list, so they are not spaced apart the way declarations are. + bool separateMembers = Kind != TypeKind.Enum; + + bool first = true; + foreach (MemberTemplate member in members) + { + if (!first && separateMembers) + { + codeBlocker.NewLine(); + } + + member.WriteTo(codeBlocker); + first = false; + } + + foreach (ClassTemplate nestedClass in NestedClasses) + { + if (!first) + { + codeBlocker.NewLine(); + } + + nestedClass.WriteTo(codeBlocker); + first = false; + } + } +} + +/// +/// Extension methods for writing type declarations. +/// +public static class ClassTemplateExtensions +{ + /// + /// Writes a type declaration. + /// + /// The to write to. + /// The type to write. + /// The same , for chaining. + /// + /// or is . + /// + public static CodeBlocker AddClass(this CodeBlocker codeBlocker, ClassTemplate classTemplate) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + ArgumentNullException.ThrowIfNull(classTemplate); + + classTemplate.WriteTo(codeBlocker); + return codeBlocker; + } +} diff --git a/CodeBlocker/Templates/ConstructorTemplate.cs b/CodeBlocker/Templates/ConstructorTemplate.cs new file mode 100644 index 0000000..594f78b --- /dev/null +++ b/CodeBlocker/Templates/ConstructorTemplate.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +using System.Collections.ObjectModel; + +/// +/// Describes a constructor declaration. +/// +public class ConstructorTemplate : MemberTemplate +{ + /// Gets the constructor's parameters, in declaration order. + public Collection Parameters { get; } = []; + + /// + /// Gets the arguments passed to the base constructor, written verbatim. Empty omits the + /// : base(...) clause entirely. + /// + public Collection BaseParameters { get; } = []; + + /// + /// Gets or sets a value indicating whether the initialiser chains to this rather than + /// base. + /// + public bool ChainsToThis { get; set; } + + /// + /// Gets or sets the callback that writes the constructor body. Defaults to a callback that + /// writes nothing, which renders as { }; terminates the + /// declaration with a semicolon instead. + /// + public Action? BodyFactory { get; set; } = _ => { }; + + /// + /// Writes the declaration, its parameter list, its constructor initialiser, and its body. + /// + /// The to write to. + /// is . + public override void WriteTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + base.WriteTo(codeBlocker); + TemplateRendering.WriteParameterList(codeBlocker, Parameters); + WriteInitialiserTo(codeBlocker); + TemplateRendering.WriteBody(codeBlocker, BodyFactory); + } + + private void WriteInitialiserTo(CodeBlocker codeBlocker) + { + if (BaseParameters.Count == 0) + { + return; + } + + // The initialiser goes on its own line, indented below the declaration it belongs to. + codeBlocker.WriteLine(); + using IndentScope indent = new(codeBlocker); + codeBlocker.Write($": {(ChainsToThis ? "this" : "base")}({string.Join(", ", BaseParameters)})"); + } +} diff --git a/CodeBlocker/Templates/DocComment.cs b/CodeBlocker/Templates/DocComment.cs new file mode 100644 index 0000000..f02ffd7 --- /dev/null +++ b/CodeBlocker/Templates/DocComment.cs @@ -0,0 +1,261 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +using System.Collections.ObjectModel; +using System.Text; + +/// +/// One named XML documentation tag: a <param>, <typeparam> or +/// <exception> entry. +/// +public class DocTag +{ + /// + /// Gets or sets the tag's identifying attribute: the parameter or type parameter name, or for an + /// exception the cref. + /// + public string Name { get; set; } = string.Empty; + + /// Gets or sets the tag's description. + public string Text { get; set; } = string.Empty; +} + +/// +/// Describes a member's XML documentation as data rather than as pre-formatted comment lines. +/// +/// +/// Assembling doc comments by hand means every generator re-derives which tags go in which order, +/// how a long description wraps, and how a multi-line <remarks> is prefixed — and +/// nothing escapes the content, so a description that happens to contain < or & +/// produces malformed XML and trips the compiler's doc-comment warnings. This owns all of that. +/// +public class DocComment +{ + /// + /// Gets or sets a value indicating whether text content is XML-escaped when written. Defaults to + /// ; clear it when the text deliberately embeds markup such as + /// <c> or <see cref="…"/>, in which case escaping is the caller's job. + /// + public bool EscapeText { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the documentation is inherited, written as + /// <inheritdoc/> ahead of anything else. + /// + public bool InheritDoc { get; set; } + + /// + /// Gets or sets the cref of the member to inherit documentation from. Ignored unless + /// is set; empty writes a bare <inheritdoc/>. + /// + public string InheritDocCref { get; set; } = string.Empty; + + /// Gets or sets the <summary> text. + public string? Summary { get; set; } + + /// Gets or sets the <remarks> text. + public string? Remarks { get; set; } + + /// Gets or sets the <returns> text. + public string? Returns { get; set; } + + /// Gets or sets the <value> text, describing what a property holds. + public string? Value { get; set; } + + /// Gets the <typeparam> entries, keyed by type parameter name. + public Collection TypeParams { get; } = []; + + /// Gets the <param> entries, keyed by parameter name. + public Collection Params { get; } = []; + + /// Gets the <exception> entries, keyed by exception type cref. + public Collection Exceptions { get; } = []; + + /// Gets the <seealso> cref values. + public Collection SeeAlso { get; } = []; + + /// + /// Gets a value indicating whether this comment would write anything at all. + /// + public bool IsEmpty => + !InheritDoc + && string.IsNullOrEmpty(Summary) + && string.IsNullOrEmpty(Remarks) + && string.IsNullOrEmpty(Returns) + && string.IsNullOrEmpty(Value) + && TypeParams.Count == 0 + && Params.Count == 0 + && Exceptions.Count == 0 + && SeeAlso.Count == 0; + + /// + /// Writes the documentation in the canonical tag order: inheritdoc, summary, + /// typeparam, param, returns, value, exception, + /// remarks, seealso. + /// + /// The to write to. + /// is . + public void WriteTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + if (InheritDoc) + { + codeBlocker.WriteLine(string.IsNullOrEmpty(InheritDocCref) + ? "/// " + : $"/// "); + } + + WriteElement(codeBlocker, "summary", null, Summary); + + foreach (DocTag typeParam in TypeParams) + { + WriteElement(codeBlocker, "typeparam", $" name=\"{EscapeAttribute(typeParam.Name)}\"", typeParam.Text); + } + + foreach (DocTag param in Params) + { + WriteElement(codeBlocker, "param", $" name=\"{EscapeAttribute(param.Name)}\"", param.Text); + } + + WriteElement(codeBlocker, "returns", null, Returns); + WriteElement(codeBlocker, "value", null, Value); + + foreach (DocTag exception in Exceptions) + { + WriteElement(codeBlocker, "exception", $" cref=\"{EscapeAttribute(exception.Name)}\"", exception.Text); + } + + WriteElement(codeBlocker, "remarks", null, Remarks); + + foreach (string cref in SeeAlso) + { + codeBlocker.WriteLine($"/// "); + } + } + + /// + /// Checks that every <param> and <typeparam> entry names something the + /// documented member actually declares, and that nothing is documented twice. + /// + /// The member's parameter names. + /// The member's type parameter names. + /// + /// One message per problem found, empty when there are none. Nothing is thrown: a generator can + /// report these as build diagnostics, which is far easier to act on than the CS1572 and CS1573 + /// warnings the mismatch would otherwise raise inside generated source. + /// + /// Either argument is . + public IReadOnlyList Validate(IEnumerable parameterNames, IEnumerable typeParameterNames) + { + ArgumentNullException.ThrowIfNull(parameterNames); + ArgumentNullException.ThrowIfNull(typeParameterNames); + + List issues = []; + Check(Params, [.. parameterNames], "param", "parameter"); + Check(TypeParams, [.. typeParameterNames], "typeparam", "type parameter"); + return issues; + + void Check(Collection tags, HashSet declared, string tagName, string what) + { + HashSet documented = []; + foreach (DocTag tag in tags) + { + if (!documented.Add(tag.Name)) + { + issues.Add($"<{tagName} name=\"{tag.Name}\"> is documented more than once."); + } + + if (!declared.Contains(tag.Name)) + { + issues.Add($"<{tagName} name=\"{tag.Name}\"> does not match any declared {what}."); + } + } + + foreach (string name in declared) + { + if (!documented.Contains(name)) + { + issues.Add($"The {what} '{name}' has no <{tagName}> entry."); + } + } + } + } + + private void WriteElement(CodeBlocker codeBlocker, string tagName, string? attributes, string? text) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + string[] lines = SplitLines(text!); + if (lines.Length == 1) + { + codeBlocker.WriteLine($"/// <{tagName}{attributes}>{Escape(lines[0])}"); + return; + } + + codeBlocker.WriteLine($"/// <{tagName}{attributes}>"); + foreach (string line in lines) + { + codeBlocker.WriteLine(line.Length == 0 ? "///" : $"/// {Escape(line)}"); + } + + codeBlocker.WriteLine($"/// "); + } + + private string Escape(string text) => EscapeText ? EscapeContent(text) : text; + + /// + /// Splits text into lines, accepting either line terminator so that a caller's verbatim or raw + /// string literal lays out the same way whichever platform the source file was written on. + /// + /// The text to split. + /// The text's lines. + private static string[] SplitLines(string text) => + text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + + /// + /// Escapes the characters that would otherwise be read as markup in element content. + /// + /// The text to escape. + /// The escaped text. + private static string EscapeContent(string text) + { + StringBuilder builder = new(text.Length); + foreach (char character in text) + { + switch (character) + { + case '&': + builder.Append("&"); + break; + + case '<': + builder.Append("<"); + break; + + case '>': + builder.Append(">"); + break; + + default: + builder.Append(character); + break; + } + } + + return builder.ToString(); + } + + /// + /// Escapes an attribute value. Always escaped, regardless of : an + /// attribute value is never a place to embed markup, so there is nothing to opt out of. + /// + /// The value to escape. + /// The escaped value. + private static string EscapeAttribute(string value) => + EscapeContent(value).Replace("\"", """); +} diff --git a/CodeBlocker/Templates/EnumMemberTemplate.cs b/CodeBlocker/Templates/EnumMemberTemplate.cs new file mode 100644 index 0000000..54a33a5 --- /dev/null +++ b/CodeBlocker/Templates/EnumMemberTemplate.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +/// +/// Describes one member of an enum. +/// +/// +/// Only and, optionally, +/// apply: an enum member has no type of its own. +/// +public class EnumMemberTemplate : MemberTemplate +{ + /// + /// Writes the member as Name or Name = value, followed by a comma. + /// + /// The to write to. + /// + /// Every member gets a trailing comma, the last one included: it is legal C#, and it keeps the + /// diff to one line when a member is appended. + /// + /// is . + public override void WriteTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + base.WriteTo(codeBlocker); + WriteDefaultValueTo(codeBlocker); + codeBlocker.WriteLine(","); + } +} diff --git a/CodeBlocker/Templates/FieldTemplate.cs b/CodeBlocker/Templates/FieldTemplate.cs new file mode 100644 index 0000000..04676a3 --- /dev/null +++ b/CodeBlocker/Templates/FieldTemplate.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +/// +/// Describes a field declaration. +/// +public class FieldTemplate : MemberTemplate +{ + /// + /// Writes the field, its initialiser when is set, and + /// the terminating semicolon. + /// + /// The to write to. + /// is . + public override void WriteTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + base.WriteTo(codeBlocker); + WriteDefaultValueTo(codeBlocker); + codeBlocker.WriteLine(";"); + } +} diff --git a/CodeBlocker/Templates/MemberTemplate.cs b/CodeBlocker/Templates/MemberTemplate.cs new file mode 100644 index 0000000..0125079 --- /dev/null +++ b/CodeBlocker/Templates/MemberTemplate.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +/// +/// Base class for anything declared inside a type. +/// +public abstract class MemberTemplate : TemplateBase +{ + /// + /// Writes the shared parts, then the member's type and name. + /// + /// The to write to. + /// is . + public override void WriteTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + base.WriteTo(codeBlocker); + + // Trimmed because a constructor has no type, which would otherwise leave a leading space. + codeBlocker.Write($"{Type} {Name}".Trim()); + } + + /// + /// The order members are emitted in within a type: enum members and fields first, then + /// constructors, properties, methods and operators. + /// + /// The member to rank. + /// The member's sort key. + /// + /// The sort is stable, so members of the same kind keep the order they were added in. Set + /// to to keep declaration order + /// across kinds too. + /// + public static int MemberSortOrder(MemberTemplate memberTemplate) => + memberTemplate switch + { + EnumMemberTemplate => 0, + FieldTemplate => 1, + ConstructorTemplate => 2, + PropertyTemplate => 3, + MethodTemplate => 4, + OperatorTemplate => 5, + _ => 6 + }; +} diff --git a/CodeBlocker/Templates/MethodTemplate.cs b/CodeBlocker/Templates/MethodTemplate.cs new file mode 100644 index 0000000..95f45bb --- /dev/null +++ b/CodeBlocker/Templates/MethodTemplate.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +using System.Collections.ObjectModel; + +/// +/// Describes a method declaration. +/// +public class MethodTemplate : MemberTemplate +{ + /// Gets the method's type parameters, written as <T, TResult>. + public Collection TypeParameters { get; } = []; + + /// Gets the method's parameters, in declaration order. + public Collection Parameters { get; } = []; + + /// + /// Gets the generic constraint clauses, each written verbatim on its own indented line — for + /// example where T : struct. + /// + public Collection Constraints { get; } = []; + + /// + /// Gets or sets the callback that writes the method body. declares the + /// method without a body — an abstract, partial or interface declaration — and terminates it + /// with a semicolon. + /// + /// + /// The callback supplies its own braces, or writes an expression body such as + /// => value;. It renders into a nested configured like this + /// one, and every line of the result is re-indented to the position it is spliced into. + /// + public Action? BodyFactory { get; set; } + + /// + /// Writes the declaration, its type parameters, its parameter list, its constraints, and its body. + /// + /// The to write to. + /// is . + public override void WriteTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + base.WriteTo(codeBlocker); + TemplateRendering.WriteTypeParameterList(codeBlocker, TypeParameters); + TemplateRendering.WriteParameterList(codeBlocker, Parameters); + TemplateRendering.WriteConstraints(codeBlocker, Constraints); + TemplateRendering.WriteBody(codeBlocker, BodyFactory); + } +} diff --git a/CodeBlocker/Templates/OperatorTemplate.cs b/CodeBlocker/Templates/OperatorTemplate.cs new file mode 100644 index 0000000..6abe334 --- /dev/null +++ b/CodeBlocker/Templates/OperatorTemplate.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +using System.Collections.ObjectModel; + +/// +/// How an operator is declared. +/// +public enum OperatorKind +{ + /// + /// An ordinary operator: public static Result operator *(Left left, Right right). + /// + Normal, + + /// + /// An implicit conversion: public static implicit operator Result(Source value). + /// + Implicit, + + /// + /// An explicit conversion: public static explicit operator Result(Source value). + /// + Explicit, +} + +/// +/// Describes an operator or conversion declaration. +/// +/// +/// is the result type, and is +/// unused — an operator is named by its symbol. Remember that operators are always +/// public static, so both belong in . +/// +public class OperatorTemplate : MemberTemplate +{ + /// Gets or sets how the operator is declared. + public OperatorKind Kind { get; set; } = OperatorKind.Normal; + + /// + /// Gets or sets the operator symbol, for example *, == or true. Ignored for + /// a conversion, which is named by its result type instead. + /// + public string Symbol { get; set; } = string.Empty; + + /// Gets the operator's parameters, in declaration order. + public Collection Parameters { get; } = []; + + /// + /// Gets or sets the callback that writes the operator body. declares it + /// without one, which is only valid in a partial declaration. + /// + public Action? BodyFactory { get; set; } + + /// + /// Writes the declaration, its parameter list, and its body. + /// + /// The to write to. + /// is . + public override void WriteTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + // Not through the MemberTemplate implementation: an operator's signature is its symbol and + // result type, not a type followed by a name. + codeBlocker.AddTemplate(this); + + switch (Kind) + { + case OperatorKind.Normal: + codeBlocker.Write($"{Type} operator {Symbol}"); + break; + + case OperatorKind.Implicit: + codeBlocker.Write($"implicit operator {Type}"); + break; + + case OperatorKind.Explicit: + codeBlocker.Write($"explicit operator {Type}"); + break; + + default: + throw new InvalidOperationException($"Unknown operator kind '{Kind}'."); + } + + TemplateRendering.WriteParameterList(codeBlocker, Parameters); + TemplateRendering.WriteBody(codeBlocker, BodyFactory); + } +} diff --git a/CodeBlocker/Templates/ParameterTemplate.cs b/CodeBlocker/Templates/ParameterTemplate.cs new file mode 100644 index 0000000..0966c3d --- /dev/null +++ b/CodeBlocker/Templates/ParameterTemplate.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +/// +/// Describes one parameter of a method, constructor, operator, or positional record. +/// +public class ParameterTemplate : TemplateBase +{ + /// + /// Writes the parameter as Type name, followed by = default when + /// is set. + /// + /// The to write to. + /// + /// A parameter's attributes and modifiers stay on the declaration line — [In] ref int x — + /// so this does not go through the base implementation, which puts attributes on their own line. + /// + /// is . + public override void WriteTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + codeBlocker.AddInlineAttributes(Attributes); + codeBlocker.AddKeywords(Keywords); + codeBlocker.Write($"{Type} {Name}"); + WriteDefaultValueTo(codeBlocker); + } +} diff --git a/CodeBlocker/Templates/PropertyTemplate.cs b/CodeBlocker/Templates/PropertyTemplate.cs new file mode 100644 index 0000000..5b7a08e --- /dev/null +++ b/CodeBlocker/Templates/PropertyTemplate.cs @@ -0,0 +1,131 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +/// +/// Describes a property declaration. +/// +public class PropertyTemplate : MemberTemplate +{ + /// Gets or sets the getter. declares no getter. + public AccessorTemplate? Getter { get; set; } + + /// Gets or sets the setter. declares no setter. + public AccessorTemplate? Setter { get; set; } + + /// + /// Gets or sets a value indicating whether the setter is written as init rather than + /// set. + /// + public bool SetterIsInitOnly { get; set; } + + /// + /// Gets or sets the callback that writes an expression body, producing + /// Type Name => expression;. It writes only the expression: the model supplies the + /// => and the semicolon. + /// + /// + /// An expression body replaces the accessor list, so and + /// are ignored when this is set. + /// + public Action? ExpressionBodyFactory { get; set; } + + /// + /// Writes the property. + /// + /// The to write to. + /// + /// An expression body is written on the declaration line. Otherwise, accessors that are all + /// automatic and unqualified collapse to { get; set; } on one line, with any + /// as its initialiser; anything else gets a braced + /// accessor list. A property with no accessors at all is declared and terminated with a + /// semicolon, which is what an abstract or interface property looks like. + /// + /// is . + public override void WriteTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + base.WriteTo(codeBlocker); + + if (ExpressionBodyFactory is not null) + { + codeBlocker.Write(" => "); + codeBlocker.Write(TemplateRendering.RenderFragment(codeBlocker, ExpressionBodyFactory)); + codeBlocker.WriteLine(";"); + return; + } + + if (Getter is null && Setter is null) + { + // Writing "Type Name;" here would emit a field, not a property — which is what an + // accessorless property used to render as. An abstract or interface property is + // spelled with accessors that have no body: Getter = AccessorTemplate.Auto(). + throw new InvalidOperationException( + $"Property '{Name}' declares no accessors and no expression body. Set Getter, " + + "Setter, or ExpressionBodyFactory."); + } + + if (CanUseShorthand) + { + WriteShorthand(codeBlocker); + return; + } + + WriteAccessorList(codeBlocker); + } + + /// The keyword the setter is written with. + private string SetterKeyword => SetterIsInitOnly ? "init" : "set"; + + /// + /// Gets a value indicating whether every declared accessor is automatic and unqualified, in + /// which case the whole property fits on one line. + /// + private bool CanUseShorthand => + (Getter is null || Getter.IsShorthandEligible) + && (Setter is null || Setter.IsShorthandEligible); + + private void WriteShorthand(CodeBlocker codeBlocker) + { + codeBlocker.Write(" { "); + if (Getter is not null) + { + codeBlocker.Write("get;"); + } + + if (Getter is not null && Setter is not null) + { + codeBlocker.Write(" "); + } + + if (Setter is not null) + { + codeBlocker.Write($"{SetterKeyword};"); + } + + codeBlocker.Write(" }"); + WriteDefaultValueTo(codeBlocker); + codeBlocker.WriteLine(DefaultValueIsSet ? ";" : string.Empty); + } + + private bool DefaultValueIsSet => !string.IsNullOrEmpty(DefaultValue); + + private void WriteAccessorList(CodeBlocker codeBlocker) + { + codeBlocker.WriteLine(); + codeBlocker.WriteLine("{"); + codeBlocker.Indent(); + + Getter?.WriteTo(codeBlocker, "get"); + Setter?.WriteTo(codeBlocker, SetterKeyword); + + codeBlocker.Outdent(); + + // Written rather than WriteLine'd so an initialiser can follow the closing brace, which a + // Scope would not allow. + codeBlocker.Write("}"); + WriteDefaultValueTo(codeBlocker); + codeBlocker.WriteLine(DefaultValueIsSet ? ";" : string.Empty); + } +} diff --git a/CodeBlocker/Templates/SourceFileTemplate.cs b/CodeBlocker/Templates/SourceFileTemplate.cs new file mode 100644 index 0000000..d756ce1 --- /dev/null +++ b/CodeBlocker/Templates/SourceFileTemplate.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +using System.Collections.ObjectModel; + +/// +/// Describes a whole source file: its namespace, its using directives, and the types it declares. +/// +public class SourceFileTemplate : TemplateBase +{ + /// + /// Gets or sets the file name. The model does not write this anywhere; it is carried so a + /// generator can hand it to whatever writes the file out. + /// + public string FileName { get; set; } = string.Empty; + + /// Gets or sets the namespace. Empty writes no namespace declaration. + public string Namespace { get; set; } = string.Empty; + + /// + /// Gets the namespaces to import, without the using keyword or trailing semicolon. + /// + public Collection Usings { get; } = []; + + /// Gets the types declared in the file. + public Collection Classes { get; } = []; +} + +/// +/// Extension methods for writing whole source files. +/// +public static class SourceFileTemplateExtensions +{ + /// + /// Writes a source file: its shared parts, its namespace, its using directives, and its types. + /// + /// The to write to. + /// The file to write. + /// The same , for chaining. + /// + /// or is . + /// + public static CodeBlocker AddSourceFile(this CodeBlocker codeBlocker, SourceFileTemplate template) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + ArgumentNullException.ThrowIfNull(template); + + codeBlocker.AddTemplate(template); + codeBlocker.WriteFileScopedNamespace(template.Namespace); + codeBlocker.WriteUsings(template.Usings); + codeBlocker.AddClasses(template.Classes); + return codeBlocker; + } + + /// + /// Writes each type followed by a blank line. + /// + /// The to write to. + /// The types to write. + /// The same , for chaining. + /// + /// or is . + /// + public static CodeBlocker AddClasses(this CodeBlocker codeBlocker, IEnumerable classes) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + ArgumentNullException.ThrowIfNull(classes); + + foreach (ClassTemplate classTemplate in classes) + { + codeBlocker.AddClass(classTemplate); + codeBlocker.NewLine(); + } + + return codeBlocker; + } +} diff --git a/CodeBlocker/Templates/TemplateBase.cs b/CodeBlocker/Templates/TemplateBase.cs new file mode 100644 index 0000000..a85db2e --- /dev/null +++ b/CodeBlocker/Templates/TemplateBase.cs @@ -0,0 +1,235 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +using System.Collections.ObjectModel; + +/// +/// Base class for every template: the parts that a declaration of any kind can carry. +/// +/// +/// A template describes what to emit and leaves the punctuation, spacing and ordering to +/// the model. Build a tree of templates, then render it with +/// or by calling . +/// +public abstract class TemplateBase +{ + /// Gets or sets the declared name. + public string Name { get; set; } = string.Empty; + + /// Gets or sets the declared type, written before . + public string Type { get; set; } = string.Empty; + + /// + /// Gets or sets the initialiser or default value. Empty means no initialiser is written. + /// + public string DefaultValue { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether is written inside double + /// quotes. Set this for string literals; leave it clear for anything already written as an + /// expression. + /// + public bool DefaultValueIsQuoted { get; set; } + + /// + /// Gets the attributes to write before the declaration, each without its square brackets. + /// + public Collection Attributes { get; } = []; + + /// + /// Gets the modifiers to write before the declaration, in the order they should appear — + /// for example public, static, partial. The declaration keyword is not a + /// modifier: supplies it. + /// + public Collection Keywords { get; } = []; + + /// + /// Gets the comment lines to write above the declaration, each written verbatim and so + /// including its own // or /// prefix. + /// + /// + /// This is the escape hatch, for a plain comment or for anything + /// does not model. Prefer for XML + /// documentation: it escapes the content and orders the tags. + /// + public Collection Comments { get; } = []; + + /// + /// Gets or sets the XML documentation, written above . + /// writes none. + /// + public DocComment? Documentation { get; set; } + + /// + /// Writes the comments, attributes and modifiers this template carries, leaving the declaration + /// line open for the derived template to continue. + /// + /// The to write to. + /// is . + public virtual void WriteTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + codeBlocker.AddDocumentation(Documentation); + codeBlocker.AddComments(Comments); + codeBlocker.AddAttributes(Attributes); + codeBlocker.AddKeywords(Keywords); + } + + /// + /// Writes = value when is set, quoting it when + /// is set. + /// + /// The to write to. + protected void WriteDefaultValueTo(CodeBlocker codeBlocker) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + if (string.IsNullOrEmpty(DefaultValue)) + { + return; + } + + codeBlocker.Write(" = "); + codeBlocker.Write(DefaultValueIsQuoted ? $"\"{DefaultValue}\"" : DefaultValue); + } +} + +/// +/// Extension methods for writing the parts shared by every template. +/// +public static class TemplateBaseExtensions +{ + /// + /// Writes the comments, attributes and modifiers a template carries. + /// + /// The to write to. + /// The template whose shared parts to write. + /// The same , for chaining. + /// + /// or is . + /// + public static CodeBlocker AddTemplate(this CodeBlocker codeBlocker, TemplateBase template) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + ArgumentNullException.ThrowIfNull(template); + + codeBlocker.AddDocumentation(template.Documentation); + codeBlocker.AddComments(template.Comments); + codeBlocker.AddAttributes(template.Attributes); + codeBlocker.AddKeywords(template.Keywords); + return codeBlocker; + } + + /// + /// Writes XML documentation, or nothing when there is none to write. + /// + /// The to write to. + /// The documentation, or . + /// The same , for chaining. + /// is . + public static CodeBlocker AddDocumentation(this CodeBlocker codeBlocker, DocComment? documentation) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + + documentation?.WriteTo(codeBlocker); + return codeBlocker; + } + + /// + /// Writes each comment on its own line, verbatim. + /// + /// The to write to. + /// The comment lines, each including its own prefix. + /// The same , for chaining. + /// + /// or is . + /// + public static CodeBlocker AddComments(this CodeBlocker codeBlocker, IEnumerable comments) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + ArgumentNullException.ThrowIfNull(comments); + + foreach (string comment in comments) + { + codeBlocker.WriteLine(comment); + } + + return codeBlocker; + } + + /// + /// Writes each attribute on its own line, bracketed. + /// + /// The to write to. + /// The attributes, each without its square brackets. + /// The same , for chaining. + /// + /// One per line rather than inline: a member carrying several attributes — and generated members + /// often carry a suppression apiece — otherwise produces a line long enough to hide the + /// declaration at the end of it. + /// + /// + /// or is . + /// + public static CodeBlocker AddAttributes(this CodeBlocker codeBlocker, IEnumerable attributes) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + ArgumentNullException.ThrowIfNull(attributes); + + foreach (string attribute in attributes) + { + codeBlocker.WriteLine($"[{attribute}]"); + } + + return codeBlocker; + } + + /// + /// Writes each attribute bracketed and inline, followed by a space. + /// + /// The to write to. + /// The attributes, each without its square brackets. + /// The same , for chaining. + /// + /// Used for parameters, where an attribute has to stay on the declaration line. + /// + /// + /// or is . + /// + public static CodeBlocker AddInlineAttributes(this CodeBlocker codeBlocker, IEnumerable attributes) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + ArgumentNullException.ThrowIfNull(attributes); + + foreach (string attribute in attributes) + { + codeBlocker.Write($"[{attribute}] "); + } + + return codeBlocker; + } + + /// + /// Writes the modifiers space-separated, followed by a trailing space when there is at least one. + /// + /// The to write to. + /// The modifiers, in the order they should appear. + /// The same , for chaining. + /// + /// or is . + /// + public static CodeBlocker AddKeywords(this CodeBlocker codeBlocker, IEnumerable keywords) + { + ArgumentNullException.ThrowIfNull(codeBlocker); + ArgumentNullException.ThrowIfNull(keywords); + + if (keywords.Any()) + { + codeBlocker.Write(string.Join(" ", keywords) + " "); + } + + return codeBlocker; + } +} diff --git a/CodeBlocker/Templates/TemplateRendering.cs b/CodeBlocker/Templates/TemplateRendering.cs new file mode 100644 index 0000000..c54a2c4 --- /dev/null +++ b/CodeBlocker/Templates/TemplateRendering.cs @@ -0,0 +1,197 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +/// +/// The rendering steps shared by more than one template. +/// +/// +/// Methods, constructors and operators differ only in their signature, so their parameter list and +/// body are written from here rather than implemented once per member kind. +/// +internal static class TemplateRendering +{ + /// + /// Renders a fragment — a body, an expression, a parameter — into its own buffer. + /// + /// The the fragment will be spliced into. + /// The callback that writes the fragment, or . + /// The rendered fragment, empty when is . + /// + /// The buffer is configured exactly like , so the fragment's own + /// indentation and line terminators match the document it is going into. In particular the + /// terminator is the configured one and never the host's, which is what lets a body assembled on + /// one platform lay out identically on another. + /// + internal static string RenderFragment(CodeBlocker parent, Action? factory) + { + if (factory is null) + { + return string.Empty; + } + + using CodeBlocker fragmentWriter = CodeBlocker.Create(parent.IndentString, parent.NewLineString); + factory(fragmentWriter); + return fragmentWriter.ToString(); + } + + /// + /// Splits a rendered fragment into its lines, discarding the empty tail a trailing terminator + /// leaves behind. + /// + /// The the fragment was rendered for. + /// The rendered fragment. + /// The fragment's lines. + internal static string[] SplitLines(CodeBlocker parent, string fragment) + { + string[] lines = fragment.Split([parent.NewLineString], StringSplitOptions.None); + if (lines.Length == 0 || lines[^1].Length != 0) + { + return lines; + } + + // Array range indexing would need RuntimeHelpers.GetSubArray, which netstandard2.0 lacks. + string[] trimmed = new string[lines.Length - 1]; + Array.Copy(lines, trimmed, trimmed.Length); + return trimmed; + } + + /// + /// Writes a rendered fragment through one line at a time, so every + /// line picks up the indentation of the position it is being spliced into. + /// + /// The to write to. + /// The rendered fragment. + /// + /// Writing the fragment as one string instead would splice it verbatim: only its first line + /// would land at the current indent and every following line would keep the indentation it had + /// in its own buffer, which is why a nested body used to come out flush against the left margin. + /// + internal static void SpliceFragment(CodeBlocker parent, string fragment) + { + foreach (string line in SplitLines(parent, fragment)) + { + if (line.Length == 0) + { + parent.NewLine(); + } + else + { + parent.WriteLine(line); + } + } + } + + /// + /// Writes a parenthesised, comma-separated parameter list. + /// + /// The to write to. + /// The parameters, in declaration order. + internal static void WriteParameterList(CodeBlocker codeBlocker, IEnumerable parameters) + { + List parameterStrings = []; + foreach (ParameterTemplate parameterTemplate in parameters) + { + parameterStrings.Add(RenderFragment(codeBlocker, parameterTemplate.WriteTo)); + } + + codeBlocker.Write("("); + codeBlocker.Write(string.Join(", ", parameterStrings)); + codeBlocker.Write(")"); + } + + /// + /// Writes the angle-bracketed type parameter list, or nothing when there are none. + /// + /// The to write to. + /// The type parameter names. + internal static void WriteTypeParameterList(CodeBlocker codeBlocker, IReadOnlyCollection typeParameters) + { + if (typeParameters.Count == 0) + { + return; + } + + codeBlocker.Write($"<{string.Join(", ", typeParameters)}>"); + } + + /// + /// Writes each generic constraint clause on its own line, indented one level below the + /// declaration it constrains. + /// + /// The to write to. + /// The constraint clauses, each written verbatim. + /// + /// The last clause is left unterminated so that whatever follows still sees an open declaration + /// line: a member with no body attaches its semicolon to the clause + /// (where T : struct;), and an expression body attaches its arrow. + /// + internal static void WriteConstraints(CodeBlocker codeBlocker, IReadOnlyCollection constraints) + { + if (constraints.Count == 0) + { + return; + } + + // Terminate the declaration line the constraints hang off. WriteLine() rather than + // NewLine(): NewLine() writes through IndentedTextWriter.WriteLineNoTabs, which does not + // re-arm the writer's pending-tab flag, so whatever came next would land at column zero. + codeBlocker.WriteLine(); + + using IndentScope indent = new(codeBlocker); + int remaining = constraints.Count; + foreach (string constraint in constraints) + { + remaining--; + if (remaining == 0) + { + codeBlocker.Write(constraint); + } + else + { + codeBlocker.WriteLine(constraint); + } + } + } + + /// + /// Writes a member body, terminating the declaration line. + /// + /// The to write to. + /// + /// The callback that writes the body, supplying its own braces or expression-body arrow. + /// writes a terminating semicolon instead, for a declaration with no body + /// — an abstract, partial or interface member. + /// + internal static void WriteBody(CodeBlocker codeBlocker, Action? bodyFactory) + { + if (bodyFactory is null) + { + codeBlocker.WriteLine(";"); + return; + } + + string body = RenderFragment(codeBlocker, bodyFactory); + string[] lines = SplitLines(codeBlocker, body); + + // A factory that wrote nothing means "declared, but empty" — a virtual base method, or a + // constructor that only forwards to its base. + if (lines.Length == 0) + { + codeBlocker.WriteLine(" { }"); + return; + } + + if (lines.Length == 1) + { + // An expression body stays on the declaration line. + codeBlocker.Write(" "); + codeBlocker.WriteLine(lines[0]); + return; + } + + // A braced body starts on the line after the declaration. + codeBlocker.WriteLine(); + SpliceFragment(codeBlocker, body); + } +} diff --git a/CodeBlocker/Templates/TypeKind.cs b/CodeBlocker/Templates/TypeKind.cs new file mode 100644 index 0000000..ad78127 --- /dev/null +++ b/CodeBlocker/Templates/TypeKind.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +/// +/// The kind of type a declares. +/// +/// +/// The kind supplies the declaration keyword, so it does not also belong in +/// — those carry only the modifiers. +/// +public enum TypeKind +{ + /// A class declaration. + Class, + + /// A struct declaration. + Struct, + + /// An interface declaration. + Interface, + + /// A record declaration. + Record, + + /// A record struct declaration. + RecordStruct, + + /// An enum declaration. + Enum, +} + +/// +/// The declaration keywords for each . +/// +internal static class TypeKindKeywords +{ + /// + /// Gets the declaration keyword for a type kind. + /// + /// The kind to translate. + /// The C# keyword or keyword pair that declares that kind. + /// is not a known kind. + internal static string For(TypeKind kind) => kind switch + { + TypeKind.Class => "class", + TypeKind.Struct => "struct", + TypeKind.Interface => "interface", + TypeKind.Record => "record", + TypeKind.RecordStruct => "record struct", + TypeKind.Enum => "enum", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown type kind."), + }; +} diff --git a/README.md b/README.md index 21da796..b327b62 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ CodeBlocker is a specialized utility built on top of `IndentedTextWriter` that s - **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 +- **Template Object Model**: Describe a whole source file as objects — types, members, operators, generics, XML docs — and let the model own the punctuation, spacing and indentation - **Flexible API**: Write individual lines or entire code blocks with proper formatting - **Any TextWriter**: Buffer into a `StringWriter`, or stream straight to a file or any other `TextWriter` - **Cross-Platform**: Supports .NET 10.0, 9.0, 8.0, 7.0, 6.0, 5.0, .NET Standard 2.0 and 2.1 @@ -154,6 +155,122 @@ using (new Scope(scopeCodeBlocker)) } ``` +### Describing Code as Templates + +Writing a generator against `WriteLine` means owning every brace, comma and blank line yourself, and re-deriving the same layout decisions in every generator you write. The `ktsu.CodeBlocker.Templates` namespace lets you describe the file instead: + +```csharp +namespace CodeBlockerExample; + +using ktsu.CodeBlocker; +using ktsu.CodeBlocker.Templates; + +internal class TemplateExample +{ + public static string GenerateCode() + { + SourceFileTemplate file = new() + { + FileName = "Money.g.cs", + Namespace = "Contoso.Billing", + Usings = { "System" }, + }; + + ClassTemplate money = new() + { + Kind = TypeKind.RecordStruct, + Name = "Money", + Keywords = { "public", "readonly" }, + PositionalParameters = { new ParameterTemplate { Type = "decimal", Name = "Amount" } }, + Documentation = new DocComment { Summary = "An amount of money." }, + }; + + money.Members.Add(new OperatorTemplate + { + Type = "Money", + Keywords = { "public", "static" }, + Symbol = "+", + Parameters = + { + new ParameterTemplate { Type = "Money", Name = "left" }, + new ParameterTemplate { Type = "Money", Name = "right" }, + }, + BodyFactory = codeBlocker => codeBlocker.Write("=> new(left.Amount + right.Amount);"), + }); + + file.Classes.Add(money); + + using CodeBlocker codeBlocker = CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.Lf); + codeBlocker.AddSourceFile(file); + return codeBlocker.ToString(); + } +} +``` + +Produces: + +```csharp +namespace Contoso.Billing; + +using System; + +/// An amount of money. +public readonly record struct Money(decimal Amount) +{ + public static Money operator +(Money left, Money right) => new(left.Amount + right.Amount); +} +``` + +Note what you did not have to decide: that the attribute goes on its own line, that the operator is indented one level, that members are separated by a blank line, that a positional record with a body still needs braces while one without gets a semicolon. + +Collection properties are read-only, so use collection-initializer syntax — `Keywords = { "public" }` — rather than assignment. + +#### Bodies + +A member body is written by a callback into a nested `CodeBlocker` and then spliced in, **re-indented line by line** to wherever it lands. Nest it as deeply as you like: + +```csharp +new MethodTemplate +{ + Type = "int", + Name = "Add", + Keywords = { "public" }, + Parameters = + { + new ParameterTemplate { Type = "int", Name = "a" }, + new ParameterTemplate { Type = "int", Name = "b" }, + }, + BodyFactory = codeBlocker => + { + using Scope scope = new(codeBlocker); + codeBlocker.WriteLine("return a + b;"); + }, +} +``` + +A callback that writes a single line becomes an expression body on the declaration line; one that writes several becomes a braced body on the following lines; one that writes nothing becomes `{ }`; and a `null` `BodyFactory` declares the member with no body at all, for an abstract, partial or interface declaration. + +#### XML documentation + +`DocComment` models documentation as data rather than as pre-formatted comment lines, so the content is escaped, the tags come out in canonical order, and a multi-line description is prefixed correctly: + +```csharp +Documentation = new DocComment +{ + Summary = "Clamps a ratio to the range <0, 1>.", + Params = { new DocTag { Name = "value", Text = "The ratio." } }, + Returns = "The clamped ratio.", +} +``` + +```csharp +/// Clamps a ratio to the range <0, 1>. +/// The ratio. +/// The clamped ratio. +``` + +Set `EscapeText = false` when the text deliberately embeds markup such as ``. `Validate(parameterNames, typeParameterNames)` returns one message per mismatched or missing ``/`` entry, so a generator can report them as build diagnostics instead of letting CS1572 and CS1573 surface inside the generated file. + ### More Than Braces `Scope` and `ScopeWithTrailingSemicolon` cover braces. The same pattern covers the other shapes that recur in generated code, and every one of them is balanced by disposal — so an unbalanced `#pragma warning disable` or a stray `#endregion` is not something you can leave behind. @@ -439,6 +556,33 @@ Helper class for managing indentation scopes with automatic brace handling. Buil - **Exception Safety**: Guaranteed cleanup even if exceptions occur within the scope - **Resource Management**: Built on `ktsu.ScopedAction` for reliable resource handling +### Template Object Model + +`ktsu.CodeBlocker.Templates`. See [Describing Code as Templates](#describing-code-as-templates). + +| Type | Describes | +|------|-----------| +| `SourceFileTemplate` | A whole file: namespace, usings, types | +| `ClassTemplate` | A type of any `TypeKind`, its generics, base list, constraints, members and nested types | +| `FieldTemplate` | A field, with an optional initializer | +| `PropertyTemplate` | A property: automatic, expression-bodied, or a full accessor list | +| `AccessorTemplate` | One accessor, as `AccessorKind.Auto`, `.Expression` or `.Block`, with an optional modifier | +| `MethodTemplate` | A method, its generics, parameters, constraints and body | +| `ConstructorTemplate` | A constructor, its parameters, its `base`/`this` initializer and body | +| `OperatorTemplate` | An operator or an `implicit`/`explicit` conversion | +| `EnumMemberTemplate` | One enum member | +| `ParameterTemplate` | One parameter, with an optional default | +| `DocComment`, `DocTag` | XML documentation as data | +| `TemplateBase` | What every template carries: name, type, modifiers, attributes, comments, documentation | + +| Enum | Values | +|------|--------| +| `TypeKind` | `Class`, `Struct`, `Interface`, `Record`, `RecordStruct`, `Enum` | +| `AccessorKind` | `Auto`, `Expression`, `Block` | +| `OperatorKind` | `Normal`, `Implicit`, `Explicit` | + +Rendering entry points: `codeBlocker.AddSourceFile(file)`, `codeBlocker.AddClass(type)`, or `template.WriteTo(codeBlocker)` for any single template. + ### `CodeBlockerExtensions` Class File-level preamble helpers. Each returns the same `CodeBlocker` so calls chain. From d6a47bb933bdff72b41ca5adb2a3aef2c086386c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 03:28:02 +0000 Subject: [PATCH 5/7] fix: guard arguments with Ensure.NotNull [patch] KTSU0003 requires Polyfill's Ensure.NotNull over ArgumentNullException.ThrowIfNull for framework compatibility, and it failed the build on every target framework. The rule lives in ktsu.Sdk.Analyzers, which could not run in the container this was written in: its SDK ships Roslyn 5.0 and the analyzer needs 5.9, so the build had to drop it and every KTSU rule went unchecked locally. Confirmed instead that CA1062 is still satisfied - Ensure.NotNull carries [NotNull] on its argument, so the nullability analysis reaches the same conclusion. Refs #81, #82, #83, #84 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ --- CodeBlocker/CodeBlocker.cs | 3 ++- CodeBlocker/CodeBlockerExtensions.cs | 14 +++++----- CodeBlocker/Scopes.cs | 21 +++++++-------- CodeBlocker/Templates/AccessorTemplate.cs | 4 ++- CodeBlocker/Templates/ClassTemplate.cs | 7 ++--- CodeBlocker/Templates/ConstructorTemplate.cs | 3 ++- CodeBlocker/Templates/DocComment.cs | 7 ++--- CodeBlocker/Templates/EnumMemberTemplate.cs | 4 ++- CodeBlocker/Templates/FieldTemplate.cs | 4 ++- CodeBlocker/Templates/MemberTemplate.cs | 4 ++- CodeBlocker/Templates/MethodTemplate.cs | 3 ++- CodeBlocker/Templates/OperatorTemplate.cs | 3 ++- CodeBlocker/Templates/ParameterTemplate.cs | 4 ++- CodeBlocker/Templates/PropertyTemplate.cs | 4 ++- CodeBlocker/Templates/SourceFileTemplate.cs | 9 ++++--- CodeBlocker/Templates/TemplateBase.cs | 27 ++++++++++---------- 16 files changed, 72 insertions(+), 49 deletions(-) diff --git a/CodeBlocker/CodeBlocker.cs b/CodeBlocker/CodeBlocker.cs index a3c6265..eb62d3b 100644 --- a/CodeBlocker/CodeBlocker.cs +++ b/CodeBlocker/CodeBlocker.cs @@ -2,6 +2,7 @@ namespace ktsu.CodeBlocker; +using Polyfills; using System.CodeDom.Compiler; /// @@ -124,7 +125,7 @@ public CodeBlocker(TextWriter writer, string indentString) /// is . public CodeBlocker(TextWriter writer, string indentString, string newLineString) { - ArgumentNullException.ThrowIfNull(writer); + Ensure.NotNull(writer); // indentString is deliberately not null-checked: a null indent has always meant "no // indentation" here, and CreateWithNullIndentStringShouldWork pins that behaviour. diff --git a/CodeBlocker/CodeBlockerExtensions.cs b/CodeBlocker/CodeBlockerExtensions.cs index d13be91..dc387c6 100644 --- a/CodeBlocker/CodeBlockerExtensions.cs +++ b/CodeBlocker/CodeBlockerExtensions.cs @@ -2,6 +2,8 @@ namespace ktsu.CodeBlocker; +using Polyfills; + /// /// Helpers for the file-level shapes that recur in generated C#: the auto-generated preamble, the /// nullable context, the namespace declaration, and the using directives. @@ -24,7 +26,7 @@ public static class CodeBlockerExtensions /// is . public static CodeBlocker WriteAutoGeneratedHeader(this CodeBlocker codeBlocker, string? copyright = null) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); if (!string.IsNullOrEmpty(copyright)) { @@ -44,7 +46,7 @@ public static CodeBlocker WriteAutoGeneratedHeader(this CodeBlocker codeBlocker, /// is . public static CodeBlocker WriteNullableEnable(this CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.WriteLine("#nullable enable"); return codeBlocker; @@ -58,7 +60,7 @@ public static CodeBlocker WriteNullableEnable(this CodeBlocker codeBlocker) /// is . public static CodeBlocker WriteNullableDisable(this CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.WriteLine("#nullable disable"); return codeBlocker; @@ -74,7 +76,7 @@ public static CodeBlocker WriteNullableDisable(this CodeBlocker codeBlocker) /// is . public static CodeBlocker WriteFileScopedNamespace(this CodeBlocker codeBlocker, string? namespaceName) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); if (string.IsNullOrEmpty(namespaceName)) { @@ -102,8 +104,8 @@ public static CodeBlocker WriteFileScopedNamespace(this CodeBlocker codeBlocker, /// public static CodeBlocker WriteUsings(this CodeBlocker codeBlocker, IEnumerable usings) { - ArgumentNullException.ThrowIfNull(codeBlocker); - ArgumentNullException.ThrowIfNull(usings); + Ensure.NotNull(codeBlocker); + Ensure.NotNull(usings); bool wroteAny = false; foreach (string usingDirective in usings) diff --git a/CodeBlocker/Scopes.cs b/CodeBlocker/Scopes.cs index a6f2d5e..170af82 100644 --- a/CodeBlocker/Scopes.cs +++ b/CodeBlocker/Scopes.cs @@ -2,6 +2,7 @@ namespace ktsu.CodeBlocker; +using Polyfills; using ktsu.ScopedAction; /// @@ -30,7 +31,7 @@ public class DelimiterScope(CodeBlocker codeBlocker, string open, string close) /// is . protected static void Begin(CodeBlocker codeBlocker, string open) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.WriteLine(open); codeBlocker.Indent(); @@ -44,7 +45,7 @@ protected static void Begin(CodeBlocker codeBlocker, string open) /// is . protected static void End(CodeBlocker codeBlocker, string close) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.Outdent(); codeBlocker.WriteLine(close); @@ -84,13 +85,13 @@ public class IndentScope(CodeBlocker codeBlocker) { private static void Begin(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.Indent(); } private static void End(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.Outdent(); } } @@ -109,13 +110,13 @@ public class RegionScope(CodeBlocker codeBlocker, string name) { private static void Begin(CodeBlocker codeBlocker, string name) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.WriteLine(string.IsNullOrEmpty(name) ? "#region" : $"#region {name}"); } private static void End(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.WriteLine("#endregion"); } } @@ -135,13 +136,13 @@ public class DirectiveScope(CodeBlocker codeBlocker, string condition) { private static void Begin(CodeBlocker codeBlocker, string condition) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.WriteLine($"#if {condition}"); } private static void End(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.WriteLine("#endif"); } } @@ -175,13 +176,13 @@ public PragmaScope(CodeBlocker codeBlocker, IEnumerable warnings) private static void Begin(CodeBlocker codeBlocker, string warnings) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.WriteLine($"#pragma warning disable {warnings}"); } private static void End(CodeBlocker codeBlocker, string warnings) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.WriteLine($"#pragma warning restore {warnings}"); } } diff --git a/CodeBlocker/Templates/AccessorTemplate.cs b/CodeBlocker/Templates/AccessorTemplate.cs index 18e5337..5e201d2 100644 --- a/CodeBlocker/Templates/AccessorTemplate.cs +++ b/CodeBlocker/Templates/AccessorTemplate.cs @@ -2,6 +2,8 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; + /// /// How a property accessor is written. /// @@ -77,7 +79,7 @@ public static AccessorTemplate Block(Action body) => /// is . public void WriteTo(CodeBlocker codeBlocker, string keyword) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); string prefix = string.IsNullOrEmpty(Modifier) ? keyword : $"{Modifier} {keyword}"; diff --git a/CodeBlocker/Templates/ClassTemplate.cs b/CodeBlocker/Templates/ClassTemplate.cs index 649f19a..bc469bf 100644 --- a/CodeBlocker/Templates/ClassTemplate.cs +++ b/CodeBlocker/Templates/ClassTemplate.cs @@ -2,6 +2,7 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; using System.Collections.ObjectModel; /// @@ -61,7 +62,7 @@ public class ClassTemplate : TemplateBase /// is . public override void WriteTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); base.WriteTo(codeBlocker); @@ -169,8 +170,8 @@ public static class ClassTemplateExtensions /// public static CodeBlocker AddClass(this CodeBlocker codeBlocker, ClassTemplate classTemplate) { - ArgumentNullException.ThrowIfNull(codeBlocker); - ArgumentNullException.ThrowIfNull(classTemplate); + Ensure.NotNull(codeBlocker); + Ensure.NotNull(classTemplate); classTemplate.WriteTo(codeBlocker); return codeBlocker; diff --git a/CodeBlocker/Templates/ConstructorTemplate.cs b/CodeBlocker/Templates/ConstructorTemplate.cs index 594f78b..92bdba7 100644 --- a/CodeBlocker/Templates/ConstructorTemplate.cs +++ b/CodeBlocker/Templates/ConstructorTemplate.cs @@ -2,6 +2,7 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; using System.Collections.ObjectModel; /// @@ -38,7 +39,7 @@ public class ConstructorTemplate : MemberTemplate /// is . public override void WriteTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); base.WriteTo(codeBlocker); TemplateRendering.WriteParameterList(codeBlocker, Parameters); diff --git a/CodeBlocker/Templates/DocComment.cs b/CodeBlocker/Templates/DocComment.cs index f02ffd7..57a9bab 100644 --- a/CodeBlocker/Templates/DocComment.cs +++ b/CodeBlocker/Templates/DocComment.cs @@ -2,6 +2,7 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; using System.Collections.ObjectModel; using System.Text; @@ -98,7 +99,7 @@ public class DocComment /// is . public void WriteTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); if (InheritDoc) { @@ -149,8 +150,8 @@ public void WriteTo(CodeBlocker codeBlocker) /// Either argument is . public IReadOnlyList Validate(IEnumerable parameterNames, IEnumerable typeParameterNames) { - ArgumentNullException.ThrowIfNull(parameterNames); - ArgumentNullException.ThrowIfNull(typeParameterNames); + Ensure.NotNull(parameterNames); + Ensure.NotNull(typeParameterNames); List issues = []; Check(Params, [.. parameterNames], "param", "parameter"); diff --git a/CodeBlocker/Templates/EnumMemberTemplate.cs b/CodeBlocker/Templates/EnumMemberTemplate.cs index 54a33a5..189e652 100644 --- a/CodeBlocker/Templates/EnumMemberTemplate.cs +++ b/CodeBlocker/Templates/EnumMemberTemplate.cs @@ -2,6 +2,8 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; + /// /// Describes one member of an enum. /// @@ -22,7 +24,7 @@ public class EnumMemberTemplate : MemberTemplate /// is . public override void WriteTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); base.WriteTo(codeBlocker); WriteDefaultValueTo(codeBlocker); diff --git a/CodeBlocker/Templates/FieldTemplate.cs b/CodeBlocker/Templates/FieldTemplate.cs index 04676a3..efee4e3 100644 --- a/CodeBlocker/Templates/FieldTemplate.cs +++ b/CodeBlocker/Templates/FieldTemplate.cs @@ -2,6 +2,8 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; + /// /// Describes a field declaration. /// @@ -15,7 +17,7 @@ public class FieldTemplate : MemberTemplate /// is . public override void WriteTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); base.WriteTo(codeBlocker); WriteDefaultValueTo(codeBlocker); diff --git a/CodeBlocker/Templates/MemberTemplate.cs b/CodeBlocker/Templates/MemberTemplate.cs index 0125079..ef64a97 100644 --- a/CodeBlocker/Templates/MemberTemplate.cs +++ b/CodeBlocker/Templates/MemberTemplate.cs @@ -2,6 +2,8 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; + /// /// Base class for anything declared inside a type. /// @@ -14,7 +16,7 @@ public abstract class MemberTemplate : TemplateBase /// is . public override void WriteTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); base.WriteTo(codeBlocker); diff --git a/CodeBlocker/Templates/MethodTemplate.cs b/CodeBlocker/Templates/MethodTemplate.cs index 95f45bb..70b4f91 100644 --- a/CodeBlocker/Templates/MethodTemplate.cs +++ b/CodeBlocker/Templates/MethodTemplate.cs @@ -2,6 +2,7 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; using System.Collections.ObjectModel; /// @@ -40,7 +41,7 @@ public class MethodTemplate : MemberTemplate /// is . public override void WriteTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); base.WriteTo(codeBlocker); TemplateRendering.WriteTypeParameterList(codeBlocker, TypeParameters); diff --git a/CodeBlocker/Templates/OperatorTemplate.cs b/CodeBlocker/Templates/OperatorTemplate.cs index 6abe334..f7e67e4 100644 --- a/CodeBlocker/Templates/OperatorTemplate.cs +++ b/CodeBlocker/Templates/OperatorTemplate.cs @@ -2,6 +2,7 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; using System.Collections.ObjectModel; /// @@ -60,7 +61,7 @@ public class OperatorTemplate : MemberTemplate /// is . public override void WriteTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); // Not through the MemberTemplate implementation: an operator's signature is its symbol and // result type, not a type followed by a name. diff --git a/CodeBlocker/Templates/ParameterTemplate.cs b/CodeBlocker/Templates/ParameterTemplate.cs index 0966c3d..5bae986 100644 --- a/CodeBlocker/Templates/ParameterTemplate.cs +++ b/CodeBlocker/Templates/ParameterTemplate.cs @@ -2,6 +2,8 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; + /// /// Describes one parameter of a method, constructor, operator, or positional record. /// @@ -19,7 +21,7 @@ public class ParameterTemplate : TemplateBase /// is . public override void WriteTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.AddInlineAttributes(Attributes); codeBlocker.AddKeywords(Keywords); diff --git a/CodeBlocker/Templates/PropertyTemplate.cs b/CodeBlocker/Templates/PropertyTemplate.cs index 5b7a08e..feebaa5 100644 --- a/CodeBlocker/Templates/PropertyTemplate.cs +++ b/CodeBlocker/Templates/PropertyTemplate.cs @@ -2,6 +2,8 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; + /// /// Describes a property declaration. /// @@ -44,7 +46,7 @@ public class PropertyTemplate : MemberTemplate /// is . public override void WriteTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); base.WriteTo(codeBlocker); diff --git a/CodeBlocker/Templates/SourceFileTemplate.cs b/CodeBlocker/Templates/SourceFileTemplate.cs index d756ce1..cb15d10 100644 --- a/CodeBlocker/Templates/SourceFileTemplate.cs +++ b/CodeBlocker/Templates/SourceFileTemplate.cs @@ -2,6 +2,7 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; using System.Collections.ObjectModel; /// @@ -43,8 +44,8 @@ public static class SourceFileTemplateExtensions /// public static CodeBlocker AddSourceFile(this CodeBlocker codeBlocker, SourceFileTemplate template) { - ArgumentNullException.ThrowIfNull(codeBlocker); - ArgumentNullException.ThrowIfNull(template); + Ensure.NotNull(codeBlocker); + Ensure.NotNull(template); codeBlocker.AddTemplate(template); codeBlocker.WriteFileScopedNamespace(template.Namespace); @@ -64,8 +65,8 @@ public static CodeBlocker AddSourceFile(this CodeBlocker codeBlocker, SourceFile /// public static CodeBlocker AddClasses(this CodeBlocker codeBlocker, IEnumerable classes) { - ArgumentNullException.ThrowIfNull(codeBlocker); - ArgumentNullException.ThrowIfNull(classes); + Ensure.NotNull(codeBlocker); + Ensure.NotNull(classes); foreach (ClassTemplate classTemplate in classes) { diff --git a/CodeBlocker/Templates/TemplateBase.cs b/CodeBlocker/Templates/TemplateBase.cs index a85db2e..42349ac 100644 --- a/CodeBlocker/Templates/TemplateBase.cs +++ b/CodeBlocker/Templates/TemplateBase.cs @@ -2,6 +2,7 @@ namespace ktsu.CodeBlocker.Templates; +using Polyfills; using System.Collections.ObjectModel; /// @@ -69,7 +70,7 @@ public abstract class TemplateBase /// is . public virtual void WriteTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); codeBlocker.AddDocumentation(Documentation); codeBlocker.AddComments(Comments); @@ -84,7 +85,7 @@ public virtual void WriteTo(CodeBlocker codeBlocker) /// The to write to. protected void WriteDefaultValueTo(CodeBlocker codeBlocker) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); if (string.IsNullOrEmpty(DefaultValue)) { @@ -112,8 +113,8 @@ public static class TemplateBaseExtensions /// public static CodeBlocker AddTemplate(this CodeBlocker codeBlocker, TemplateBase template) { - ArgumentNullException.ThrowIfNull(codeBlocker); - ArgumentNullException.ThrowIfNull(template); + Ensure.NotNull(codeBlocker); + Ensure.NotNull(template); codeBlocker.AddDocumentation(template.Documentation); codeBlocker.AddComments(template.Comments); @@ -131,7 +132,7 @@ public static CodeBlocker AddTemplate(this CodeBlocker codeBlocker, TemplateBase /// is . public static CodeBlocker AddDocumentation(this CodeBlocker codeBlocker, DocComment? documentation) { - ArgumentNullException.ThrowIfNull(codeBlocker); + Ensure.NotNull(codeBlocker); documentation?.WriteTo(codeBlocker); return codeBlocker; @@ -148,8 +149,8 @@ public static CodeBlocker AddDocumentation(this CodeBlocker codeBlocker, DocComm /// public static CodeBlocker AddComments(this CodeBlocker codeBlocker, IEnumerable comments) { - ArgumentNullException.ThrowIfNull(codeBlocker); - ArgumentNullException.ThrowIfNull(comments); + Ensure.NotNull(codeBlocker); + Ensure.NotNull(comments); foreach (string comment in comments) { @@ -175,8 +176,8 @@ public static CodeBlocker AddComments(this CodeBlocker codeBlocker, IEnumerable< /// public static CodeBlocker AddAttributes(this CodeBlocker codeBlocker, IEnumerable attributes) { - ArgumentNullException.ThrowIfNull(codeBlocker); - ArgumentNullException.ThrowIfNull(attributes); + Ensure.NotNull(codeBlocker); + Ensure.NotNull(attributes); foreach (string attribute in attributes) { @@ -200,8 +201,8 @@ public static CodeBlocker AddAttributes(this CodeBlocker codeBlocker, IEnumerabl /// public static CodeBlocker AddInlineAttributes(this CodeBlocker codeBlocker, IEnumerable attributes) { - ArgumentNullException.ThrowIfNull(codeBlocker); - ArgumentNullException.ThrowIfNull(attributes); + Ensure.NotNull(codeBlocker); + Ensure.NotNull(attributes); foreach (string attribute in attributes) { @@ -222,8 +223,8 @@ public static CodeBlocker AddInlineAttributes(this CodeBlocker codeBlocker, IEnu /// public static CodeBlocker AddKeywords(this CodeBlocker codeBlocker, IEnumerable keywords) { - ArgumentNullException.ThrowIfNull(codeBlocker); - ArgumentNullException.ThrowIfNull(keywords); + Ensure.NotNull(codeBlocker); + Ensure.NotNull(keywords); if (keywords.Any()) { From e27aa88aaf1e8e78dce9588d57b8f2452b1c22c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 03:36:56 +0000 Subject: [PATCH 6/7] test: adopt main's host-terminator expectations [patch] main fixed the same problem this branch did, differently: d4a7153 rewrote the CRLF expectations to use Environment.NewLine, where this branch had pointed the tests at a CRLF-pinned factory. Both make the suite pass on Linux; merged together they contradict each other, and 45 tests failed on ubuntu while windows stayed green. Taking main's version of the four affected files and dropping TestCodeBlocker. Expecting the host terminator is the better fit for tests that exercise the default, and the pinned-terminator behaviour has its own coverage in NewLineTests, which asserts exact bytes for LF, CRLF and a custom terminator. 167 passed, 0 failed on Linux in Release. Refs #81 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ --- CodeBlocker.Test/CodeBlockerTests.cs | 54 +++++++++---------- CodeBlocker.Test/IntegrationTests.cs | 28 +++++----- CodeBlocker.Test/ScopeTests.cs | 30 +++++------ .../ScopeWithTrailingSemicolonTests.cs | 20 +++---- CodeBlocker.Test/TestCodeBlocker.cs | 34 ------------ 5 files changed, 66 insertions(+), 100 deletions(-) delete mode 100644 CodeBlocker.Test/TestCodeBlocker.cs diff --git a/CodeBlocker.Test/CodeBlockerTests.cs b/CodeBlocker.Test/CodeBlockerTests.cs index b932b96..eecc4bf 100644 --- a/CodeBlocker.Test/CodeBlockerTests.cs +++ b/CodeBlocker.Test/CodeBlockerTests.cs @@ -13,7 +13,7 @@ public void CreateShouldReturnValidInstance() { // Act - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Assert @@ -26,7 +26,7 @@ public void ToStringEmptyCodeBlockerShouldReturnEmptyString() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -42,7 +42,7 @@ public void WriteLineShouldAddLineWithIndentation() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -59,7 +59,7 @@ public void NewLineShouldAddEmptyLine() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -76,7 +76,7 @@ public void WriteLineWithIndentationShouldRespectIndentLevel() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -94,7 +94,7 @@ public void MultipleLinesShouldMaintainProperIndentation() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -116,7 +116,7 @@ public void DisposeShouldNotThrowException() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act & Assert @@ -129,7 +129,7 @@ public void DisposeMultipleCallsShouldNotThrow() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act & Assert @@ -147,7 +147,7 @@ public void CreateWithCustomIndentStringShouldUseSpecifiedIndent() // Act - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(customIndent); + using CodeBlocker codeBlocker = CodeBlocker.Create(customIndent); codeBlocker.Indent(); codeBlocker.WriteLine("test line"); string result = codeBlocker.ToString(); @@ -168,7 +168,7 @@ public void ConstructorWithCustomIndentStringShouldWork() // Act - using CodeBlocker codeBlocker = new(stringWriter, customIndent, NewLines.CrLf); + using CodeBlocker codeBlocker = new(stringWriter, customIndent); codeBlocker.Indent(); codeBlocker.WriteLine("indented content"); string result = codeBlocker.ToString(); @@ -184,7 +184,7 @@ public void DefaultIndentStringShouldBeTab() { // Act - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Assert @@ -198,7 +198,7 @@ public void CustomIndentStringWithMultipleIndentLevels() const string customIndent = ">>"; // Custom string - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(customIndent); + using CodeBlocker codeBlocker = CodeBlocker.Create(customIndent); // Act @@ -221,7 +221,7 @@ public void WriteLineWithoutParametersShouldAddEmptyLineWithIndentation() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -240,7 +240,7 @@ public void WriteMethodShouldAddTextWithoutNewline() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -258,7 +258,7 @@ public void WriteMethodWithIndentationShouldRespectIndentLevel() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -276,7 +276,7 @@ public void CurrentIndentSetterShouldUpdateIndentationLevel() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -295,7 +295,7 @@ public void CurrentIndentSetterWithZeroShouldRemoveIndentation() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); codeBlocker.Indent(); codeBlocker.Indent(); @@ -332,7 +332,7 @@ public void CreateWithNullIndentStringShouldWork() { // Arrange & Act - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(null!); + using CodeBlocker codeBlocker = CodeBlocker.Create(null!); codeBlocker.Indent(); codeBlocker.WriteLine("test"); string result = codeBlocker.ToString(); @@ -348,7 +348,7 @@ public void WriteLineWithNullParameterShouldWork() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -365,7 +365,7 @@ public void WriteWithNullParameterShouldWork() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -382,7 +382,7 @@ public void OutdentBelowZeroShouldNotThrow() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act & Assert - Should not throw, but may not go below 0 @@ -399,7 +399,7 @@ public void DisposeWithStringWriterManagementShouldNotThrowWhenCalledMultipleTim { // Arrange - CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); // This should manage StringWriter disposal + CodeBlocker codeBlocker = CodeBlocker.Create(); // This should manage StringWriter disposal // Act & Assert - Should not throw @@ -413,7 +413,7 @@ public void MixedWriteAndWriteLineShouldFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -434,7 +434,7 @@ public void DeepIndentationStressTest() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); const int maxDepth = 100; // Act @@ -458,7 +458,7 @@ public void LargeStringContentShouldBeHandledCorrectly() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); string largeString = new('x', 10000); // Act @@ -477,7 +477,7 @@ public void EmptyIndentStringShouldWork() { // Arrange & Act - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(string.Empty); + using CodeBlocker codeBlocker = CodeBlocker.Create(string.Empty); codeBlocker.Indent(); codeBlocker.WriteLine("test"); string result = codeBlocker.ToString(); @@ -495,7 +495,7 @@ public void VeryLongIndentStringShouldWork() // Arrange const string longIndent = "===================================="; - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(longIndent); + using CodeBlocker codeBlocker = CodeBlocker.Create(longIndent); // Act diff --git a/CodeBlocker.Test/IntegrationTests.cs b/CodeBlocker.Test/IntegrationTests.cs index f2f11b6..d03a954 100644 --- a/CodeBlocker.Test/IntegrationTests.cs +++ b/CodeBlocker.Test/IntegrationTests.cs @@ -13,7 +13,7 @@ public void ComplexCodeGenerationShouldFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act - Simulate generating a class with methods @@ -64,7 +64,7 @@ public void DeepNestingShouldMaintainCorrectIndentation() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); const int nestingLevels = 5; // Act @@ -102,7 +102,7 @@ public void MixedContentTypesShouldFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -149,7 +149,7 @@ public void EmptyScopesShouldNotAffectOtherContent() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -175,8 +175,8 @@ public void MultipleCodeBlockersShouldBeIndependent() { // Arrange - using CodeBlocker codeBlocker1 = TestCodeBlocker.CreateCrLf(); - using CodeBlocker codeBlocker2 = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker1 = CodeBlocker.Create(); + using CodeBlocker codeBlocker2 = CodeBlocker.Create(); // Act @@ -210,9 +210,9 @@ public void ComplexTemplateGenerationWithMultipleIndentTypesShouldWork() { // Arrange - using CodeBlocker htmlBlocker = TestCodeBlocker.CreateCrLf(" "); // 2 spaces for HTML + using CodeBlocker htmlBlocker = CodeBlocker.Create(" "); // 2 spaces for HTML - using CodeBlocker jsBlocker = TestCodeBlocker.CreateCrLf("\t"); // Tabs for JS + using CodeBlocker jsBlocker = CodeBlocker.Create("\t"); // Tabs for JS // Act - Generate HTML structure @@ -268,7 +268,7 @@ public void MixedWriteOperationsWithComplexIndentationShouldFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act - Mix Write and WriteLine operations @@ -314,7 +314,7 @@ public void LargeScaleCodeGenerationShouldPerformReasonably() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); const int classCount = 100; const int methodsPerClass = 10; @@ -386,14 +386,14 @@ public void SharedStringWriterBetweenCodeBlockersShouldWork() // Act - using (CodeBlocker codeBlocker1 = new(sharedWriter, CodeBlocker.DefaultIndentString, NewLines.CrLf)) + using (CodeBlocker codeBlocker1 = new(sharedWriter)) { codeBlocker1.WriteLine("// First CodeBlocker"); using Scope scope1 = new(codeBlocker1); codeBlocker1.WriteLine("content from first"); } - using (CodeBlocker codeBlocker2 = new(sharedWriter, " ", NewLines.CrLf)) + using (CodeBlocker codeBlocker2 = new(sharedWriter, " ")) { codeBlocker2.WriteLine("// Second CodeBlocker with different indent"); using Scope scope2 = new(codeBlocker2); @@ -419,7 +419,7 @@ public void ErrorRecoveryAfterExceptionShouldNotAffectFutureOperations() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act & Assert - Test error recovery #pragma warning disable CA1031 // Do not catch general exception types - This test specifically needs to catch any potential exception @@ -455,7 +455,7 @@ public void UnicodeAndSpecialCharactersShouldBeHandledCorrectly() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf("→→"); // Unicode arrows as indent + using CodeBlocker codeBlocker = CodeBlocker.Create("→→"); // Unicode arrows as indent // Act diff --git a/CodeBlocker.Test/ScopeTests.cs b/CodeBlocker.Test/ScopeTests.cs index a963a44..52c7db6 100644 --- a/CodeBlocker.Test/ScopeTests.cs +++ b/CodeBlocker.Test/ScopeTests.cs @@ -13,7 +13,7 @@ public void ConstructorShouldOpenBraceAndIncreaseIndentation() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); int initialIndent = codeBlocker.CurrentIndent; // Act @@ -32,7 +32,7 @@ public void DisposeShouldCloseBraceAndDecreaseIndentation() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); int initialIndent = codeBlocker.CurrentIndent; Scope scope = new(codeBlocker); @@ -52,7 +52,7 @@ public void UsingStatementShouldProperlyOpenAndCloseScope() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -73,7 +73,7 @@ public void NestedScopesShouldMaintainProperIndentation() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -99,7 +99,7 @@ public void MultipleDisposeShouldNotThrowException() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); Scope scope = new(codeBlocker); // Act & Assert @@ -114,7 +114,7 @@ public void ScopeWithoutContentShouldStillFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -135,7 +135,7 @@ public void MultipleSequentialScopesShouldFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -163,7 +163,7 @@ public void ScopeWithCustomIndentStringShouldWork() const string customIndent = " "; // Two spaces - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(customIndent); + using CodeBlocker codeBlocker = CodeBlocker.Create(customIndent); // Act @@ -193,7 +193,7 @@ public void ScopeWithDisposedCodeBlockerShouldThrowException() { // Arrange - CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + CodeBlocker codeBlocker = CodeBlocker.Create(); codeBlocker.Dispose(); // Act & Assert - Should throw when trying to use disposed CodeBlocker @@ -206,7 +206,7 @@ public void ScopeWithVeryDeepNestingShouldWork() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); const int nestingLevels = 50; List scopes = []; @@ -244,7 +244,7 @@ public void ScopeWithMixedManualIndentAndScopeIndentShouldWork() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -275,7 +275,7 @@ public void ScopeWithCurrentIndentSetterShouldWork() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -303,7 +303,7 @@ public void ScopeDisposalOrderShouldNotMatterForCorrectness() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -333,7 +333,7 @@ public void ScopeWithEmptyCustomIndentStringShouldWork() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(string.Empty); + using CodeBlocker codeBlocker = CodeBlocker.Create(string.Empty); // Act @@ -354,7 +354,7 @@ public void ScopeAfterManualDisposeOfCodeBlockerShouldThrowException() { // Arrange - CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + CodeBlocker codeBlocker = CodeBlocker.Create(); // Act - Dispose the CodeBlocker while scope is still active diff --git a/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs b/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs index 906ea34..08fe5f8 100644 --- a/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs +++ b/CodeBlocker.Test/ScopeWithTrailingSemicolonTests.cs @@ -13,7 +13,7 @@ public void ConstructorShouldOpenBraceAndIncreaseIndentation() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); int initialIndent = codeBlocker.CurrentIndent; // Act @@ -32,7 +32,7 @@ public void DisposeShouldCloseBraceWithSemicolonAndDecreaseIndentation() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); int initialIndent = codeBlocker.CurrentIndent; ScopeWithTrailingSemicolon scope = new(codeBlocker); @@ -52,7 +52,7 @@ public void UsingStatementShouldProperlyOpenAndCloseScope() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -73,7 +73,7 @@ public void NestedScopesShouldMaintainProperIndentation() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -99,7 +99,7 @@ public void MultipleDisposeShouldNotThrowException() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); ScopeWithTrailingSemicolon scope = new(codeBlocker); // Act & Assert @@ -113,7 +113,7 @@ public void ScopeWithoutContentShouldStillFormatCorrectly() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act @@ -136,7 +136,7 @@ public void ScopeWithCustomIndentStringShouldWork() const string customIndent = " "; // Two spaces - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(customIndent); + using CodeBlocker codeBlocker = CodeBlocker.Create(customIndent); // Act @@ -166,7 +166,7 @@ public void ScopeWithDisposedCodeBlockerShouldThrowException() { // Arrange - CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + CodeBlocker codeBlocker = CodeBlocker.Create(); codeBlocker.Dispose(); // Act & Assert - Should throw when trying to use disposed CodeBlocker @@ -179,7 +179,7 @@ public void MixedWithRegularScopeShouldWork() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(); + using CodeBlocker codeBlocker = CodeBlocker.Create(); // Act - Mix Scope and ScopeWithTrailingSemicolon @@ -224,7 +224,7 @@ public void ScopeWithEmptyCustomIndentStringShouldWork() { // Arrange - using CodeBlocker codeBlocker = TestCodeBlocker.CreateCrLf(string.Empty); + using CodeBlocker codeBlocker = CodeBlocker.Create(string.Empty); // Act diff --git a/CodeBlocker.Test/TestCodeBlocker.cs b/CodeBlocker.Test/TestCodeBlocker.cs deleted file mode 100644 index 944e215..0000000 --- a/CodeBlocker.Test/TestCodeBlocker.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace CodeBlocker.Tests; - -using ktsu.CodeBlocker; - -/// -/// Factory used by the test suite in place of . -/// -/// -/// The assertions throughout these tests spell their expected output with CRLF line endings, which -/// only matched the writer's behaviour while it inherited from the -/// host — so the suite passed on Windows and failed on every other platform. Pinning the terminator -/// here makes those expectations true everywhere, and keeps the CRLF spelling in the expectations -/// (which is far more readable for the multi-line fixtures) rather than splicing -/// into every literal. -/// -/// The default terminator is covered separately by NewLineTests, which is the only place that -/// should call directly. -/// -/// -internal static class TestCodeBlocker -{ - /// Creates a CRLF-terminated with the default indent string. - /// A new . - internal static CodeBlocker CreateCrLf() => - CodeBlocker.Create(CodeBlocker.DefaultIndentString, NewLines.CrLf); - - /// Creates a CRLF-terminated with a custom indent string. - /// The string to use for indentation. - /// A new . - internal static CodeBlocker CreateCrLf(string indentString) => - CodeBlocker.Create(indentString, NewLines.CrLf); -} From 659c92ed6fc90c237007c37d9148ea1b23be0f37 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 03:48:24 +0000 Subject: [PATCH 7/7] refactor: clear the three Sonar findings on new code [patch] SonarCloud's gate passed but reported three new issues, and the comment only links to a dashboard this environment cannot reach. Reproduced them locally instead by wiring SonarAnalyzer.CSharp into the build the way ktsu.Semantics does, which named all three: - S3267 twice in DocComment.Validate: one loop used only DocTag.Name, so it now iterates the names; the other filtered inside its body, so it now filters with Where. - S3878 in the empty-usings test, which passed an explicit empty collection expression to a params method. It now exercises both overloads - no arguments and an empty sequence - which is better coverage for a helper a generator calls unconditionally. The six findings left in CodeBlockerTests, ScopeTests and ScopeWithTrailingSemicolonTests are on lines identical to main, which is why the gate counted three and not nine. Left alone: they are not this PR's, and fixing them would widen it. 167 passed, 0 failed on Linux in Release. Refs #84, #86 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ --- CodeBlocker.Test/CodeBlockerExtensionsTests.cs | 11 ++++++++--- CodeBlocker/Templates/DocComment.cs | 17 +++++++---------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/CodeBlocker.Test/CodeBlockerExtensionsTests.cs b/CodeBlocker.Test/CodeBlockerExtensionsTests.cs index 29a7331..2aac3dd 100644 --- a/CodeBlocker.Test/CodeBlockerExtensionsTests.cs +++ b/CodeBlocker.Test/CodeBlockerExtensionsTests.cs @@ -94,11 +94,16 @@ public void UsingsAreWrittenVerbatimSoAliasesAndStaticImportsWork() [TestMethod] public void NoUsingsWritesNothingIncludingTheBlankLine() { - using CodeBlocker codeBlocker = Create(); + // Both overloads, because a generator calls this unconditionally and either spelling has to + // come out empty rather than leaving a stray blank line at the top of the file. + using CodeBlocker noArguments = Create(); + using CodeBlocker emptySequence = Create(); - codeBlocker.WriteUsings([]); + noArguments.WriteUsings(); + emptySequence.WriteUsings(Enumerable.Empty()); - Assert.AreEqual(string.Empty, codeBlocker.ToString()); + Assert.AreEqual(string.Empty, noArguments.ToString()); + Assert.AreEqual(string.Empty, emptySequence.ToString()); } [TestMethod] diff --git a/CodeBlocker/Templates/DocComment.cs b/CodeBlocker/Templates/DocComment.cs index 57a9bab..f13eea7 100644 --- a/CodeBlocker/Templates/DocComment.cs +++ b/CodeBlocker/Templates/DocComment.cs @@ -161,25 +161,22 @@ public IReadOnlyList Validate(IEnumerable parameterNames, IEnume void Check(Collection tags, HashSet declared, string tagName, string what) { HashSet documented = []; - foreach (DocTag tag in tags) + foreach (string name in tags.Select(tag => tag.Name)) { - if (!documented.Add(tag.Name)) + if (!documented.Add(name)) { - issues.Add($"<{tagName} name=\"{tag.Name}\"> is documented more than once."); + issues.Add($"<{tagName} name=\"{name}\"> is documented more than once."); } - if (!declared.Contains(tag.Name)) + if (!declared.Contains(name)) { - issues.Add($"<{tagName} name=\"{tag.Name}\"> does not match any declared {what}."); + issues.Add($"<{tagName} name=\"{name}\"> does not match any declared {what}."); } } - foreach (string name in declared) + foreach (string name in declared.Where(name => !documented.Contains(name))) { - if (!documented.Contains(name)) - { - issues.Add($"The {what} '{name}' has no <{tagName}> entry."); - } + issues.Add($"The {what} '{name}' has no <{tagName}> entry."); } } }