Generalize CodeBlocker into a code-generation substrate - #87
Merged
Conversation
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
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
…lize-codegen-hjt2iy
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
|
This was referenced Aug 27, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Work towards ktsu-dev/Semantics#181 — extracting the general-purpose code-generation stack out of
Semantics.SourceGeneratorsso 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)
CodeBlockerwrites throughIndentedTextWriter, which terminates lines withEnvironment.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.Semanticscarries a post-processing pass purely to undo this.CodeBlocker(StringWriter, string indentString, string newLineString)andCreate(string indentString, string newLineString).NewLinesclass naming the usual choices (Lf,CrLf,Host), andNewLineStringreporting 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 fornetstandard2.0, where the running framework suppliesIndentedTextWriterand 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 justStringWriter(#82)IndentedTextWritertakes anyTextWriter, 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. TheStringWriterconstructors stay and delegate, so no caller changes.ToString()returns the buffered code when the writer is aStringWriterand the type name otherwise, rather than throwing — debuggers callToString()freely.IsBufferedtells callers which case they are in.Create()made for itself is disposed, never a caller-supplied one. (Verified: disposing anIndentedTextWriterdoes not reach the writer it wraps.)Scopes and directive helpers (#83)
DelimiterScopeas a shared base, withParenScopeandBracketScopeover it, plusIndentScopefor 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.ScopeandScopeWithTrailingSemicolonare deliberately not reparented ontoDelimiterScope: their tests pinNullReferenceExceptionfor a nullCodeBlocker, and the new types validate properly withArgumentNullException.The C# template object model (#84, #85, #86)
ktsu.Semanticsbuilt a declarative C# syntax object model on top ofCodeBlocker, 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 asktsu.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:
};, and the kind came from free text inKeywords. NowTypeKind— class, struct, interface, record, record struct, enum.CodeBlocker.NewLine()goes throughIndentedTextWriter.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 useWriteLine()now — that one was the root cause of most of the misindentation.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 setis expressible, and a property with no accessors throws instead of renderingint Value;— a field.this(...)constructor chaining. Attributes go on their own line.ClassTemplateExtensions.AddInheritance— which wrote its base list twice and was called by nothing — is not carried over.XML documentation is data too (#86).
DocCommentescapes text content by default (a description containing<or&used to emit malformed XML), orders tags canonically, prefixes multi-line descriptions, and can validate itsparam/typeparamentries against the member — so a mismatch is reportable as a build diagnostic rather than surfacing as CS1572 inside generated source. The verbatimCommentslist stays as the escape hatch.Tests
167 passed, 0 failedon Linux in Release.TemplateGoldenTestsrenders 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'sd4a7153, which fixed the pre-existing Linux test failures by expectingEnvironment.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 inNewLineTests.Notes for the reviewer
README.mdgains 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.mdis generated by the release pipeline from commit tags, so it is untouched; feature commits carry[minor].-p:CustomAfterMicrosoftCommonTargets=…to dropktsu.Sdk.Analyzers(this container's SDK ships Roslyn 5.0; the analyzer wants 5.9), so the KTSU rules could not run locally — CI caughtKTSU0003and 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 inSonarAnalyzer.CSharpthe wayktsu.Semanticsdoes, 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 tomain. Worth a glance from someone with dashboard access; the gate passes either way.