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