From b5a92b98e5c0ed0e58b3c8263fa985e12146473c Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 14 Aug 2026 20:42:42 +1000 Subject: [PATCH] Put an injected snapshot literal on its own line A multi-line raw string that trailed the open paren now starts on the line below it, indented one level in, so the opening delimiter lines up with the content and the closing one. Applies to the appended call and to every set path, so re-patching old-style code reformats it. --- src/DiffEngine.Tests/InlinePatcherTests.cs | 58 ++++++++++++---------- src/DiffEngine/Inline/InlinePatcher.cs | 56 ++++++++++++++++++--- 2 files changed, 80 insertions(+), 34 deletions(-) diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index 7bd02c6b..d1d7421b 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -26,10 +26,10 @@ public async Task ReplaceRegularLiteral() 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("" - """ + "\""); - await Assert.That(newSource).Contains(" new"); + " await Snapshot(\n" + + " \"\"\"\n" + + " new\n" + + " \"\"\");"); } [Test] @@ -225,7 +225,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\n \"\"\");"); + await Assert.That(newSource).Contains("await Snapshot(\n \"\"\"\n new\n \"\"\");"); } [Test] @@ -234,7 +234,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\n \"\"\", file, line);"); + await Assert.That(newSource).Contains("await Snapshot(\n \"\"\"\n new\n \"\"\", file, line);"); } [Test] @@ -290,7 +290,8 @@ public async Task AppendToABareVerify() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " await Verify(value)\n" + - " .Snapshot(\"\"\"\n" + + " .Snapshot(\n" + + " \"\"\"\n" + " new\n" + " \"\"\");"); } @@ -309,7 +310,8 @@ public async Task AppendGoesAfterAnExistingChain() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " .ScrubLinesContaining(\"x\")\n" + - " .Snapshot(\"\"\"\n" + + " .Snapshot(\n" + + " \"\"\"\n" + " new\n" + " \"\"\");"); } @@ -322,7 +324,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(\"\"\""); + await Assert.That(newSource).Contains("await VerifyXml(value)\n .Snapshot(\n \"\"\""); } // The verify call spans lines, so the closing paren is nowhere near the hint @@ -339,7 +341,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(\"\"\""); + await Assert.That(newSource).Contains(" })\n .Snapshot(\n \"\"\""); } [Test] @@ -409,7 +411,8 @@ public async Task AppendPrefersTheEntryPointOverANestedHelper() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " await Verify(ContentValidation.Verify(value))\n" + - " .Snapshot(\"\"\""); + " .Snapshot(\n" + + " \"\"\""); } [Test] @@ -420,7 +423,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(\"\"\""); + await Assert.That(newSource).Contains("await Verifier.Verify(value)\n .Snapshot(\n \"\"\""); } [Test] @@ -431,7 +434,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(\"\"\""); + await Assert.That(newSource).Contains("await this.Verify(value)\n .Snapshot(\n \"\"\""); } [Test] @@ -529,7 +532,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\tnew\n\t\t\t\"\"\");"); + await Assert.That(newSource).Contains("Snapshot(\n\t\t\t\"\"\"\n\t\t\tnew\n\t\t\t\"\"\");"); } [Test] @@ -538,7 +541,8 @@ 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); - await Assert.That(newSource).Contains("Snapshot(\"\"\""); + // No newline in the file, so the splice falls back to the environment's + await Assert.That(newSource).Contains($"Snapshot({Environment.NewLine} \"\"\""); } [Test] @@ -721,7 +725,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(\"\"\""); + await Assert.That(newSource).Contains("await Verify(\"same\").Snapshot(\n \"\"\""); await Assert.That(newSource).Contains("changed"); } @@ -764,7 +768,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(\"\"\""); + await Assert.That(newSource).Contains(" await Verify(x).Snapshot(\n \"\"\""); } [Test] @@ -778,7 +782,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(\"\"\""); + await Assert.That(newSource).Contains("await Verify(x).Snapshot(\n \"\"\""); } // A declaration is a name followed by parens too, so it has to be told apart by what precedes it @@ -814,7 +818,8 @@ public async Task AppendSkipsAVerifyPrefixedDeclaration() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " Task VerifyThing(string value) => Verify(value)\n" + - " .Snapshot(\"\"\""); + " .Snapshot(\n" + + " \"\"\""); } // Snapshot terminates the chain, so a comment in the middle of one must not end the walk @@ -830,7 +835,8 @@ public async Task AppendGoesAfterACommentInTheChain() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).Contains( " .UseDirectory(\"snapshots\")\n" + - " .Snapshot(\"\"\""); + " .Snapshot(\n" + + " \"\"\""); } [Test] @@ -844,7 +850,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(\"\"\""); + await Assert.That(newSource).Contains("Snapshot(\n \"\"\""); } [Test] @@ -864,7 +870,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(\"\"\""); + await Assert.That(newSource).Contains("Snapshot(\n \"\"\""); } // The empty literal is two characters, and the opening of every raw literal holds a pair @@ -888,7 +894,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\n \"\"\");"); + await Assert.That(newSource).Contains("Verify(b).Snapshot(\n \"\"\"\n new\n \"\"\");"); } [Test] @@ -899,7 +905,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(\"\"\""); + await Assert.That(newSource).Contains(".Snapshot(\n \"\"\""); } [Test] @@ -910,7 +916,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(\"\"\""); + await Assert.That(newSource).Contains("await Verify(value)\n .Snapshot(\n \"\"\""); } [Test] @@ -921,7 +927,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 */\"\"\""); + await Assert.That(newSource).Contains("Snapshot(/* keep */\n \"\"\""); } [Test] diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index 1928b50e..d658eeed 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -83,8 +83,7 @@ public static PatchStatus TryApply( return PatchStatus.AlreadyApplied; } - var indent = IndentForSpan(source, lineStarts, expected.Start); - var rendered = CsStringLiteral.RenderRaw(newContent, indent, eol); + var rendered = RenderArgument(source, lineStarts, expected.Start, newContent, eol); newSource = Splice(source, expected.Start, expected.End, rendered); return PatchStatus.Applied; } @@ -128,8 +127,7 @@ static PatchStatus InsertOrCheck( return PatchStatus.NotFound; } - var emptyIndent = IndentForSpan(source, lineStarts, expected.Start); - var emptyRendered = CsStringLiteral.RenderRaw(newContent, emptyIndent, eol); + var emptyRendered = RenderArgument(source, lineStarts, expected.Start, newContent, eol); newSource = Splice(source, expected.Start, expected.Start, emptyRendered); return PatchStatus.Applied; } @@ -159,8 +157,7 @@ static PatchStatus InsertOrCheck( return PatchStatus.NotFound; } - var indent = IndentForSpan(source, lineStarts, expected.Start); - var rendered = CsStringLiteral.RenderRaw(newContent, indent, eol); + var rendered = RenderArgument(source, lineStarts, expected.Start, newContent, eol); newSource = Splice(source, expected.Start, expected.End, rendered); return PatchStatus.Applied; } @@ -286,8 +283,10 @@ static PatchStatus TryAppend( var callIndent = LineOf(lineStarts, insertAt - 1) == LineOf(lineStarts, nameStart) ? statementIndent + unit : LeadingWhitespace(source, lineStarts, insertAt - 1); - var rendered = CsStringLiteral.RenderRaw(newContent, callIndent + unit, eol); - newSource = Splice(source, insertAt, insertAt, $"{eol}{callIndent}.{methodName}({rendered})"); + var contentIndent = callIndent + unit; + var rendered = CsStringLiteral.RenderRaw(newContent, contentIndent, eol); + var argument = OnOwnLine(rendered, contentIndent, eol); + newSource = Splice(source, insertAt, insertAt, $"{eol}{callIndent}.{methodName}({argument})"); return PatchStatus.Applied; } @@ -728,6 +727,47 @@ static void TrimSpan(string source, CsScan scan, ref int start, ref int end) } } + /// + /// Renders the literal for a splice at , indented to suit where it + /// lands. + /// + 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); + if (StartsLine(source, lineStarts, spanStart)) + { + return rendered; + } + + return OnOwnLine(rendered, indent, eol); + } + + /// + /// 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. + /// + static string OnOwnLine(string rendered, string indent, string eol) => + rendered.IndexOf('\n') == -1 ? rendered : $"{eol}{indent}{rendered}"; + + /// + /// True when only whitespace precedes the offset on its line. + /// + static bool StartsLine(string source, List lineStarts, int offset) + { + for (var index = lineStarts[LineOf(lineStarts, offset) - 1]; index < offset; index++) + { + if (source[index] != ' ' && + source[index] != '\t') + { + return false; + } + } + + return true; + } + static string Splice(string source, int start, int end, string replacement) => new StringBuilder(source.Length - (end - start) + replacement.Length) .Append(source, 0, start)