From 439c494f72d54ae171ce5f9234b6d336c3da318c Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 14 Aug 2026 21:01:26 +1000 Subject: [PATCH 1/2] Render a single line snapshot as a regular literal A raw string spends three lines and an indentation rule to carry one line of content, so Render picks the form: a regular literal (escaping what that form cannot hold verbatim) when the content is one line, the raw one otherwise. The argument still starts on the line below the open paren either way. --- src/DiffEngine.Tests/CsStringLiteralTests.cs | 50 +++++++++++++- src/DiffEngine.Tests/InlinePatcherTests.cs | 61 ++++++++--------- src/DiffEngine/Inline/CsStringLiteral.cs | 69 +++++++++++++++++++- src/DiffEngine/Inline/InlinePatcher.cs | 13 ++-- 4 files changed, 149 insertions(+), 44 deletions(-) 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..6a01dd0d 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(\n \"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(\n \"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(\n \"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] @@ -291,9 +287,7 @@ public async Task AppendToABareVerify() await Assert.That(newSource).Contains( " await Verify(value)\n" + " .Snapshot(\n" + - " \"\"\"\n" + - " new\n" + - " \"\"\");"); + " \"new\");"); } // Snapshot terminates the chain, so it has to land after everything already chained on @@ -311,9 +305,7 @@ public async Task AppendGoesAfterAnExistingChain() await Assert.That(newSource).Contains( " .ScrubLinesContaining(\"x\")\n" + " .Snapshot(\n" + - " \"\"\"\n" + - " new\n" + - " \"\"\");"); + " \"new\");"); } [Test] @@ -324,7 +316,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(\n \"new\");"); } // The verify call spans lines, so the closing paren is nowhere near the hint @@ -341,7 +333,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(\n \"new\");"); } [Test] @@ -412,7 +404,7 @@ public async Task AppendPrefersTheEntryPointOverANestedHelper() await Assert.That(newSource).Contains( " await Verify(ContentValidation.Verify(value))\n" + " .Snapshot(\n" + - " \"\"\""); + " \"new\");"); } [Test] @@ -423,7 +415,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(\n \"new\");"); } [Test] @@ -434,7 +426,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(\n \"new\");"); } [Test] @@ -532,7 +524,7 @@ 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 _); 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\"new\");"); } [Test] @@ -542,7 +534,7 @@ public async Task HintBeyondEndOfFile() 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({Environment.NewLine} \"new\");"); } [Test] @@ -703,7 +695,7 @@ public async Task MixedEolFileLeavesUntouchedRegionsAlone() 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 \"new\");"); } [Test] @@ -725,8 +717,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(\n \"changed\");"); } // The expression search must match a whole argument, not the quoted part of a longer literal @@ -768,7 +759,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(\n \"new\");"); } [Test] @@ -782,7 +773,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(\n \"new\");"); } // A declaration is a name followed by parens too, so it has to be told apart by what precedes it @@ -819,7 +810,7 @@ public async Task AppendSkipsAVerifyPrefixedDeclaration() await Assert.That(newSource).Contains( " Task VerifyThing(string value) => Verify(value)\n" + " .Snapshot(\n" + - " \"\"\""); + " \"new\");"); } // Snapshot terminates the chain, so a comment in the middle of one must not end the walk @@ -836,7 +827,7 @@ public async Task AppendGoesAfterACommentInTheChain() await Assert.That(newSource).Contains( " .UseDirectory(\"snapshots\")\n" + " .Snapshot(\n" + - " \"\"\""); + " \"new\");"); } [Test] @@ -850,7 +841,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(\n \"new\");"); } [Test] @@ -870,7 +861,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(\n \"new\");"); } // The empty literal is two characters, and the opening of every raw literal holds a pair @@ -894,7 +885,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(\n \"new\");"); } [Test] @@ -905,7 +896,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(\n \"new\");"); } [Test] @@ -916,7 +907,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(\n \"new\");"); } [Test] @@ -927,7 +918,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 */\n \"new\");"); } [Test] @@ -938,7 +929,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..851ee5f4 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,12 +744,11 @@ 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 the literal on its own line rather than trailing the open paren, so a raw string's + /// opening delimiter sits with its content and its closing one. /// static string OnOwnLine(string rendered, string indent, string eol) => - rendered.IndexOf('\n') == -1 ? rendered : $"{eol}{indent}{rendered}"; + $"{eol}{indent}{rendered}"; /// /// True when only whitespace precedes the offset on its line. From b213fb53c576acb03afb179757c19405e4b5d29a Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 14 Aug 2026 21:07:42 +1000 Subject: [PATCH 2/2] Keep a regular literal on the call line Only the raw form needs a line of its own, so its opening delimiter lines up with the content and the closing one. A regular literal has nothing to line up with, so it stays in the argument list. --- src/DiffEngine.Tests/InlinePatcherTests.cs | 74 +++++++++++++--------- src/DiffEngine/Inline/InlinePatcher.cs | 7 +- 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index 6a01dd0d..74bfabde 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -25,7 +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 \"new\");"); + await Assert.That(newSource).Contains(" await Snapshot(\"new\");"); } [Test] @@ -221,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 \"new\");"); + await Assert.That(newSource).Contains("await Snapshot(\"new\");"); } [Test] @@ -230,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 \"new\", file, line);"); + await Assert.That(newSource).Contains("await Snapshot(\"new\", file, line);"); } [Test] @@ -283,11 +283,28 @@ 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" + - " \"new\");"); + " \"\"\"\n" + + " a\n" + + " b\n" + + " \"\"\");"); } // Snapshot terminates the chain, so it has to land after everything already chained on @@ -304,8 +321,7 @@ public async Task AppendGoesAfterAnExistingChain() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " .ScrubLinesContaining(\"x\")\n" + - " .Snapshot(\n" + - " \"new\");"); + " .Snapshot(\"new\");"); } [Test] @@ -316,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 \"new\");"); + 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 @@ -333,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 \"new\");"); + await Assert.That(newSource).Contains(" })\n .Snapshot(\"new\");"); } [Test] @@ -403,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" + - " \"new\");"); + " .Snapshot(\"new\");"); } [Test] @@ -415,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 \"new\");"); + await Assert.That(newSource).Contains("await Verifier.Verify(value)\n .Snapshot(\"new\");"); } [Test] @@ -426,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 \"new\");"); + await Assert.That(newSource).Contains("await this.Verify(value)\n .Snapshot(\"new\");"); } [Test] @@ -522,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\"new\");"); + await Assert.That(newSource).Contains("Snapshot(\n\t\t\t\"\"\"\n\t\t\ta\n\t\t\tb\n\t\t\t\"\"\");"); } [Test] @@ -533,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} \"new\");"); + await Assert.That(newSource).Contains("Snapshot(\"new\");"); } [Test] @@ -688,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("Snapshot(\r\n \"new\");"); + await Assert.That(newSource).Contains("Snapshot(\r\n \"\"\"\r\n new1\r\n new2\r\n \"\"\");"); } [Test] @@ -717,7 +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 \"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 @@ -759,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 \"new\");"); + await Assert.That(newSource).Contains(" await Verify(x).Snapshot(\"new\");"); } [Test] @@ -773,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 \"new\");"); + 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 @@ -809,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" + - " \"new\");"); + " .Snapshot(\"new\");"); } // Snapshot terminates the chain, so a comment in the middle of one must not end the walk @@ -826,8 +839,7 @@ public async Task AppendGoesAfterACommentInTheChain() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " .UseDirectory(\"snapshots\")\n" + - " .Snapshot(\n" + - " \"new\");"); + " .Snapshot(\"new\");"); } [Test] @@ -841,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 \"new\");"); + await Assert.That(newSource).Contains("Snapshot(\"new\");"); } [Test] @@ -861,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 \"new\");"); + await Assert.That(newSource).Contains("Snapshot(\"new\");"); } // The empty literal is two characters, and the opening of every raw literal holds a pair @@ -885,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 \"new\");"); + await Assert.That(newSource).Contains("Verify(b).Snapshot(\"new\");"); } [Test] @@ -896,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 \"new\");"); + await Assert.That(newSource).Contains(".Snapshot(\"new\");"); } [Test] @@ -907,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 \"new\");"); + await Assert.That(newSource).Contains("await Verify(value)\n .Snapshot(\"new\");"); } [Test] @@ -918,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 \"new\");"); + await Assert.That(newSource).Contains("Snapshot(/* keep */\"new\");"); } [Test] diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index 851ee5f4..1c5d23b0 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -744,11 +744,12 @@ static string RenderArgument(string source, List lineStarts, int spanStart, } /// - /// Puts the literal on its own line rather than trailing the open paren, so a raw string's - /// opening delimiter sits with its content and its closing one. + /// 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) => - $"{eol}{indent}{rendered}"; + rendered.IndexOf('\n') == -1 ? rendered : $"{eol}{indent}{rendered}"; /// /// True when only whitespace precedes the offset on its line.