Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,13 @@ apart.
quotes, braces and newlines, and the `inline` body carries an `InlinePatchFile` payload verbatim.
- Compiles for every DiffEngine target, so the socket calls carry `#if` branches for the
frameworks with no cancellation overloads. `ViewerProtocolTests` runs on all of them.
- `ViewerServer`'s accept loop awaits with `ConfigureAwait(false)`, the one place that matters
- `ViewerServer`'s accept loop awaits with `ConfigureAwait(false)`, one of two places that matter
in a repo that otherwise leaves it off. The Windows viewer starts listening on its UI thread,
and resuming there left every connection waiting on the render loop to pump.
`AnOwnerAnswersWhileTheThreadThatStartedItIsBusy` pins it.
`AnOwnerAnswersWhileTheThreadThatStartedItIsBusy` pins it. The other is
`ViewerLaunchGate.LaunchAsync`, which also runs the launch on the pool: a sync `Launch` blocks
its thread on the same gate, and on a single threaded context that is the thread the held
gate's continuations would need.

**Native shim (`native/`), used by the Mac and Linux heads only:**
- `raylib` and `imgui` are fetched by CMake (`FetchContent`), pinned by tag in
Expand Down
5 changes: 5 additions & 0 deletions src/DiffEngine.Tests/FsCompilerRoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ public class FsCompilerRoundTripTests
"\"starts with a quote\nsecond",
"ends with a quote\nsecond\"",
"has \"\"\" inside\nsecond",
// Content only a regular literal can hold that is also in the layout shape: a blank first
// line and nothing but indentation after the last newline. The reader strips a value in
// that shape whatever literal held it, so it has to be written wrapped in layout of its own
"\nx = \"\"\"\n",
"\n x = \"\"\"\n ",
"(* not a comment *)\nsecond",
"// not a comment\nsecond",
"'ticked'\nsecond",
Expand Down
18 changes: 13 additions & 5 deletions src/DiffEngine.Tests/FsStringLiteralTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,19 @@ public async Task StripLayoutLeavesOtherValuesAlone(string value) =>
[Arguments("\"\"\"a\"b\"\"\"", "a\"b")]
// Ordinary F# strings may span lines
[Arguments("\"a\nb\"", "a\nb")]
// A backslash that starts no F# escape, or one cut short, is kept with what follows it, as
// fsi keeps it and with no warning: a trigraph is three digits, \x two hex digits, \u four,
// \U eight, and F# has no \0, \d or \e
[Arguments("\"\\d+\"", "\\d+")]
[Arguments("\"\\0\"", "\\0")]
[Arguments("\"\\12\"", "\\12")]
[Arguments("\"\\e\"", "\\e")]
[Arguments("\"\\x4\"", "\\x4")]
[Arguments("\"\\u12\"", "\\u12")]
[Arguments("\"\\U0041\"", "\\U0041")]
[Arguments("\"a\\qb\"", "a\\qb")]
// Past 255 a trigraph wraps into the byte range, which fsi does with warning FS1252
[Arguments("\"\\999\"", "\u00e7")]
public async Task Parse(string expression, string expected)
{
var parsed = FsStringLiteral.TryParse(expression, out var value);
Expand Down Expand Up @@ -289,11 +302,6 @@ public async Task ParseMultiLineVerbatim()
// A byte string is not a string
[Arguments("\"bytes\"B")]
[Arguments("@\"bytes\"B")]
// A trigraph is three digits or nothing
[Arguments("\"\\0\"")]
[Arguments("\"\\12\"")]
// Not an F# escape
[Arguments("\"\\e\"")]
public async Task ParseRejects(string expression)
{
var parsed = FsStringLiteral.TryParse(expression, out _);
Expand Down
60 changes: 60 additions & 0 deletions src/DiffEngine.Tests/InlineApplierTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
#if NET
using System.Security.AccessControl;
#endif

public class InlineApplierTests
{
static string WriteTemp(byte[] bytes, string extension = ".cs")
Expand Down Expand Up @@ -1246,4 +1250,60 @@ public async Task ABadTestNameBase64Fails()

await Assert.That(read).IsFalse();
}

#if NET
/// <summary>
/// The per file mutex already exists and this process may not open it, which is what a
/// non elevated applier meets while an elevated one (an IDE run as administrator, say) is
/// applying to the same file: an elevated token's default DACL grants Administrators and
/// SYSTEM, and a filtered token has Administrators as deny only. Modelled with an empty DACL.
/// </summary>
[Test]
[RunOn(TUnit.Core.Enums.OS.Windows)]
public async Task AMutexThisProcessCannotOpenFailsTheApplyRatherThanThrowing()
{
if (!OperatingSystem.IsWindows())
{
return;
}

var directory = Path.Combine(Path.GetTempPath(), $"InlineApplierTests_mutex_{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
var source = Path.Combine(directory, "Sample.cs");
File.WriteAllText(source, "class C\n{\n void M() => Verify(\"old\");\n}\n");

var name = (string) typeof(InlineApplier)
.GetMethod("MutexName", BindingFlags.NonPublic | BindingFlags.Static)!
.Invoke(null, [Path.GetFullPath(source).ToLowerInvariant()])!;
var security = new MutexSecurity();
security.SetSecurityDescriptorSddlForm("D:P");
using var held = MutexAcl.Create(false, name, out var created, security);
await Assert.That(created).IsTrue();

InlineApplyResult? result = null;
Exception? thrown = null;
try
{
result = InlineApplier.Apply(
new(source, 3, "\"old\"", "new")
{
TestName = null
});
}
catch (Exception exception)
{
thrown = exception;
}

await Assert.That(thrown).IsNull();
await Assert.That(result!.Status).IsEqualTo(InlineApplyStatus.Failed);
}
finally
{
Directory.Delete(directory, true);
}
}
#endif
}
64 changes: 64 additions & 0 deletions src/DiffEngine.Tests/InlinePatcherFsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -852,4 +852,68 @@ let MyTest () =
await Assert.That(newSource).Contains("#nowarn \"0044\"");
await Assert.That(newSource).Contains("#if INTERACTIVE");
}

