diff --git a/CodeBlocker.Test/CodeBlockerExtensionsTests.cs b/CodeBlocker.Test/CodeBlockerExtensionsTests.cs
new file mode 100644
index 0000000..2aac3dd
--- /dev/null
+++ b/CodeBlocker.Test/CodeBlockerExtensionsTests.cs
@@ -0,0 +1,146 @@
+// 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()
+ {
+ // 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();
+
+ noArguments.WriteUsings();
+ emptySequence.WriteUsings(Enumerable.Empty());
+
+ Assert.AreEqual(string.Empty, noArguments.ToString());
+ Assert.AreEqual(string.Empty, emptySequence.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/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/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/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.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.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 1602039..eb62d3b 100644
--- a/CodeBlocker/CodeBlocker.cs
+++ b/CodeBlocker/CodeBlocker.cs
@@ -2,64 +2,175 @@
namespace ktsu.CodeBlocker;
+using Polyfills;
using System.CodeDom.Compiler;
///
/// 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 TextWriter writer;
+
private bool disposedValue;
- private bool shouldDisposeStringWriter;
+ private bool shouldDisposeWriter;
- 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; }
+
+ ///
+ /// 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((TextWriter)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((TextWriter)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
+ /// .
+ ///
+ 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)
{
-#pragma warning disable CA2000 // Dispose objects before losing scope - StringWriter will be disposed by CodeBlocker when shouldDisposeStringWriter is true
- return new(new())
+ }
+
+ ///
+ /// 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)
+ {
+ }
+
+ ///
+ /// 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)
+ {
+ Ensure.NotNull(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.writer = writer;
+ 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.
+ writer.NewLine = newLineString;
+ IndentedTextWriter = new IndentedTextWriter(writer, 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)
+#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
}
@@ -67,8 +178,17 @@ public static CodeBlocker Create(string indentString)
///
/// 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.
@@ -121,12 +241,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/CodeBlocker/CodeBlockerExtensions.cs b/CodeBlocker/CodeBlockerExtensions.cs
new file mode 100644
index 0000000..dc387c6
--- /dev/null
+++ b/CodeBlocker/CodeBlockerExtensions.cs
@@ -0,0 +1,137 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+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.
+///
+///
+/// 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)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ Ensure.NotNull(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/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/CodeBlocker/Scopes.cs b/CodeBlocker/Scopes.cs
new file mode 100644
index 0000000..170af82
--- /dev/null
+++ b/CodeBlocker/Scopes.cs
@@ -0,0 +1,188 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker;
+
+using Polyfills;
+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)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ codeBlocker.Indent();
+ }
+
+ private static void End(CodeBlocker codeBlocker)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ codeBlocker.WriteLine(string.IsNullOrEmpty(name) ? "#region" : $"#region {name}");
+ }
+
+ private static void End(CodeBlocker codeBlocker)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ codeBlocker.WriteLine($"#if {condition}");
+ }
+
+ private static void End(CodeBlocker codeBlocker)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ codeBlocker.WriteLine($"#pragma warning disable {warnings}");
+ }
+
+ private static void End(CodeBlocker codeBlocker, string warnings)
+ {
+ Ensure.NotNull(codeBlocker);
+ codeBlocker.WriteLine($"#pragma warning restore {warnings}");
+ }
+}
diff --git a/CodeBlocker/Templates/AccessorTemplate.cs b/CodeBlocker/Templates/AccessorTemplate.cs
new file mode 100644
index 0000000..5e201d2
--- /dev/null
+++ b/CodeBlocker/Templates/AccessorTemplate.cs
@@ -0,0 +1,118 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+
+///
+/// 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)
+ {
+ Ensure.NotNull(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..bc469bf
--- /dev/null
+++ b/CodeBlocker/Templates/ClassTemplate.cs
@@ -0,0 +1,179 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+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)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ Ensure.NotNull(classTemplate);
+
+ classTemplate.WriteTo(codeBlocker);
+ return codeBlocker;
+ }
+}
diff --git a/CodeBlocker/Templates/ConstructorTemplate.cs b/CodeBlocker/Templates/ConstructorTemplate.cs
new file mode 100644
index 0000000..92bdba7
--- /dev/null
+++ b/CodeBlocker/Templates/ConstructorTemplate.cs
@@ -0,0 +1,62 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+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)
+ {
+ Ensure.NotNull(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..f13eea7
--- /dev/null
+++ b/CodeBlocker/Templates/DocComment.cs
@@ -0,0 +1,259 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+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)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(parameterNames);
+ Ensure.NotNull(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 (string name in tags.Select(tag => tag.Name))
+ {
+ if (!documented.Add(name))
+ {
+ issues.Add($"<{tagName} name=\"{name}\"> is documented more than once.");
+ }
+
+ if (!declared.Contains(name))
+ {
+ issues.Add($"<{tagName} name=\"{name}\"> does not match any declared {what}.");
+ }
+ }
+
+ foreach (string name in declared.Where(name => !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])}{tagName}>");
+ return;
+ }
+
+ codeBlocker.WriteLine($"/// <{tagName}{attributes}>");
+ foreach (string line in lines)
+ {
+ codeBlocker.WriteLine(line.Length == 0 ? "///" : $"/// {Escape(line)}");
+ }
+
+ codeBlocker.WriteLine($"/// {tagName}>");
+ }
+
+ 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..189e652
--- /dev/null
+++ b/CodeBlocker/Templates/EnumMemberTemplate.cs
@@ -0,0 +1,33 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+
+///
+/// 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)
+ {
+ Ensure.NotNull(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..efee4e3
--- /dev/null
+++ b/CodeBlocker/Templates/FieldTemplate.cs
@@ -0,0 +1,26 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+
+///
+/// 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)
+ {
+ Ensure.NotNull(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..ef64a97
--- /dev/null
+++ b/CodeBlocker/Templates/MemberTemplate.cs
@@ -0,0 +1,49 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+
+///
+/// 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)
+ {
+ Ensure.NotNull(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..70b4f91
--- /dev/null
+++ b/CodeBlocker/Templates/MethodTemplate.cs
@@ -0,0 +1,52 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+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)
+ {
+ Ensure.NotNull(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..f7e67e4
--- /dev/null
+++ b/CodeBlocker/Templates/OperatorTemplate.cs
@@ -0,0 +1,91 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+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)
+ {
+ 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.
+ 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..5bae986
--- /dev/null
+++ b/CodeBlocker/Templates/ParameterTemplate.cs
@@ -0,0 +1,31 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+
+///
+/// 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)
+ {
+ Ensure.NotNull(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..feebaa5
--- /dev/null
+++ b/CodeBlocker/Templates/PropertyTemplate.cs
@@ -0,0 +1,133 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+
+///
+/// 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)
+ {
+ Ensure.NotNull(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..cb15d10
--- /dev/null
+++ b/CodeBlocker/Templates/SourceFileTemplate.cs
@@ -0,0 +1,79 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+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)
+ {
+ Ensure.NotNull(codeBlocker);
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ Ensure.NotNull(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..42349ac
--- /dev/null
+++ b/CodeBlocker/Templates/TemplateBase.cs
@@ -0,0 +1,236 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.CodeBlocker.Templates;
+
+using Polyfills;
+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)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ Ensure.NotNull(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)
+ {
+ Ensure.NotNull(codeBlocker);
+ Ensure.NotNull(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 9d75c0a..b327b62 100644
--- a/README.md
+++ b/README.md
@@ -18,9 +18,13 @@ 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`
+- **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
-- **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
@@ -151,6 +155,294 @@ 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.
+
+| 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:
+
+```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.
+
+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 +503,10 @@ 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
@@ -218,6 +514,8 @@ 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
@@ -232,6 +530,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
@@ -257,6 +556,85 @@ 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.
+
+| 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.