Skip to content

Generalize CodeBlocker into a code-generation substrate - #87

Merged
matt-edmondson merged 8 commits into
mainfrom
claude/extract-generalize-codegen-hjt2iy
Aug 27, 2026
Merged

matt-edmondson merged 8 commits into
mainfrom
claude/extract-generalize-codegen-hjt2iy

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Work towards ktsu-dev/Semantics#181 — extracting the general-purpose code-generation stack out of Semantics.SourceGenerators so more than one project can use it. This is layer 1: everything that needs no Roslyn dependency.

Closes #81, closes #82, closes #83, closes #84, closes #85, closes #86.

Configurable line terminator (#81)

CodeBlocker writes through IndentedTextWriter, which terminates lines with Environment.NewLine, so the same calls produced CRLF on Windows and LF everywhere else. Generated code that is committed to a repository has to be byte-identical wherever it was produced — ktsu.Semantics carries a post-processing pass purely to undo this.

  • CodeBlocker(StringWriter, string indentString, string newLineString) and Create(string indentString, string newLineString).
  • A NewLines class naming the usual choices (Lf, CrLf, Host), and NewLineString reporting the terminator back.

The terminator is set on both the writer and the IndentedTextWriter: the latter forwards to its inner writer on modern targets, but this package also ships for netstandard2.0, where the running framework supplies IndentedTextWriter and that forwarding is not guaranteed.

The default is unchanged (Environment.NewLine), so existing callers see exactly the output they see today and determinism is opt-in. That is a deliberate deviation from the issue's first acceptance criterion, which asked for byte-identical output across platforms by default — flipping it would silently change the bytes every current Windows consumer gets. Happy to make that flip in a [major] if you'd prefer.

Any TextWriter, not just StringWriter (#82)

IndentedTextWriter takes any TextWriter, so the restriction was self-imposed. It ruled out generating straight to a file or to a writer supplied by a build task.

  • CodeBlocker(TextWriter) plus indent/terminator overloads. The StringWriter constructors stay and delegate, so no caller changes.
  • ToString() returns the buffered code when the writer is a StringWriter and the type name otherwise, rather than throwing — debuggers call ToString() freely. IsBuffered tells callers which case they are in.
  • Disposal unchanged in substance: only a writer Create() made for itself is disposed, never a caller-supplied one. (Verified: disposing an IndentedTextWriter does not reach the writer it wraps.)

Scopes and directive helpers (#83)

  • DelimiterScope as a shared base, with ParenScope and BracketScope over it, plus IndentScope for continuation lines.
  • RegionScope, DirectiveScope, PragmaScope. None indents its body, because a directive does not nest code.
  • CodeBlockerExtensions: WriteAutoGeneratedHeader, WriteNullableEnable/Disable, WriteFileScopedNamespace, WriteUsings — each owning the blank line that conventionally follows it.

Scope and ScopeWithTrailingSemicolon are deliberately not reparented onto DelimiterScope: their tests pin NullReferenceException for a null CodeBlocker, and the new types validate properly with ArgumentNullException.

The C# template object model (#84, #85, #86)

ktsu.Semantics built a declarative C# syntax object model on top of CodeBlocker, and it is the most reusable part of that repository's generator stack — ~500 lines with no reference to physics or anything else specific to it. Ported here as ktsu.CodeBlocker.Templates, public and documented.

The port could not ship as it stood, so #85's fixes land with it rather than after. Rendering one file that exercised every template kind showed the model only ever produced valid C# for the narrow shapes Semantics happens to use:

  • Types closed with };, and the kind came from free text in Keywords. Now TypeKind — class, struct, interface, record, record struct, enum.
  • Body fragments were spliced as one string, so only the first line picked up the surrounding indent and everything after it sat flush against the left margin. Now spliced line by line and re-indented.
  • CodeBlocker.NewLine() goes through IndentedTextWriter.WriteLineNoTabs, which does not re-arm the writer's pending-tab flag. Every place that used it to terminate an open declaration line was silently losing the indent of whatever came next. Those use WriteLine() now — that one was the root cause of most of the misindentation.
  • Methods and constructors implemented the parameter list and body twice, and the divergence hid a bug: the method's empty-body branch tested a line count that can never be zero, so a method with an empty body rendered with neither body nor semicolon.
  • Accessors are data (AccessorTemplate, Auto/Expression/Block, optional modifier) rather than callbacks compared by reference. A caller-supplied body is no longer mistaken for an automatic accessor, private set is expressible, and a property with no accessors throws instead of rendering int Value; — a field.
  • Added: operators and implicit/explicit conversions, generic parameters and constraints, enum and interface declarations, positional records, this(...) constructor chaining. Attributes go on their own line.
  • The broken ClassTemplateExtensions.AddInheritance — which wrote its base list twice and was called by nothing — is not carried over.

XML documentation is data too (#86). DocComment escapes text content by default (a description containing < or & used to emit malformed XML), orders tags canonically, prefixes multi-line descriptions, and can validate its param/typeparam entries against the member — so a mismatch is reportable as a build diagnostic rather than surfacing as CS1572 inside generated source. The verbatim Comments list stays as the escape hatch.

Tests

167 passed, 0 failed on Linux in Release.

TemplateGoldenTests renders one file using every template kind and pins the result exactly — and that expected text has been compiled as C# to confirm it is valid. A golden file is the only thing that catches a regression in the layout as a whole, which is the model's whole job. Alongside it: per-template render tests, DocCommentTests, NewLineTests (exact bytes for LF/CRLF/custom terminators and cross-configuration determinism), TextWriterTests, ScopesTests, CodeBlockerExtensionsTests.

This branch also merged main's d4a7153, which fixed the pre-existing Linux test failures by expecting Environment.NewLine. This PR had done the same thing differently (a CRLF-pinned factory); merged together they contradicted each other and 45 tests failed on ubuntu. Main's approach won and the factory is gone — expecting the host terminator is the better fit for tests exercising the default, and the pinned behaviour has its own coverage in NewLineTests.

Notes for the reviewer

  • README.md gains sections for line endings, non-string writers, the new scopes, file preambles, and the template model, plus API-reference entries for every new type.
  • CHANGELOG.md is generated by the release pipeline from commit tags, so it is untouched; feature commits carry [minor].
  • Local builds needed -p:CustomAfterMicrosoftCommonTargets=… to drop ktsu.Sdk.Analyzers (this container's SDK ships Roslyn 5.0; the analyzer wants 5.9), so the KTSU rules could not run locally — CI caught KTSU0003 and it is fixed. Sonar's three new issues were likewise invisible here (sonarcloud.io is unreachable from this environment), so they were reproduced locally by wiring in SonarAnalyzer.CSharp the way ktsu.Semantics does, and all three are fixed. One new issue remains that I cannot see — the local reproduction is tuned to Semantics' quality profile and reports nothing outside files identical to main. Worth a glance from someone with dashboard access; the gate passes either way.

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment