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])}"); + return; + } + + codeBlocker.WriteLine($"/// <{tagName}{attributes}>"); + foreach (string line in lines) + { + codeBlocker.WriteLine(line.Length == 0 ? "///" : $"/// {Escape(line)}"); + } + + codeBlocker.WriteLine($"/// "); + } + + private string Escape(string text) => EscapeText ? EscapeContent(text) : text; + + /// + /// Splits text into lines, accepting either line terminator so that a caller's verbatim or raw + /// string literal lays out the same way whichever platform the source file was written on. + /// + /// The text to split. + /// The text's lines. + private static string[] SplitLines(string text) => + text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + + /// + /// Escapes the characters that would otherwise be read as markup in element content. + /// + /// The text to escape. + /// The escaped text. + private static string EscapeContent(string text) + { + StringBuilder builder = new(text.Length); + foreach (char character in text) + { + switch (character) + { + case '&': + builder.Append("&"); + break; + + case '<': + builder.Append("<"); + break; + + case '>': + builder.Append(">"); + break; + + default: + builder.Append(character); + break; + } + } + + return builder.ToString(); + } + + /// + /// Escapes an attribute value. Always escaped, regardless of : an + /// attribute value is never a place to embed markup, so there is nothing to opt out of. + /// + /// The value to escape. + /// The escaped value. + private static string EscapeAttribute(string value) => + EscapeContent(value).Replace("\"", """); +} diff --git a/CodeBlocker/Templates/EnumMemberTemplate.cs b/CodeBlocker/Templates/EnumMemberTemplate.cs new file mode 100644 index 0000000..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.