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