Skip to content
17 changes: 16 additions & 1 deletion claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,21 @@ is at now, rather than queueing a second one beside it. `InlineStaging` asks the
trio, and scopes a settle to the running framework (`InlineStaging.Settle`), which
`InlinePatchFile.Write` labels a staged patch with.

Those questions are for an edit nothing reports. An accept the queue's owner makes is reported: an
applied patch says which lines it moved (`InlineApplyResult.MovedFrom` and `MovedBy`, read off the
source before and after by `SourceScan.LinesMoved`), and the owner takes what is still pending for
that file to the lines its call sites are on now (`InlineQueue.Rebased`), so the key stays right
and the run after an accept finds its entries by it. It is a step of its own, after the outcomes
of a file are recorded and never part of recording one, because a batch finds what it claimed by
its variants and an entry at another line has others. The viewer does it to its list where it
stands, an entry at another line being the same texts and diff under another key, and brings
along the keys its batch holds (`AcceptBatch.Remaining` and `Only`) and the entry being read.
Inside a batch the applier does the same for itself: each patch is asked about the line the edits
before it moved its call site to, since an Append has nothing but its line. A key therefore
changes under whoever is showing the queue, which is why `RemoteInlineHost` takes an accept to
have failed only where the entry under its key says why. An accept applied by another process,
the IDE plugin among them, moves the lines and tells nobody which.

What the patcher writes has to compile where it lands, and three shapes did not. F# measures a
continuation from the column the call's expression starts at, not from the line's indentation,
whenever something precedes the call on its line (`do!`, `let! x =`): the offside rule
Expand Down Expand Up @@ -729,7 +744,7 @@ the comment there about not caching "nothing staged" asks for.
- `ViewerClient` remembers a port found unowned for ten minutes (`RecheckUnownedAfter`), and the library's telling sends - settle, retire, move, delete, the first inline or diff send - skip the connect while that stands. A refused loopback connection costs two seconds on Windows (firewall stealth mode drops the reset), and a green run settles once per inline verification, which was six minutes for a class of 188 inline tests. Probes (`IsOwned`), the hosts and `InlineQueueClient` always ask and correct the memory; so does `SettleAppliedInline`, being one send per accept. Asking, on Windows, is the operating system's listener table first (`ListenerTable`, shared with `PiperClient`): no listener on the port means nobody to connect to, said without the two seconds, and a listener or a table that cannot be read leaves the connect to answer. So the first telling send of a test process, the launch gate's probe and each of its polls no longer wait to be refused. By port alone, whichever address, since the table is only believed when it says nobody is there. Not for a port that accepted a connection in the last second (`TrustOwnerFor`), because reading the table is reading every connection the machine has, and a run of settles to a live owner would pay more for each than the connect costs. The table is Windows' listeners alone, by `GetExtendedTcpTable` (`ListenerTable.Listeners`), falling back to .NET's list of every connection where that call is not there: 0.32 ms beside 3,000 connections where it was 13. What the table found empty is asked about again after a second (`RecheckUnlistedAfter`), so a tray started after a test process is found; the ten minutes is only for what a connect found. And only a refusal or an unanswered connect is remembered as nobody being there (`NobodyThere`). A viewer launch that failed gives its `MaxInstance` slot back
- `TrayDisabledChecker` respects `DiffEngine_TrayDisabled` env var, behind `DiffRunner.TrayDisabled`. Separate from `Disabled` because tracking a pending move is separate from launching a tool: every exit of `InnerLaunch`, `Disabled` included, still calls `AddMove`. `PendingFiles.TrayAvailable` is the single gate
- Tests use TUnit and Verify for snapshot testing
- The three WinForms test and benchmark hosts set `UnhandledExceptionMode.ThrowException` from a module initializer with `threadScope: false`. The one argument overload covers only the thread that calls it, which leaves every test thread showing WinForms' dialog on the desktop of whoever is running the tests, and waiting for a click. Never run a test that throws inside a window message in a build without it. `DiffEngineTray.Tests` also declines the tray's "open an issue" box (`IssueLauncher.Declined`) and records what was asked in `ModuleInitializer.IssuesAsked`. A form shown the ordinary way is the foreground window even parked off screen, and takes the keyboard of whoever is at the machine, so a test that posts input shows its form without activating it (`ViewerForm.Parked`, `ParkedForm`) and says what modifiers its thread holds (`ThreadKeys`)
- The three WinForms test and benchmark hosts set `UnhandledExceptionMode.ThrowException` from a module initializer with `threadScope: false`. The one argument overload covers only the thread that calls it, which leaves every test thread showing WinForms' dialog on the desktop of whoever is running the tests, and waiting for a click. Never run a test that throws inside a window message in a build without it. `DiffEngineTray.Tests` also declines the tray's "open an issue" box (`IssueLauncher.Declined`) and records what was asked in `ModuleInitializer.IssuesAsked`. A form shown the ordinary way is the foreground window even parked off screen, and takes the keyboard of whoever is at the machine, so a test that posts input shows its form without activating it (`ViewerForm.Parked`, `ParkedForm`) and says what modifiers its thread holds (`ThreadKeys`). A test whose subject is activation, or a maximised or minimised window, cannot be parked: WinForms changes window state by calls that activate, and `Raise` ends in `Activate`. Those run their windows inside `UnseenDesktop.Run`, on a desktop of the process's own that is neither drawn nor given input, and on a thread of its own, since a test's STA thread could not be moved to it. A tray test that needs a menu's `Opening` shows it with `TopLevel` false (`MenuBuilderTest.Open`), as Verify.WinForms does, and a drop down is a second window that needs the same. No test registers a hot key with Windows or touches the machine's tray version marker: `KeyRegister` is handed a stand-in desktop and `TrayVersionFile` has internal overloads that take the path
- CI's runners have two to four cores. A test must not block pool threads while something in its own process needs the pool to answer it, nor assert that something happened inside a fixed short wait: `DOTNET_PROCESSOR_COUNT=2` shows both locally
- `MachineSettings.Ignore` clears `DiffEngine_Disabled`, so a snapshot test that is expected to fail is run with a build server variable such as `TEAMCITY_VERSION` set, or Verify tells the real tray about it
- `ps` is asked with `-ww`: procps lets an exported `COLUMNS` cut every command line `ProcessCleanup` reads
Expand Down
166 changes: 162 additions & 4 deletions src/DiffEngine.Tests/InlineApplierBatchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,15 @@ InlinePatch[] Patches(string path) =>
];

