From 071b6fb02177acd475db70a46dc3dca6718cb840 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:34:10 +0000 Subject: [PATCH] Skip a preprocessor line's text when looking for verbatim strings [patch] The compiler does not tokenize the text after #region, #error, #warning or #pragma, but VerbatimStringScanner did: an @" in a region name opened a verbatim string that never closed, so SplitLines stopped splitting and every later line in the member lost its indent. At the top level, a line whose first non-whitespace character is # is now skipped to its line break, without recording a comment start. A # inside a verbatim literal, inside an interpolation hole, or mid-line starts nothing. Fixes ktsu-dev/CodeBlocker#194 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW677dmfMN4vzuGiiZH --- CodeBlocker.Test/SplitLinesTests.cs | 7 ++ CodeBlocker.Test/TemplateTests.cs | 35 +++++++++ CodeBlocker.Test/WriteLinesTests.cs | 12 +++ .../Templates/VerbatimStringScanner.cs | 75 +++++++++++++++++-- 4 files changed, 123 insertions(+), 6 deletions(-) diff --git a/CodeBlocker.Test/SplitLinesTests.cs b/CodeBlocker.Test/SplitLinesTests.cs index 8632e96..54d1eeb 100644 --- a/CodeBlocker.Test/SplitLinesTests.cs +++ b/CodeBlocker.Test/SplitLinesTests.cs @@ -38,6 +38,13 @@ private static string Split(string fragment, string newLine = NewLines.Lf) [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")] + [DataRow("#region a @\"C:\\\nint a;\n#endregion", "#region a @\"C:\\|int a;|#endregion", DisplayName = "Verbatim prefix in a #region name")] + [DataRow("\t #error $@\"x\nb", "\t #error $@\"x|b", DisplayName = "Interpolated verbatim prefix in an indented #error")] + [DataRow("#warning @$\"x\nb", "#warning @$\"x|b", DisplayName = "Verbatim interpolated prefix in a #warning")] + [DataRow("#pragma warning disable // @\"\nb", "#pragma warning disable // @\"|b", DisplayName = "Verbatim prefix in a #pragma comment")] + [DataRow("s = @\"p\n#q\";\nb", "s = @\"p\n#q\";|b", DisplayName = "Verbatim line starting with # stays in its literal")] + [DataRow("s = @\"p\n #region @\"\"q\";\nb", "s = @\"p\n #region @\"\"q\";|b", DisplayName = "Directive-like line inside a verbatim literal is literal text")] + [DataRow("a = b # @\"p\nq\";\nc", "a = b # @\"p\nq\";|c", DisplayName = "A # mid-line starts no directive")] public void SplitsOnlyOutsideVerbatimStrings(string fragment, string expected) => Assert.AreEqual(expected, Split(fragment)); diff --git a/CodeBlocker.Test/TemplateTests.cs b/CodeBlocker.Test/TemplateTests.cs index 3fd8237..fe9fb81 100644 --- a/CodeBlocker.Test/TemplateTests.cs +++ b/CodeBlocker.Test/TemplateTests.cs @@ -22,6 +22,41 @@ private static string Render(Action write) private static string Render(TemplateBase template) => Render(template.WriteTo); + [TestMethod] + public void RegionNameWithAVerbatimPrefixKeepsTheMemberIndented() + { + // ktsu-dev/CodeBlocker#194. + ClassTemplate template = new() + { + Name = "C", + Members = + { + new MethodTemplate + { + Type = "void", + Name = "M", + BodyFactory = b => + { + b.WriteLine("{"); + b.Indent(); + using (new RegionScope(b, "Paths like @\"C:\\")) + { + b.WriteLine("int a = 1;"); + b.WriteLine("int b = 2;"); + } + + b.Outdent(); + b.WriteLine("}"); + }, + }, + }, + }; + + Assert.AreEqual( + "class C\n{\n\tvoid M()\n\t{\n\t\t#region Paths like @\"C:\\\n\t\tint a = 1;\n\t\tint b = 2;\n\t\t#endregion\n\t}\n}\n", + Render(template)); + } + #region ParameterTemplate [TestMethod] diff --git a/CodeBlocker.Test/WriteLinesTests.cs b/CodeBlocker.Test/WriteLinesTests.cs index 5509b99..d258309 100644 --- a/CodeBlocker.Test/WriteLinesTests.cs +++ b/CodeBlocker.Test/WriteLinesTests.cs @@ -48,6 +48,18 @@ public void LeavesAVerbatimLiteralsLinesAsWritten() Assert.AreEqual($"\tstring s = @\"first\r\nsecond\";{Nl}\tf();{Nl}", codeBlocker.ToString()); } + [TestMethod] + public void IndentsEveryLineAfterAVerbatimPrefixInADirective() + { + // ktsu-dev/CodeBlocker#194: the @" in the #region name opened a verbatim string that never + // closed, so the three lines after it were written at column 0. + using CodeBlocker codeBlocker = CodeBlocker.Create(); + codeBlocker.Indent(); + codeBlocker.WriteLines("#region Handles @\" prefixes\nint a;\nint b;\n#endregion\n"); + + Assert.AreEqual($"\t#region Handles @\" prefixes{Nl}\tint a;{Nl}\tint b;{Nl}\t#endregion{Nl}", codeBlocker.ToString()); + } + [TestMethod] public void KeepsBlankLinesFreeOfIndentation() { diff --git a/CodeBlocker/Templates/VerbatimStringScanner.cs b/CodeBlocker/Templates/VerbatimStringScanner.cs index 56caf0b..6eb554e 100644 --- a/CodeBlocker/Templates/VerbatimStringScanner.cs +++ b/CodeBlocker/Templates/VerbatimStringScanner.cs @@ -65,14 +65,10 @@ private static void ScanCode(string text, ref int position, bool[] inVerbatimTex { int braceDepth = 0; int bracketDepth = 0; + bool atLineStart = !isInterpolationHole; while (position < text.Length) { - if (lineCommentStarts is not null && text[position] == '/' && At(text, position + 1) == '/') - { - lineCommentStarts.Add(position); - } - - if (TrySkipCommentOrLiteral(text, ref position, inVerbatimText)) + if (TrySkipNonCode(text, ref position, inVerbatimText, lineCommentStarts, ref atLineStart, isInterpolationHole)) { continue; } @@ -96,6 +92,73 @@ private static void ScanCode(string text, ref int position, bool[] inVerbatimTex } } + /// + /// Moves past the preprocessor line, comment or literal that starts at + /// , if one does, recording a line comment's start on the way. + /// + /// True when something was skipped. + private static bool TrySkipNonCode(string text, ref int position, bool[] inVerbatimText, List? lineCommentStarts, ref bool atLineStart, bool isInterpolationHole) + { + if (TrySkipPreprocessorLine(text, ref position, ref atLineStart, isInterpolationHole)) + { + return true; + } + + if (lineCommentStarts is not null && text[position] == '/' && At(text, position + 1) == '/') + { + lineCommentStarts.Add(position); + } + + if (!TrySkipCommentOrLiteral(text, ref position, inVerbatimText)) + { + return false; + } + + // A literal can span lines, but the code after it does not start a line. + atLineStart = false; + return true; + } + + /// + /// Moves past the rest of a preprocessor line when is at its + /// #, and otherwise tracks whether the walk is still at the start of a line. + /// + /// + /// The compiler does not tokenize the rest of a preprocessor line: the text after + /// #region, #error or #warning is free text, so an @" in it opens + /// nothing (ktsu-dev/CodeBlocker#194). The line break is left to the code around it. Only a + /// # preceded on its line by nothing but whitespace starts a directive, and never inside + /// an interpolation hole. + /// + /// The C# fragment to scan. + /// The current position, moved to the line break when a directive is skipped. + /// Whether only whitespace has been seen since the last line break. + /// Whether the walk is inside an interpolation hole. + /// True when a directive line was skipped. + private static bool TrySkipPreprocessorLine(string text, ref int position, ref bool atLineStart, bool isInterpolationHole) + { + char c = text[position]; + if (IsLineBreak(c)) + { + atLineStart = !isInterpolationHole; + return false; + } + + if (!atLineStart) + { + return false; + } + + if (c == '#') + { + SkipLineComment(text, ref position); + return true; + } + + atLineStart = char.IsWhiteSpace(c); + return false; + } + /// /// Tracks parenthesis and bracket nesting inside an interpolation hole, and reports whether the /// character at is the colon that starts the hole's format specifier.