diff --git a/src/DiffEngine.Tests/CsStringLiteralTests.cs b/src/DiffEngine.Tests/CsStringLiteralTests.cs index 393f4fe4..96c6252c 100644 --- a/src/DiffEngine.Tests/CsStringLiteralTests.cs +++ b/src/DiffEngine.Tests/CsStringLiteralTests.cs @@ -1,4 +1,4 @@ -public class CsStringLiteralTests +public class CsStringLiteralTests { static readonly string[] renderRoundTripCases = [ @@ -105,6 +105,54 @@ public async Task RenderRoundTrips() } } + // A single line snapshot says nothing a raw string can say, and costs three lines to say it + [Test] + [Arguments("abc", "\"abc\"")] + [Arguments("", "\"\"")] + [Arguments(" ", "\" \"")] + [Arguments("has \"quotes\"", "\"has \\\"quotes\\\"\"")] + [Arguments("back\\slash", "\"back\\\\slash\"")] + [Arguments("tab\there", "\"tab\\there\"")] + [Arguments("bell\a", "\"bell\\a\"")] + [Arguments("esc\u001b", "\"esc\\u001b\"")] + [Arguments("emoji 🎈 and unicode ☂", "\"emoji 🎈 and unicode ☂\"")] + [Arguments("$ {value} {{x}}", "\"$ {value} {{x}}\"")] + public async Task RenderSingleLineIsRegular(string content, string expected) + { + var rendered = CsStringLiteral.Render(content, " ", "\n"); + await Assert.That(rendered).IsEqualTo(expected); + } + + [Test] + [Arguments("a\nb")] + [Arguments("a\rb")] + [Arguments("a\r\nb")] + [Arguments("\nabc")] + [Arguments("abc\n")] + public async Task RenderMultiLineIsRaw(string content) + { + var rendered = CsStringLiteral.Render(content, " ", "\n"); + await Assert.That(rendered).IsEqualTo(CsStringLiteral.RenderRaw(content, " ", "\n")); + } + + [Test] + public async Task RenderRoundTripsWhicheverFormItPicks() + { + foreach (var content in renderRoundTripCases) + { + foreach (var eol in new[] { "\n", "\r\n" }) + { + foreach (var indent in new[] { "", " ", "\t\t" }) + { + var rendered = CsStringLiteral.Render(content, indent, eol); + var parsed = CsStringLiteral.TryParse(rendered, out var value); + await Assert.That(parsed).IsTrue(); + await Assert.That(value).IsEqualTo(content); + } + } + } + } + [Test] [Arguments("\"a\"", "a")] [Arguments("\"\"", "")] diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index d1d7421b..74bfabde 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -25,11 +25,7 @@ public async Task ReplaceRegularLiteral() var source = Method(" await Snapshot(\"old\");"); var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains( - " await Snapshot(\n" + - " \"\"\"\n" + - " new\n" + - " \"\"\");"); + await Assert.That(newSource).Contains(" await Snapshot(\"new\");"); } [Test] @@ -225,7 +221,7 @@ public async Task InsertIntoEmptyArgumentList() var source = Method(" await Snapshot();"); var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("await Snapshot(\n \"\"\"\n new\n \"\"\");"); + await Assert.That(newSource).Contains("await Snapshot(\"new\");"); } [Test] @@ -234,7 +230,7 @@ public async Task InsertReplacesNullArgument() var source = Method(" await Snapshot(null, file, line);"); var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("await Snapshot(\n \"\"\"\n new\n \"\"\", file, line);"); + await Assert.That(newSource).Contains("await Snapshot(\"new\", file, line);"); } [Test] @@ -243,7 +239,7 @@ public async Task InsertBeforeAnotherNamedArgument() var source = Method(" await Snapshot(file: myFile);"); var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("await Snapshot(expected: \"\"\"\n new\n \"\"\", file: myFile);"); + await Assert.That(newSource).Contains("await Snapshot(expected: \"new\", file: myFile);"); } [Test] @@ -287,12 +283,27 @@ public async Task AppendToABareVerify() var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains( + " await Verify(value)\n" + + " .Snapshot(\"new\");"); + } + + // The raw form is the one that has to sit on its own line, indented under the call + [Test] + public async Task AppendMultiLineContent() + { + var source = Method(" await Verify(value);"); + + var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "a\nb", out var newSource, out _); + await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " await Verify(value)\n" + " .Snapshot(\n" + " \"\"\"\n" + - " new\n" + + " a\n" + + " b\n" + " \"\"\");"); } @@ -310,10 +321,7 @@ public async Task AppendGoesAfterAnExistingChain() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " .ScrubLinesContaining(\"x\")\n" + - " .Snapshot(\n" + - " \"\"\"\n" + - " new\n" + - " \"\"\");"); + " .Snapshot(\"new\");"); } [Test] @@ -324,7 +332,7 @@ public async Task AppendToAnEntryPointOverload() var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("await VerifyXml(value)\n .Snapshot(\n \"\"\""); + await Assert.That(newSource).Contains("await VerifyXml(value)\n .Snapshot(\"new\");"); } // The verify call spans lines, so the closing paren is nowhere near the hint @@ -341,7 +349,7 @@ public async Task AppendToAMultiLineVerifyCall() var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains(" })\n .Snapshot(\n \"\"\""); + await Assert.That(newSource).Contains(" })\n .Snapshot(\"new\");"); } [Test] @@ -411,8 +419,7 @@ public async Task AppendPrefersTheEntryPointOverANestedHelper() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " await Verify(ContentValidation.Verify(value))\n" + - " .Snapshot(\n" + - " \"\"\""); + " .Snapshot(\"new\");"); } [Test] @@ -423,7 +430,7 @@ public async Task AppendToAVerifierQualifiedCall() var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("await Verifier.Verify(value)\n .Snapshot(\n \"\"\""); + await Assert.That(newSource).Contains("await Verifier.Verify(value)\n .Snapshot(\"new\");"); } [Test] @@ -434,7 +441,7 @@ public async Task AppendToAThisQualifiedCall() var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("await this.Verify(value)\n .Snapshot(\n \"\"\""); + await Assert.That(newSource).Contains("await this.Verify(value)\n .Snapshot(\"new\");"); } [Test] @@ -530,9 +537,9 @@ public async Task RemoveWithNoSnapshotCall() public async Task TabIndentedFileUsesTabUnit() { var source = "class Tests\n{\n\tasync Task Test()\n\t{\n\t\tawait Snapshot();\n\t}\n}"; - var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); + var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "a\nb", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("Snapshot(\n\t\t\t\"\"\"\n\t\t\tnew\n\t\t\t\"\"\");"); + await Assert.That(newSource).Contains("Snapshot(\n\t\t\t\"\"\"\n\t\t\ta\n\t\t\tb\n\t\t\t\"\"\");"); } [Test] @@ -541,8 +548,7 @@ public async Task HintBeyondEndOfFile() var source = "await Snapshot();"; var status = InlinePatcher.TryApply(source, 500, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - // No newline in the file, so the splice falls back to the environment's - await Assert.That(newSource).Contains($"Snapshot({Environment.NewLine} \"\"\""); + await Assert.That(newSource).Contains("Snapshot(\"new\");"); } [Test] @@ -696,14 +702,14 @@ public async Task MixedEolFileLeavesUntouchedRegionsAlone() var suffix = "\r\n// trailing\n// mixed tail\n"; var source = prefix + body + suffix; - var status = InlinePatcher.TryApply(source, 7, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); + var status = InlinePatcher.TryApply(source, 7, InlinePatchMode.Set, "\"old\"", "new1\nnew2", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); // Untouched regions keep their original endings byte for byte await Assert.That(newSource.StartsWith(prefix, StringComparison.Ordinal)).IsTrue(); await Assert.That(newSource.EndsWith(suffix, StringComparison.Ordinal)).IsTrue(); // The spliced literal uses the file's dominant ending - await Assert.That(newSource).Contains("\"\"\"\r\n new\r\n \"\"\""); + await Assert.That(newSource).Contains("Snapshot(\r\n \"\"\"\r\n new1\r\n new2\r\n \"\"\");"); } [Test] @@ -725,8 +731,7 @@ public async Task SameLiteralInTheVerifyArgumentIsNotPatched() var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"same\"", "changed", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("await Verify(\"same\").Snapshot(\n \"\"\""); - await Assert.That(newSource).Contains("changed"); + await Assert.That(newSource).Contains("await Verify(\"same\").Snapshot(\"changed\");"); } // The expression search must match a whole argument, not the quoted part of a longer literal @@ -768,7 +773,7 @@ public async Task CommentedOutCallIsSkipped() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains(" // await Verify(x).Snapshot(\"doc example\");\n"); - await Assert.That(newSource).Contains(" await Verify(x).Snapshot(\n \"\"\""); + await Assert.That(newSource).Contains(" await Verify(x).Snapshot(\"new\");"); } [Test] @@ -782,7 +787,7 @@ public async Task CallInsideAStringIsSkipped() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("var text = \"await Snapshot(\\\"x\\\")\";\n"); - await Assert.That(newSource).Contains("await Verify(x).Snapshot(\n \"\"\""); + await Assert.That(newSource).Contains("await Verify(x).Snapshot(\"new\");"); } // A declaration is a name followed by parens too, so it has to be told apart by what precedes it @@ -818,8 +823,7 @@ public async Task AppendSkipsAVerifyPrefixedDeclaration() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " Task VerifyThing(string value) => Verify(value)\n" + - " .Snapshot(\n" + - " \"\"\""); + " .Snapshot(\"new\");"); } // Snapshot terminates the chain, so a comment in the middle of one must not end the walk @@ -835,8 +839,7 @@ public async Task AppendGoesAfterACommentInTheChain() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " .UseDirectory(\"snapshots\")\n" + - " .Snapshot(\n" + - " \"\"\""); + " .Snapshot(\"new\");"); } [Test] @@ -850,7 +853,7 @@ public async Task LiteralInACommentIsNotPatched() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("// was \"old\"\n"); - await Assert.That(newSource).Contains("Snapshot(\n \"\"\""); + await Assert.That(newSource).Contains("Snapshot(\"new\");"); } [Test] @@ -870,7 +873,7 @@ public async Task LiteralInAnotherMethodIsNotPatched() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains("void Helper() => Log(\"old\");"); - await Assert.That(newSource).Contains("Snapshot(\n \"\"\""); + await Assert.That(newSource).Contains("Snapshot(\"new\");"); } // The empty literal is two characters, and the opening of every raw literal holds a pair @@ -894,7 +897,7 @@ public async Task EmptyOriginalIsNotMatchedInsideARawDelimiter() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains(" content\n \"\"\");"); - await Assert.That(newSource).Contains("Verify(b).Snapshot(\n \"\"\"\n new\n \"\"\");"); + await Assert.That(newSource).Contains("Verify(b).Snapshot(\"new\");"); } [Test] @@ -905,7 +908,7 @@ public async Task GenericSnapshotCall() var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains(".Snapshot(\n \"\"\""); + await Assert.That(newSource).Contains(".Snapshot(\"new\");"); } [Test] @@ -916,7 +919,7 @@ public async Task AppendToAGenericVerify() var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("await Verify(value)\n .Snapshot(\n \"\"\""); + await Assert.That(newSource).Contains("await Verify(value)\n .Snapshot(\"new\");"); } [Test] @@ -927,7 +930,7 @@ public async Task CommentInTheArgumentListIsNotTheArgument() var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("Snapshot(/* keep */\n \"\"\""); + await Assert.That(newSource).Contains("Snapshot(/* keep */\"new\");"); } [Test] @@ -938,7 +941,7 @@ public async Task CommentAfterTheArgumentIsKept() var status = InlinePatcher.TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); - await Assert.That(newSource).Contains("\"\"\" /* why */);"); + await Assert.That(newSource).Contains("\"new\" /* why */);"); } // Pulling the call up onto the line above would take the semicolon into the comment diff --git a/src/DiffEngine/Inline/CsStringLiteral.cs b/src/DiffEngine/Inline/CsStringLiteral.cs index 778824f3..53e1682b 100644 --- a/src/DiffEngine/Inline/CsStringLiteral.cs +++ b/src/DiffEngine/Inline/CsStringLiteral.cs @@ -1,4 +1,4 @@ -namespace DiffEngine; +namespace DiffEngine; /// /// Renders snapshot text as a C# raw string literal, and parses C# string literal @@ -6,6 +6,73 @@ /// public static class CsStringLiteral { + /// + /// Renders (\n newlines) as a C# string literal expression: a + /// regular literal when it is a single line, since a raw string spends three lines and an + /// indentation rule to say the same thing, and a multi-line raw literal otherwise. + /// + /// Snapshot text with \n newlines. + /// Whitespace prefix for content lines and the closing delimiter. + /// The target file's line ending ("\r\n" or "\n"). + public static string Render(string content, string indent, string eol) => + content.IndexOf('\n') == -1 && + content.IndexOf('\r') == -1 + ? RenderRegular(content) + : RenderRaw(content, indent, eol); + + /// + /// Renders single line content as a regular literal, escaping what the form cannot hold + /// verbatim. + /// + static string RenderRegular(string content) + { + var builder = new StringBuilder(content.Length + 2); + builder.Append('"'); + foreach (var ch in content) + { + switch (ch) + { + case '\\': + builder.Append("\\\\"); + continue; + case '"': + builder.Append("\\\""); + continue; + case '\0': + builder.Append("\\0"); + continue; + case '\a': + builder.Append("\\a"); + continue; + case '\b': + builder.Append("\\b"); + continue; + case '\f': + builder.Append("\\f"); + continue; + case '\t': + builder.Append("\\t"); + continue; + case '\v': + builder.Append("\\v"); + continue; + } + + // Everything else a literal cannot carry as itself + if (ch < ' ' || ch == '\u007f') + { + builder.Append("\\u"); + builder.Append(((int) ch).ToString("x4")); + continue; + } + + builder.Append(ch); + } + + builder.Append('"'); + return builder.ToString(); + } + /// /// Renders (\n newlines) as a multi-line raw string literal. /// The returned text starts with the opening quotes (no leading indent on the first line) diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index d658eeed..1c5d23b0 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -143,7 +143,7 @@ static PatchStatus InsertOrCheck( } var namedIndent = IndentForSpan(source, lineStarts, expected.ListStart); - var namedRendered = CsStringLiteral.RenderRaw(newContent, namedIndent, eol); + var namedRendered = CsStringLiteral.Render(newContent, namedIndent, eol); newSource = Splice(source, expected.ListStart, expected.ListStart, $"{parameterName}: {namedRendered}, "); return PatchStatus.Applied; } @@ -284,7 +284,7 @@ static PatchStatus TryAppend( ? statementIndent + unit : LeadingWhitespace(source, lineStarts, insertAt - 1); var contentIndent = callIndent + unit; - var rendered = CsStringLiteral.RenderRaw(newContent, contentIndent, eol); + var rendered = CsStringLiteral.Render(newContent, contentIndent, eol); var argument = OnOwnLine(rendered, contentIndent, eol); newSource = Splice(source, insertAt, insertAt, $"{eol}{callIndent}.{methodName}({argument})"); return PatchStatus.Applied; @@ -734,7 +734,7 @@ static void TrimSpan(string source, CsScan scan, ref int start, ref int end) static string RenderArgument(string source, List lineStarts, int spanStart, string newContent, string eol) { var indent = IndentForSpan(source, lineStarts, spanStart); - var rendered = CsStringLiteral.RenderRaw(newContent, indent, eol); + var rendered = CsStringLiteral.Render(newContent, indent, eol); if (StartsLine(source, lineStarts, spanStart)) { return rendered; @@ -744,9 +744,9 @@ static string RenderArgument(string source, List lineStarts, int spanStart, } /// - /// Puts a multi-line literal on its own line, so the opening delimiter sits with the content - /// and the closing one rather than trailing the open paren. A single line literal (only the - /// empty snapshot renders as one) stays where it is, since there is nothing to line up with. + /// Puts a raw literal on its own line rather than trailing the open paren, so its opening + /// delimiter sits with its content and its closing one. A regular literal stays where it is, + /// since it has nothing to line up with. /// static string OnOwnLine(string rendered, string indent, string eol) => rendered.IndexOf('\n') == -1 ? rendered : $"{eol}{indent}{rendered}";