From 49425190eeebb5162fc387790f66986ccc3bfb0c Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 24 Aug 2026 22:45:04 +1000 Subject: [PATCH 1/2] Reach a hint past a local declaration when appending A member floor judged the recorded line stale whenever any declaration sat between the member's declaration and the hint, and IsDeclaration answers "is this identifier being declared rather than called", which a local satisfies: var hash = Hash(), and F#'s let hash = hash (), read as declarations exactly as a sibling test method does. That dropped the hint from the first try and from the outward walk both, so the call sitting exactly where the hint said was never looked at. Every test of the ordinary shape - a local, then a verify call on it - failed to accept, reporting "No Verify or Throws call at line N". Indentation is what tells the two apart, since nothing in front of the name does. A declaration indented past the member's own is inside its body - a local, a local function, a nested type - and none of those put the recorded line in another member. A sibling shares the member's indentation, so the comparison is inclusive and the stale-hint protection is unchanged. Fixtures that a raw string can carry are now written as one, normalized on the way in because a raw string takes the line endings of the file holding it. The rest stay hand-built: runs of three or more quotes, tabs, and the fixtures whose subject is line endings. --- src/DiffEngine.Tests/InlinePatcherFsTests.cs | 269 ++++++++------ src/DiffEngine.Tests/InlinePatcherTests.cs | 351 ++++++++++++------- src/DiffEngine/Inline/InlinePatcher.cs | 32 +- 3 files changed, 418 insertions(+), 234 deletions(-) diff --git a/src/DiffEngine.Tests/InlinePatcherFsTests.cs b/src/DiffEngine.Tests/InlinePatcherFsTests.cs index 86350c0b..8eb34314 100644 --- a/src/DiffEngine.Tests/InlinePatcherFsTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherFsTests.cs @@ -1,8 +1,27 @@ public class InlinePatcherFsTests { + /// + /// A whole file of source, written as a raw string so that it reads as the code it stands for. + /// + /// Normalized on the way in because a raw string carries the line endings of the file holding + /// it rather than normalizing them, and every expectation here is written in LF. The checkout + /// is LF whatever the platform (* text=auto eol=lf), so this only ever matters to a file + /// that arrived some other way - but it is line endings, in the suite that patches them, and + /// the failure it produces names the wrong thing entirely. + /// + /// + /// A fixture whose subject is line endings, tabs, or runs of quotes is built by hand instead: a + /// raw string cannot carry those without a delimiter wider than the thing being described, or + /// without indentation that a formatter is free to rewrite. F# writes its multi-line snapshots + /// triple quoted, so that last one covers most of the fixtures here. + /// + /// + static string Source(string source) => + SourceLanguage.NormalizeNewlines(source); + // Line 5 is the first line of the body static string Test(string body) => - $"module Tests\n\n[]\nlet MyTest () =\n{body}\n"; + Source($"module Tests\n\n[]\nlet MyTest () =\n{body}\n"); static PatchStatus TryApply( string source, @@ -101,9 +120,11 @@ await Assert.That(newSource).IsEqualTo( public async Task DeepCallSiteIndentsFurther() { var source = Test( - " let inner () =\n" + - " Verifier.Verify(15).Snapshot().ToTask()\n" + - " inner ()"); + """ + let inner () = + Verifier.Verify(15).Snapshot().ToTask() + inner () + """); var status = TryApply(source, 6, InlinePatchMode.Set, null, "a\nb", out var newSource, out _); @@ -191,15 +212,16 @@ public async Task ValueAnchorFindsTheCall() [Test] public async Task ValueAnchorBeatsAStaleHint() { - var source = string.Join( - "\n", - "module Tests", - "", - "let TestA () =", - " Verifier.Verify(a).Snapshot(\"a\").ToTask()", - "", - "let TestB () =", - " Verifier.Verify(b).Snapshot(\"b\").ToTask()"); + var source = Source( + """ + module Tests + + let TestA () = + Verifier.Verify(a).Snapshot("a").ToTask() + + let TestB () = + Verifier.Verify(b).Snapshot("b").ToTask() + """); var status = TryApply(source, 4, InlinePatchMode.Set, null, "new", out var newSource, out _, originalValue: "b"); @@ -250,15 +272,16 @@ public async Task ValueAnchorAcrossAMultiLineLiteral() } static string TwoTests(string literalA, string literalB) => - string.Join( - "\n", - "module Tests", - "", - "let TestA () =", - $" Verifier.Verify(a).Snapshot({literalA}).ToTask()", - "", - "let TestB () =", - $" Verifier.Verify(b).Snapshot({literalB}).ToTask()"); + Source( + $$""" + module Tests + + let TestA () = + Verifier.Verify(a).Snapshot({{literalA}}).ToTask() + + let TestB () = + Verifier.Verify(b).Snapshot({{literalB}}).ToTask() + """); // A call above TestB's declaration is not inside TestB, whatever the hint says, so the // identical snapshot in the test above is not even a candidate @@ -346,31 +369,60 @@ public async Task AppendGoesInFrontOfToTask() await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( Test( - " Verifier.Verify(15)\n" + - " .Snapshot(\"new\").ToTask() |> Async.AwaitTask")); + """ + Verifier.Verify(15) + .Snapshot("new").ToTask() |> Async.AwaitTask + """)); } [Test] public async Task AppendToAMultiLineChain() { var source = Test( - " Verifier\n" + - " .Verify(15)\n" + - " .UseMethodName(\"customName\")\n" + - " .ToTask()\n" + - " |> Async.AwaitTask"); + """ + Verifier + .Verify(15) + .UseMethodName("customName") + .ToTask() + |> Async.AwaitTask + """); var status = TryApply(source, 6, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( Test( - " Verifier\n" + - " .Verify(15)\n" + - " .UseMethodName(\"customName\")\n" + - " .Snapshot(\"new\")\n" + - " .ToTask()\n" + - " |> Async.AwaitTask")); + """ + Verifier + .Verify(15) + .UseMethodName("customName") + .Snapshot("new") + .ToTask() + |> Async.AwaitTask + """)); + } + + // A let binding inside the test is a declaration exactly as the test's own let is, and reading + // one as another member declared the hint stale, which put the call it names out of reach + [Test] + public async Task AppendReachesAHintPastALocalBinding() + { + var source = Test( + """ + let value = build () + Verifier.Verify(value).ToTask() + """); + + var status = TryApply(source, 6, InlinePatchMode.Append, null, "new", out var newSource, out _, memberName: "MyTest"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).IsEqualTo( + Test( + """ + let value = build () + Verifier.Verify(value) + .Snapshot("new").ToTask() + """)); } // Awaited in a task expression instead, so there is no ToTask and the chain end is the @@ -379,19 +431,23 @@ await Assert.That(newSource).IsEqualTo( public async Task AppendWithNoToTask() { var source = Test( - " task {\n" + - " do! Verifier.Verify(15)\n" + - " }"); + """ + task { + do! Verifier.Verify(15) + } + """); var status = TryApply(source, 6, InlinePatchMode.Append, null, "new", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( Test( - " task {\n" + - " do! Verifier.Verify(15)\n" + - " .Snapshot(\"new\")\n" + - " }")); + """ + task { + do! Verifier.Verify(15) + .Snapshot("new") + } + """)); } [Test] @@ -465,13 +521,14 @@ public async Task RemoveFromASingleLineChain() [Test] public async Task LineCommentedOutCallIsSkipped() { - var source = string.Join( - "\n", - "module Tests", - "", - "// Verifier.Verify(x).Snapshot(\"doc example\")", - "let MyTest () =", - " Verifier.Verify(x).Snapshot().ToTask()"); + var source = Source( + """ + module Tests + + // Verifier.Verify(x).Snapshot("doc example") + let MyTest () = + Verifier.Verify(x).Snapshot().ToTask() + """); var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out var newSource, out _); @@ -483,13 +540,14 @@ public async Task LineCommentedOutCallIsSkipped() [Test] public async Task BlockCommentedOutCallIsSkipped() { - var source = string.Join( - "\n", - "module Tests", - "", - "(* Verifier.Verify(x).Snapshot(\"doc example\") *)", - "let MyTest () =", - " Verifier.Verify(x).Snapshot().ToTask()"); + var source = Source( + """ + module Tests + + (* Verifier.Verify(x).Snapshot("doc example") *) + let MyTest () = + Verifier.Verify(x).Snapshot().ToTask() + """); var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out var newSource, out _); @@ -502,13 +560,14 @@ public async Task BlockCommentedOutCallIsSkipped() [Test] public async Task NestedBlockCommentIsOneComment() { - var source = string.Join( - "\n", - "module Tests", - "", - "(* outer (* inner *) .Snapshot(\"commented\") *)", - "let MyTest () =", - " Verifier.Verify(x).Snapshot().ToTask()"); + var source = Source( + """ + module Tests + + (* outer (* inner *) .Snapshot("commented") *) + let MyTest () = + Verifier.Verify(x).Snapshot().ToTask() + """); var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out var newSource, out _); @@ -521,13 +580,14 @@ public async Task NestedBlockCommentIsOneComment() [Test] public async Task MultiplyOperatorIsNotAComment() { - var source = string.Join( - "\n", - "module Tests", - "", - "let multiply = (*)", - "let MyTest () =", - " Verifier.Verify(x).Snapshot().ToTask()"); + var source = Source( + """ + module Tests + + let multiply = (*) + let MyTest () = + Verifier.Verify(x).Snapshot().ToTask() + """); var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); @@ -539,8 +599,10 @@ public async Task MultiplyOperatorIsNotAComment() public async Task CallInsideAStringIsSkipped() { var source = Test( - " let text = \"Verifier.Verify(x).Snapshot(\\\"y\\\")\"\n" + - " Verifier.Verify(text).Snapshot().ToTask()"); + """ + let text = "Verifier.Verify(x).Snapshot(\"y\")" + Verifier.Verify(text).Snapshot().ToTask() + """); var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); @@ -569,8 +631,10 @@ public async Task CallInsideATripleQuotedStringIsSkipped() public async Task TypeParameterIsNotACharLiteral() { var source = Test( - " let values : 'T list = []\n" + - " Verifier.Verify(values).Snapshot(\"old\").ToTask()"); + """ + let values : 'T list = [] + Verifier.Verify(values).Snapshot("old").ToTask() + """); var status = TryApply(source, 6, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); @@ -582,8 +646,10 @@ public async Task TypeParameterIsNotACharLiteral() public async Task TickInAnIdentifierIsNotACharLiteral() { var source = Test( - " let value' = 15\n" + - " Verifier.Verify(value').Snapshot(\"old\").ToTask()"); + """ + let value' = 15 + Verifier.Verify(value').Snapshot("old").ToTask() + """); var status = TryApply(source, 6, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); @@ -596,8 +662,10 @@ public async Task TickInAnIdentifierIsNotACharLiteral() public async Task CharLiteralIsSkipped() { var source = Test( - " let quote = '\"'\n" + - " Verifier.Verify(quote).Snapshot(\"old\").ToTask()"); + """ + let quote = '"' + Verifier.Verify(quote).Snapshot("old").ToTask() + """); var status = TryApply(source, 6, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); @@ -609,11 +677,12 @@ public async Task CharLiteralIsSkipped() [Test] public async Task LetDeclarationIsNotMistakenForACall() { - var source = string.Join( - "\n", - "module Tests", - "", - "let Snapshot (expected: string) = expected"); + var source = Source( + """ + module Tests + + let Snapshot (expected: string) = expected + """); var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out _, out var reason); @@ -624,13 +693,14 @@ public async Task LetDeclarationIsNotMistakenForACall() [Test] public async Task MemberDeclarationIsNotMistakenForACall() { - var source = string.Join( - "\n", - "module Tests", - "", - "type Extensions =", - " member this.Snapshot (expected: string) = expected", - " static member Snapshot (expected: string, other: string) = expected"); + var source = Source( + """ + module Tests + + type Extensions = + member this.Snapshot (expected: string) = expected + static member Snapshot (expected: string, other: string) = expected + """); var status = TryApply(source, 4, InlinePatchMode.Set, null, "new", out _, out var reason); @@ -677,15 +747,16 @@ public async Task NoCallFound() [Test] public async Task SequentialPatchesOfIdenticalLiterals() { - var source = string.Join( - "\n", - "module Tests", - "", - "let TestA () =", - " Verifier.Verify(a).Snapshot(\"old\").ToTask()", - "", - "let TestB () =", - " Verifier.Verify(b).Snapshot(\"old\").ToTask()"); + var source = Source( + """ + module Tests + + let TestA () = + Verifier.Verify(a).Snapshot("old").ToTask() + + let TestB () = + Verifier.Verify(b).Snapshot("old").ToTask() + """); var first = TryApply(source, 4, InlinePatchMode.Set, "\"old\"", "newA", out var afterFirst, out _); var second = TryApply(afterFirst, 7, InlinePatchMode.Set, "\"old\"", "newB", out var afterSecond, out _); diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index 6dd990ad..2cdcf063 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -14,8 +14,26 @@ static PatchStatus TryApply( const string rawOld = "\"\"\"\n old\n \"\"\""; + /// + /// A whole file of source, written as a raw string so that it reads as the code it stands for. + /// + /// Normalized on the way in because a raw string carries the line endings of the file holding + /// it rather than normalizing them, and every expectation here is written in LF. The checkout + /// is LF whatever the platform (* text=auto eol=lf), so this only ever matters to a file + /// that arrived some other way - but it is line endings, in the suite that patches them, and + /// the failure it produces names the wrong thing entirely. + /// + /// + /// A fixture whose subject is line endings, tabs, or runs of quotes is built by hand instead: a + /// raw string cannot carry those without a delimiter wider than the thing being described, or + /// without indentation that a formatter is free to rewrite. + /// + /// + static string Source(string source) => + SourceLanguage.NormalizeNewlines(source); + static string Method(string body) => - $"class Tests\n{{\n async Task Test()\n {{\n{body}\n }}\n}}"; + Source($"class Tests\n{{\n async Task Test()\n {{\n{body}\n }}\n}}"); [Test] public async Task ReplaceRawLiteral() @@ -132,8 +150,10 @@ public async Task ASnapshotBeforeAVerbatimStringOpeningOnAnEscapedQuoteIsStillFo public async Task RemoveTakesTheCallTheAnchorNamesRatherThanTheNearest() { var source = Method( - " await A().Snapshot(\"one\");\n" + - " await B().Snapshot(\"two\");"); + """ + await A().Snapshot("one"); + await B().Snapshot("two"); + """); // Hint on the second call, anchor on the first var status = TryApply(source, 6, InlinePatchMode.Remove, "\"one\"", "", out var newSource, out _); @@ -167,19 +187,21 @@ public async Task RemoveReportsWhenTheAnchorIsGone() [Test] public async Task AStaleHintDoesNotReachIntoTheNextMember() { - var source = - "class Tests\n" + - "{\n" + - " async Task First()\n" + - " {\n" + - " await Snapshot(\"dup\");\n" + - " }\n" + - "\n" + - " async Task Second()\n" + - " {\n" + - " await Snapshot(\"dup\");\n" + - " }\n" + - "}"; + var source = Source( + """ + class Tests + { + async Task First() + { + await Snapshot("dup"); + } + + async Task Second() + { + await Snapshot("dup"); + } + } + """); // Line 10 is Second's snapshot; the patch came from First var status = TryApply(source, 10, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _, memberName: "First"); @@ -226,8 +248,10 @@ public async Task AnAnchorMatchesAcrossMixedLineEndings() public async Task ATrailingCommentIsNotPartOfTheArgument() { var source = Method( - " await Snapshot(\"old\" // note\n" + - " );"); + """ + await Snapshot("old" // note + ); + """); var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out var reason); @@ -291,16 +315,17 @@ public async Task DuplicateLiteralsPicksNearestToHint() // Two call sites, A on line 4 and B on line 7 static string TwoCallSites(string literalA, string literalB) => - string.Join( - "\n", - "class Tests", - "{", - " Task A() =>", - $" Verify(a).Snapshot({literalA});", - "", - " Task B() =>", - $" Verify(b).Snapshot({literalB});", - "}"); + Source( + $$""" + class Tests + { + Task A() => + Verify(a).Snapshot({{literalA}}); + + Task B() => + Verify(b).Snapshot({{literalB}}); + } + """); static (string a, string b) Segments(string text) { @@ -328,15 +353,16 @@ public async Task DuplicateLiteralsPicksNearestToHintFirst() [Test] public async Task EquidistantDuplicatesPreferAtOrAfterHint() { - var source = string.Join( - "\n", - "class Tests", - "{", - " Task A() =>", - " Verify(a).Snapshot(\"dup\");", - " Task B() =>", - " Verify(b).Snapshot(\"dup\");", - "}"); + var source = Source( + """ + class Tests + { + Task A() => + Verify(a).Snapshot("dup"); + Task B() => + Verify(b).Snapshot("dup"); + } + """); // Line 5 is equidistant from the sites on lines 4 and 6 var status = TryApply(source, 5, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _); @@ -525,16 +551,17 @@ public async Task MemberNameBeatsAStaleHint() [Test] public async Task RecordedLineWinsOverTheMemberDeclaration() { - var source = string.Join( - "\n", - "class Tests", - "{", - " async Task Test()", - " {", - " await Verify(a).Snapshot(\"dup\");", - " await Verify(b).Snapshot(\"dup\");", - " }", - "}"); + var source = Source( + """ + class Tests + { + async Task Test() + { + await Verify(a).Snapshot("dup"); + await Verify(b).Snapshot("dup"); + } + } + """); var status = TryApply(source, 6, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out _, memberName: "Test"); @@ -565,16 +592,17 @@ public async Task UnknownMemberNameFallsBackToTheHint() [Test] public async Task AppendDoesNotReachAHelperDeclaredAboveTheMember() { - var source = string.Join( - "\n", - "class Tests", - "{", - " static Task Run(string value) =>", - " Verify(value);", - "", - " async Task Test() =>", - " await Run(\"value\");", - "}"); + var source = Source( + """ + class Tests + { + static Task Run(string value) => + Verify(value); + + async Task Test() => + await Run("value"); + } + """); var status = TryApply(source, 4, InlinePatchMode.Append, null, "new", out _, out var reason, memberName: "Test"); @@ -587,19 +615,20 @@ public async Task AppendDoesNotReachAHelperDeclaredAboveTheMember() [Test] public async Task AppendPrefersACallInsideTheMemberOverAHelperAbove() { - var source = string.Join( - "\n", - "class Tests", - "{", - " static Task Run(string value) =>", - " Verify(value);", - "", - " async Task Test()", - " {", - " await Verify(direct);", - " await Run(\"value\");", - " }", - "}"); + var source = Source( + """ + class Tests + { + static Task Run(string value) => + Verify(value); + + async Task Test() + { + await Verify(direct); + await Run("value"); + } + } + """); var status = TryApply(source, 4, InlinePatchMode.Append, null, "new", out var newSource, out _, memberName: "Test"); @@ -609,6 +638,56 @@ await Assert.That(newSource).Contains( " .Snapshot(\"new\");"); } + // The ordinary shape of a test: a local, then a verify call on it. The local sits between the + // member's declaration and the hint, and taking it for another member declared the hint stale - + // which drops it from the first try and from the outward walk both, so the call sitting exactly + // where the hint said was never looked at and an append reported NotFound + [Test] + public async Task AppendReachesAHintPastALocalDeclaration() + { + var source = Source( + """ + class Tests + { + async Task Test() + { + var value = Build(); + await Verify(value); + } + } + """); + + var status = TryApply(source, 6, InlinePatchMode.Append, null, "new", out var newSource, out _, memberName: "Test"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains( + " await Verify(value)\n" + + " .Snapshot(\"new\");"); + } + + // Same cause, and every mode that locates by hint pays it: an explicitly typed local is as + // much a declaration as a var one + [Test] + public async Task SetReachesAHintPastALocalDeclaration() + { + var source = Source( + """ + class Tests + { + async Task Test() + { + string value = Build(); + await Snapshot("old"); + } + } + """); + + var status = TryApply(source, 6, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _, memberName: "Test"); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(newSource).Contains("await Snapshot(\"new\");"); + } + [Test] public async Task ExpressionWinsOverValue() { @@ -675,9 +754,11 @@ await Assert.That(newSource).Contains( public async Task AppendGoesAfterAnExistingChain() { var source = Method( - " await Verify(value)\n" + - " .UseDirectory(\"snapshots\")\n" + - " .ScrubLinesContaining(\"x\");"); + """ + await Verify(value) + .UseDirectory("snapshots") + .ScrubLinesContaining("x"); + """); var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); @@ -703,11 +784,13 @@ public async Task AppendToAnEntryPointOverload() public async Task AppendToAMultiLineVerifyCall() { var source = Method( - " await Verify(\n" + - " new\n" + - " {\n" + - " value\n" + - " });"); + """ + await Verify( + new + { + value + }); + """); var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); @@ -924,17 +1007,21 @@ public async Task RemoveTakesTheWholeLine() public async Task RemoveLeavesTheRestOfTheChain() { var source = Method( - " await Verify(value)\n" + - " .UseDirectory(\"snapshots\")\n" + - " .Snapshot(\"old\");"); + """ + await Verify(value) + .UseDirectory("snapshots") + .Snapshot("old"); + """); var status = TryApply(source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( Method( - " await Verify(value)\n" + - " .UseDirectory(\"snapshots\");")); + """ + await Verify(value) + .UseDirectory("snapshots"); + """)); } [Test] @@ -1466,14 +1553,15 @@ public async Task SuffixedLiteralIsNotPatchedThroughItsQuote() [Test] public async Task CommentedOutCallIsSkipped() { - var source = string.Join( - "\n", - "class Tests", - "{", - " // await Verify(x).Snapshot(\"doc example\");", - " async Task Test() =>", - " await Verify(x).Snapshot();", - "}"); + var source = Source( + """ + class Tests + { + // await Verify(x).Snapshot("doc example"); + async Task Test() => + await Verify(x).Snapshot(); + } + """); var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out var newSource, out _); @@ -1486,8 +1574,10 @@ public async Task CommentedOutCallIsSkipped() public async Task CallInsideAStringIsSkipped() { var source = Method( - " var text = \"await Snapshot(\\\"x\\\")\";\n" + - " await Verify(x).Snapshot();"); + """ + var text = "await Snapshot(\"x\")"; + await Verify(x).Snapshot(); + """); var status = TryApply(source, 5, InlinePatchMode.Set, null, "new", out var newSource, out _); @@ -1500,13 +1590,14 @@ public async Task CallInsideAStringIsSkipped() [Test] public async Task SnapshotDeclarationIsNotMistakenForACall() { - var source = string.Join( - "\n", - "static class Extensions", - "{", - " public static Task Snapshot(this Task task, string? expected = null) =>", - " task;", - "}"); + var source = Source( + """ + static class Extensions + { + public static Task Snapshot(this Task task, string? expected = null) => + task; + } + """); var status = TryApply(source, 3, InlinePatchMode.Set, null, "new", out _, out var reason); @@ -1517,12 +1608,13 @@ public async Task SnapshotDeclarationIsNotMistakenForACall() [Test] public async Task AppendSkipsAVerifyPrefixedDeclaration() { - var source = string.Join( - "\n", - "class Tests", - "{", - " Task VerifyThing(string value) => Verify(value);", - "}"); + var source = Source( + """ + class Tests + { + Task VerifyThing(string value) => Verify(value); + } + """); var status = TryApply(source, 3, InlinePatchMode.Append, null, "new", out var newSource, out _); @@ -1537,8 +1629,10 @@ await Assert.That(newSource).Contains( public async Task AppendGoesAfterACommentInTheChain() { var source = Method( - " await Verify(value) // note\n" + - " .UseDirectory(\"snapshots\");"); + """ + await Verify(value) // note + .UseDirectory("snapshots"); + """); var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out _); @@ -1552,8 +1646,10 @@ await Assert.That(newSource).Contains( public async Task LiteralInACommentIsNotPatched() { var source = Method( - " // was \"old\"\n" + - " await Verify(x).Snapshot(\"old\");"); + """ + // was "old" + await Verify(x).Snapshot("old"); + """); var status = TryApply(source, 5, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); @@ -1565,15 +1661,16 @@ public async Task LiteralInACommentIsNotPatched() [Test] public async Task LiteralInAnotherMethodIsNotPatched() { - var source = string.Join( - "\n", - "class Tests", - "{", - " void Helper() => Log(\"old\");", - "", - " async Task Test() =>", - " await Verify(x).Snapshot(\"old\");", - "}"); + var source = Source( + """ + class Tests + { + void Helper() => Log("old"); + + async Task Test() => + await Verify(x).Snapshot("old"); + } + """); var status = TryApply(source, 3, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out _); @@ -1655,17 +1752,21 @@ public async Task CommentAfterTheArgumentIsKept() public async Task RemoveLeavesALineCommentAboveIntact() { var source = Method( - " await Verify(value)\n" + - " // note\n" + - " .Snapshot(\"old\");"); + """ + await Verify(value) + // note + .Snapshot("old"); + """); var status = TryApply(source, 7, InlinePatchMode.Remove, null, "", out var newSource, out _); await Assert.That(status).IsEqualTo(PatchStatus.Applied); await Assert.That(newSource).IsEqualTo( Method( - " await Verify(value)\n" + - " // note\n" + - " ;")); + """ + await Verify(value) + // note + ; + """)); } } diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index 7034122c..ef11f3a1 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -689,13 +689,13 @@ static bool TryFindCall( var floor = memberLine is null ? 1 : Clamp(memberLine.Value, lineCount); var origin = memberLine is null ? lineHint : floor; // The recorded line is tried first so that two snapshots in one member stay apart. It is - // only evidence about this member while it is still inside it, though, and a declaration - // between the two says it is not: the hint went stale, something above it moved, and it - // now points into the test next door. Trying it anyway rewrote that test's snapshot and - // left this one alone, which is the failure the member name exists to prevent + // only evidence about this member while it is still inside it, though, and another member + // declared between the two says it is not: the hint went stale, something above it moved, + // and it now points into the test next door. Trying it anyway rewrote that test's snapshot + // and left this one alone, which is the failure the member name exists to prevent if (lineHint >= floor && (memberLine is null || - !DeclarationBetween(source, scan, lineStarts, floor, lineHint))) + !MemberDeclaredBetween(source, scan, lineStarts, floor, lineHint))) { foreach (var call in CallsOnLine(source, scan, lineStarts, lineHint, names, byPrefix)) { @@ -734,11 +734,21 @@ static bool TryFindCall( } /// - /// Whether a declaration sits after and at or before - /// . Cheap because it only ever runs over the span between a - /// member's declaration and the recorded line. + /// Whether another member is declared after and at or before + /// , which is what says the recorded line has left the member it was + /// recorded in. Cheap because it only ever runs over the span between a member's declaration + /// and the recorded line. + /// + /// Indentation is what tells a member from a local, because nothing in front of the name does: + /// var hash = Hash() and F#'s let hash = hash () are declarations to + /// exactly as a sibling test method is. A declaration + /// indented past the member's own sits inside its body - a local, a local function, a nested + /// type - and none of those put the recorded line in another member. Counting them did, which + /// made the hint unreachable for the ordinary shape of a test: a local, then a verify call on + /// it. A sibling shares the member's own indentation, so the comparison is inclusive. + /// /// - static bool DeclarationBetween(string source, SourceScan scan, List lineStarts, int afterLine, int uptoLine) + static bool MemberDeclaredBetween(string source, SourceScan scan, List lineStarts, int afterLine, int uptoLine) { if (uptoLine <= afterLine || afterLine >= lineStarts.Count) @@ -746,6 +756,7 @@ static bool DeclarationBetween(string source, SourceScan scan, List lineSta return false; } + var memberIndent = LeadingWhitespace(source, lineStarts, lineStarts[afterLine - 1]).Length; var start = lineStarts[afterLine]; var end = uptoLine < lineStarts.Count ? lineStarts[uptoLine] : source.Length; for (var index = start; index < end; index++) @@ -757,7 +768,8 @@ static bool DeclarationBetween(string source, SourceScan scan, List lineSta continue; } - if (scan.IsDeclaration(index)) + if (scan.IsDeclaration(index) && + LeadingWhitespace(source, lineStarts, index).Length <= memberIndent) { return true; } From 92a0d7802348ed6ee866602943a68925d8e8c75f Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 24 Aug 2026 22:45:10 +1000 Subject: [PATCH 2/2] Update Directory.Build.props --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b6fb339d..2e727e82 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,7 @@ - 20.0.0-beta.33 + 20.0.0-beta.34 1.0.0 Testing, Snapshot, Diff, Compare Launches diff tools based on file extensions. Designed to be consumed by snapshot testing libraries.