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; } 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.