/// <summary>
/// An F# local shadowing the test's own name, declared one line below the hint while the test
/// is declared two above it. The local becomes the member, so the call at the hint is out of
/// bounds; with the anchor matching nothing past it, the accept is a miss.
/// </summary>
[Test]
public async Task AnFsLocalNamedLikeTheTestDoesNotFloorTheSearchPastTheHint()
{
var source = Source(
"""
module Tests

[<Test>]
let result () =
task {
do! Verifier.Verify(1).Snapshot("one").ToTask()
let result = 2
do! Verifier.Verify(result).Snapshot("two").ToTask()
}
""");

var status = TryApply(source, 6, InlinePatchMode.Set, null, "uno", out var newSource, out var reason, originalValue: "one", memberName: "result");

await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, ""));
await Assert.That(newSource).Contains("Verify(1).Snapshot(\"uno\")");
}

/// <summary>
/// A regular literal whose value is in the layout shape, as a writer that did not wrap it left
/// it. The test library compares against the stripped value and fails, and sends that value as
/// the anchor. A patcher that read the literal without stripping found it equal to the new
/// content and reported AlreadyApplied - so the entry was dropped, the next run failed the
/// same way, and it went round again.
/// </summary>
[Test]
public async Task FsPatcherDoesNotCallALayoutShapedRegularLiteralAlreadyApplied()
{
const string content = "\nx = \"\"\"\n";
var source = Source("module Tests\n\n[<Test>]\nlet MyTest () =\n Verifier.Verify(value).Snapshot(\"\\nx = \\\"\\\"\\\"\\n\").ToTask()\n");
// What F# hands the test library for that literal is the content itself (fsi), and this is
// what the library compares against and sends as the anchor
var seen = SourceLanguage.FSharp.SnapshotValue(content);

var status = TryApply(source, 5, InlinePatchMode.Set, null, content, out _, out _, originalValue: seen, memberName: "MyTest");

await Assert.That(status).IsNotEqualTo(PatchStatus.AlreadyApplied);
}

/// <summary>
/// What that costs an accept: a hand-written regex snapshot can never be updated. The anchor
/// matches nothing because the literal does not parse, and the insert path then refuses the
/// call as not holding a string literal.
/// </summary>
[Test]
public async Task FsPatcherUpdatesALiteralHoldingAnUnknownEscape()
{
var source = Source("module Tests\n\n[<Test>]\nlet MyTest () =\n Verifier.Verify(value).Snapshot(\"\\d+\").ToTask()\n");

var status = TryApply(source, 5, InlinePatchMode.Set, null, "\\d+x", out var newSource, out var reason, originalValue: "\\d+", memberName: "MyTest");

await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, ""));
await Assert.That(newSource).Contains("Snapshot(\"\\\\d+x\")");
}
}
131 changes: 131 additions & 0 deletions src/DiffEngine.Tests/InlinePatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2199,4 +2199,135 @@ public async Task AnAnchorMatchingOnlyTheNextMemberIsStale()
await Assert.That(status).IsEqualTo(PatchStatus.NotFound);
await Assert.That(reason).Contains("Re-run the test.");
}

/// <summary>
/// Two nested types each declaring Works, the scenario-per-class layout. The hint is
/// A.Works's call on line 11. A.Works is declared six lines above it and B.Works five below,
/// so the nearest declaration is the wrong one, the floor lands past the hint, and the only
/// call left in reach is B's - which holds the same literal, so it is rewritten instead.
/// </summary>
[Test]
public async Task ASameNamedMemberInTheNextNestedTypeDoesNotTakeThePatch()
{
var source = Source(
"""
class Tests
{
public class A
{
public async Task Works()
{
var value = Build();
value.Add(1);
value.Add(2);
value.Add(3);
await Verify(value).Snapshot("dup");
}
}
public class B
{
public Task Works() =>
Verify(other).Snapshot("dup");
}
}
""");

var status = TryApply(source, 11, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out var reason, memberName: "Works");

await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, ""));
await Assert.That(newSource).Contains("await Verify(value).Snapshot(\"new\");");
await Assert.That(newSource).Contains("Verify(other).Snapshot(\"dup\");");
}

