diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs index dc41c54c..b3898492 100644 --- a/src/DiffEngine.Tests/InlinePatcherTests.cs +++ b/src/DiffEngine.Tests/InlinePatcherTests.cs @@ -123,6 +123,29 @@ public async Task ASnapshotBeforeAVerbatimStringOpeningOnAnEscapedQuoteIsStillFo const string q3 = "\"\"\""; const string q4 = "\"\"\"\""; + /// + /// A chain the test ended by hand. Snapshot returns the SettingsTask and GetAwaiter does not, + /// so appending after the end of the chain produced source that does not compile - and + /// reported Applied while doing it, which leaves the snapshot recorded as accepted. + /// + [Test] + [Arguments("GetAwaiter().GetResult()")] + [Arguments("ConfigureAwait(false)")] + [Arguments("ToTask()")] + public async Task AppendGoesInFrontOfAChainTerminator(string tail) + { + var source = Method($" await Verify(x).{tail};"); + + var status = TryApply(source, 5, InlinePatchMode.Append, null, "new", out var newSource, out var reason); + + await Assert.That(status).IsEqualTo(PatchStatus.Applied); + await Assert.That(reason).IsEmpty(); + // In front of the terminator, so the chain the Snapshot is appended to is still a chain + await Assert.That(newSource).Contains("Snapshot("); + await Assert.That(newSource.IndexOf("Snapshot(", StringComparison.Ordinal)) + .IsLessThan(newSource.IndexOf(tail.Split('(')[0], StringComparison.Ordinal)); + } + /// /// A hint that has gone stale and now points into another member. The recorded line is tried /// first so two snapshots in one member stay apart, but it is only evidence while it is still diff --git a/src/DiffEngine/Inline/CsLanguage.cs b/src/DiffEngine/Inline/CsLanguage.cs index f8756749..0e745e2c 100644 --- a/src/DiffEngine/Inline/CsLanguage.cs +++ b/src/DiffEngine/Inline/CsLanguage.cs @@ -19,6 +19,26 @@ public override bool TryParse(string expression, [NotNullWhen(true)] out string? internal override char NameSeparator => ':'; + /// + /// The calls a C# verify chain ends with when it stops being a verify chain: awaiting it by + /// hand, blocking on it, or converting it. Each is a real member of SettingsTask - checked + /// against Verify's source rather than guessed - and none of them returns one. + /// + /// So a Snapshot appended after any of them is not merely bad style. SettingsTask.ToTask sets + /// its task field, and CurrentSettings then throws "This SettingsTask instance has already + /// been converted to a Task and can no longer be modified" - so where such a patch compiles at + /// all it fails at run time, and where it does not compile the patch still reported Applied + /// and the snapshot was recorded as accepted. + /// + /// + internal override string[] ChainTerminators => + [ + "GetAwaiter", + "GetResult", + "ConfigureAwait", + "ToTask" + ]; + internal override bool IsIdentifierChar(char ch) => char.IsLetterOrDigit(ch) || ch == '_'; diff --git a/src/DiffEngine/Inline/FsLanguage.cs b/src/DiffEngine/Inline/FsLanguage.cs index 3f415493..8acd0c50 100644 --- a/src/DiffEngine/Inline/FsLanguage.cs +++ b/src/DiffEngine/Inline/FsLanguage.cs @@ -28,7 +28,7 @@ public override bool TryParse(string expression, [NotNullWhen(true)] out string? /// test ends the chain with ToTask. Snapshot returns the SettingsTask and ToTask does not, so /// an appended call goes in front of it rather than after it. /// - internal override string? ChainTerminator => "ToTask"; + internal override string[] ChainTerminators => ["ToTask"]; /// /// The F# compiler does not implement - it diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs index cb99189d..c65d1c10 100644 --- a/src/DiffEngine/Inline/InlinePatcher.cs +++ b/src/DiffEngine/Inline/InlinePatcher.cs @@ -472,13 +472,13 @@ static PatchStatus TryRemove( /// /// Walks the calls chained onto an invocation and returns where a call should be appended: /// the end of the chain, or the point in front of the language's - /// when the chain ends in one. + /// when the chain ends in one. /// is set when one of them is a call to . /// static int WalkChain(string source, SourceScan scan, int index, string name, out bool found) { found = false; - var terminator = scan.Language.ChainTerminator; + var terminators = scan.Language.ChainTerminators; // Where the chain was before the terminating call, which is where an appended one goes: // in front of the terminator, and behind the whitespace and line break that introduced it var beforeTerminator = -1; @@ -514,9 +514,8 @@ static int WalkChain(string source, SourceScan scan, int index, string name, out found = true; } - if (terminator != null && - beforeTerminator < 0 && - IsCall(source, nameStart, cursor, terminator)) + if (beforeTerminator < 0 && + IsTerminator(source, nameStart, cursor, terminators)) { beforeTerminator = index; } @@ -527,6 +526,19 @@ static int WalkChain(string source, SourceScan scan, int index, string name, out return beforeTerminator < 0 ? index : beforeTerminator; } + static bool IsTerminator(string source, int nameStart, int nameEnd, string[] terminators) + { + foreach (var terminator in terminators) + { + if (IsCall(source, nameStart, nameEnd, terminator)) + { + return true; + } + } + + return false; + } + static bool IsCall(string source, int nameStart, int nameEnd, string name) => nameEnd - nameStart == name.Length && string.CompareOrdinal(source, nameStart, name, 0, name.Length) == 0; diff --git a/src/DiffEngine/Inline/SourceLanguage.cs b/src/DiffEngine/Inline/SourceLanguage.cs index c2679d8c..45c66a44 100644 --- a/src/DiffEngine/Inline/SourceLanguage.cs +++ b/src/DiffEngine/Inline/SourceLanguage.cs @@ -86,10 +86,15 @@ public static SourceLanguage ForFile(string path) internal abstract char NameSeparator { get; } /// - /// A chained call that a Snapshot call has to be appended in front of rather than after, or - /// null when the end of the chain is always the insertion point. + /// Chained calls that a Snapshot call has to be appended in front of rather than after, or + /// empty when the end of the chain is always the insertion point. + /// + /// These are the calls that turn the verify chain into something that is no longer one - + /// awaiting it, blocking on it, converting it - so a Snapshot appended after one is appended + /// to the wrong type and the file stops compiling. + /// /// - internal virtual string? ChainTerminator => null; + internal virtual string[] ChainTerminators => []; /// /// Whether a patch from this language carries the source text of the expected argument, which