From 041acd639d876c018bfc2b9b412ed052e88af125 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:31:13 +0000 Subject: [PATCH 1/2] Keep a verbatim string's continuation lines as written in template bodies [patch] SplitLines split a rendered body at every terminator, so SpliceFragment re-indented the continuation lines of a multi-line verbatim string literal and silently changed its runtime value. A small scanner now finds verbatim literal text (skipping comments, char literals, regular, raw and interpolated strings) and SplitLines no longer splits inside it. CodeBlocker.WriteLine does not indent after an embedded terminator, so the continuation lines come out exactly as the factory wrote them. Raw string literals and ordinary bodies are re-indented as before. Fixes ktsu-dev/CodeBlocker#121 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dzcato9evJfMk7iLBAHZpo --- CLAUDE.md | 5 +- CodeBlocker.Test/TemplateTests.cs | 118 +++++++ CodeBlocker/Templates/TemplateRendering.cs | 46 ++- .../Templates/VerbatimStringScanner.cs | 323 ++++++++++++++++++ 4 files changed, 484 insertions(+), 8 deletions(-) create mode 100644 CodeBlocker/Templates/VerbatimStringScanner.cs diff --git a/CLAUDE.md b/CLAUDE.md index 6e624d3..e3dbaef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,10 @@ The library is built around these types: When emitting a multi-line fragment inside a template, route it through `TemplateRendering.SpliceFragment` rather than `NewLine()`/`WriteLineNoTabs` — `IndentedTextWriter.WriteLineNoTabs` does not re-arm the pending-tab flag, so the next line - silently lands at column 0. + silently lands at column 0. `SplitLines` (which `SpliceFragment` uses) does not split at a + terminator inside a verbatim string literal (`@"..."`, `$@"..."`): `VerbatimStringScanner` + finds them, so a literal's continuation lines keep their exact text instead of picking up the + indent, which would change the string's value. Raw string literals are still re-indented. ## SDK and Dependencies diff --git a/CodeBlocker.Test/TemplateTests.cs b/CodeBlocker.Test/TemplateTests.cs index 03a37b8..5c40f52 100644 --- a/CodeBlocker.Test/TemplateTests.cs +++ b/CodeBlocker.Test/TemplateTests.cs @@ -1047,4 +1047,122 @@ public void EveryTemplateRejectsANullCodeBlocker() } #endregion + + #region Verbatim string literals in bodies + + private static string RenderInClass(MemberTemplate member) + { + ClassTemplate type = new() { Name = "C", Members = { member } }; + return Render(type); + } + + [TestMethod] + public void AMethodBodyKeepsAVerbatimStringsContinuationLinesAsWritten() + { + MethodTemplate method = new() + { + Name = "M", + Type = "string", + BodyFactory = b => + { + b.WriteLine("{"); + b.Indent(); + b.WriteLine("return @\"line1\nline2\";"); + b.Outdent(); + b.WriteLine("}"); + }, + }; + + Assert.AreEqual( + "class C\n{\n\tstring M()\n\t{\n\t\treturn @\"line1\nline2\";\n\t}\n}\n", + RenderInClass(method)); + } + + [TestMethod] + public void ABlockAccessorKeepsAnInterpolatedVerbatimStringsContinuationLinesAsWritten() + { + PropertyTemplate property = new() + { + Type = "string", + Name = "P", + Getter = AccessorTemplate.Block(b => b.WriteLine("return $@\"{name}\n \"\"{name}\"\"\n{{done}}\";")), + }; + + Assert.AreEqual( + "class C\n{\n\tstring P\n\t{\n\t\tget\n\t\t{\n\t\t\treturn $@\"{name}\n \"\"{name}\"\"\n{{done}}\";\n\t\t}\n\t}\n}\n", + RenderInClass(property)); + } + + [TestMethod] + public void AnExpressionBodyKeepsAVerbatimStringsContinuationLinesAsWritten() + { + PropertyTemplate property = new() + { + Type = "string", + Name = "P", + ExpressionBodyFactory = b => + { + b.WriteLine("Join("); + b.Indent(); + b.WriteLine("@$\"a{Format(\"}\", @\"x\ny\")}\nb\","); + b.WriteLine("c)"); + b.Outdent(); + }, + }; + + Assert.AreEqual( + "class C\n{\n\tstring P => Join(\n\t\t@$\"a{Format(\"}\", @\"x\ny\")}\nb\",\n\t\tc);\n}\n", + RenderInClass(property)); + } + + [TestMethod] + public void QuotesInCommentsCharactersAndRegularStringsDoNotStartAVerbatimString() + { + MethodTemplate method = new() + { + Name = "M", + Type = "void", + BodyFactory = b => + { + b.WriteLine("{"); + b.Indent(); + b.WriteLine("// say @\""); + b.WriteLine("/* or @\" */ char q = '\"';"); + b.WriteLine("string s = \"@\\\"\" + @\"\";"); + b.WriteLine("Call();"); + b.Outdent(); + b.WriteLine("}"); + }, + }; + + Assert.AreEqual( + "class C\n{\n\tvoid M()\n\t{\n\t\t// say @\"\n\t\t/* or @\" */ char q = '\"';\n\t\tstring s = \"@\\\"\" + @\"\";\n\t\tCall();\n\t}\n}\n", + RenderInClass(method)); + } + + [TestMethod] + public void ARawStringLiteralIsStillReindented() + { + MethodTemplate method = new() + { + Name = "M", + Type = "string", + BodyFactory = b => + { + b.WriteLine("{"); + b.Indent(); + b.WriteLine("return \"\"\""); + b.WriteLine("\"@ text"); + b.WriteLine("\"\"\";"); + b.Outdent(); + b.WriteLine("}"); + }, + }; + + Assert.AreEqual( + "class C\n{\n\tstring M()\n\t{\n\t\treturn \"\"\"\n\t\t\"@ text\n\t\t\"\"\";\n\t}\n}\n", + RenderInClass(method)); + } + + #endregion } diff --git a/CodeBlocker/Templates/TemplateRendering.cs b/CodeBlocker/Templates/TemplateRendering.cs index 82d7967..017955e 100644 --- a/CodeBlocker/Templates/TemplateRendering.cs +++ b/CodeBlocker/Templates/TemplateRendering.cs @@ -45,18 +45,50 @@ internal static string RenderFragment(CodeBlocker parent, Action? f /// The the fragment was rendered for. /// The rendered fragment. /// The fragment's lines. + /// + /// A terminator inside a verbatim string literal (@"...", $@"...", @$"...") + /// is part of the string's value, not a line break in the code, so it is not a split point: the + /// line that holds it keeps the terminator, and writing that line through + /// indents only its first physical line. Re-indenting + /// the continuation lines instead would add the indent to the string's value. + /// internal static string[] SplitLines(CodeBlocker parent, string fragment) { - string[] lines = fragment.Split([parent.NewLineString], StringSplitOptions.None); - if (lines.Length == 0 || lines[^1].Length != 0) + string terminator = parent.NewLineString; + if (terminator.Length == 0) + { + return fragment.Length == 0 ? [] : [fragment]; + } + + bool[] inVerbatimText = VerbatimStringScanner.FindVerbatimText(fragment); + List lines = []; + int lineStart = 0; + int searchFrom = 0; + while (true) + { + int split = fragment.IndexOf(terminator, searchFrom, StringComparison.Ordinal); + if (split < 0) + { + break; + } + + searchFrom = split + terminator.Length; + if (inVerbatimText[split]) + { + continue; + } + + lines.Add(fragment[lineStart..split]); + lineStart = searchFrom; + } + + // A trailing terminator leaves an empty tail, which is not a line of its own. + if (lineStart < fragment.Length) { - return lines; + lines.Add(fragment[lineStart..]); } - // 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; + return [.. lines]; } /// diff --git a/CodeBlocker/Templates/VerbatimStringScanner.cs b/CodeBlocker/Templates/VerbatimStringScanner.cs new file mode 100644 index 0000000..e0f14df --- /dev/null +++ b/CodeBlocker/Templates/VerbatimStringScanner.cs @@ -0,0 +1,323 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CodeBlocker.Templates; + +/// +/// Finds the text of verbatim string literals in a fragment of C#, so that a line break inside one +/// is not mistaken for a line break in the code around it. +/// +/// +/// This is a lexer only as far as it needs to be to find where literals start and end: comments, +/// character literals, regular and raw strings, and the interpolation holes of interpolated strings +/// are walked so that a quote inside any of them is not read as the start of a verbatim string. +/// Text it cannot make sense of is treated as ordinary code, so a malformed fragment is split as it +/// always was. +/// +internal static class VerbatimStringScanner +{ + /// + /// Marks each character of that lies inside the body of a verbatim + /// string literal. + /// + /// The C# fragment to scan. + /// + /// One flag per character of , true where that character is part of a + /// verbatim literal's value. Interpolation holes inside the literal are code, not value, and are + /// not marked. + /// + internal static bool[] FindVerbatimText(string text) + { + bool[] inVerbatimText = new bool[text.Length]; + int position = 0; + ScanCode(text, ref position, inVerbatimText, isInterpolationHole: false); + return inVerbatimText; + } + + /// + /// Walks code up to the end of or, inside an interpolation hole, up to + /// the brace that closes the hole, which is left for the caller to consume. + /// + private static void ScanCode(string text, ref int position, bool[] inVerbatimText, bool isInterpolationHole) + { + int braceDepth = 0; + while (position < text.Length) + { + if (TrySkipCommentOrLiteral(text, ref position, inVerbatimText)) + { + continue; + } + + if (isInterpolationHole && IsClosingBrace(text[position], ref braceDepth)) + { + return; + } + + position++; + } + } + + /// + /// Moves past the comment or literal that starts at , if one does. + /// + private static bool TrySkipCommentOrLiteral(string text, ref int position, bool[] inVerbatimText) + { + char c = text[position]; + if (c == '/' && At(text, position + 1) == '/') + { + SkipLineComment(text, ref position); + return true; + } + + if (c == '/' && At(text, position + 1) == '*') + { + int end = text.IndexOf("*/", position + 2, StringComparison.Ordinal); + position = end < 0 ? text.Length : end + 2; + return true; + } + + if (c == '\'') + { + SkipCharLiteral(text, ref position); + return true; + } + + return c is '"' or '$' or '@' && TryScanString(text, ref position, inVerbatimText); + } + + /// + /// Tracks brace nesting inside an interpolation hole. + /// + /// True when is the brace that closes the hole. + private static bool IsClosingBrace(char c, ref int braceDepth) + { + if (c == '{') + { + braceDepth++; + } + else if (c == '}') + { + if (braceDepth == 0) + { + return true; + } + + braceDepth--; + } + + return false; + } + + private static char At(string text, int position) => position < text.Length ? text[position] : '\0'; + + private static bool IsLineBreak(char c) => c is '\n' or '\r' or '\u0085' or '\u2028' or '\u2029'; + + private static void SkipLineComment(string text, ref int position) + { + while (position < text.Length && !IsLineBreak(text[position])) + { + position++; + } + } + + private static void SkipCharLiteral(string text, ref int position) + { + position++; + while (position < text.Length) + { + char c = text[position]; + if (c == '\\') + { + position += 2; + } + else if (c == '\'') + { + position++; + return; + } + else if (IsLineBreak(c)) + { + // Unterminated: leave the line break to the code around it. + return; + } + else + { + position++; + } + } + } + + /// + /// Moves past the string literal that starts at , if one does. + /// + /// + /// False, without moving, when the $, @ or quote there does not start a string + /// literal — an @ before an identifier, for one. + /// + private static bool TryScanString(string text, ref int position, bool[] inVerbatimText) + { + int cursor = position; + int dollars = 0; + bool isVerbatim = false; + while (At(text, cursor) == '$') + { + dollars++; + cursor++; + } + + if (At(text, cursor) == '@') + { + isVerbatim = true; + cursor++; + while (At(text, cursor) == '$') + { + dollars++; + cursor++; + } + } + + if (At(text, cursor) != '"') + { + return false; + } + + bool isInterpolated = dollars > 0; + if (isVerbatim) + { + position = cursor + 1; + ScanVerbatimBody(text, ref position, inVerbatimText, isInterpolated); + return true; + } + + int quotes = 0; + while (At(text, cursor + quotes) == '"') + { + quotes++; + } + + if (quotes >= 3) + { + position = cursor + quotes; + SkipRawBody(text, ref position, quotes); + } + else if (quotes == 2) + { + // An empty regular string. + position = cursor + 2; + } + else + { + position = cursor + 1; + SkipRegularBody(text, ref position, inVerbatimText, isInterpolated); + } + + return true; + } + + private static void ScanVerbatimBody(string text, ref int position, bool[] inVerbatimText, bool isInterpolated) + { + while (position < text.Length) + { + char c = text[position]; + if (c == '"') + { + if (At(text, position + 1) != '"') + { + position++; + return; + } + + // A doubled quote is an escaped quote. + inVerbatimText[position] = true; + inVerbatimText[position + 1] = true; + position += 2; + } + else if (isInterpolated && c == '{' && At(text, position + 1) != '{') + { + SkipInterpolationHole(text, ref position, inVerbatimText); + } + else if (isInterpolated && c == '{') + { + // A doubled brace is an escaped brace. + inVerbatimText[position] = true; + inVerbatimText[position + 1] = true; + position += 2; + } + else + { + inVerbatimText[position] = true; + position++; + } + } + } + + private static void SkipRegularBody(string text, ref int position, bool[] inVerbatimText, bool isInterpolated) + { + while (position < text.Length) + { + char c = text[position]; + if (c == '\\') + { + position += 2; + } + else if (c == '"') + { + position++; + return; + } + else if (isInterpolated && c == '{' && At(text, position + 1) == '{') + { + position += 2; + } + else if (isInterpolated && c == '{') + { + SkipInterpolationHole(text, ref position, inVerbatimText); + } + else if (IsLineBreak(c)) + { + // Unterminated: leave the line break to the code around it. + return; + } + else + { + position++; + } + } + } + + /// + /// Moves past a raw string's body and its closing delimiter. A raw string is re-indented like + /// any other code: the compiler strips the closing delimiter's indentation from every line. + /// + private static void SkipRawBody(string text, ref int position, int delimiterLength) + { + while (position < text.Length) + { + int run = 0; + while (At(text, position + run) == '"') + { + run++; + } + + if (run >= delimiterLength) + { + position += run; + return; + } + + position += Math.Max(run, 1); + } + } + + /// + /// Moves past an interpolation hole, from its opening brace to just after its closing one. + /// + private static void SkipInterpolationHole(string text, ref int position, bool[] inVerbatimText) + { + position++; + ScanCode(text, ref position, inVerbatimText, isInterpolationHole: true); + if (position < text.Length) + { + position++; + } + } +} From d15f4f20a986529cbbb2f6e4142e25eb09e26dd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:38:09 +0000 Subject: [PATCH 2/2] Pin SplitLines' handling of every literal the scanner walks [patch] Data-driven tests for the scanner's edge cases: escapes in char, regular and verbatim literals, escaped braces, nested braces in a hole, empty and raw strings, a verbatim identifier, unterminated literals and comments, CRLF, and the empty terminator. They also bring the new code's coverage over the SonarCloud gate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dzcato9evJfMk7iLBAHZpo --- CodeBlocker.Test/SplitLinesTests.cs | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 CodeBlocker.Test/SplitLinesTests.cs diff --git a/CodeBlocker.Test/SplitLinesTests.cs b/CodeBlocker.Test/SplitLinesTests.cs new file mode 100644 index 0000000..67b233e --- /dev/null +++ b/CodeBlocker.Test/SplitLinesTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace CodeBlocker.Tests; + +using ktsu.CodeBlocker; +using ktsu.CodeBlocker.Templates; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Pins where splits a fragment: at every terminator +/// except one inside a verbatim string literal, however that literal is reached. +/// +[TestClass] +public sealed class SplitLinesTests +{ + private static string Split(string fragment, string newLine = NewLines.Lf) + { + using CodeBlocker parent = CodeBlocker.Create(CodeBlocker.DefaultIndentString, newLine); + return string.Join("|", TemplateRendering.SplitLines(parent, fragment)); + } + + [TestMethod] + [DataRow("a\nb\n", "a|b", DisplayName = "Plain lines, trailing terminator dropped")] + [DataRow("a = @\"p\nq\";\nb", "a = @\"p\nq\";|b", DisplayName = "Verbatim")] + [DataRow("a = @\"p\"\"\nq\";\nb", "a = @\"p\"\"\nq\";|b", DisplayName = "Verbatim with an escaped quote")] + [DataRow("a = @\"p\nq", "a = @\"p\nq", DisplayName = "Unterminated verbatim")] + [DataRow("s = $\"{f(new[] { 1 })}\" + @\"p\nq\";\nb", "s = $\"{f(new[] { 1 })}\" + @\"p\nq\";|b", DisplayName = "Nested braces in a hole")] + [DataRow("s = $\"{{\" + @\"p\nq\";\nb", "s = $\"{{\" + @\"p\nq\";|b", DisplayName = "Escaped brace in an interpolated string")] + [DataRow("s = \"\\\"\" + @\"p\nq\";\nb", "s = \"\\\"\" + @\"p\nq\";|b", DisplayName = "Escaped quote in a regular string")] + [DataRow("s = \"\" + @\"p\nq\";\nb", "s = \"\" + @\"p\nq\";|b", DisplayName = "Empty regular string")] + [DataRow("s = \"\"\"a\"\"b\"\"\" + @\"p\nq\";\nb", "s = \"\"\"a\"\"b\"\"\" + @\"p\nq\";|b", DisplayName = "Raw string with a shorter run of quotes inside")] + [DataRow("c = '\\'' + @\"p\nq\";\nb", "c = '\\'' + @\"p\nq\";|b", DisplayName = "Escaped quote in a char literal")] + [DataRow("@class = 1; // @\"\nb", "@class = 1; // @\"|b", DisplayName = "Verbatim identifier, and a quote in a line comment")] + [DataRow("c = '\nb", "c = '|b", DisplayName = "Unterminated char literal")] + [DataRow("s = \"p\nb", "s = \"p|b", DisplayName = "Unterminated regular string")] + [DataRow("/* @\"\nb", "/* @\"|b", DisplayName = "Unterminated block comment")] + public void SplitsOnlyOutsideVerbatimStrings(string fragment, string expected) => + Assert.AreEqual(expected, Split(fragment)); + + [TestMethod] + public void ACrLfTerminatorInsideAVerbatimStringIsNotASplitPoint() => + Assert.AreEqual("a = @\"p\r\nq\";|b", Split("a = @\"p\r\nq\";\r\nb\r\n", NewLines.CrLf)); + + [TestMethod] + public void AnEmptyTerminatorLeavesTheFragmentWhole() + { + Assert.AreEqual("a\nb", Split("a\nb", string.Empty)); + Assert.AreEqual(string.Empty, Split(string.Empty, string.Empty)); + } +}