From 4bcb4fba06e7015456455b9a0fafc236c0392bd4 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 7 Aug 2026 21:53:49 +1000 Subject: [PATCH 1/2] Add inline snapshot support Shared engine for splicing inline snapshots into C# source: InlineApplier/InlinePatcher/CsStringLiteral (content-anchored locate, raw string literal rendering, encoding/BOM/EOL preservation, cross process locking), InlinePatch/InlinePatchFile (text patch format). Tray: new InlineMove payload, Pending Snapshots menu group with accept/discard/diff, version marker file gating the new payload so pre-20 trays never receive it, and unknown payload types are now logged and ignored instead of throwing. --- src/DiffEngine.Tests/CsStringLiteralTests.cs | 144 ++++ src/DiffEngine.Tests/InlineApplierTests.cs | 227 +++++ src/DiffEngine.Tests/InlinePatcherTests.cs | 215 +++++ src/DiffEngine/DiffRunner_InlineMove.cs | 81 ++ src/DiffEngine/Inline/CsStringLiteral.cs | 419 ++++++++++ src/DiffEngine/Inline/InlineApplier.cs | 194 +++++ src/DiffEngine/Inline/InlineApplyResult.cs | 47 ++ src/DiffEngine/Inline/InlinePatch.cs | 41 + src/DiffEngine/Inline/InlinePatchFile.cs | 105 +++ src/DiffEngine/Inline/InlinePatcher.cs | 778 ++++++++++++++++++ src/DiffEngine/Tray/PiperClient.cs | 43 + src/DiffEngine/Tray/TrayDetector.cs | 21 + src/DiffEngine/Tray/TrayVersionFile.cs | 53 ++ .../DiffRunnerCanKillTest.cs | 2 +- ...enuBuilderTest.FullWithInline.verified.png | Bin 0 -> 11663 bytes .../MenuBuilderTest.OnlyInline.verified.png | Bin 0 -> 7622 bytes src/DiffEngineTray.Tests/MenuBuilderTest.cs | 26 + .../PiperTest.InlineMove.verified.txt | 6 + .../PiperTest.InlineMoveJson.verified.txt | 7 + ...nlineMoveJsonNoStagedVerified.verified.txt | 6 + src/DiffEngineTray.Tests/PiperTest.cs | 65 +- src/DiffEngineTray.Tests/RecordingTracker.cs | 6 +- .../TrackerInlineMoveTest.cs | 221 +++++ src/DiffEngineTray/DiffToolLauncher.cs | 24 +- src/DiffEngineTray/MenuBuilder.cs | 53 +- .../Payloads/InlineMovePayload.cs | 7 + src/DiffEngineTray/PiperServer.cs | 20 +- src/DiffEngineTray/Program.cs | 30 +- src/DiffEngineTray/TrackedInlineMove.cs | 31 + src/DiffEngineTray/Tracker.cs | 163 +++- src/Directory.Build.props | 2 +- 31 files changed, 3008 insertions(+), 29 deletions(-) create mode 100644 src/DiffEngine.Tests/CsStringLiteralTests.cs create mode 100644 src/DiffEngine.Tests/InlineApplierTests.cs create mode 100644 src/DiffEngine.Tests/InlinePatcherTests.cs create mode 100644 src/DiffEngine/DiffRunner_InlineMove.cs create mode 100644 src/DiffEngine/Inline/CsStringLiteral.cs create mode 100644 src/DiffEngine/Inline/InlineApplier.cs create mode 100644 src/DiffEngine/Inline/InlineApplyResult.cs create mode 100644 src/DiffEngine/Inline/InlinePatch.cs create mode 100644 src/DiffEngine/Inline/InlinePatchFile.cs create mode 100644 src/DiffEngine/Inline/InlinePatcher.cs create mode 100644 src/DiffEngine/Tray/TrayDetector.cs create mode 100644 src/DiffEngine/Tray/TrayVersionFile.cs create mode 100644 src/DiffEngineTray.Tests/MenuBuilderTest.FullWithInline.verified.png create mode 100644 src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png create mode 100644 src/DiffEngineTray.Tests/PiperTest.InlineMove.verified.txt create mode 100644 src/DiffEngineTray.Tests/PiperTest.InlineMoveJson.verified.txt create mode 100644 src/DiffEngineTray.Tests/PiperTest.InlineMoveJsonNoStagedVerified.verified.txt create mode 100644 src/DiffEngineTray.Tests/TrackerInlineMoveTest.cs create mode 100644 src/DiffEngineTray/Payloads/InlineMovePayload.cs create mode 100644 src/DiffEngineTray/TrackedInlineMove.cs 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 0000000000000000000000000000000000000000..b1b466f88dbb1d74fd5c5de209a1b9f72252a9ba GIT binary patch literal 11663 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 literal 0 HcmV?d00001 diff --git a/src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png b/src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png new file mode 100644 index 0000000000000000000000000000000000000000..4552fb8bb687de372387826a25dc4fe6d5095b73 GIT binary patch literal 7622 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 literal 0 HcmV?d00001 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..0db631fd 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 1.0.0 Testing, Snapshot, Diff, Compare Launches diff tools based on file extensions. Designed to be consumed by snapshot testing libraries. From bd565d2d2b7af1d4156df1191cd58fe2958d16c6 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 7 Aug 2026 21:54:40 +1000 Subject: [PATCH 2/2] Update Directory.Build.props --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0db631fd..a8a5078c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;CS0649;NU1608;NU1109 - 20.0.0 + 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.