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.