/// <summary>
/// The same layout with different literals. Nothing in B's span matches the anchor, and A's
/// call is never looked at, so the accept is a miss - and the same miss on every re-run, since
/// the re-run sends the same hint and member.
/// </summary>
[Test]
public async Task ASameNamedMemberInTheNextNestedTypeDoesNotHideTheCall()
{
var source = Source(
"""
class Tests
{
public class A
{
public async Task Works()
{
var value = Build();
value.Add(1);
value.Add(2);
value.Add(3);
await Verify(value).Snapshot("old");
}
}
public class B
{
public Task Works() =>
Verify(other).Snapshot("other");
}
}
""");

var status = TryApply(source, 11, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out var reason, memberName: "Works");

await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, ""));
await Assert.That(newSource).Contains("await Verify(value).Snapshot(\"new\");");
}


// Built by hand, because the subject is tabs: a space indented member whose body is tabs
static string SpaceMemberTabBody(string body) =>
"class Tests\n{\n public async Task Test()\n {\n" + body + "\n }\n}\n";

/// <summary>
/// "\t\t" is two characters and the member's four spaces are four, so the local at line 5
/// reads as the next member and ends this one before the call at line 6. The call the hint
/// names is then outside the span, and the accept is a miss on every re-run.
/// </summary>
[Test]
public async Task ATabIndentedLocalDoesNotEndASpaceIndentedMember()
{
var source = SpaceMemberTabBody("\t\tvar value = Build();\n\t\tawait Verify(value).Snapshot(\"old\");");

var status = TryApply(source, 6, InlinePatchMode.Set, "\"old\"", "new", out var newSource, out var reason, memberName: "Test");

await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, ""));
await Assert.That(newSource).Contains("await Verify(value).Snapshot(\"new\");");
}

/// <summary>
/// The same cut with a sibling above it holding the same literal, which is ordinary for a
/// member verifying two values that serialise alike. The hint names line 7, the truncated span
/// ends at line 6, and the sibling on line 5 is the only match left, so it is rewritten.
/// </summary>
[Test]
public async Task ATabIndentedLocalDoesNotSendThePatchToTheSiblingAboveIt()
{
var source = SpaceMemberTabBody(
"\t\tawait Verify(a).Snapshot(\"dup\");\n\t\tvar b = Build();\n\t\tawait Verify(b).Snapshot(\"dup\");");

var status = TryApply(source, 7, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out var reason, memberName: "Test");

await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, ""));
await Assert.That(newSource).Contains("Verify(a).Snapshot(\"dup\")");
await Assert.That(newSource).Contains("Verify(b).Snapshot(\"new\")");
}

/// <summary>
/// Control: the same source with the body indented by eight spaces instead of two tabs.
/// </summary>
[Test]
public async Task ControlSpaceIndentedLocalLeavesTheSiblingAlone()
{
var source = SpaceMemberTabBody(
" await Verify(a).Snapshot(\"dup\");\n var b = Build();\n await Verify(b).Snapshot(\"dup\");");

var status = TryApply(source, 7, InlinePatchMode.Set, "\"dup\"", "new", out var newSource, out var reason, memberName: "Test");

await Assert.That((status, reason)).IsEqualTo((PatchStatus.Applied, ""));
await Assert.That(newSource).Contains("Verify(a).Snapshot(\"dup\")");
await Assert.That(newSource).Contains("Verify(b).Snapshot(\"new\")");
}
}
36 changes: 36 additions & 0 deletions src/DiffEngine.Tests/InlineStagingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,4 +347,40 @@ public void Dispose()
}
}
}

/// <summary>
/// A 242 character test name makes a 272 character received file name, past the 255 every
/// file system here allows per component. The write throws IOException, TryPersist catches it,
/// and the entry is reported as not written with nothing said anywhere.
/// </summary>
[Test]
public async Task ALongTestNameIsStillPersisted()
{
var directory = Path.Combine(Path.GetTempPath(), $"InlineStagingTests_long_{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
File.WriteAllText(Path.Combine(directory, "Sample.csproj"), "<Project />");
var source = Path.Combine(directory, "SampleTests.cs");
File.WriteAllText(source, "// sample");

var patch = new InlinePatch(source, 42, "\"old\"", "new")
{
TestName = $"SampleTests.{new string('a', 230)}",
OriginalValue = "old",
Framework = "net10.0"
};

var written = InlineStaging.Persist([new(patch)]);

var staging = Path.Combine(directory, "obj", InlineStaging.DirectoryName);
var files = Directory.Exists(staging) ? Directory.GetFiles(staging).Length : 0;
await Assert.That(written).IsEqualTo(1);
await Assert.That(files).IsEqualTo(3);
}
finally
{
Directory.Delete(directory, true);
}
}
}
Loading
Loading