var batch = InlineApplier.ApplyAll(Patches(together.FullName));
var single = Patches(inTurn.FullName)
.Select(InlineApplier.Apply)
.ToList();
// In turn, each asked about the line its call site is on by then: a batch brings a
// patch's line along with the edits made above it, where every patch here was recorded
// against the file as it started
var single = new List<InlineApplyResult>();
foreach (var patch in Patches(inTurn.FullName))
{
var line = single.Aggregate(patch.LineHint, (current, earlier) => earlier.Rebase(current));
single.Add(InlineApplier.Apply(patch.At(line)));
}

await Assert.That(Statuses(batch)).IsEqualTo(Statuses(single));
await Assert.That(Statuses(batch)).IsEqualTo("Applied, Applied, AlreadyApplied, NotFound, Applied, NotFound, Applied, Applied");
Expand Down Expand Up @@ -236,6 +242,124 @@ public async Task EncodingAndLineEndingsSurviveABatch()
await Assert.That(text.Replace("\r\n", "")).DoesNotContain("\n");
}

/// <summary>
/// An applied patch says which lines it moved, so whoever holds other patches for the file
/// can bring their lines along: the lines under the call site, by what the literal grew, and
/// none at or above it.
/// </summary>
[Test]
public async Task AnAppliedPatchSaysWhichLinesItMoved()
{
using var file = new TempSource(Members(4));

var result = InlineApplier.Apply(Set(file.FullName, 1));

await Assert.That(result.Status).IsEqualTo(InlineApplyStatus.Applied);
await Assert.That(result.MovedBy).IsGreaterThan(0);
var lines = file.Text.Split('\n');
// Each member was on line member + 3
await Assert.That(lines[result.Rebase(3) - 1]).Contains("void M0()");
await Assert.That(lines[result.Rebase(4) - 1]).Contains("void M1()");
await Assert.That(lines[result.Rebase(5) - 1]).Contains("void M2()");
await Assert.That(lines[result.Rebase(6) - 1]).Contains("void M3()");
await Assert.That(result.Rebase(4)).IsEqualTo(4);
await Assert.That(result.Rebase(5)).IsEqualTo(5 + result.MovedBy);
}

/// <summary>
/// A literal that gets shorter moves the lines under it up, and one that stays as long moves
/// none.
/// </summary>
[Test]
public async Task ALiteralThatShrinksMovesTheLinesUnderItUp()
{
using var file = new TempSource(Members(3));
var grown = InlineApplier.Apply(Set(file.FullName, 0));

var shrunk = InlineApplier.Apply(
new(file.FullName, 3, null, "one line")
{
TestName = null,
MemberName = "M0",
OriginalValue = "new 0\nsecond line"
});
var same = InlineApplier.Apply(
new(file.FullName, 3, null, "another")
{
TestName = null,
MemberName = "M0",
OriginalValue = "one line"
});

await Assert.That(shrunk.Status).IsEqualTo(InlineApplyStatus.Applied);
await Assert.That(shrunk.MovedBy).IsLessThan(0);
await Assert.That(shrunk.MovedBy).IsGreaterThan(-grown.MovedBy - 1);
var lines = file.Text.Split('\n');
await Assert.That(lines[shrunk.Rebase(grown.Rebase(5)) - 1]).Contains("void M2()");
await Assert.That(same.Status).IsEqualTo(InlineApplyStatus.Applied);
await Assert.That(same.MovedBy).IsEqualTo(0);
await Assert.That(same).IsSameReferenceAs(InlineApplyResult.Applied);
}

