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/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));
+ }
+}
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++;
+ }
+ }
+}