diff --git a/CodeBlocker.Test/SplitLinesTests.cs b/CodeBlocker.Test/SplitLinesTests.cs index 67b233e..8632e96 100644 --- a/CodeBlocker.Test/SplitLinesTests.cs +++ b/CodeBlocker.Test/SplitLinesTests.cs @@ -31,6 +31,10 @@ private static string Split(string fragment, string newLine = NewLines.Lf) [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("s = $\"{d:yyyy//MM}\" + @\"p\nq\";\nb", "s = $\"{d:yyyy//MM}\" + @\"p\nq\";|b", DisplayName = "Slash pair in a format specifier")] + [DataRow("s = $\"{d:h' o''clock'}\" + @\"p\nq\";\nb", "s = $\"{d:h' o''clock'}\" + @\"p\nq\";|b", DisplayName = "Apostrophes in a format specifier")] + [DataRow("s = $\"{(a ? b : @\"p\nq\")}\";\nb", "s = $\"{(a ? b : @\"p\nq\")}\";|b", DisplayName = "Conditional in a hole is not a format specifier")] + [DataRow("s = $\"{global::X.Y:@\"}\" + @\"p\nq\";\nb", "s = $\"{global::X.Y:@\"}\" + @\"p\nq\";|b", DisplayName = "Alias qualifier, then a quote in a format specifier")] [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")] diff --git a/CodeBlocker.Test/TemplateTests.cs b/CodeBlocker.Test/TemplateTests.cs index cbe4b4a..be8fd9b 100644 --- a/CodeBlocker.Test/TemplateTests.cs +++ b/CodeBlocker.Test/TemplateTests.cs @@ -462,6 +462,18 @@ public void ASlashPairInsideAStringLiteralIsNotTakenForAComment() => "public int X => \"http://x\";\n", RenderExpressionBody(codeBlocker => codeBlocker.Write("\"http://x\""))); + [TestMethod] + public void ASlashPairInAnInterpolationFormatSpecifierIsNotTakenForAComment() => + Assert.AreEqual( + "public int X => $\"{d:yyyy//MM}\"; // c\n", + RenderExpressionBody(codeBlocker => codeBlocker.Write("$\"{d:yyyy//MM}\" // c"))); + + [TestMethod] + public void ASlashPairInAFormatSpecifierAfterAnAliasQualifierIsNotTakenForAComment() => + Assert.AreEqual( + "public int X => $\"{global::System.DateTime.Now:yyyy//MM}\"; // c\n", + RenderExpressionBody(codeBlocker => codeBlocker.Write("$\"{global::System.DateTime.Now:yyyy//MM}\" // c"))); + [TestMethod] public void ASlashPairInsideAVerbatimStringOpenedOnAnEarlierLineIsNotTakenForAComment() => Assert.AreEqual( @@ -1292,6 +1304,28 @@ public void AMethodBodyKeepsAVerbatimStringsContinuationLinesAsWritten() RenderInClass(method)); } + [TestMethod] + public void AMethodBodyKeepsAVerbatimStringsContinuationLinesAsWrittenAfterASlashPairInAFormatSpecifier() + { + MethodTemplate method = new() + { + Name = "M", + Type = "string", + BodyFactory = b => + { + b.WriteLine("{"); + b.Indent(); + b.WriteLine("return $@\"{d:yyyy//MM}\nline2\";"); + b.Outdent(); + b.WriteLine("}"); + }, + }; + + Assert.AreEqual( + "class C\n{\n\tstring M()\n\t{\n\t\treturn $@\"{d:yyyy//MM}\nline2\";\n\t}\n}\n", + RenderInClass(method)); + } + [TestMethod] public void ABlockAccessorKeepsAnInterpolatedVerbatimStringsContinuationLinesAsWritten() { diff --git a/CodeBlocker/Templates/VerbatimStringScanner.cs b/CodeBlocker/Templates/VerbatimStringScanner.cs index 528e1db..56caf0b 100644 --- a/CodeBlocker/Templates/VerbatimStringScanner.cs +++ b/CodeBlocker/Templates/VerbatimStringScanner.cs @@ -64,6 +64,7 @@ internal static List FindLineComments(string text) private static void ScanCode(string text, ref int position, bool[] inVerbatimText, List? lineCommentStarts, bool isInterpolationHole = false) { int braceDepth = 0; + int bracketDepth = 0; while (position < text.Length) { if (lineCommentStarts is not null && text[position] == '/' && At(text, position + 1) == '/') @@ -76,15 +77,65 @@ private static void ScanCode(string text, ref int position, bool[] inVerbatimTex continue; } - if (isInterpolationHole && IsClosingBrace(text[position], ref braceDepth)) + if (isInterpolationHole) { - return; + char c = text[position]; + if (IsClosingBrace(c, ref braceDepth)) + { + return; + } + + if (braceDepth == 0 && IsFormatSpecifierColon(text, position, ref bracketDepth)) + { + SkipFormatSpecifier(text, ref position); + return; + } } position++; } } + /// + /// 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. + /// + /// + /// Only a colon outside every nested parenthesis, bracket and brace starts one: a conditional + /// expression or a named argument is always nested. The :: of an alias qualifier is not + /// one either. + /// + private static bool IsFormatSpecifierColon(string text, int position, ref int bracketDepth) + { + char c = text[position]; + if (c is '(' or '[') + { + bracketDepth++; + } + else if (c is ')' or ']') + { + bracketDepth = Math.Max(bracketDepth - 1, 0); + } + + return c == ':' + && bracketDepth == 0 + && At(text, position + 1) != ':' + && (position == 0 || text[position - 1] != ':'); + } + + /// + /// Moves from a format specifier's colon up to the brace that closes the interpolation hole, + /// which is left for the caller to consume. A format specifier is literal text: a //, + /// quote or apostrophe in it starts nothing. + /// + private static void SkipFormatSpecifier(string text, ref int position) + { + while (position < text.Length && text[position] != '}' && !IsLineBreak(text[position])) + { + position++; + } + } + /// /// Moves past the comment or literal that starts at , if one does. /// @@ -344,7 +395,7 @@ private static void SkipInterpolationHole(string text, ref int position, bool[] { position++; ScanCode(text, ref position, inVerbatimText, lineCommentStarts: null, isInterpolationHole: true); - if (position < text.Length) + if (At(text, position) == '}') { position++; }