/// <summary>
/// Each result of a batch counts lines as the patches before it left them, so rebasing a
/// line through the results in order gives where it is in the file that was written.
/// </summary>
[Test]
public async Task ABatchsResultsRebaseALineInOrder()
{
using var file = new TempSource(Members(5));

var results = InlineApplier.ApplyAll([Set(file.FullName, 2), Set(file.FullName, 0), Set(file.FullName, 3)]);

await Assert.That(Statuses(results)).IsEqualTo("Applied, Applied, Applied");
var lines = file.Text.Split('\n');
for (var member = 0; member < 5; member++)
{
var line = results.Aggregate(member + 3, (current, result) => result.Rebase(current));
await Assert.That(lines[line - 1]).Contains($"void M{member}()");
}
}

/// <summary>
/// An Append is the patch that cannot do without its line: with no anchor, a line that names
/// no call leaves it to choose among the member's calls, and it is refused where there are
/// two. In a batch the snapshot accepted above it had moved that line, so the second
/// snapshot of a file was refused for the accept of the first.
/// </summary>
[Test]
public async Task AnAppendUnderAnEarlierEditIsAskedAboutTheLineItsCallIsOnNow()
{
using var file = new TempSource(
"""
class C
{
void M0() => Verify(value0).Snapshot("old 0");
Task M1()
{
Verify(first);
return Verify(second);
}
}
""");
InlinePatch[] patches =
[
Set(file.FullName, 0),
new(file.FullName, 7, null, "appended", InlinePatchMode.Append)
{
TestName = null,
MemberName = "M1"
}
];

var results = InlineApplier.ApplyAll(patches);

await Assert.That(Statuses(results)).IsEqualTo("Applied, Applied");
await Assert.That(file.Text).Contains("Verify(second)");
await Assert.That(file.Text).Contains("Verify(first);");
await Assert.That(file.Text).DoesNotContain("Verify(second);");
}

[Test]
public async Task AnEmptyBatchIsNoResults() =>
await Assert.That(InlineApplier.ApplyAll([])).IsEmpty();
Expand Down Expand Up @@ -266,13 +390,25 @@ public async Task ACarriedScanTellsEachPatchWhatLexingAgainWould(string extensio
InlineApplier.PatchInTurn(language, ref carried, patches, path, results);

var lexedAgain = source;
// Where each edit so far moved the lines, worked out here from the two texts, since
// a patch is asked about the line its call site has been moved to
var moves = new List<(int From, int By)>();
for (var index = 0; index < patches.Count; index++)
{
var patch = patches[index];
var line = patch.LineHint;
foreach (var (from, by) in moves)
{
if (line >= from)
{
line += by;
}
}

var status = InlinePatcher.TryApply(
language,
lexedAgain,
patch.LineHint,
line,
patch.Mode,
patch.OriginalExpression,
patch.OriginalValue,
Expand All @@ -284,6 +420,7 @@ public async Task ACarriedScanTellsEachPatchWhatLexingAgainWould(string extensio
out var reason);
if (status == PatchStatus.Applied)
{
moves.Add(Moved(lexedAgain, patched));
lexedAgain = patched;
}

Expand Down Expand Up @@ -314,6 +451,27 @@ public async Task ACarriedScanTellsEachPatchWhatLexingAgainWould(string extensio
await Assert.That(seen).IsEquivalentTo([InlineApplyStatus.Applied, InlineApplyStatus.AlreadyApplied, InlineApplyStatus.NotFound]);
}

/// <summary>
/// Which lines an edit moved, the slow way: the first line that is whole in what the two
/// texts end with alike, and how many more lines there are.
/// </summary>
static (int From, int By) Moved(string before, string after)
{
var beforeLines = before.Split('\n');
var afterLines = after.Split('\n');
var limit = Math.Min(beforeLines.Length, afterLines.Length);
var same = 0;
while (same < limit &&
beforeLines[beforeLines.Length - 1 - same] == afterLines[afterLines.Length - 1 - same])
{
same++;
}

// Counted from the end, so the lines of a literal that were in both texts are not the
// edit's. The first of them is 1 based
return (beforeLines.Length - same + 1, afterLines.Length - beforeLines.Length);
}

/// <summary>
/// A file of members, each with a call that has a snapshot and one that has none, and a
/// shuffled batch of patches for them.
Expand Down
Loading
Loading