diff --git a/CodeBlocker.Test/TemplateTests.cs b/CodeBlocker.Test/TemplateTests.cs index 999de40..e766c11 100644 --- a/CodeBlocker.Test/TemplateTests.cs +++ b/CodeBlocker.Test/TemplateTests.cs @@ -425,6 +425,67 @@ public void AnEmptyExpressionBodyStillTerminatesTheDeclaration() Assert.AreEqual("public int X => ;\n", Render(property)); } + private static string RenderExpressionBody(Action expressionFactory) => + Render(new PropertyTemplate { Type = "int", Name = "X", Keywords = { "public" }, ExpressionBodyFactory = expressionFactory }); + + [TestMethod] + public void AnExpressionBodyEndingInALineCommentGetsItsSemicolonBeforeTheComment() => + Assert.AreEqual( + "public int X => x; // c\n", + RenderExpressionBody(codeBlocker => codeBlocker.Write("x // c"))); + + [TestMethod] + public void ASwitchExpressionEndingInALineCommentGetsItsSemicolonAfterTheBrace() => + Assert.AreEqual( + "public int X => v switch\n{\n\t_ => 1,\n}; // c\n", + RenderExpressionBody(codeBlocker => + { + codeBlocker.WriteLine("v switch"); + codeBlocker.WriteLine("{"); + codeBlocker.WriteLine("\t_ => 1,"); + codeBlocker.Write("} // c"); + })); + + [TestMethod] + public void AnExpressionBodyEndingInACommentOnlyLineKeepsItsSemicolonOnTheLastLineOfCode() => + Assert.AreEqual( + "public int X => x;\n// why\n", + RenderExpressionBody(codeBlocker => + { + codeBlocker.WriteLine("x"); + codeBlocker.Write("// why"); + })); + + [TestMethod] + public void ASlashPairInsideAStringLiteralIsNotTakenForAComment() => + Assert.AreEqual( + "public int X => \"http://x\";\n", + RenderExpressionBody(codeBlocker => codeBlocker.Write("\"http://x\""))); + + [TestMethod] + public void ASlashPairInsideAVerbatimStringOpenedOnAnEarlierLineIsNotTakenForAComment() => + Assert.AreEqual( + "public int X => @\"a\n// b\";\n", + RenderExpressionBody(codeBlocker => + { + codeBlocker.WriteLine("@\"a"); + codeBlocker.Write("// b\""); + })); + + [TestMethod] + public void AnExpressionBodiedAccessorEndingInALineCommentGetsItsSemicolonBeforeTheComment() + { + PropertyTemplate property = new() + { + Type = "int", + Name = "X", + Keywords = { "public" }, + Getter = AccessorTemplate.Expression(codeBlocker => codeBlocker.Write("x // c")), + }; + + Assert.AreEqual("public int X\n{\n\tget => x; // c\n}\n", Render(property)); + } + [TestMethod] public void ABlockBodiedAccessorIsBracedAndIndented() { diff --git a/CodeBlocker/Templates/TemplateRendering.cs b/CodeBlocker/Templates/TemplateRendering.cs index 11bf57b..a2625bd 100644 --- a/CodeBlocker/Templates/TemplateRendering.cs +++ b/CodeBlocker/Templates/TemplateRendering.cs @@ -319,8 +319,9 @@ internal static void WriteBody(CodeBlocker codeBlocker, Action? bod /// The first line of the expression stays on the declaration line, after the arrow, and every /// following line is spliced at the current indent, so a multi-line switch expression, collection /// expression or initialiser keeps the nesting it was written with. The semicolon goes on the last - /// line that has content, so a factory that ends with a line terminator does not leave it on a - /// line of its own. + /// line that has code, so a factory that ends with a line terminator does not leave it on a line + /// of its own. A line comment there keeps the semicolon in front of it, and a line holding only a + /// comment is not code, so the semicolon never ends up inside a comment. /// internal static void WriteExpressionBody(CodeBlocker codeBlocker, Action? expressionFactory) { @@ -347,11 +348,18 @@ internal static void WriteExpressionBody(CodeBlocker codeBlocker, Action first && string.IsNullOrWhiteSpace(lines[terminated][..codeEnds[terminated]])) + { + terminated--; + } + for (int i = first; i <= last; i++) { - if (i == last) + if (i == terminated) { - codeBlocker.WriteLine(lines[i] + ";"); + codeBlocker.WriteLine(Terminate(lines[i], codeEnds[i])); } else if (lines[i].Length == 0) { @@ -363,4 +371,58 @@ internal static void WriteExpressionBody(CodeBlocker codeBlocker, Action + /// Finds where the code on each line ends: at the start of the line comment the line ends in, + /// or at the end of the line when it has none. + /// + /// The lines of a fragment, as returns them. + /// One position per line, relative to the start of that line. + /// + /// The lines are scanned together rather than one at a time, so a // on a line that a + /// literal or block comment opened on an earlier line is not taken for a comment. + /// + private static int[] FindCodeEnds(string[] lines) + { + int[] codeEnds = new int[lines.Length]; + int[] lineStarts = new int[lines.Length]; + int offset = 0; + for (int i = 0; i < lines.Length; i++) + { + lineStarts[i] = offset; + codeEnds[i] = lines[i].Length; + offset += lines[i].Length + 1; + } + + int line = 0; + foreach (int commentStart in VerbatimStringScanner.FindLineComments(string.Join("\n", lines))) + { + while (line + 1 < lines.Length && lineStarts[line + 1] <= commentStart) + { + line++; + } + + codeEnds[line] = Math.Min(codeEnds[line], commentStart - lineStarts[line]); + } + + return codeEnds; + } + + /// + /// Puts the terminating semicolon on a line, after its code and before any line comment. + /// + /// The line to terminate. + /// Where the code on the line ends, as found. + /// The line with its semicolon. + private static string Terminate(string line, int codeEnd) + { + if (codeEnd == line.Length) + { + return line + ";"; + } + + string code = line[..codeEnd]; + string trimmed = code.TrimEnd(); + return trimmed + ";" + code[trimmed.Length..] + line[codeEnd..]; + } } diff --git a/CodeBlocker/Templates/VerbatimStringScanner.cs b/CodeBlocker/Templates/VerbatimStringScanner.cs index e0f14df..528e1db 100644 --- a/CodeBlocker/Templates/VerbatimStringScanner.cs +++ b/CodeBlocker/Templates/VerbatimStringScanner.cs @@ -29,19 +29,48 @@ internal static bool[] FindVerbatimText(string text) { bool[] inVerbatimText = new bool[text.Length]; int position = 0; - ScanCode(text, ref position, inVerbatimText, isInterpolationHole: false); + ScanCode(text, ref position, inVerbatimText, lineCommentStarts: null); return inVerbatimText; } + /// + /// Finds the line comments in that belong to the code itself. + /// + /// The C# fragment to scan. + /// + /// The position of the first / of each such // comment, in order. A // + /// inside a string or character literal, a block comment or an interpolation hole is not one. + /// + internal static List FindLineComments(string text) + { + List lineCommentStarts = []; + int position = 0; + ScanCode(text, ref position, new bool[text.Length], lineCommentStarts); + return lineCommentStarts; + } + /// /// 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) + /// The C# fragment to scan. + /// Where to start, and on return where the walk stopped. + /// Receives the verbatim literal flags. + /// + /// Receives the start of each line comment, at the top level only; when + /// they are not wanted, and for an interpolation hole. + /// + /// Whether the walk is inside an interpolation hole. + private static void ScanCode(string text, ref int position, bool[] inVerbatimText, List? lineCommentStarts, bool isInterpolationHole = false) { int braceDepth = 0; while (position < text.Length) { + if (lineCommentStarts is not null && text[position] == '/' && At(text, position + 1) == '/') + { + lineCommentStarts.Add(position); + } + if (TrySkipCommentOrLiteral(text, ref position, inVerbatimText)) { continue; @@ -314,7 +343,7 @@ private static void SkipRawBody(string text, ref int position, int delimiterLeng private static void SkipInterpolationHole(string text, ref int position, bool[] inVerbatimText) { position++; - ScanCode(text, ref position, inVerbatimText, isInterpolationHole: true); + ScanCode(text, ref position, inVerbatimText, lineCommentStarts: null, isInterpolationHole: true); if (position < text.Length) { position++;