diff --git a/src/DiffEngine.Tests/CsStringLiteralTests.cs b/src/DiffEngine.Tests/CsStringLiteralTests.cs
new file mode 100644
index 00000000..b2b150f3
--- /dev/null
+++ b/src/DiffEngine.Tests/CsStringLiteralTests.cs
@@ -0,0 +1,144 @@
+public class CsStringLiteralTests
+{
+ static readonly string[] renderRoundTripCases =
+ [
+ "abc",
+ "a\nb",
+ "\nabc",
+ "abc\n",
+ "\nabc\n",
+ "a\n\n\nb",
+ "\"",
+ "\"\"",
+ "\"\"\"",
+ "\"\"\"\"\"\"",
+ "\"\"\"starts with quotes",
+ "ends with quote\"",
+ "$ {value} {{x}}",
+ "a\n \nb",
+ "trailing space \nnext",
+ "emoji 🎈 and unicode ☂",
+ "line1\n indented\nline3"
+ ];
+
+ [Test]
+ public async Task RenderSimple()
+ {
+ var rendered = CsStringLiteral.RenderRaw("abc", " ", "\n");
+ await Assert.That(rendered).IsEqualTo("\"\"\"\n abc\n \"\"\"");
+ }
+
+ [Test]
+ public async Task RenderMultiLine()
+ {
+ var rendered = CsStringLiteral.RenderRaw("a\nb", " ", "\n");
+ await Assert.That(rendered).IsEqualTo("\"\"\"\n a\n b\n \"\"\"");
+ }
+
+ [Test]
+ public async Task RenderEmpty()
+ {
+ var rendered = CsStringLiteral.RenderRaw("", " ", "\n");
+ await Assert.That(rendered).IsEqualTo("\"\"\"\n \"\"\"");
+ }
+
+ [Test]
+ public async Task RenderBlankLineHasNoTrailingWhitespace()
+ {
+ var rendered = CsStringLiteral.RenderRaw("a\n\nb", " ", "\n");
+ await Assert.That(rendered).IsEqualTo("\"\"\"\n a\n\n b\n \"\"\"");
+ }
+
+ [Test]
+ public async Task RenderQuoteRunEscalatesDelimiter()
+ {
+ var rendered = CsStringLiteral.RenderRaw("has \"\"\" inside", "", "\n");
+ await Assert.That(rendered).IsEqualTo("\"\"\"\"\nhas \"\"\" inside\n\"\"\"\"");
+ }
+
+ [Test]
+ public async Task RenderCrlf()
+ {
+ var rendered = CsStringLiteral.RenderRaw("a\nb", "\t", "\r\n");
+ await Assert.That(rendered).IsEqualTo("\"\"\"\r\n\ta\r\n\tb\r\n\t\"\"\"");
+ }
+
+ [Test]
+ public async Task RenderRoundTrips()
+ {
+ foreach (var content in renderRoundTripCases)
+ {
+ foreach (var eol in new[] { "\n", "\r\n" })
+ {
+ foreach (var indent in new[] { "", " ", "\t\t" })
+ {
+ var rendered = CsStringLiteral.RenderRaw(content, indent, eol);
+ var parsed = CsStringLiteral.TryParse(rendered, out var value);
+ await Assert.That(parsed).IsTrue();
+ await Assert.That(value).IsEqualTo(content);
+ }
+ }
+ }
+ }
+
+ [Test]
+ [Arguments("\"a\"", "a")]
+ [Arguments("\"\"", "")]
+ [Arguments("\"a\\nb\"", "a\nb")]
+ [Arguments("\"tab\\there\"", "tab\there")]
+ [Arguments("\"quote\\\"q\"", "quote\"q")]
+ [Arguments("\"back\\\\slash\"", "back\\slash")]
+ [Arguments("\"\\u0041\"", "A")]
+ [Arguments("\"\\x41\"", "A")]
+ [Arguments("\"\\U0001F600\"", "😀")]
+ [Arguments("@\"a\"\"b\"", "a\"b")]
+ [Arguments("\"\"\"a\"b\"\"\"", "a\"b")]
+ [Arguments("\"\"\"\"has \"\"\" inside\"\"\"\"", "has \"\"\" inside")]
+ public async Task Parse(string expression, string expected)
+ {
+ var parsed = CsStringLiteral.TryParse(expression, out var value);
+ await Assert.That(parsed).IsTrue();
+ await Assert.That(value).IsEqualTo(expected);
+ }
+
+ [Test]
+ public async Task ParseMultiLineVerbatim()
+ {
+ var parsed = CsStringLiteral.TryParse("@\"a\r\nb\"", out var value);
+ await Assert.That(parsed).IsTrue();
+ await Assert.That(value).IsEqualTo("a\nb");
+ }
+
+ [Test]
+ public async Task ParseMultiLineRawStripsIndent()
+ {
+ var expression = "\"\"\"\n a\n\n b\n \"\"\"";
+ var parsed = CsStringLiteral.TryParse(expression, out var value);
+ await Assert.That(parsed).IsTrue();
+ await Assert.That(value).IsEqualTo("a\n\nb");
+ }
+
+ [Test]
+ [Arguments("$\"interpolated\"")]
+ [Arguments("$$\"\"\"raw interpolated\"\"\"")]
+ [Arguments("nameof(x)")]
+ [Arguments("\"a\" + \"b\"")]
+ [Arguments("\"unterminated")]
+ [Arguments("identifier")]
+ [Arguments("")]
+ [Arguments("@$\"combined\"")]
+ public async Task ParseRejects(string expression)
+ {
+ var parsed = CsStringLiteral.TryParse(expression, out _);
+ await Assert.That(parsed).IsFalse();
+ }
+
+ [Test]
+ public async Task ParseRejectsMalformedRawIndent()
+ {
+ // Content line is less indented than the closing delimiter
+ var expression = "\"\"\"\n a\n \"\"\"";
+ var parsed = CsStringLiteral.TryParse(expression, out _);
+ await Assert.That(parsed).IsFalse();
+ }
+}
diff --git a/src/DiffEngine.Tests/InlineApplierTests.cs b/src/DiffEngine.Tests/InlineApplierTests.cs
new file mode 100644
index 00000000..fb0cc3f6
--- /dev/null
+++ b/src/DiffEngine.Tests/InlineApplierTests.cs
@@ -0,0 +1,227 @@
+public class InlineApplierTests
+{
+ static string WriteTemp(byte[] bytes)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"InlineApplierTests_{Guid.NewGuid():N}.cs");
+ File.WriteAllBytes(path, bytes);
+ return path;
+ }
+
+ static byte[] Utf8(string text, bool bom)
+ {
+ var encoding = new UTF8Encoding(bom);
+ var content = encoding.GetBytes(text);
+ if (!bom)
+ {
+ return content;
+ }
+
+ var preamble = encoding.GetPreamble();
+ var result = new byte[preamble.Length + content.Length];
+ Buffer.BlockCopy(preamble, 0, result, 0, preamble.Length);
+ Buffer.BlockCopy(content, 0, result, preamble.Length, content.Length);
+ return result;
+ }
+
+ const string source = "class C\n{\n void M() => VerifyInline(value, \"old\");\n}";
+
+ [Test]
+ public async Task Utf8BomPreserved()
+ {
+ var path = WriteTemp(Utf8(source, bom: true));
+ try
+ {
+ var result = InlineApplier.Apply(new(path, 3, "\"old\"", "new"));
+ await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.Applied);
+ var bytes = File.ReadAllBytes(path);
+ await Assert.That(bytes[0]).IsEqualTo((byte)0xEF);
+ await Assert.That(bytes[1]).IsEqualTo((byte)0xBB);
+ await Assert.That(bytes[2]).IsEqualTo((byte)0xBF);
+ await Assert.That(File.ReadAllText(path)).Contains("new");
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Test]
+ public async Task NoBomStaysNoBom()
+ {
+ var path = WriteTemp(Utf8(source, bom: false));
+ try
+ {
+ var result = InlineApplier.Apply(new(path, 3, "\"old\"", "new"));
+ await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.Applied);
+ var bytes = File.ReadAllBytes(path);
+ await Assert.That(bytes[0]).IsEqualTo((byte)'c');
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Test]
+ public async Task Utf16Preserved()
+ {
+ var encoding = new UnicodeEncoding(false, true);
+ var path = WriteTemp(encoding.GetPreamble().Concat(encoding.GetBytes(source)).ToArray());
+ try
+ {
+ var result = InlineApplier.Apply(new(path, 3, "\"old\"", "new"));
+ await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.Applied);
+ var bytes = File.ReadAllBytes(path);
+ await Assert.That(bytes[0]).IsEqualTo((byte)0xFF);
+ await Assert.That(bytes[1]).IsEqualTo((byte)0xFE);
+ await Assert.That(File.ReadAllText(path, encoding)).Contains("new");
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Test]
+ public async Task CrlfPreserved()
+ {
+ var path = WriteTemp(Utf8(source.Replace("\n", "\r\n"), bom: false));
+ try
+ {
+ var result = InlineApplier.Apply(new(path, 3, "\"old\"", "a\nb"));
+ await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.Applied);
+ var text = File.ReadAllText(path);
+ await Assert.That(text).DoesNotContain("a\nb");
+ await Assert.That(text).Contains("a\r\n");
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Test]
+ public async Task MissingFileFails()
+ {
+ var result = InlineApplier.Apply(new(Path.Combine(Path.GetTempPath(), "does-not-exist-inline.cs"), 1, null, "x"));
+ await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.Failed);
+ }
+
+ [Test]
+ public async Task AlreadyAppliedDoesNotWrite()
+ {
+ var path = WriteTemp(Utf8(source, bom: false));
+ try
+ {
+ var before = File.GetLastWriteTimeUtc(path);
+ var result = InlineApplier.Apply(new(path, 3, "\"old\"", "old"));
+ await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.AlreadyApplied);
+ await Assert.That(File.GetLastWriteTimeUtc(path)).IsEqualTo(before);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Test]
+ public async Task ParallelAppliesToSameFile()
+ {
+ var multi = "class C\n{\n void A() => VerifyInline(a, \"oldA\");\n void B() => VerifyInline(b, \"oldB\");\n}";
+ var path = WriteTemp(Utf8(multi, bom: false));
+ try
+ {
+ var taskA = InlineApplier.ApplyAsync(new(path, 3, "\"oldA\"", "newA"));
+ var taskB = InlineApplier.ApplyAsync(new(path, 4, "\"oldB\"", "newB"));
+ var results = await Task.WhenAll(taskA, taskB);
+ await Assert.That(results[0].Status).IsEqualTo(InlineApplyStatus.Applied);
+ await Assert.That(results[1].Status).IsEqualTo(InlineApplyStatus.Applied);
+ var text = File.ReadAllText(path);
+ await Assert.That(text).Contains("newA");
+ await Assert.That(text).Contains("newB");
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Test]
+ public async Task NotFoundWhenSourceChanged()
+ {
+ var path = WriteTemp(Utf8(source, bom: false));
+ try
+ {
+ var result = InlineApplier.Apply(new(path, 3, "\"gone-expression\"", "new"));
+ await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.NotFound);
+ await Assert.That(result.Message!).Contains("Re-run the test");
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+}
+
+public class InlinePatchFileTests
+{
+ [Test]
+ public async Task RoundTrip()
+ {
+ var patch = new InlinePatch(@"C:\proj\Tests.cs", 42, "\"\"\"\nold\n\"\"\"", "line1\nline2");
+ var path = Path.Combine(Path.GetTempPath(), $"InlinePatchFileTests_{Guid.NewGuid():N}.inlinepatch");
+ try
+ {
+ InlinePatchFile.Write(path, patch);
+ var read = InlinePatchFile.TryRead(path, out var result);
+ await Assert.That(read).IsTrue();
+ await Assert.That(result!.SourceFile).IsEqualTo(patch.SourceFile);
+ await Assert.That(result.LineHint).IsEqualTo(42);
+ await Assert.That(result.OriginalExpression).IsEqualTo(patch.OriginalExpression);
+ await Assert.That(result.NewContent).IsEqualTo(patch.NewContent);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Test]
+ public async Task RoundTripNullExpression()
+ {
+ var patch = new InlinePatch("Tests.cs", 1, null, "content");
+ var path = Path.Combine(Path.GetTempPath(), $"InlinePatchFileTests_{Guid.NewGuid():N}.inlinepatch");
+ try
+ {
+ InlinePatchFile.Write(path, patch);
+ var read = InlinePatchFile.TryRead(path, out var result);
+ await Assert.That(read).IsTrue();
+ await Assert.That(result!.OriginalExpression).IsNull();
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Test]
+ public async Task MissingFileFails()
+ {
+ var read = InlinePatchFile.TryRead(Path.Combine(Path.GetTempPath(), "missing.inlinepatch"), out _);
+ await Assert.That(read).IsFalse();
+ }
+
+ [Test]
+ public async Task GarbageFails()
+ {
+ var read = InlinePatchFile.TryParse("not a patch", out _);
+ await Assert.That(read).IsFalse();
+ }
+
+ [Test]
+ public async Task WrongVersionFails()
+ {
+ var read = InlinePatchFile.TryParse("version: 2\nsourceFile: x\nlineHint: 1\noriginalExpression:\nnewContent: YQ==\n", out _);
+ await Assert.That(read).IsFalse();
+ }
+}
diff --git a/src/DiffEngine.Tests/InlinePatcherTests.cs b/src/DiffEngine.Tests/InlinePatcherTests.cs
new file mode 100644
index 00000000..7b10c1b1
--- /dev/null
+++ b/src/DiffEngine.Tests/InlinePatcherTests.cs
@@ -0,0 +1,215 @@
+public class InlinePatcherTests
+{
+ const string rawOld = "\"\"\"\n old\n \"\"\"";
+
+ static string Method(string body) =>
+ $"class Tests\n{{\n async Task Test()\n {{\n{body}\n }}\n}}";
+
+ [Test]
+ public async Task ReplaceRawLiteral()
+ {
+ var source = Method($" await VerifyInline(value, {rawOld.Replace("\n", "\n ")});");
+ var status = InlinePatcher.TryApply(source, 5, rawOld.Replace("\n", "\n "), "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains("new");
+ await Assert.That(newSource).DoesNotContain("old");
+ // Everything outside the span is untouched
+ await Assert.That(newSource).Contains("class Tests");
+ await Assert.That(newSource).Contains("await VerifyInline(value, ");
+ await Assert.That(newSource.EndsWith(");\n }\n}")).IsTrue();
+ }
+
+ [Test]
+ public async Task ReplaceRegularLiteral()
+ {
+ var source = Method(" await VerifyInline(value, \"old\");");
+ var status = InlinePatcher.TryApply(source, 5, "\"old\"", "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains(
+ """
+ await VerifyInline(value, ""
+ """ + "\"");
+ await Assert.That(newSource).Contains(" new");
+ }
+
+ [Test]
+ public async Task ReplacementUsesFileEol()
+ {
+ var source = Method(" await VerifyInline(value, \"old\");").Replace("\n", "\r\n");
+ var status = InlinePatcher.TryApply(source, 5, "\"old\"", "a\nb", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).DoesNotContain("a\nb");
+ await Assert.That(newSource).Contains("a\r\n b");
+ }
+
+ [Test]
+ public async Task AlreadyAppliedWhenLiteralMatches()
+ {
+ var source = Method(" await VerifyInline(value, \"same\");");
+ var status = InlinePatcher.TryApply(source, 5, "\"same\"", "same", out _, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied);
+ }
+
+ [Test]
+ public async Task ShiftedLinesStillFound()
+ {
+ var padding = string.Concat(Enumerable.Repeat(" // padding\n", 30));
+ var source = Method(padding + " await VerifyInline(value, \"old\");");
+ var status = InlinePatcher.TryApply(source, 5, "\"old\"", "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).DoesNotContain("\"old\"");
+ }
+
+ [Test]
+ public async Task DuplicateLiteralsPicksNearestToHint()
+ {
+ var source =
+ "await VerifyInline(a, \"dup\");\n" +
+ string.Concat(Enumerable.Repeat("// filler\n", 10)) +
+ "await VerifyInline(b, \"dup\");\n";
+ var status = InlinePatcher.TryApply(source, 12, "\"dup\"", "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ // First occurrence untouched, second replaced
+ await Assert.That(newSource).Contains("VerifyInline(a, \"dup\")");
+ await Assert.That(newSource).DoesNotContain("VerifyInline(b, \"dup\")");
+ }
+
+ [Test]
+ public async Task ExpressionGoneAndLiteralMatchesIsAlreadyApplied()
+ {
+ // The other TFM already applied: the old expression is gone,
+ // and the current argument renders to the new content.
+ var source = Method(" await VerifyInline(value, \"new\");");
+ var status = InlinePatcher.TryApply(source, 5, "\"old-gone\"", "new", out _, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied);
+ }
+
+ [Test]
+ public async Task ExpressionGoneAndLiteralDiffersIsNotFound()
+ {
+ var source = Method(" await VerifyInline(value, \"different\");");
+ var status = InlinePatcher.TryApply(source, 5, "\"old-gone\"", "new", out _, out var reason);
+ await Assert.That(status).IsEqualTo(PatchStatus.NotFound);
+ await Assert.That(reason).Contains("Re-run the test");
+ }
+
+ [Test]
+ public async Task InsertIntoSingleArgumentCall()
+ {
+ var source = Method(" await VerifyInline(value);");
+ var status = InlinePatcher.TryApply(source, 5, null, "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains("await VerifyInline(value, \"\"\"\n new\n \"\"\");");
+ }
+
+ [Test]
+ public async Task InsertWithComplexTargetExpression()
+ {
+ var source = Method(" await VerifyInline(new { a = 1, b = Call(\"x, y\") });");
+ var status = InlinePatcher.TryApply(source, 5, null, "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains("Call(\"x, y\") }, \"\"\"");
+ }
+
+ [Test]
+ public async Task InsertReplacesNullArgument()
+ {
+ var source = Method(" await VerifyInline(value, null, settings);");
+ var status = InlinePatcher.TryApply(source, 5, null, "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains("await VerifyInline(value, \"\"\"\n new\n \"\"\", settings);");
+ }
+
+ [Test]
+ public async Task InsertBeforeNamedSettingsArgument()
+ {
+ var source = Method(" await VerifyInline(value, settings: mySettings);");
+ var status = InlinePatcher.TryApply(source, 5, null, "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains("await VerifyInline(value, expected: \"\"\"\n new\n \"\"\", settings: mySettings);");
+ }
+
+ [Test]
+ public async Task InsertLeavesFluentContinuationIntact()
+ {
+ var source = Method(" await VerifyInline(value)\n .UseDirectory(\"snapshots\");");
+ var status = InlinePatcher.TryApply(source, 5, null, "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains(".UseDirectory(\"snapshots\");");
+ await Assert.That(newSource).Contains("VerifyInline(value, \"\"\"");
+ }
+
+ [Test]
+ public async Task NullOriginalWithDifferingLiteralIsNotFound()
+ {
+ var source = Method(" await VerifyInline(value, \"different\");");
+ var status = InlinePatcher.TryApply(source, 5, null, "new", out _, out var reason);
+ await Assert.That(status).IsEqualTo(PatchStatus.NotFound);
+ await Assert.That(reason).Contains("different expected argument");
+ }
+
+ [Test]
+ public async Task NullOriginalWithEqualLiteralIsAlreadyApplied()
+ {
+ var source = Method(" await VerifyInline(value, \"new\");");
+ var status = InlinePatcher.TryApply(source, 5, null, "new", out _, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.AlreadyApplied);
+ }
+
+ [Test]
+ public async Task NoCallFound()
+ {
+ var source = Method(" await Verify(value);");
+ var status = InlinePatcher.TryApply(source, 5, null, "new", out _, out var reason);
+ await Assert.That(status).IsEqualTo(PatchStatus.NotFound);
+ await Assert.That(reason).Contains("Could not find a VerifyInline call");
+ }
+
+ [Test]
+ public async Task PartialTokenIsNotMatched()
+ {
+ var source = Method(" await MyVerifyInlineHelper(value);");
+ var status = InlinePatcher.TryApply(source, 5, null, "new", out _, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.NotFound);
+ }
+
+ [Test]
+ public async Task TabIndentedFileUsesTabUnit()
+ {
+ var source = "class Tests\n{\n\tasync Task Test()\n\t{\n\t\tawait VerifyInline(value);\n\t}\n}";
+ var status = InlinePatcher.TryApply(source, 5, null, "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains("VerifyInline(value, \"\"\"\n\t\t\tnew\n\t\t\t\"\"\");");
+ }
+
+ [Test]
+ public async Task HintBeyondEndOfFile()
+ {
+ var source = "await VerifyInline(value);";
+ var status = InlinePatcher.TryApply(source, 500, null, "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).Contains("VerifyInline(value, \"\"\"");
+ }
+
+ [Test]
+ public async Task LfExpressionFoundInCrlfFile()
+ {
+ var source = Method($" await VerifyInline(value, {rawOld.Replace("\n", "\n ")});").Replace("\n", "\r\n");
+ var expression = rawOld.Replace("\n", "\n ");
+ var status = InlinePatcher.TryApply(source, 5, expression, "new", out var newSource, out _);
+ await Assert.That(status).IsEqualTo(PatchStatus.Applied);
+ await Assert.That(newSource).DoesNotContain("old");
+ }
+
+ [Test]
+ public async Task OutsideSpanIsCharacterIdentical()
+ {
+ var body = " await VerifyInline(value, \"old\");";
+ var source = Method(body);
+ InlinePatcher.TryApply(source, 5, "\"old\"", "new", out var newSource, out _);
+ var prefix = source.Substring(0, source.IndexOf("\"old\"", StringComparison.Ordinal));
+ var suffix = source.Substring(source.IndexOf("\"old\"", StringComparison.Ordinal) + 5);
+ await Assert.That(newSource.StartsWith(prefix)).IsTrue();
+ await Assert.That(newSource.EndsWith(suffix)).IsTrue();
+ }
+}
diff --git a/src/DiffEngine/DiffRunner_InlineMove.cs b/src/DiffEngine/DiffRunner_InlineMove.cs
new file mode 100644
index 00000000..be791630
--- /dev/null
+++ b/src/DiffEngine/DiffRunner_InlineMove.cs
@@ -0,0 +1,81 @@
+namespace DiffEngine;
+
+public enum InlineMoveResult
+{
+ Sent,
+ Disabled,
+ TrayNotRunning,
+
+ ///
+ /// The running tray predates inline snapshot support.
+ /// Update with: dotnet tool update -g DiffEngineTray
+ ///
+ TrayTooOld
+}
+
+public static partial class DiffRunner
+{
+ static readonly Version minInlineTrayVersion = new(20, 0, 0);
+
+ ///
+ /// Notifies the tray of a pending inline snapshot edit.
+ ///
+ /// The staged received text file. Used as the tracking key and the diff left side.
+ /// The .cs source file the snapshot will be spliced into.
+ /// The staged patch file (see ).
+ /// Optional staged expected text file, used as the diff right side.
+ public static InlineMoveResult AddInlineMove(
+ string tempFile,
+ string targetFile,
+ string patchFile,
+ string? stagedVerifiedFile = null)
+ {
+ var check = CheckInlineMove();
+ if (check != InlineMoveResult.Sent)
+ {
+ return check;
+ }
+
+ PiperClient.SendInlineMove(tempFile, targetFile, patchFile, stagedVerifiedFile);
+ return InlineMoveResult.Sent;
+ }
+
+ ///
+ public static async Task AddInlineMoveAsync(
+ string tempFile,
+ string targetFile,
+ string patchFile,
+ string? stagedVerifiedFile = null,
+ Cancel cancel = default)
+ {
+ var check = CheckInlineMove();
+ if (check != InlineMoveResult.Sent)
+ {
+ return check;
+ }
+
+ await PiperClient.SendInlineMoveAsync(tempFile, targetFile, patchFile, stagedVerifiedFile, cancel);
+ return InlineMoveResult.Sent;
+ }
+
+ static InlineMoveResult CheckInlineMove()
+ {
+ if (Disabled)
+ {
+ return InlineMoveResult.Disabled;
+ }
+
+ if (!TrayDetector.IsRunning())
+ {
+ return InlineMoveResult.TrayNotRunning;
+ }
+
+ if (!TrayVersionFile.TryRead(out var version) ||
+ version < minInlineTrayVersion)
+ {
+ return InlineMoveResult.TrayTooOld;
+ }
+
+ return InlineMoveResult.Sent;
+ }
+}
diff --git a/src/DiffEngine/Inline/CsStringLiteral.cs b/src/DiffEngine/Inline/CsStringLiteral.cs
new file mode 100644
index 00000000..23c48c12
--- /dev/null
+++ b/src/DiffEngine/Inline/CsStringLiteral.cs
@@ -0,0 +1,419 @@
+namespace DiffEngine;
+
+///
+/// Renders snapshot text as a C# raw string literal, and parses C# string literal
+/// expressions back to their runtime values.
+///
+public static class CsStringLiteral
+{
+ ///
+ /// Renders (\n newlines) as a multi-line raw string literal.
+ /// The returned text starts with the opening quotes (no leading indent on the first line)
+ /// and ends with the closing quotes (no trailing newline).
+ ///
+ /// Snapshot text with \n newlines.
+ /// Whitespace prefix for content lines and the closing delimiter.
+ /// The target file's line ending ("\r\n" or "\n").
+ public static string RenderRaw(string content, string indent, string eol)
+ {
+ var delimiter = new string('"', Math.Max(3, LongestQuoteRun(content) + 1));
+ var builder = new StringBuilder();
+ builder.Append(delimiter);
+ builder.Append(eol);
+ if (content.Length > 0)
+ {
+ foreach (var line in content.Split('\n'))
+ {
+ if (line.Length > 0)
+ {
+ builder.Append(indent);
+ builder.Append(line);
+ }
+
+ builder.Append(eol);
+ }
+ }
+
+ builder.Append(indent);
+ builder.Append(delimiter);
+ return builder.ToString();
+ }
+
+ static int LongestQuoteRun(string content)
+ {
+ var longest = 0;
+ var current = 0;
+ foreach (var ch in content)
+ {
+ if (ch == '"')
+ {
+ current++;
+ if (current > longest)
+ {
+ longest = current;
+ }
+ }
+ else
+ {
+ current = 0;
+ }
+ }
+
+ return longest;
+ }
+
+ ///
+ /// Parses a C# string literal expression back to its runtime value.
+ /// Supports raw ("""..."""), verbatim (@"...") and regular ("...") literals.
+ /// Returns false for interpolated strings, concatenations, or any other expression.
+ /// Newlines in the returned value are normalized to \n.
+ ///
+ public static bool TryParse(string expression, [NotNullWhen(true)] out string? value)
+ {
+ value = null;
+ var text = expression.Trim();
+ if (text.Length == 0)
+ {
+ return false;
+ }
+
+ if (!TryScanLiteral(text, 0, out value, out var end))
+ {
+ return false;
+ }
+
+ // The scan must consume the whole expression (rejects "a" + "b" etc.)
+ if (end != text.Length)
+ {
+ value = null;
+ return false;
+ }
+
+ value = NormalizeNewlines(value!);
+ return true;
+ }
+
+ internal static string NormalizeNewlines(string value) =>
+ value
+ .Replace("\r\n", "\n")
+ .Replace('\r', '\n');
+
+ ///
+ /// Scans one string literal starting at (which must point at the
+ /// first character of the literal: '"' or '@'). On success is the
+ /// index one past the closing quote. The value is NOT newline normalized.
+ ///
+ internal static bool TryScanLiteral(string text, int start, out string? value, out int end)
+ {
+ value = null;
+ end = start;
+ if (start >= text.Length)
+ {
+ return false;
+ }
+
+ var index = start;
+ var verbatim = false;
+ if (text[index] == '@')
+ {
+ verbatim = true;
+ index++;
+ }
+
+ if (index >= text.Length || text[index] != '"')
+ {
+ // Interpolated ($) and everything else is unsupported.
+ return false;
+ }
+
+ var quotes = QuoteRunLength(text, index);
+ if (quotes >= 3)
+ {
+ if (verbatim)
+ {
+ return false;
+ }
+
+ return TryScanRaw(text, index, quotes, out value, out end);
+ }
+
+ if (verbatim)
+ {
+ return TryScanVerbatim(text, index + 1, out value, out end);
+ }
+
+ if (quotes == 2)
+ {
+ // Empty regular string ""
+ value = "";
+ end = index + 2;
+ return true;
+ }
+
+ return TryScanRegular(text, index + 1, out value, out end);
+ }
+
+ static int QuoteRunLength(string text, int index)
+ {
+ var count = 0;
+ while (index + count < text.Length &&
+ text[index + count] == '"')
+ {
+ count++;
+ }
+
+ return count;
+ }
+
+ static bool TryScanRaw(string text, int start, int quotes, out string? value, out int end)
+ {
+ value = null;
+ end = start;
+ var contentStart = start + quotes;
+ // Find the closing delimiter: a run of quotes with length >= quotes.
+ // Content quote runs are shorter than the delimiter by the language rules.
+ var index = contentStart;
+ while (true)
+ {
+ if (index >= text.Length)
+ {
+ return false;
+ }
+
+ if (text[index] != '"')
+ {
+ index++;
+ continue;
+ }
+
+ var run = QuoteRunLength(text, index);
+ if (run >= quotes)
+ {
+ break;
+ }
+
+ index += run;
+ }
+
+ var contentEnd = index;
+ end = index + quotes;
+ var content = text.Substring(contentStart, contentEnd - contentStart);
+ if (!content.Contains('\n'))
+ {
+ // Single line raw string: content is verbatim.
+ value = content;
+ return true;
+ }
+
+ // Multi line raw string:
+ // * first line (after the opening quotes) must be whitespace only and is dropped
+ // * the last line holds the closing quotes; its leading whitespace is the indent
+ // stripped from every content line, and the line itself is dropped
+ var normalized = NormalizeNewlines(content);
+ var lines = normalized.Split('\n');
+ var first = lines[0];
+ if (first.Trim().Length > 0)
+ {
+ return false;
+ }
+
+ var closeIndent = lines[^1];
+ if (closeIndent.Trim().Length > 0)
+ {
+ return false;
+ }
+
+ var builder = new StringBuilder();
+ for (var lineIndex = 1; lineIndex < lines.Length - 1; lineIndex++)
+ {
+ if (lineIndex > 1)
+ {
+ builder.Append('\n');
+ }
+
+ var line = lines[lineIndex];
+ if (line.Length == 0)
+ {
+ continue;
+ }
+
+ if (line.StartsWith(closeIndent, StringComparison.Ordinal))
+ {
+ builder.Append(line, closeIndent.Length, line.Length - closeIndent.Length);
+ continue;
+ }
+
+ if (line.Trim().Length == 0)
+ {
+ // Whitespace-only line shorter than the indent
+ continue;
+ }
+
+ // Malformed indentation
+ return false;
+ }
+
+ value = builder.ToString();
+ return true;
+ }
+
+ static bool TryScanVerbatim(string text, int start, out string? value, out int end)
+ {
+ value = null;
+ end = start;
+ var builder = new StringBuilder();
+ var index = start;
+ while (index < text.Length)
+ {
+ var ch = text[index];
+ if (ch == '"')
+ {
+ if (index + 1 < text.Length &&
+ text[index + 1] == '"')
+ {
+ builder.Append('"');
+ index += 2;
+ continue;
+ }
+
+ value = builder.ToString();
+ end = index + 1;
+ return true;
+ }
+
+ builder.Append(ch);
+ index++;
+ }
+
+ return false;
+ }
+
+ static bool TryScanRegular(string text, int start, out string? value, out int end)
+ {
+ value = null;
+ end = start;
+ var builder = new StringBuilder();
+ var index = start;
+ while (index < text.Length)
+ {
+ var ch = text[index];
+ if (ch == '"')
+ {
+ value = builder.ToString();
+ end = index + 1;
+ return true;
+ }
+
+ if (ch == '\n' ||
+ ch == '\r')
+ {
+ // Regular strings cannot span lines
+ return false;
+ }
+
+ if (ch != '\\')
+ {
+ builder.Append(ch);
+ index++;
+ continue;
+ }
+
+ index++;
+ if (index >= text.Length)
+ {
+ return false;
+ }
+
+ var escape = text[index];
+ index++;
+ switch (escape)
+ {
+ case '\\':
+ builder.Append('\\');
+ break;
+ case '"':
+ builder.Append('"');
+ break;
+ case '\'':
+ builder.Append('\'');
+ break;
+ case '0':
+ builder.Append('\0');
+ break;
+ case 'a':
+ builder.Append('\a');
+ break;
+ case 'b':
+ builder.Append('\b');
+ break;
+ case 'e':
+ builder.Append('');
+ break;
+ case 'f':
+ builder.Append('\f');
+ break;
+ case 'n':
+ builder.Append('\n');
+ break;
+ case 'r':
+ builder.Append('\r');
+ break;
+ case 't':
+ builder.Append('\t');
+ break;
+ case 'v':
+ builder.Append('\v');
+ break;
+ case 'u':
+ if (!TryReadHex(text, ref index, 4, 4, out var utf16))
+ {
+ return false;
+ }
+
+ builder.Append((char)utf16);
+ break;
+ case 'x':
+ if (!TryReadHex(text, ref index, 1, 4, out var variable))
+ {
+ return false;
+ }
+
+ builder.Append((char)variable);
+ break;
+ case 'U':
+ if (!TryReadHex(text, ref index, 8, 8, out var codePoint))
+ {
+ return false;
+ }
+
+ if (codePoint > 0x10FFFF)
+ {
+ return false;
+ }
+
+ builder.Append(char.ConvertFromUtf32((int)codePoint));
+ break;
+ default:
+ return false;
+ }
+ }
+
+ return false;
+ }
+
+ static bool TryReadHex(string text, ref int index, int min, int max, out uint result)
+ {
+ result = 0;
+ var count = 0;
+ while (count < max &&
+ index < text.Length &&
+ Uri.IsHexDigit(text[index]))
+ {
+ result = (result << 4) + (uint)Uri.FromHex(text[index]);
+ index++;
+ count++;
+ }
+
+ return count >= min;
+ }
+}
diff --git a/src/DiffEngine/Inline/InlineApplier.cs b/src/DiffEngine/Inline/InlineApplier.cs
new file mode 100644
index 00000000..ebe99f31
--- /dev/null
+++ b/src/DiffEngine/Inline/InlineApplier.cs
@@ -0,0 +1,194 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+
+namespace DiffEngine;
+
+///
+/// Applies an to a C# source file, preserving the file's
+/// encoding, BOM and line endings. Owns all locking (cross process and in process);
+/// callers must not add their own.
+///
+public static class InlineApplier
+{
+ static readonly ConcurrentDictionary gates = new(StringComparer.OrdinalIgnoreCase);
+
+ public static Task ApplyAsync(InlinePatch patch, Cancel cancel = default) =>
+ Task.Run(() => Apply(patch), cancel);
+
+ public static InlineApplyResult Apply(InlinePatch patch)
+ {
+ if (string.IsNullOrWhiteSpace(patch.SourceFile))
+ {
+ return InlineApplyResult.Failed("InlinePatch.SourceFile is empty");
+ }
+
+ if (patch.NewContent is null)
+ {
+ return InlineApplyResult.Failed("InlinePatch.NewContent is null");
+ }
+
+ if (patch.LineHint < 1)
+ {
+ return InlineApplyResult.Failed($"InlinePatch.LineHint must be 1 or greater. Value: {patch.LineHint}");
+ }
+
+ string fullPath;
+ try
+ {
+ fullPath = Path.GetFullPath(patch.SourceFile);
+ }
+ catch (Exception exception)
+ {
+ return InlineApplyResult.Failed($"Invalid InlinePatch.SourceFile: {patch.SourceFile}", exception);
+ }
+
+ if (!File.Exists(fullPath))
+ {
+ return InlineApplyResult.Failed($"Source file does not exist: {fullPath}");
+ }
+
+ var newContent = CsStringLiteral.NormalizeNewlines(patch.NewContent);
+ var normalizedPath = fullPath.ToLowerInvariant();
+ lock (gates.GetOrAdd(normalizedPath, static _ => new()))
+ {
+ using var mutex = new Mutex(false, MutexName(normalizedPath));
+ var owned = false;
+ try
+ {
+ try
+ {
+ owned = mutex.WaitOne(TimeSpan.FromSeconds(10));
+ }
+ catch (AbandonedMutexException)
+ {
+ owned = true;
+ }
+
+ if (!owned)
+ {
+ return InlineApplyResult.Failed($"Timed out waiting for the inline patch mutex for: {fullPath}");
+ }
+
+ return LockedApply(fullPath, patch, newContent);
+ }
+ finally
+ {
+ if (owned)
+ {
+ mutex.ReleaseMutex();
+ }
+ }
+ }
+ }
+
+ static InlineApplyResult LockedApply(string fullPath, InlinePatch patch, string newContent)
+ {
+ byte[] bytes;
+ try
+ {
+ bytes = File.ReadAllBytes(fullPath);
+ }
+ catch (Exception exception)
+ {
+ return InlineApplyResult.Failed($"Failed to read: {fullPath}", exception);
+ }
+
+ var (encoding, bomLength) = DetectEncoding(bytes);
+ string source;
+ try
+ {
+ source = encoding.GetString(bytes, bomLength, bytes.Length - bomLength);
+ }
+ catch (Exception exception)
+ {
+ return InlineApplyResult.Failed($"Failed to decode: {fullPath}", exception);
+ }
+
+ var status = InlinePatcher.TryApply(
+ source,
+ patch.LineHint,
+ patch.OriginalExpression,
+ newContent,
+ out var newSource,
+ out var failReason);
+
+ switch (status)
+ {
+ case PatchStatus.AlreadyApplied:
+ return InlineApplyResult.AlreadyApplied;
+ case PatchStatus.NotFound:
+ return InlineApplyResult.NotFound(failReason);
+ }
+
+ try
+ {
+ var content = encoding.GetBytes(newSource);
+ byte[] output;
+ if (bomLength > 0)
+ {
+ var preamble = encoding.GetPreamble();
+ output = new byte[preamble.Length + content.Length];
+ Buffer.BlockCopy(preamble, 0, output, 0, preamble.Length);
+ Buffer.BlockCopy(content, 0, output, preamble.Length, content.Length);
+ }
+ else
+ {
+ output = content;
+ }
+
+ File.WriteAllBytes(fullPath, output);
+ }
+ catch (Exception exception)
+ {
+ return InlineApplyResult.Failed($"Failed to write: {fullPath}", exception);
+ }
+
+ return InlineApplyResult.Applied;
+ }
+
+ static (Encoding encoding, int bomLength) DetectEncoding(byte[] bytes)
+ {
+ if (bytes.Length >= 4 &&
+ bytes[0] == 0xFF && bytes[1] == 0xFE && bytes[2] == 0x00 && bytes[3] == 0x00)
+ {
+ return (new UTF32Encoding(false, true), 4);
+ }
+
+ if (bytes.Length >= 3 &&
+ bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
+ {
+ return (new UTF8Encoding(true), 3);
+ }
+
+ if (bytes.Length >= 2 &&
+ bytes[0] == 0xFF && bytes[1] == 0xFE)
+ {
+ return (new UnicodeEncoding(false, true), 2);
+ }
+
+ if (bytes.Length >= 2 &&
+ bytes[0] == 0xFE && bytes[1] == 0xFF)
+ {
+ return (new UnicodeEncoding(true, true), 2);
+ }
+
+ return (new UTF8Encoding(false), 0);
+ }
+
+ static string MutexName(string normalizedPath)
+ {
+#if NET6_0_OR_GREATER
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalizedPath));
+#else
+ using var sha = SHA256.Create();
+ var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(normalizedPath));
+#endif
+ var builder = new StringBuilder("DiffEngineInline_");
+ foreach (var b in hash)
+ {
+ builder.Append(b.ToString("X2"));
+ }
+
+ return builder.ToString();
+ }
+}
diff --git a/src/DiffEngine/Inline/InlineApplyResult.cs b/src/DiffEngine/Inline/InlineApplyResult.cs
new file mode 100644
index 00000000..8a3189ed
--- /dev/null
+++ b/src/DiffEngine/Inline/InlineApplyResult.cs
@@ -0,0 +1,47 @@
+namespace DiffEngine;
+
+public enum InlineApplyStatus
+{
+ ///
+ /// The source file was modified.
+ ///
+ Applied,
+
+ ///
+ /// The source file already contains the new content at the call site. No write performed.
+ ///
+ AlreadyApplied,
+
+ ///
+ /// The call site could not be located; the source has changed since the patch was created. No write performed.
+ ///
+ NotFound,
+
+ ///
+ /// IO, locking, or validation failure. See .
+ ///
+ Failed
+}
+
+public sealed class InlineApplyResult
+{
+ InlineApplyResult(InlineApplyStatus status, string? message, Exception? exception)
+ {
+ Status = status;
+ Message = message;
+ Exception = exception;
+ }
+
+ public InlineApplyStatus Status { get; }
+ public string? Message { get; }
+ public Exception? Exception { get; }
+
+ public static readonly InlineApplyResult Applied = new(InlineApplyStatus.Applied, null, null);
+ public static readonly InlineApplyResult AlreadyApplied = new(InlineApplyStatus.AlreadyApplied, null, null);
+
+ public static InlineApplyResult NotFound(string message) =>
+ new(InlineApplyStatus.NotFound, message, null);
+
+ public static InlineApplyResult Failed(string message, Exception? exception = null) =>
+ new(InlineApplyStatus.Failed, message, exception);
+}
diff --git a/src/DiffEngine/Inline/InlinePatch.cs b/src/DiffEngine/Inline/InlinePatch.cs
new file mode 100644
index 00000000..6f6a50bb
--- /dev/null
+++ b/src/DiffEngine/Inline/InlinePatch.cs
@@ -0,0 +1,41 @@
+namespace DiffEngine;
+
+///
+/// Describes a pending inline-snapshot edit to a C# source file.
+/// Mutable POCO so it round-trips through .
+///
+public sealed class InlinePatch
+{
+ public InlinePatch()
+ {
+ }
+
+ public InlinePatch(string sourceFile, int lineHint, string? originalExpression, string newContent)
+ {
+ SourceFile = sourceFile;
+ LineHint = lineHint;
+ OriginalExpression = originalExpression;
+ NewContent = newContent;
+ }
+
+ ///
+ /// Full path to the .cs file.
+ ///
+ public string SourceFile { get; set; } = null!;
+
+ ///
+ /// 1 based line of the VerifyInline call. A hint only; content search is the locator.
+ ///
+ public int LineHint { get; set; }
+
+ ///
+ /// Verbatim source text of the previous expected argument.
+ /// Null when the call had no expected argument (or a bare null argument).
+ ///
+ public string? OriginalExpression { get; set; }
+
+ ///
+ /// The new snapshot text. Newlines are \n.
+ ///
+ public string NewContent { get; set; } = null!;
+}
diff --git a/src/DiffEngine/Inline/InlinePatchFile.cs b/src/DiffEngine/Inline/InlinePatchFile.cs
new file mode 100644
index 00000000..815a05ff
--- /dev/null
+++ b/src/DiffEngine/Inline/InlinePatchFile.cs
@@ -0,0 +1,105 @@
+namespace DiffEngine;
+
+///
+/// Reads and writes the staged inline patch file. Plain text with base64 encoded
+/// content fields so the format is readable without a JSON dependency.
+///
+public static class InlinePatchFile
+{
+ public static void Write(string path, InlinePatch patch)
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllText(path, Build(patch), new UTF8Encoding(false));
+ }
+
+ public static string Build(InlinePatch patch)
+ {
+ var expression = patch.OriginalExpression is null
+ ? ""
+ : Convert.ToBase64String(Encoding.UTF8.GetBytes(patch.OriginalExpression));
+ var content = Convert.ToBase64String(Encoding.UTF8.GetBytes(patch.NewContent));
+ return $"version: 1\nsourceFile: {patch.SourceFile}\nlineHint: {patch.LineHint}\noriginalExpression: {expression}\nnewContent: {content}\n";
+ }
+
+ public static bool TryRead(string path, [NotNullWhen(true)] out InlinePatch? patch)
+ {
+ patch = null;
+ string text;
+ try
+ {
+ if (!File.Exists(path))
+ {
+ return false;
+ }
+
+ text = File.ReadAllText(path);
+ }
+ catch
+ {
+ return false;
+ }
+
+ return TryParse(text, out patch);
+ }
+
+ public static bool TryParse(string text, [NotNullWhen(true)] out InlinePatch? patch)
+ {
+ patch = null;
+ var lines = text
+ .Replace("\r\n", "\n")
+ .Split('\n');
+ if (lines.Length < 5 ||
+ !TryValue(lines[0], "version", out var version) ||
+ version != "1" ||
+ !TryValue(lines[1], "sourceFile", out var sourceFile) ||
+ sourceFile.Length == 0 ||
+ !TryValue(lines[2], "lineHint", out var lineText) ||
+ !int.TryParse(lineText, out var lineHint) ||
+ !TryValue(lines[3], "originalExpression", out var expressionBase64) ||
+ !TryValue(lines[4], "newContent", out var contentBase64))
+ {
+ return false;
+ }
+
+ string? expression;
+ string content;
+ try
+ {
+ expression = expressionBase64.Length == 0
+ ? null
+ : Encoding.UTF8.GetString(Convert.FromBase64String(expressionBase64));
+ content = Encoding.UTF8.GetString(Convert.FromBase64String(contentBase64));
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+
+ patch = new(sourceFile, lineHint, expression, content);
+ return true;
+ }
+
+ static bool TryValue(string line, string key, out string value)
+ {
+ value = "";
+ var prefix = key + ": ";
+ if (line.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ value = line.Substring(prefix.Length);
+ return true;
+ }
+
+ // Empty value: "key:" with no trailing space
+ if (line == key + ":")
+ {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/DiffEngine/Inline/InlinePatcher.cs b/src/DiffEngine/Inline/InlinePatcher.cs
new file mode 100644
index 00000000..3a34621a
--- /dev/null
+++ b/src/DiffEngine/Inline/InlinePatcher.cs
@@ -0,0 +1,778 @@
+enum PatchStatus
+{
+ Applied,
+ AlreadyApplied,
+ NotFound
+}
+
+///
+/// Pure string in / string out engine that locates an inline snapshot call site in C# source
+/// and splices in a new raw string literal. No file IO.
+///
+static class InlinePatcher
+{
+ const string methodName = "VerifyInline";
+
+ public static PatchStatus TryApply(
+ string source,
+ int lineHint,
+ string? originalExpression,
+ string newContent,
+ out string newSource,
+ out string failReason)
+ {
+ newSource = "";
+ failReason = "";
+ var eol = DetectEol(source);
+ var lineStarts = BuildLineStarts(source);
+
+ if (!string.IsNullOrEmpty(originalExpression))
+ {
+ // Search for the previous expression verbatim, with newlines matched to the file's EOL
+ var needle = NormalizeTo(originalExpression!, eol);
+ var occurrences = FindAll(source, needle);
+ if (occurrences.Count > 0)
+ {
+ var start = Nearest(occurrences, lineStarts, lineHint);
+ if (CsStringLiteral.TryParse(needle, out var oldValue) &&
+ oldValue == newContent)
+ {
+ return PatchStatus.AlreadyApplied;
+ }
+
+ var indent = IndentForSpan(source, lineStarts, start);
+ var rendered = CsStringLiteral.RenderRaw(newContent, indent, eol);
+ newSource = Splice(source, start, start + needle.Length, rendered);
+ return PatchStatus.Applied;
+ }
+
+ // Expression gone: another process may have applied the same patch already
+ return InsertOrCheck(source, lineStarts, lineHint, newContent, eol, alreadyOnly: true, ref newSource, ref failReason);
+ }
+
+ return InsertOrCheck(source, lineStarts, lineHint, newContent, eol, alreadyOnly: false, ref newSource, ref failReason);
+ }
+
+ static PatchStatus InsertOrCheck(
+ string source,
+ List lineStarts,
+ int lineHint,
+ string newContent,
+ string eol,
+ bool alreadyOnly,
+ ref string newSource,
+ ref string failReason)
+ {
+ if (!TryFindCall(source, lineStarts, lineHint, out var openParen))
+ {
+ failReason = $"Could not find a {methodName} call near line {lineHint}. The source may have changed since the test run. Re-run the test.";
+ return PatchStatus.NotFound;
+ }
+
+ if (!TryScanArguments(source, openParen, out var closeParen, out var topCommas))
+ {
+ failReason = $"Could not parse the argument list of the {methodName} call near line {lineHint}.";
+ return PatchStatus.NotFound;
+ }
+
+ if (topCommas.Count == 0)
+ {
+ // Only the target argument exists
+ if (source.Substring(openParen + 1, closeParen - openParen - 1).Trim().Length == 0)
+ {
+ failReason = $"The {methodName} call near line {lineHint} has no arguments.";
+ return PatchStatus.NotFound;
+ }
+
+ if (alreadyOnly)
+ {
+ failReason = $"The previous expected expression was not found near line {lineHint}. The source may have changed since the test run. Re-run the test.";
+ return PatchStatus.NotFound;
+ }
+
+ var insertAt = EndOfLastNonWhitespace(source, openParen + 1, closeParen);
+ var indent = IndentForSpan(source, lineStarts, insertAt) ;
+ var rendered = CsStringLiteral.RenderRaw(newContent, indent, eol);
+ newSource = Splice(source, insertAt, insertAt, ", " + rendered);
+ return PatchStatus.Applied;
+ }
+
+ // A second argument exists
+ var argStart = topCommas[0] + 1;
+ var argEnd = topCommas.Count > 1 ? topCommas[1] : closeParen;
+ TrimSpan(source, ref argStart, ref argEnd);
+ var argOriginalStart = argStart;
+ var named = TryStripArgumentName(source, ref argStart, out var argumentName);
+ var argText = source.Substring(argStart, argEnd - argStart);
+
+ if (named && argumentName != "expected")
+ {
+ // Second argument is some other named argument (eg settings:).
+ // Insert a named expected argument before it.
+ if (alreadyOnly)
+ {
+ failReason = $"The previous expected expression was not found near line {lineHint}. The source may have changed since the test run. Re-run the test.";
+ return PatchStatus.NotFound;
+ }
+
+ var namedIndent = IndentForSpan(source, lineStarts, argOriginalStart);
+ var namedRendered = CsStringLiteral.RenderRaw(newContent, namedIndent, eol);
+ newSource = Splice(source, argOriginalStart, argOriginalStart, "expected: " + namedRendered + ", ");
+ return PatchStatus.Applied;
+ }
+
+ if (argText == "null")
+ {
+ if (alreadyOnly)
+ {
+ failReason = $"The previous expected expression was not found near line {lineHint}. The source may have changed since the test run. Re-run the test.";
+ return PatchStatus.NotFound;
+ }
+
+ var indent = IndentForSpan(source, lineStarts, argStart);
+ var rendered = CsStringLiteral.RenderRaw(newContent, indent, eol);
+ newSource = Splice(source, argStart, argEnd, rendered);
+ return PatchStatus.Applied;
+ }
+
+ if (CsStringLiteral.TryParse(argText, out var currentValue))
+ {
+ if (currentValue == newContent)
+ {
+ return PatchStatus.AlreadyApplied;
+ }
+
+ failReason = alreadyOnly
+ ? $"The previous expected expression was not found near line {lineHint}, and the current expected argument has different content. The source may have changed since the test run. Re-run the test."
+ : $"The {methodName} call near line {lineHint} already has a different expected argument.";
+ return PatchStatus.NotFound;
+ }
+
+ failReason = $"The expected argument of the {methodName} call near line {lineHint} is not a string literal.";
+ return PatchStatus.NotFound;
+ }
+
+ static bool TryFindCall(string source, List lineStarts, int lineHint, out int openParen)
+ {
+ openParen = -1;
+ var lineCount = lineStarts.Count;
+ lineHint = Math.Min(Math.Max(lineHint, 1), lineCount);
+ // Outward from the hint: hint, hint-1, hint+1, hint-2, ...
+ for (var distance = 0; distance < lineCount; distance++)
+ {
+ var candidates = distance == 0
+ ? new[] { lineHint }
+ : new[] { lineHint - distance, lineHint + distance };
+ foreach (var line in candidates)
+ {
+ if (line < 1 || line > lineCount)
+ {
+ continue;
+ }
+
+ var start = lineStarts[line - 1];
+ var end = line < lineCount ? lineStarts[line] : source.Length;
+ var index = start;
+ while (true)
+ {
+ index = source.IndexOf(methodName, index, StringComparison.Ordinal);
+ if (index < 0 || index >= end)
+ {
+ break;
+ }
+
+ if (IsToken(source, index, methodName.Length) &&
+ TrySkipToParen(source, index + methodName.Length, out var paren))
+ {
+ openParen = paren;
+ return true;
+ }
+
+ index += methodName.Length;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ static bool IsToken(string source, int index, int length)
+ {
+ if (index > 0 && IsIdentifierChar(source[index - 1]))
+ {
+ return false;
+ }
+
+ var after = index + length;
+ return after >= source.Length || !IsIdentifierChar(source[after]);
+ }
+
+ static bool IsIdentifierChar(char ch) =>
+ char.IsLetterOrDigit(ch) || ch == '_';
+
+ static bool TrySkipToParen(string source, int index, out int paren)
+ {
+ paren = -1;
+ while (index < source.Length && char.IsWhiteSpace(source[index]))
+ {
+ index++;
+ }
+
+ if (index < source.Length && source[index] == '(')
+ {
+ paren = index;
+ return true;
+ }
+
+ return false;
+ }
+
+ // Scans a balanced argument list starting at the open paren.
+ // Records top level comma positions. Skips strings, chars and comments.
+ static bool TryScanArguments(string source, int openParen, out int closeParen, out List topCommas)
+ {
+ closeParen = -1;
+ topCommas = [];
+ var depth = 1;
+ var index = openParen + 1;
+ while (index < source.Length)
+ {
+ var ch = source[index];
+ switch (ch)
+ {
+ case '/':
+ if (!TrySkipComment(source, ref index))
+ {
+ index++;
+ }
+
+ continue;
+ case '\'':
+ if (!TrySkipCharLiteral(source, ref index))
+ {
+ return false;
+ }
+
+ continue;
+ case '"':
+ case '@':
+ case '$':
+ if (!TrySkipStringLike(source, ref index))
+ {
+ index++;
+ }
+
+ continue;
+ case '(':
+ case '[':
+ case '{':
+ depth++;
+ index++;
+ continue;
+ case ')':
+ depth--;
+ if (depth == 0)
+ {
+ closeParen = index;
+ return true;
+ }
+
+ index++;
+ continue;
+ case ']':
+ case '}':
+ depth--;
+ if (depth <= 0)
+ {
+ return false;
+ }
+
+ index++;
+ continue;
+ case ',':
+ if (depth == 1)
+ {
+ topCommas.Add(index);
+ }
+
+ index++;
+ continue;
+ default:
+ index++;
+ continue;
+ }
+ }
+
+ return false;
+ }
+
+ static bool TrySkipComment(string source, ref int index)
+ {
+ if (index + 1 >= source.Length)
+ {
+ return false;
+ }
+
+ var next = source[index + 1];
+ if (next == '/')
+ {
+ var end = source.IndexOf('\n', index);
+ index = end < 0 ? source.Length : end + 1;
+ return true;
+ }
+
+ if (next == '*')
+ {
+ var end = source.IndexOf("*/", index + 2, StringComparison.Ordinal);
+ index = end < 0 ? source.Length : end + 2;
+ return true;
+ }
+
+ return false;
+ }
+
+ static bool TrySkipCharLiteral(string source, ref int index)
+ {
+ // index at the opening quote
+ index++;
+ while (index < source.Length)
+ {
+ var ch = source[index];
+ if (ch == '\\')
+ {
+ index += 2;
+ continue;
+ }
+
+ if (ch == '\'')
+ {
+ index++;
+ return true;
+ }
+
+ if (ch == '\n')
+ {
+ return false;
+ }
+
+ index++;
+ }
+
+ return false;
+ }
+
+ // index at '$', '@' or '"'. Returns false when the characters do not start a string literal
+ // (eg '@identifier'); the caller then advances by one.
+ static bool TrySkipStringLike(string source, ref int index)
+ {
+ var cursor = index;
+ var dollars = 0;
+ var verbatim = false;
+ while (cursor < source.Length)
+ {
+ var ch = source[cursor];
+ if (ch == '$')
+ {
+ dollars++;
+ cursor++;
+ continue;
+ }
+
+ if (ch == '@')
+ {
+ verbatim = true;
+ cursor++;
+ continue;
+ }
+
+ break;
+ }
+
+ if (cursor >= source.Length || source[cursor] != '"')
+ {
+ return false;
+ }
+
+ var quotes = QuoteRun(source, cursor);
+ if (quotes >= 3)
+ {
+ // Raw string (interpolated or not): skip blindly to a closing run of >= quotes.
+ // Interpolation holes are skipped as part of the content.
+ var search = cursor + quotes;
+ while (true)
+ {
+ if (search >= source.Length)
+ {
+ index = source.Length;
+ return true;
+ }
+
+ if (source[search] != '"')
+ {
+ search++;
+ continue;
+ }
+
+ var run = QuoteRun(source, search);
+ if (run >= quotes)
+ {
+ index = search + run;
+ return true;
+ }
+
+ search += run;
+ }
+ }
+
+ cursor += quotes == 2 ? 2 : 1;
+ if (quotes == 2 && dollars == 0)
+ {
+ // Empty string ""
+ index = cursor;
+ return true;
+ }
+
+ if (quotes == 2)
+ {
+ // Interpolated empty string $""
+ index = cursor;
+ return true;
+ }
+
+ while (cursor < source.Length)
+ {
+ var ch = source[cursor];
+ if (ch == '"')
+ {
+ if (verbatim &&
+ cursor + 1 < source.Length &&
+ source[cursor + 1] == '"')
+ {
+ cursor += 2;
+ continue;
+ }
+
+ index = cursor + 1;
+ return true;
+ }
+
+ if (!verbatim && ch == '\\')
+ {
+ cursor += 2;
+ continue;
+ }
+
+ if (!verbatim && ch == '\n')
+ {
+ // Malformed: unterminated regular string. Stop at the line end.
+ index = cursor;
+ return true;
+ }
+
+ if (dollars > 0 && ch == '{')
+ {
+ if (cursor + 1 < source.Length && source[cursor + 1] == '{')
+ {
+ cursor += 2;
+ continue;
+ }
+
+ if (!TrySkipHole(source, ref cursor))
+ {
+ index = source.Length;
+ return true;
+ }
+
+ continue;
+ }
+
+ if (dollars > 0 && ch == '}' &&
+ cursor + 1 < source.Length && source[cursor + 1] == '}')
+ {
+ cursor += 2;
+ continue;
+ }
+
+ cursor++;
+ }
+
+ index = source.Length;
+ return true;
+ }
+
+ // cursor at '{' of an interpolation hole; skips past the matching '}'
+ static bool TrySkipHole(string source, ref int cursor)
+ {
+ var depth = 1;
+ cursor++;
+ while (cursor < source.Length)
+ {
+ var ch = source[cursor];
+ switch (ch)
+ {
+ case '/':
+ if (!TrySkipComment(source, ref cursor))
+ {
+ cursor++;
+ }
+
+ continue;
+ case '\'':
+ if (!TrySkipCharLiteral(source, ref cursor))
+ {
+ return false;
+ }
+
+ continue;
+ case '"':
+ case '@':
+ case '$':
+ if (!TrySkipStringLike(source, ref cursor))
+ {
+ cursor++;
+ }
+
+ continue;
+ case '{':
+ depth++;
+ cursor++;
+ continue;
+ case '}':
+ depth--;
+ cursor++;
+ if (depth == 0)
+ {
+ return true;
+ }
+
+ continue;
+ default:
+ cursor++;
+ continue;
+ }
+ }
+
+ return false;
+ }
+
+ static int QuoteRun(string source, int index)
+ {
+ var count = 0;
+ while (index + count < source.Length &&
+ source[index + count] == '"')
+ {
+ count++;
+ }
+
+ return count;
+ }
+
+ static bool TryStripArgumentName(string source, ref int start, out string name)
+ {
+ name = "";
+ var index = start;
+ if (index >= source.Length || !char.IsLetter(source[index]) && source[index] != '_')
+ {
+ return false;
+ }
+
+ while (index < source.Length && IsIdentifierChar(source[index]))
+ {
+ index++;
+ }
+
+ var nameEnd = index;
+ while (index < source.Length && char.IsWhiteSpace(source[index]))
+ {
+ index++;
+ }
+
+ if (index >= source.Length ||
+ source[index] != ':' ||
+ index + 1 < source.Length && source[index + 1] == ':')
+ {
+ return false;
+ }
+
+ name = source.Substring(start, nameEnd - start);
+ index++;
+ while (index < source.Length && char.IsWhiteSpace(source[index]))
+ {
+ index++;
+ }
+
+ start = index;
+ return true;
+ }
+
+ static void TrimSpan(string source, ref int start, ref int end)
+ {
+ while (start < end && char.IsWhiteSpace(source[start]))
+ {
+ start++;
+ }
+
+ while (end > start && char.IsWhiteSpace(source[end - 1]))
+ {
+ end--;
+ }
+ }
+
+ static int EndOfLastNonWhitespace(string source, int start, int end)
+ {
+ var index = end;
+ while (index > start && char.IsWhiteSpace(source[index - 1]))
+ {
+ index--;
+ }
+
+ return index;
+ }
+
+
+ static string Splice(string source, int start, int end, string replacement) =>
+ new StringBuilder(source.Length - (end - start) + replacement.Length)
+ .Append(source, 0, start)
+ .Append(replacement)
+ .Append(source, end, source.Length - end)
+ .ToString();
+
+ static string DetectEol(string source)
+ {
+ var crlf = 0;
+ var lf = 0;
+ for (var index = 0; index < source.Length; index++)
+ {
+ if (source[index] != '\n')
+ {
+ continue;
+ }
+
+ if (index > 0 && source[index - 1] == '\r')
+ {
+ crlf++;
+ }
+ else
+ {
+ lf++;
+ }
+ }
+
+ if (crlf >= lf && crlf > 0)
+ {
+ return "\r\n";
+ }
+
+ if (lf > 0)
+ {
+ return "\n";
+ }
+
+ return Environment.NewLine;
+ }
+
+ static string NormalizeTo(string value, string eol) =>
+ value
+ .Replace("\r\n", "\n")
+ .Replace('\r', '\n')
+ .Replace("\n", eol);
+
+ static List BuildLineStarts(string source)
+ {
+ List starts = [0];
+ for (var index = 0; index < source.Length; index++)
+ {
+ if (source[index] == '\n' && index + 1 < source.Length)
+ {
+ starts.Add(index + 1);
+ }
+ }
+
+ return starts;
+ }
+
+ static int LineOf(List lineStarts, int offset)
+ {
+ var low = 0;
+ var high = lineStarts.Count - 1;
+ while (low < high)
+ {
+ var mid = (low + high + 1) / 2;
+ if (lineStarts[mid] <= offset)
+ {
+ low = mid;
+ }
+ else
+ {
+ high = mid - 1;
+ }
+ }
+
+ return low + 1;
+ }
+
+ static List FindAll(string source, string needle)
+ {
+ List result = [];
+ var index = 0;
+ while (true)
+ {
+ index = source.IndexOf(needle, index, StringComparison.Ordinal);
+ if (index < 0)
+ {
+ break;
+ }
+
+ result.Add(index);
+ index++;
+ }
+
+ return result;
+ }
+
+ static int Nearest(List occurrences, List lineStarts, int lineHint)
+ {
+ var best = occurrences[0];
+ var bestDistance = int.MaxValue;
+ var bestAfter = false;
+ foreach (var occurrence in occurrences)
+ {
+ var line = LineOf(lineStarts, occurrence);
+ var distance = Math.Abs(line - lineHint);
+ var after = line >= lineHint;
+ if (distance < bestDistance ||
+ distance == bestDistance && after && !bestAfter)
+ {
+ best = occurrence;
+ bestDistance = distance;
+ bestAfter = after;
+ }
+ }
+
+ return best;
+ }
+
+ static string IndentForSpan(string source, List lineStarts, int spanStart)
+ {
+ var line = LineOf(lineStarts, spanStart);
+ var lineStart = lineStarts[line - 1];
+ var lead = new StringBuilder();
+ var index = lineStart;
+ while (index < source.Length &&
+ (source[index] == ' ' || source[index] == '\t'))
+ {
+ lead.Append(source[index]);
+ index++;
+ }
+
+ if (index >= spanStart)
+ {
+ // The span starts on its own line: align with it
+ return source.Substring(lineStart, spanStart - lineStart);
+ }
+
+ var leadText = lead.ToString();
+ var unit = leadText.Contains('\t') ? "\t" : " ";
+ return leadText + unit;
+ }
+}
diff --git a/src/DiffEngine/Tray/PiperClient.cs b/src/DiffEngine/Tray/PiperClient.cs
index 62d8c616..f50f245e 100644
--- a/src/DiffEngine/Tray/PiperClient.cs
+++ b/src/DiffEngine/Tray/PiperClient.cs
@@ -79,6 +79,49 @@ public static string BuildMovePayload(string tempFile, string targetFile, string
return builder.ToString();
}
+ public static void SendInlineMove(
+ string tempFile,
+ string targetFile,
+ string patchFile,
+ string? stagedVerified) =>
+ Send(BuildInlineMovePayload(tempFile, targetFile, patchFile, stagedVerified));
+
+ public static Task SendInlineMoveAsync(
+ string tempFile,
+ string targetFile,
+ string patchFile,
+ string? stagedVerified,
+ Cancel cancel = default)
+ {
+ var payload = BuildInlineMovePayload(tempFile, targetFile, patchFile, stagedVerified);
+ return SendAsync(payload, cancel);
+ }
+
+ public static string BuildInlineMovePayload(string tempFile, string targetFile, string patchFile, string? stagedVerified)
+ {
+ var builder = new StringBuilder(
+ $$"""
+ {
+ "Type":"InlineMove",
+ "Temp":"{{tempFile.JsonEscape()}}",
+ "Target":"{{targetFile.JsonEscape()}}",
+ "PatchFile":"{{patchFile.JsonEscape()}}"
+ """);
+
+ if (stagedVerified != null)
+ {
+ builder.Append(
+ $"""
+ ,
+ "StagedVerified":"{stagedVerified.JsonEscape()}"
+ """);
+ }
+
+ builder.AppendLine();
+ builder.Append('}');
+ return builder.ToString();
+ }
+
static void Send(string payload)
{
try
diff --git a/src/DiffEngine/Tray/TrayDetector.cs b/src/DiffEngine/Tray/TrayDetector.cs
new file mode 100644
index 00000000..893e1c6c
--- /dev/null
+++ b/src/DiffEngine/Tray/TrayDetector.cs
@@ -0,0 +1,21 @@
+static class TrayDetector
+{
+ // Checked live (not cached) so a tray started after the test process still counts
+ public static bool IsRunning()
+ {
+ try
+ {
+ if (Mutex.TryOpenExisting("DiffEngine", out var mutex))
+ {
+ mutex.Dispose();
+ return true;
+ }
+ }
+ //net7 on mac throws an exception if the mutex does not exist
+ catch (IOException)
+ {
+ }
+
+ return false;
+ }
+}
diff --git a/src/DiffEngine/Tray/TrayVersionFile.cs b/src/DiffEngine/Tray/TrayVersionFile.cs
new file mode 100644
index 00000000..4eb9c25e
--- /dev/null
+++ b/src/DiffEngine/Tray/TrayVersionFile.cs
@@ -0,0 +1,53 @@
+///
+/// A marker file written by DiffEngineTray on startup so client libraries can
+/// detect the running tray's version. Old trays (pre 20.0.0) never write it.
+///
+static class TrayVersionFile
+{
+ public static string FilePath { get; } =
+ Path.Combine(Path.GetTempPath(), "DiffEngineTray", "version.txt");
+
+ public static void Write(string informationalVersion)
+ {
+ var directory = Path.GetDirectoryName(FilePath)!;
+ Directory.CreateDirectory(directory);
+ File.WriteAllText(FilePath, StripSuffix(informationalVersion));
+ }
+
+ public static void Delete()
+ {
+ try
+ {
+ File.Delete(FilePath);
+ }
+ catch
+ {
+ // Best effort
+ }
+ }
+
+ public static bool TryRead([NotNullWhen(true)] out Version? version)
+ {
+ version = null;
+ try
+ {
+ if (!File.Exists(FilePath))
+ {
+ return false;
+ }
+
+ var text = StripSuffix(File.ReadAllText(FilePath).Trim());
+ return Version.TryParse(text, out version);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ static string StripSuffix(string version)
+ {
+ var index = version.IndexOfAny(['-', '+']);
+ return index < 0 ? version : version.Substring(0, index);
+ }
+}
diff --git a/src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs b/src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs
index f5b30342..8ce6ab29 100644
--- a/src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs
+++ b/src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs
@@ -54,7 +54,7 @@ static async Task CaptureMove(Func> launch)
{
MovePayload? received = null;
var source = new CancelSource();
- var server = PiperServer.Start(move => received = move, _ => { }, source.Token);
+ var server = PiperServer.Start(move => received = move, _ => { }, _ => { }, source.Token);
try
{
var result = await launch();
diff --git a/src/DiffEngineTray.Tests/MenuBuilderTest.FullWithInline.verified.png b/src/DiffEngineTray.Tests/MenuBuilderTest.FullWithInline.verified.png
new file mode 100644
index 00000000..b1b466f8
Binary files /dev/null and b/src/DiffEngineTray.Tests/MenuBuilderTest.FullWithInline.verified.png differ
diff --git a/src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png b/src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png
new file mode 100644
index 00000000..4552fb8b
Binary files /dev/null and b/src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png differ
diff --git a/src/DiffEngineTray.Tests/MenuBuilderTest.cs b/src/DiffEngineTray.Tests/MenuBuilderTest.cs
index b2dfbf38..5d0a5171 100644
--- a/src/DiffEngineTray.Tests/MenuBuilderTest.cs
+++ b/src/DiffEngineTray.Tests/MenuBuilderTest.cs
@@ -116,6 +116,32 @@ public async Task FullGrouped()
await Verify(menu, settings);
}
+ [Test]
+ public async Task OnlyInline()
+ {
+ await using var tracker = new RecordingTracker();
+ tracker.AddInlineMove(file1, "Tests.cs", file2, file3);
+ var menu = MenuBuilder.Build(
+ emptyAction,
+ emptyAction,
+ tracker);
+ await Verify(menu, settings);
+ }
+
+ [Test]
+ public async Task FullWithInline()
+ {
+ await using var tracker = new RecordingTracker();
+ tracker.AddDelete(file1);
+ tracker.AddMove(file3, file3, "theExe", "theArguments", true, null);
+ tracker.AddInlineMove(file4, "Tests.cs", file2, null);
+ var menu = MenuBuilder.Build(
+ emptyAction,
+ emptyAction,
+ tracker);
+ await Verify(menu, settings);
+ }
+
public MenuBuilderTest()
{
settings = new();
diff --git a/src/DiffEngineTray.Tests/PiperTest.InlineMove.verified.txt b/src/DiffEngineTray.Tests/PiperTest.InlineMove.verified.txt
new file mode 100644
index 00000000..34a4e90d
--- /dev/null
+++ b/src/DiffEngineTray.Tests/PiperTest.InlineMove.verified.txt
@@ -0,0 +1,6 @@
+{
+ Temp: Foo,
+ Target: Bar.cs,
+ PatchFile: patch.txt,
+ StagedVerified: verified.txt
+}
\ No newline at end of file
diff --git a/src/DiffEngineTray.Tests/PiperTest.InlineMoveJson.verified.txt b/src/DiffEngineTray.Tests/PiperTest.InlineMoveJson.verified.txt
new file mode 100644
index 00000000..dfdcf094
--- /dev/null
+++ b/src/DiffEngineTray.Tests/PiperTest.InlineMoveJson.verified.txt
@@ -0,0 +1,7 @@
+{
+"Type":"InlineMove",
+"Temp":"theTempFilePath",
+"Target":"theTargetFilePath",
+"PatchFile":"thePatchFilePath",
+"StagedVerified":"theStagedVerifiedPath"
+}
\ No newline at end of file
diff --git a/src/DiffEngineTray.Tests/PiperTest.InlineMoveJsonNoStagedVerified.verified.txt b/src/DiffEngineTray.Tests/PiperTest.InlineMoveJsonNoStagedVerified.verified.txt
new file mode 100644
index 00000000..34f1da1e
--- /dev/null
+++ b/src/DiffEngineTray.Tests/PiperTest.InlineMoveJsonNoStagedVerified.verified.txt
@@ -0,0 +1,6 @@
+{
+"Type":"InlineMove",
+"Temp":"theTempFilePath",
+"Target":"theTargetFilePath",
+"PatchFile":"thePatchFilePath"
+}
\ No newline at end of file
diff --git a/src/DiffEngineTray.Tests/PiperTest.cs b/src/DiffEngineTray.Tests/PiperTest.cs
index a3dba172..ffda195c 100644
--- a/src/DiffEngineTray.Tests/PiperTest.cs
+++ b/src/DiffEngineTray.Tests/PiperTest.cs
@@ -63,7 +63,7 @@ public async Task Delete()
{
DeletePayload received = null!;
var source = new CancelSource();
- var task = PiperServer.Start(_ => { }, s => received = s, source.Token);
+ var task = PiperServer.Start(_ => { }, s => received = s, _ => { }, source.Token);
await PiperClient.SendDeleteAsync("Foo", source.Token);
await Task.Delay(1000, source.Token);
await source.CancelAsync();
@@ -76,7 +76,7 @@ public async Task Move()
{
MovePayload received = null!;
var source = new CancelSource();
- var task = PiperServer.Start(s => received = s, _ => { }, source.Token);
+ var task = PiperServer.Start(s => received = s, _ => { }, _ => { }, source.Token);
await PiperClient.SendMoveAsync("Foo", "Bar", "theExe", "TheArguments \"s\"", true, 10, source.Token);
await Task.Delay(1000, source.Token);
await source.CancelAsync();
@@ -108,7 +108,7 @@ public async Task ClientDisconnectsAbruptly()
{
DeletePayload? received = null;
var source = new CancelSource();
- var task = PiperServer.Start(_ => { }, s => received = s, source.Token);
+ var task = PiperServer.Start(_ => { }, s => received = s, _ => { }, source.Token);
// Connect and immediately close with RST (no data sent),
// simulating a client that was canceled mid-connection.
@@ -154,6 +154,65 @@ await Verify(Logs)
.ScrubLinesContaining("PiperClient");
}
+ [Test]
+ public Task InlineMoveJson() =>
+ Verify(
+ PiperClient.BuildInlineMovePayload(
+ "theTempFilePath",
+ "theTargetFilePath",
+ "thePatchFilePath",
+ "theStagedVerifiedPath"));
+
+ [Test]
+ public Task InlineMoveJsonNoStagedVerified() =>
+ Verify(
+ PiperClient.BuildInlineMovePayload(
+ "theTempFilePath",
+ "theTargetFilePath",
+ "thePatchFilePath",
+ null));
+
+ [Test]
+ public async Task InlineMove()
+ {
+ InlineMovePayload received = null!;
+ var source = new CancelSource();
+ var task = PiperServer.Start(_ => { }, _ => { }, s => received = s, source.Token);
+ await PiperClient.SendInlineMoveAsync("Foo", "Bar.cs", "patch.txt", "verified.txt", source.Token);
+ await Task.Delay(1000, source.Token);
+ await source.CancelAsync();
+ await task;
+ await Verify(received);
+ }
+
+ [Test]
+ public async Task UnknownTypeIgnored()
+ {
+ DeletePayload? received = null;
+ var source = new CancelSource();
+ var task = PiperServer.Start(_ => { }, s => received = s, _ => { }, source.Token);
+
+ // A payload type from a future client version must not throw
+ using (var client = new TcpClient())
+ {
+ await client.ConnectAsync(IPAddress.Loopback, PiperClient.Port, source.Token);
+ await using var stream = client.GetStream();
+ await using var writer = new StreamWriter(stream);
+ await writer.WriteAsync("{\"Type\":\"Nonsense\"}");
+ }
+
+ await Task.Delay(500, source.Token);
+
+ // Server should still process a subsequent valid message
+ await PiperClient.SendDeleteAsync("Foo", source.Token);
+ await Task.Delay(1000, source.Token);
+ await source.CancelAsync();
+ await task;
+
+ await Assert.That(received).IsNotNull();
+ await Assert.That(received!.File).IsEqualTo("Foo");
+ }
+
class LogCapture(List logs) : TraceListener
{
public override void Write(string? message) { }
diff --git a/src/DiffEngineTray.Tests/RecordingTracker.cs b/src/DiffEngineTray.Tests/RecordingTracker.cs
index 71696a8a..faf3b536 100644
--- a/src/DiffEngineTray.Tests/RecordingTracker.cs
+++ b/src/DiffEngineTray.Tests/RecordingTracker.cs
@@ -1,4 +1,4 @@
-class RecordingTracker(LockedFilesResolver? lockedFilesResolver = null, Action? acceptFailed = null) :
+class RecordingTracker(LockedFilesResolver? lockedFilesResolver = null, Action? acceptFailed = null, Action? inlineFailed = null) :
Tracker(
() =>
{
@@ -7,12 +7,14 @@ class RecordingTracker(LockedFilesResolver? lockedFilesResolver = null, Action message = m);
+ var move = tracker.AddInlineMove(temp, cs, patch, verified);
+ tracker.Accept(move);
+ await tracker.AssertEmpty();
+ await Assert.That(message!).Contains("Re-run the test");
+ await Assert.That(File.Exists(temp)).IsFalse();
+ }
+ finally
+ {
+ Cleanup(temp);
+ }
+ }
+
+ [Test]
+ public async Task DiscardCleansStaging()
+ {
+ var (temp, patch, verified, cs) = WriteStaging();
+ try
+ {
+ await using var tracker = new RecordingTracker();
+ var move = tracker.AddInlineMove(temp, cs, patch, verified);
+ tracker.Discard(move);
+ await tracker.AssertEmpty();
+ await Assert.That(File.Exists(temp)).IsFalse();
+ await Assert.That(File.Exists(patch)).IsFalse();
+ // Source untouched
+ await Assert.That(File.ReadAllText(cs)).Contains("old");
+ }
+ finally
+ {
+ Cleanup(temp);
+ }
+ }
+
+ [Test]
+ public async Task AcceptAllMixed()
+ {
+ var (temp, patch, verified, cs) = WriteStaging();
+ try
+ {
+ await using var tracker = new RecordingTracker();
+ tracker.AddInlineMove(temp, cs, patch, verified);
+ tracker.AcceptAll();
+ await tracker.AssertEmpty();
+ await Assert.That(File.ReadAllText(cs)).Contains("new");
+ }
+ finally
+ {
+ Cleanup(temp);
+ }
+ }
+
+ [Test]
+ public async Task ClearRemovesInlineMoves()
+ {
+ var (temp, patch, verified, cs) = WriteStaging();
+ try
+ {
+ await using var tracker = new RecordingTracker();
+ tracker.AddInlineMove(temp, cs, patch, verified);
+ tracker.Clear();
+ await tracker.AssertEmpty();
+ // Clear does not delete staging files (matches TrackedMove behavior)
+ await Assert.That(File.Exists(temp)).IsTrue();
+ }
+ finally
+ {
+ Cleanup(temp);
+ }
+ }
+}
+
+public class TrayVersionFileTest
+{
+ [Test]
+ public async Task RoundTrip()
+ {
+ TrayVersionFile.Write("20.1.3+abc123");
+ try
+ {
+ var read = TrayVersionFile.TryRead(out var version);
+ await Assert.That(read).IsTrue();
+ await Assert.That(version).IsEqualTo(new Version(20, 1, 3));
+ }
+ finally
+ {
+ TrayVersionFile.Delete();
+ }
+ }
+
+ [Test]
+ public async Task PrereleaseSuffixStripped()
+ {
+ TrayVersionFile.Write("21.0.0-beta.1");
+ try
+ {
+ var read = TrayVersionFile.TryRead(out var version);
+ await Assert.That(read).IsTrue();
+ await Assert.That(version).IsEqualTo(new Version(21, 0, 0));
+ }
+ finally
+ {
+ TrayVersionFile.Delete();
+ }
+ }
+
+ [Test]
+ public async Task MissingFileFails()
+ {
+ TrayVersionFile.Delete();
+ var read = TrayVersionFile.TryRead(out _);
+ await Assert.That(read).IsFalse();
+ }
+
+ [Test]
+ public async Task GarbageFails()
+ {
+ TrayVersionFile.Write("garbage");
+ try
+ {
+ var read = TrayVersionFile.TryRead(out _);
+ await Assert.That(read).IsFalse();
+ }
+ finally
+ {
+ TrayVersionFile.Delete();
+ }
+ }
+}
diff --git a/src/DiffEngineTray/DiffToolLauncher.cs b/src/DiffEngineTray/DiffToolLauncher.cs
index ef41b626..a4ba1c4d 100644
--- a/src/DiffEngineTray/DiffToolLauncher.cs
+++ b/src/DiffEngineTray/DiffToolLauncher.cs
@@ -3,9 +3,15 @@ static class DiffToolLauncher
[DllImport("user32.dll")]
static extern bool SetForegroundWindow(IntPtr hWnd);
- public static void Launch(TrackedMove move)
+ public static void Launch(TrackedMove move) =>
+ Launch(move.Exe!, move.Arguments!, move.CanKill, move.Process, _ => move.Process = _);
+
+ // Inline diff processes are always tray owned, so always killable
+ public static void Launch(TrackedInlineMove move) =>
+ Launch(move.Exe!, move.Arguments!, canKill: true, move.Process, _ => move.Process = _);
+
+ static void Launch(string exe, string arguments, bool canKill, Process? process, Action assign)
{
- var process = move.Process;
if (process is { HasExited: false })
{
if (SetForegroundWindow(process.MainWindowHandle))
@@ -14,15 +20,15 @@ public static void Launch(TrackedMove move)
}
}
- if (move.CanKill)
+ if (canKill)
{
process?.Kill();
}
process?.Dispose();
- move.Process = null;
+ assign(null);
- var startInfo = new ProcessStartInfo(move.Exe!, move.Arguments!)
+ var startInfo = new ProcessStartInfo(exe, arguments)
{
// Given the full exe path is known we dont need UseShellExecute https://stackoverflow.com/a/5255335
// however UseShellExecute allows the test running to not block when the difftool is launched
@@ -35,7 +41,7 @@ public static void Launch(TrackedMove move)
process = Process.Start(startInfo);
if (process != null)
{
- move.Process = process;
+ assign(process);
return;
}
@@ -44,7 +50,7 @@ public static void Launch(TrackedMove move)
Failed to launch diff tool.
{Exe} {Arguments}
""",
- move.Exe, move.Arguments);
+ exe, arguments);
}
catch (Exception exception)
{
@@ -54,8 +60,8 @@ Failed to launch diff tool.
Failed to launch diff tool.
{Exe} {Arguments}
""",
- move.Exe,
- move.Arguments);
+ exe,
+ arguments);
}
}
}
\ No newline at end of file
diff --git a/src/DiffEngineTray/MenuBuilder.cs b/src/DiffEngineTray/MenuBuilder.cs
index c9be7785..e48ca565 100644
--- a/src/DiffEngineTray/MenuBuilder.cs
+++ b/src/DiffEngineTray/MenuBuilder.cs
@@ -73,11 +73,16 @@ static IEnumerable BuildTrackingMenuItems(Tracker tracker)
.OrderBy(_ => _.Temp)
.ToList();
- var count = moves.Count + deletes.Count;
+ var inlineMoves = tracker
+ .InlineMoves
+ .OrderBy(_ => _.Temp)
+ .ToList();
+
+ var count = moves.Count + deletes.Count + inlineMoves.Count;
yield return new ToolStripSeparator();
- foreach (var item in BuildGroupedMenuItems(tracker, deletes, moves))
+ foreach (var item in BuildGroupedMenuItems(tracker, deletes, moves, inlineMoves))
{
yield return item;
}
@@ -89,11 +94,13 @@ static IEnumerable BuildTrackingMenuItems(Tracker tracker)
static IEnumerable BuildGroupedMenuItems(
Tracker tracker,
List deletes,
- List moves)
+ List moves,
+ List inlineMoves)
{
var groups = deletes
.Select(_ => _.Group)
.Concat(moves.Select(_ => _.Group))
+ .Concat(inlineMoves.Select(_ => _.Group))
.Distinct()
.ToList();
@@ -107,6 +114,9 @@ static IEnumerable BuildGroupedMenuItems(
.Where(_ => _.Group == group)
.ToList(),
moves
+ .Where(_ => _.Group == group)
+ .ToList(),
+ inlineMoves
.Where(_ => _.Group == group)
.ToList()))
{
@@ -125,7 +135,8 @@ static IEnumerable BuildMovesAndDeletes(
string? name,
Tracker tracker,
List deletes,
- List moves)
+ List moves,
+ List inlineMoves)
{
if (name != null)
{
@@ -159,9 +170,43 @@ static IEnumerable BuildMovesAndDeletes(
}
}
+ if (inlineMoves.Count != 0)
+ {
+ yield return new MenuButton(
+ $"Pending Snapshots ({inlineMoves.Count}):",
+ () => tracker.Accept(inlineMoves),
+ Images.Accept);
+ foreach (var move in inlineMoves)
+ {
+ yield return BuildInlineMove(
+ move,
+ () => tracker.Accept(move),
+ () => tracker.Discard(move));
+ }
+ }
+
yield return new ToolStripSeparator();
}
+ static ToolStripDropDownButton BuildInlineMove(TrackedInlineMove move, Action accept, Action discard)
+ {
+ var targetName = Path.GetFileName(move.Target);
+ var menu = new ToolStripDropDownButton($"{move.Name} > {targetName} (inline)")
+ {
+ DropDownDirection = ToolStripDropDownDirection.Left
+ };
+ menu.DropDownItems.Add(new MenuButton("Accept snapshot", accept));
+ menu.DropDownItems.Add(new MenuButton("Discard", discard));
+ if (move.Exe != null)
+ {
+ menu.DropDownItems.Add(new MenuButton("Open diff tool", () => DiffToolLauncher.Launch(move)));
+ }
+
+ menu.DropDownItems.Add(new MenuButton("Open source file", () => ExplorerLauncher.ShowFileInExplorer(move.Target)));
+ menu.DropDownItems.Add(BuildShowInExplorer(move.Temp));
+ return menu;
+ }
+
static ToolStripDropDownButton BuildDelete(TrackedDelete delete, Action accept)
{
var menu = new ToolStripDropDownButton($"{delete.Name}")
diff --git a/src/DiffEngineTray/Payloads/InlineMovePayload.cs b/src/DiffEngineTray/Payloads/InlineMovePayload.cs
new file mode 100644
index 00000000..8f72187b
--- /dev/null
+++ b/src/DiffEngineTray/Payloads/InlineMovePayload.cs
@@ -0,0 +1,7 @@
+class InlineMovePayload
+{
+ public string Temp { get; set; } = null!;
+ public string Target { get; set; } = null!;
+ public string PatchFile { get; set; } = null!;
+ public string? StagedVerified { get; set; }
+}
diff --git a/src/DiffEngineTray/PiperServer.cs b/src/DiffEngineTray/PiperServer.cs
index 62e8f0ae..65236c7b 100644
--- a/src/DiffEngineTray/PiperServer.cs
+++ b/src/DiffEngineTray/PiperServer.cs
@@ -6,6 +6,7 @@ static class PiperServer
public static async Task Start(
Action move,
Action delete,
+ Action inlineMove,
Cancel cancel = default)
{
TcpListener? listener = default;
@@ -24,7 +25,7 @@ public static async Task Start(
try
{
- await Handle(listener, move, delete, cancel);
+ await Handle(listener, move, delete, inlineMove, cancel);
}
catch (TaskCanceledException)
{
@@ -57,7 +58,7 @@ public static async Task Start(
}
}
- static async Task Handle(TcpListener listener, Action move, Action delete, Cancel cancel)
+ static async Task Handle(TcpListener listener, Action move, Action delete, Action inlineMove, Cancel cancel)
{
await using (cancel.Register(listener.Stop))
{
@@ -66,8 +67,15 @@ static async Task Handle(TcpListener listener, Action move, Action<
var payload = await reader.ReadToEndAsync(cancel);
- if (payload.Contains("\"Type\":\"Move\"") ||
- payload.Contains("\"Type\": \"Move\""))
+ // InlineMove is checked before Move for specific-before-general ordering
+ // (not strictly load bearing: "Type":"Move" is not a substring of "Type":"InlineMove")
+ if (payload.Contains("\"Type\":\"InlineMove\"") ||
+ payload.Contains("\"Type\": \"InlineMove\""))
+ {
+ inlineMove(Serializer.Deserialize(payload));
+ }
+ else if (payload.Contains("\"Type\":\"Move\"") ||
+ payload.Contains("\"Type\": \"Move\""))
{
move(Serializer.Deserialize(payload));
}
@@ -80,7 +88,9 @@ static async Task Handle(TcpListener listener, Action move, Action<
{
if (payload.Length > 0)
{
- throw new($"Unknown payload: {payload}");
+ // Tolerate payloads from newer clients so future additions dont
+ // surface an error dialog on this tray version
+ Log.Error("Received unknown payload type. Ignoring. Payload: {payload}", payload);
}
}
diff --git a/src/DiffEngineTray/Program.cs b/src/DiffEngineTray/Program.cs
index c4d084d0..6315ca77 100644
--- a/src/DiffEngineTray/Program.cs
+++ b/src/DiffEngineTray/Program.cs
@@ -41,6 +41,16 @@ static async Task Inner()
return;
}
+ try
+ {
+ TrayVersionFile.Write(VersionReader.VersionString);
+ }
+ catch (Exception exception)
+ {
+ // The marker only gates inline snapshot payloads; the tray must still start
+ Log.Error(exception, "Failed to write the tray version marker");
+ }
+
using var icon = new NotifyIcon
{
Icon = Images.Default,
@@ -56,6 +66,11 @@ static async Task Inner()
10000,
"DiffEngineTray",
$"Could not accept '{move.Name}': the file move keeps failing. The move is still pending, so accept can be retried.",
+ ToolTipIcon.Warning),
+ inlineFailed: (_, message) => icon.ShowBalloonTip(
+ 10000,
+ "DiffEngineTray",
+ message,
ToolTipIcon.Warning));
using var task = StartServer(tracker, cancel);
@@ -81,7 +96,15 @@ static async Task Inner()
icon.ContextMenuStrip = menuStrip;
- Application.Run();
+ try
+ {
+ Application.Run();
+ }
+ finally
+ {
+ TrayVersionFile.Delete();
+ }
+
await tokenSource.CancelAsync();
await task;
}
@@ -153,5 +176,10 @@ static Task StartServer(Tracker tracker, Cancel cancel) =>
payload.ProcessId);
},
payload => tracker.AddDelete(payload.File),
+ payload => tracker.AddInlineMove(
+ payload.Temp,
+ payload.Target,
+ payload.PatchFile,
+ payload.StagedVerified),
cancel);
}
\ No newline at end of file
diff --git a/src/DiffEngineTray/TrackedInlineMove.cs b/src/DiffEngineTray/TrackedInlineMove.cs
new file mode 100644
index 00000000..5066537d
--- /dev/null
+++ b/src/DiffEngineTray/TrackedInlineMove.cs
@@ -0,0 +1,31 @@
+class TrackedInlineMove
+{
+ public TrackedInlineMove(
+ string temp,
+ string target,
+ string patchFile,
+ string? stagedVerified,
+ string? group,
+ string? exe,
+ string? arguments)
+ {
+ Temp = temp;
+ Target = target;
+ PatchFile = patchFile;
+ StagedVerified = stagedVerified;
+ Group = group;
+ Exe = exe;
+ Arguments = arguments;
+ Name = Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(temp));
+ }
+
+ public string Temp { get; }
+ public string Target { get; }
+ public string PatchFile { get; }
+ public string? StagedVerified { get; }
+ public string? Group { get; }
+ public string? Exe { get; }
+ public string? Arguments { get; }
+ public string Name { get; }
+ public Process? Process { get; set; }
+}
diff --git a/src/DiffEngineTray/Tracker.cs b/src/DiffEngineTray/Tracker.cs
index 01d944bd..181d2a5e 100644
--- a/src/DiffEngineTray/Tracker.cs
+++ b/src/DiffEngineTray/Tracker.cs
@@ -5,17 +5,20 @@ class Tracker :
Action inactive;
LockedFilesResolver? lockedFilesResolver;
Action? acceptFailed;
+ Action? inlineFailed;
ConcurrentDictionary moves = new(StringComparer.OrdinalIgnoreCase);
ConcurrentDictionary deletes = new(StringComparer.OrdinalIgnoreCase);
+ ConcurrentDictionary inlineMoves = new(StringComparer.OrdinalIgnoreCase);
AsyncTimer timer;
int lastScanCount;
- public Tracker(Action active, Action inactive, LockedFilesResolver? lockedFilesResolver = null, Action? acceptFailed = null)
+ public Tracker(Action active, Action inactive, LockedFilesResolver? lockedFilesResolver = null, Action? acceptFailed = null, Action? inlineFailed = null)
{
this.active = active;
this.inactive = inactive;
this.lockedFilesResolver = lockedFilesResolver;
this.acceptFailed = acceptFailed;
+ this.inlineFailed = inlineFailed;
timer = new(
ScanFiles,
TimeSpan.FromSeconds(2),
@@ -33,7 +36,24 @@ Task ScanFiles(Cancel cancel)
deletes.TryRemove(delete.Key, out _);
}
- var newCount = moves.Count + deletes.Count;
+ // Inline moves are settled when a passing re-run deletes the staging files.
+ // No FilesAreEqual check: comparing a text temp to a .cs target is meaningless.
+ foreach (var pair in inlineMoves.ToList())
+ {
+ var inline = pair.Value;
+ if (File.Exists(inline.Temp) &&
+ File.Exists(inline.PatchFile))
+ {
+ continue;
+ }
+
+ if (inlineMoves.TryRemove(pair.Key, out var removed))
+ {
+ removed.Process?.KillAndDispose();
+ }
+ }
+
+ var newCount = moves.Count + deletes.Count + inlineMoves.Count;
if (lastScanCount != newCount)
{
ToggleActive();
@@ -95,7 +115,8 @@ void ToggleActive()
public bool TrackingAny =>
!moves.IsEmpty ||
- !deletes.IsEmpty;
+ !deletes.IsEmpty ||
+ !inlineMoves.IsEmpty;
public TrackedMove AddMove(
string temp,
@@ -196,6 +217,125 @@ static TrackedMove BuildTrackedMove(string temp, string? exe, string? arguments,
return new(temp, target, exe, arguments, canKill.GetValueOrDefault(false), process, solution, extension, killLockingProcess);
}
+ public TrackedInlineMove AddInlineMove(
+ string temp,
+ string target,
+ string patchFile,
+ string? stagedVerified)
+ {
+ var targetFile = Path.GetFileName(target);
+ return inlineMoves.AddOrUpdate(
+ temp,
+ addValueFactory: key =>
+ {
+ Log.Information("InlineMoveAdded. Target:{target}", targetFile);
+ return BuildTrackedInlineMove(key, target, patchFile, stagedVerified, null);
+ },
+ updateValueFactory: (key, existing) =>
+ {
+ Log.Information("InlineMoveUpdated. Target:{target}", targetFile);
+ return BuildTrackedInlineMove(key, target, patchFile, stagedVerified, existing.Process);
+ });
+ }
+
+ static TrackedInlineMove BuildTrackedInlineMove(string temp, string target, string patchFile, string? stagedVerified, Process? process)
+ {
+ var solution = SolutionDirectoryFinder.Find(target);
+ string? exe = null;
+ string? arguments = null;
+ if (stagedVerified != null)
+ {
+ var extension = Path.GetExtension(temp).TrimStart('.');
+ if (DiffTools.TryFindByExtension(extension, out var tool))
+ {
+ exe = tool.ExePath;
+ arguments = tool.GetArguments(temp, stagedVerified);
+ }
+ }
+
+ return new(temp, target, patchFile, stagedVerified, solution, exe, arguments)
+ {
+ Process = process
+ };
+ }
+
+ public void Accept(TrackedInlineMove move)
+ {
+ if (!inlineMoves.TryRemove(move.Temp, out var removed))
+ {
+ return;
+ }
+
+ removed.Process?.KillAndDispose();
+ removed.Process = null;
+
+ if (!InlinePatchFile.TryRead(removed.PatchFile, out var patch))
+ {
+ DiscardInlineStaging(removed);
+ Log.Warning("Could not read patch file for `{Name}`: {PatchFile}", removed.Name, removed.PatchFile);
+ inlineFailed?.Invoke(removed, $"Could not read the patch file for '{removed.Name}'. Re-run the test.");
+ return;
+ }
+
+ var result = InlineApplier.Apply(patch);
+ switch (result.Status)
+ {
+ case InlineApplyStatus.Applied:
+ case InlineApplyStatus.AlreadyApplied:
+ Log.Information("Inline snapshot accepted for `{Name}`. Target:{Target}", removed.Name, removed.Target);
+ DiscardInlineStaging(removed);
+ return;
+ case InlineApplyStatus.NotFound:
+ // The patch is stale; a re-run regenerates a fresh one. Discard.
+ Log.Warning("Inline snapshot for `{Name}` could not be applied: {Message}", removed.Name, result.Message);
+ DiscardInlineStaging(removed);
+ inlineFailed?.Invoke(removed, $"Could not apply the snapshot for '{removed.Name}': the source has changed. Re-run the test.");
+ return;
+ default:
+ // Retryable (eg file locked by an IDE). Keep pending
+ Log.Warning(result.Exception, "Inline snapshot accept failed for `{Name}`: {Message}. Kept pending", removed.Name, result.Message);
+ inlineMoves.TryAdd(removed.Temp, removed);
+ inlineFailed?.Invoke(removed, $"Could not accept the snapshot for '{removed.Name}': {result.Message}. The item is still pending, so accept can be retried.");
+ return;
+ }
+ }
+
+ public void Accept(IEnumerable toAccept)
+ {
+ // Sequential arbitrary order is safe: anchoring is content based, and each
+ // apply is its own locked read-modify-write, even into the same .cs file
+ foreach (var move in toAccept)
+ {
+ Accept(move);
+ }
+ }
+
+ public void Discard(TrackedInlineMove move)
+ {
+ if (inlineMoves.TryRemove(move.Temp, out var removed))
+ {
+ removed.Process?.KillAndDispose();
+ removed.Process = null;
+ DiscardInlineStaging(removed);
+ }
+ }
+
+ static void DiscardInlineStaging(TrackedInlineMove move)
+ {
+ FileEx.SafeDeleteFile(move.Temp);
+ FileEx.SafeDeleteFile(move.PatchFile);
+ if (move.StagedVerified != null)
+ {
+ FileEx.SafeDeleteFile(move.StagedVerified);
+ }
+
+ var directory = Path.GetDirectoryName(move.Temp);
+ if (directory != null)
+ {
+ FileEx.SafeDeleteDirectory(directory);
+ }
+ }
+
public TrackedDelete AddDelete(string file) =>
deletes.AddOrUpdate(
file,
@@ -459,6 +599,14 @@ public void Clear()
}
moves.Clear();
+
+ foreach (var inline in inlineMoves.Values)
+ {
+ inline.Process?.KillAndDispose();
+ inline.Process = null;
+ }
+
+ inlineMoves.Clear();
}
public void AcceptOpen()
@@ -469,6 +617,11 @@ public void AcceptOpen()
moves.Values
.Where(_ => _.Process is { HasExited: false })
.ToList());
+
+ Accept(
+ inlineMoves.Values
+ .Where(_ => _.Process is { HasExited: false })
+ .ToList());
}
public void AcceptAll()
@@ -476,6 +629,8 @@ public void AcceptAll()
AcceptAllDeletes();
AcceptMoves(moves.Values);
+
+ Accept(inlineMoves.Values.ToList());
}
void AcceptAllDeletes()
@@ -492,6 +647,8 @@ void AcceptAllDeletes()
public ICollection Moves => moves.Values;
+ public ICollection InlineMoves => inlineMoves.Values;
+
public ValueTask DisposeAsync()
{
Clear();
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index aafb5eba..a8a5078c 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -2,7 +2,7 @@
CS1591;CS0649;NU1608;NU1109
- 19.3.3
+ 20.0.0-beta.1
1.0.0
Testing, Snapshot, Diff, Compare
Launches diff tools based on file extensions. Designed to be consumed by snapshot testing libraries.