Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions CodeBlocker.Test/TemplateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,67 @@ public void AnEmptyExpressionBodyStillTerminatesTheDeclaration()
Assert.AreEqual("public int X => ;\n", Render(property));
}

private static string RenderExpressionBody(Action<CodeBlocker> 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()
{
Expand Down
70 changes: 66 additions & 4 deletions CodeBlocker/Templates/TemplateRendering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,9 @@ internal static void WriteBody(CodeBlocker codeBlocker, Action<CodeBlocker>? 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.
/// </remarks>
internal static void WriteExpressionBody(CodeBlocker codeBlocker, Action<CodeBlocker>? expressionFactory)
{
Expand All @@ -347,11 +348,18 @@ internal static void WriteExpressionBody(CodeBlocker codeBlocker, Action<CodeBlo
return;
}

int[] codeEnds = FindCodeEnds(lines);
int terminated = last;
while (terminated > 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)
{
Expand All @@ -363,4 +371,58 @@ internal static void WriteExpressionBody(CodeBlocker codeBlocker, Action<CodeBlo
}
}
}

/// <summary>
/// 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.
/// </summary>
/// <param name="lines">The lines of a fragment, as <see cref="SplitLines"/> returns them.</param>
/// <returns>One position per line, relative to the start of that line.</returns>
/// <remarks>
/// The lines are scanned together rather than one at a time, so a <c>//</c> on a line that a
/// literal or block comment opened on an earlier line is not taken for a comment.
/// </remarks>
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;
}

/// <summary>
/// Puts the terminating semicolon on a line, after its code and before any line comment.
/// </summary>
/// <param name="line">The line to terminate.</param>
/// <param name="codeEnd">Where the code on the line ends, as <see cref="FindCodeEnds"/> found.</param>
/// <returns>The line with its semicolon.</returns>
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..];
}
}
35 changes: 32 additions & 3 deletions CodeBlocker/Templates/VerbatimStringScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
/// Finds the line comments in <paramref name="text"/> that belong to the code itself.
/// </summary>
/// <param name="text">The C# fragment to scan.</param>
/// <returns>
/// The position of the first <c>/</c> of each such <c>//</c> comment, in order. A <c>//</c>
/// inside a string or character literal, a block comment or an interpolation hole is not one.
/// </returns>
internal static List<int> FindLineComments(string text)
{
List<int> lineCommentStarts = [];
int position = 0;
ScanCode(text, ref position, new bool[text.Length], lineCommentStarts);
return lineCommentStarts;
}

/// <summary>
/// Walks code up to the end of <paramref name="text"/> or, inside an interpolation hole, up to
/// the brace that closes the hole, which is left for the caller to consume.
/// </summary>
private static void ScanCode(string text, ref int position, bool[] inVerbatimText, bool isInterpolationHole)
/// <param name="text">The C# fragment to scan.</param>
/// <param name="position">Where to start, and on return where the walk stopped.</param>
/// <param name="inVerbatimText">Receives the verbatim literal flags.</param>
/// <param name="lineCommentStarts">
/// Receives the start of each line comment, at the top level only; <see langword="null"/> when
/// they are not wanted, and for an interpolation hole.
/// </param>
/// <param name="isInterpolationHole">Whether the walk is inside an interpolation hole.</param>
private static void ScanCode(string text, ref int position, bool[] inVerbatimText, List<int>? 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;
Expand Down Expand Up @@ -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++;
Expand Down
Loading