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

Generalize CodeBlocker into a code-generation substrate#87
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.

claude added 4 commits August 27, 2026 02:38
CodeBlocker writes through IndentedTextWriter, which terminates lines with
Environment.NewLine, so the same calls produced CRLF on Windows and LF
everywhere else. Generated code that is committed to a repository has to be
byte-identical wherever it was produced, so consumers had to post-process the
output to undo this.

Add a newLineString to the constructor and to Create(), plus a NewLines class
naming the usual choices, and report the terminator back as NewLineString. The
terminator is set on both the StringWriter and the IndentedTextWriter: the
latter forwards to its inner writer on modern targets, but this package also
ships for netstandard2.0, where the running framework supplies
IndentedTextWriter and that forwarding is not guaranteed.

The default is unchanged (Environment.NewLine), so existing callers see the same
output as before; determinism is opt-in.

The test suite spelled its expectations with CRLF throughout, so 46 of its 67
tests failed on any non-Windows host. They now build their writer through a
CRLF-pinned test factory, which makes those expectations true on every platform
and keeps the readable CRLF spelling in the multi-line fixtures. The default
terminator is covered by the new NewLineTests.

Refs #81

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ
IndentedTextWriter takes any TextWriter, so restricting CodeBlocker to
StringWriter was self-imposed. It ruled out generating straight to a file, to a
writer supplied by a build task, or to a test double - which matters for a
generator emitting many files, where buffering each one in memory as a string is
wasteful.

The restriction existed because ToString() reads back from the captured
StringWriter. ToString() now returns the buffered code when the writer is a
StringWriter and the type name otherwise, rather than throwing: debuggers and
diagnostics call ToString() freely, so throwing there would be worse than the
fallback. IsBuffered tells callers which case they are in.

The existing StringWriter constructors stay and delegate, so no caller has to
change. Disposal is unchanged in substance: only a writer Create() made for
itself is disposed, never a caller-supplied one - disposing the
IndentedTextWriter does not reach the writer it wraps.

Refs #82

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ
Scope and ScopeWithTrailingSemicolon covered braces; every generator built on
CodeBlocker then re-implemented the same handful of other shapes on top of raw
WriteLine, and got to invent its own spacing conventions doing it.

Add DelimiterScope as the shared base, ParenScope and BracketScope over it,
IndentScope for continuation lines, and RegionScope, DirectiveScope and
PragmaScope for the directive pairs. The directive scopes do not indent their
body, because a directive does not nest code. Balance is the point for the
pragma in particular: a suppression left open leaks into the rest of the file
and is tedious to trace back.

Add the file-preamble helpers next to them - the auto-generated marker, the
nullable context, the file-scoped namespace, and the using directives - each
owning the blank line that conventionally follows it, so a preamble is spaced
consistently no matter which parts a generator emits.

Scope and ScopeWithTrailingSemicolon are deliberately left as they are rather
than reparented onto DelimiterScope: their tests pin NullReferenceException for
a null CodeBlocker, and the new types validate properly instead.

Refs #83

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ
ktsu.Semantics built a declarative C# syntax object model on top of CodeBlocker
and it turned out to be the most reusable part of that repository's generator
stack: ~500 lines with no reference to physics, quantities or anything else
specific to it. It belongs here, where any generator can use it.

Ported as ktsu.CodeBlocker.Templates in the same package, made public,
documented, and given per-template render tests plus a golden test over a file
exercising every template kind - whose expected text has been compiled as C# to
confirm it is valid.

The port could not ship as it stood. Rendering a representative file showed the
model only ever produced valid C# for the narrow shapes Semantics happens to
use, so the fixes tracked separately land with it:

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

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

Refs #84, #85, #86

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ
claude added 4 commits August 27, 2026 03:28
KTSU0003 requires Polyfill's Ensure.NotNull over
ArgumentNullException.ThrowIfNull for framework compatibility, and it failed
the build on every target framework.

The rule lives in ktsu.Sdk.Analyzers, which could not run in the container this
was written in: its SDK ships Roslyn 5.0 and the analyzer needs 5.9, so the
build had to drop it and every KTSU rule went unchecked locally. Confirmed
instead that CA1062 is still satisfied - Ensure.NotNull carries [NotNull] on its
argument, so the nullability analysis reaches the same conclusion.

Refs #81, #82, #83, #84

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ
main fixed the same problem this branch did, differently: d4a7153 rewrote the
CRLF expectations to use Environment.NewLine, where this branch had pointed the
tests at a CRLF-pinned factory. Both make the suite pass on Linux; merged
together they contradict each other, and 45 tests failed on ubuntu while
windows stayed green.

Taking main's version of the four affected files and dropping TestCodeBlocker.
Expecting the host terminator is the better fit for tests that exercise the
default, and the pinned-terminator behaviour has its own coverage in
NewLineTests, which asserts exact bytes for LF, CRLF and a custom terminator.

167 passed, 0 failed on Linux in Release.

Refs #81

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ
SonarCloud's gate passed but reported three new issues, and the comment only
links to a dashboard this environment cannot reach. Reproduced them locally
instead by wiring SonarAnalyzer.CSharp into the build the way ktsu.Semantics
does, which named all three:

- S3267 twice in DocComment.Validate: one loop used only DocTag.Name, so it now
  iterates the names; the other filtered inside its body, so it now filters with
  Where.
- S3878 in the empty-usings test, which passed an explicit empty collection
  expression to a params method. It now exercises both overloads - no arguments
  and an empty sequence - which is better coverage for a helper a generator
  calls unconditionally.

The six findings left in CodeBlockerTests, ScopeTests and
ScopeWithTrailingSemicolonTests are on lines identical to main, which is why
the gate counted three and not nine. Left alone: they are not this PR's, and
fixing them would widen it.

167 passed, 0 failed on Linux in Release.

Refs #84, #86

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sagQjzFv3cnFNM3G271TJ
@sonarqubecloud

Copy link
Copy Markdown

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