Skip to content

Template bodies re-indent every line of a multi-line verbatim string, silently changing the generated string's value #121

Description

@matt-edmondson

What's wrong

TemplateRendering.SpliceFragment (CodeBlocker/Templates/TemplateRendering.cs:73-86) splits a rendered body on NewLineString and writes each piece back with WriteLine, so every line picks up the enclosing indent. This path handles method, constructor and operator bodies (WriteBody, line 244) and block accessors (AccessorTemplate.cs:411). Since #112 it also handles expression bodies (WriteExpressionBody, lines 287-301).

That is only safe if a line break never falls inside a token. A multi-line verbatim string literal (@"...", $@"...", @$"...") keeps its whitespace literally. Adding tabs to its continuation lines therefore changes the string's runtime value, and nothing reports it because the code still compiles.

Writing the same text directly through CodeBlocker preserves it: IndentedTextWriter.WriteLine(string) does not indent after an embedded newline. The problem is specific to the template layer.

Repro (HEAD d04adc9)

var cls = new ClassTemplate { Name = "C" };
cls.Members.Add(new MethodTemplate {
    Name = "M", Type = "string",
    BodyFactory = b => { b.WriteLine("{"); b.Indent(); b.WriteLine("return @\"line1\nline2\";"); b.Outdent(); b.WriteLine("}"); },
});
using var cb = CodeBlocker.Create();
cb.WriteLine("namespace N");
using (new Scope(cb)) { cb.AddClass(cls); }

Output (→ = tab):

→→string M()
→→{
→→→return @"line1
→→line2";
→→}

M() now returns "line1\n\t\tline2" instead of "line1\nline2".

For comparison, cb.WriteLine("string s = @\"a\nb\";") inside a Scope, written directly, puts b"; at column 0, which is correct.

Raw string literals (""") are not affected, because the closing delimiter is indented by the same amount and the compiler strips it. The problem is limited to verbatim strings, interpolated verbatim strings included.

Why it matters

Generators often embed multi-line text (SQL, templates, help text, resource contents), and verbatim strings are the usual way to do that on older language versions. The corruption is silent: the output compiles, and golden files simply record the wrong value. The point of the library is that callers don't have to think about indentation, and the template model is the recommended path (CLAUDE.md: "route it through TemplateRendering.SpliceFragment"), so it shouldn't change what the generated program does.

Suggested fix

Make SplitLines/SpliceFragment aware of line breaks inside verbatim strings. A small scanner over the fragment would track:

  • // and /* */ comments
  • char literals
  • regular strings with \ escapes
  • verbatim strings with "" escapes
  • raw strings, by counting quotes

A terminator that falls inside a verbatim literal then stays part of the current line instead of becoming a split point. Continuation lines are written unchanged, using WriteLineNoTabs and then re-arming the pending tabs the way NewLine() now does.

An alternative that needs no scanner is to document the limitation on BodyFactory/ExpressionBodyFactory and recommend raw string literals. The scanner is the better option, because template output would then match direct CodeBlocker output.

Acceptance criteria

  • A MethodTemplate, AccessorTemplate.Block or ExpressionBodyFactory whose body contains @"a\nb" renders the continuation lines byte-for-byte as the factory wrote them.
  • Ordinary multi-line bodies and multi-line raw string literals are still re-indented as they are today, and the existing golden tests are unchanged.
  • A regression test covers the literal's continuation line, by compiling it or at least by string comparison.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions