Skip to content
Merged
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
105 changes: 103 additions & 2 deletions src/Operations/EditPairs.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,118 @@ public static function apply(string $content, array $edits, string $sourceLabel
return ['ok' => false, 'error' => 'edit #' . ($i + 1) . ' has an empty `find`'];
}
$times = substr_count($content, $find);
// WHAT THE NEXT CALL IS WRITTEN FROM (greenhouse evidence/1162). The text a pair is judged against is the
// one the pairs before it left, and is named as that.
$label = $i === 0 ? $sourceLabel : $sourceLabel . ', with edit' . ($i === 1 ? ' #1' : 's #1–#' . $i) . ' of this call applied,';
$where = $i === 0 ? "the {$sourceLabel}" : 'the text the earlier edits of this call left';
if ($times === 0) {
return ['ok' => false, 'error' => 'edit #' . ($i + 1)
. " matches nothing — this `find` does not appear in the file:\n{$find}\n"
. "The {$sourceLabel} is:\n---\n{$content}\n---\nBuild your pairs against that text exactly."];
. self::whereItStops($content, $find, $where)
. "The {$label} is:\n---\n{$content}\n---\nBuild your pairs against that text exactly."];
}
if ($times > 1) {
return ['ok' => false, 'error' => 'edit #' . ($i + 1)
. " is ambiguous: `find` appears {$times} times and must appear exactly once — widen it with surrounding lines"];
. " is ambiguous: `find` appears {$times} times and must appear exactly once — widen it with surrounding lines"
. self::whereItSits($content, $find, $where)];
}
$content = str_replace($find, $replace, $content);
}
return ['ok' => true, 'content' => $content, 'edits_applied' => count($edits)];
}

/** How many places of an ambiguous text are shown. */
private const PLACES = 8;

/** How much of a `find` has to be in the text before saying where it stops: a letter or two is in every text. */
private const ENOUGH = 6;

/**
* Where a `find` that is not in the text parts ways with it, and the text's own lines there.
*
* The whole text was already handed back, and a builder was left to compare the two by eye. The longest start of
* the `find` that IS in the text says where they part: the line it reaches, how the text reads there, how the
* `find` reads there. And the text's own lines over that stretch are handed over as the `find` that matches —
* only when they do, and only once: a text that sits in several places would be the next refusal.
*/
private static function whereItStops(string $content, string $find, string $where): string
{
// The longest prefix of `find` the text holds: a shorter prefix of a prefix it holds is held too.
$low = 0;
$high = \strlen($find);
while ($low < $high) {
$middle = intdiv($low + $high + 1, 2);
str_contains($content, substr($find, 0, $middle)) ? $low = $middle : $high = $middle - 1;
}
$held = substr($find, 0, $low);
// Too little of it is there to say where it stops: a letter or two is in every text.
$first = trim(explode("\n", ltrim($find, "\n"))[0]);
if (\strlen(trim($held)) < min(\strlen($first), self::ENOUGH)) {
return "Nothing of its first line is in {$where}.\n";
}
$at = (int) strpos($content, $held);
$places = substr_count($content, $held);
$lines = explode("\n", $content);
$from = substr_count($content, "\n", 0, $at); // the line the match starts on, from 0
$stops = substr_count($content, "\n", 0, $at + $low); // the line it stops on
$findLines = explode("\n", $find);
$findStops = substr_count($held, "\n");
$said = "It matches {$where} up to line " . ($stops + 1) . ' and stops there'
. ($places > 1 ? " (the first of {$places} places it matches that far)" : '') . '. There the file reads:' . "\n"
. $lines[$stops] . "\nand your `find` reads:\n" . $findLines[$findStops] . "\n";

// The text's own lines over that stretch — or, when only spacing parts the two, over the stretch that says
// the same words.
$squash = static fn (string $text): string => trim((string) preg_replace('/\s+/', ' ', $text));
$wanted = $squash($find);
$own = null;
$onlySpacing = false;
for ($to = $from; $to < \count($lines); ++$to) {
$candidate = $squash(implode("\n", \array_slice($lines, $from, $to - $from + 1)));
if ($candidate === $wanted) {
$own = implode("\n", \array_slice($lines, $from, $to - $from + 1));
$onlySpacing = true;
break;
}
// A stretch that already says another word never becomes the `find` by growing: stop there. It changes
// nothing of the answer — it is what keeps its cost that of the stretch, not of the file.
if (!str_starts_with($wanted, $candidate)) {
break;
}
}
$own ??= implode("\n", \array_slice($lines, $from, \count($findLines)));
// Those lines hold the start that is in the text only once, so they are in it only once too.
if ($places > 1) {
return $said;
}

return $said . ($onlySpacing ? 'Only its spacing differs from the file. ' : '')
. "The file's own text over those lines — as a `find` it matches exactly once:\n{$own}\n";
}

/** The lines an ambiguous `find` sits on, so the next one can be widened with what tells them apart. */
private static function whereItSits(string $content, string $find, string $where): string
{
$lines = explode("\n", $content);
$numbers = [];
$line = 1;
$counted = 0;
for ($at = strpos($content, $find); $at !== false; $at = strpos($content, $find, $at + \strlen($find))) {
$line += substr_count($content, "\n", $counted, $at - $counted); // counted from the place before it
$counted = $at;
$numbers[$line] = true; // a line it is on twice is one line
}
$numbers = array_keys($numbers);
$shown = \array_slice($numbers, 0, self::PLACES);
$more = \count($numbers) - \count($shown);
$width = \strlen((string) max($shown));
$list = match (true) {
$more > 0 => 'lines ' . implode(', ', $shown) . " and {$more} more",
\count($shown) > 1 => 'lines ' . implode(', ', \array_slice($shown, 0, -1)) . ' and ' . end($shown),
default => "line {$shown[0]}, more than once,",
};
$rows = array_map(static fn (int $n): string => ' ' . str_pad((string) $n, $width, ' ', STR_PAD_LEFT) . ' │ ' . $lines[$n - 1], $shown);

return ". It is on {$list} of {$where}:\n" . implode("\n", $rows);
}
}
8 changes: 6 additions & 2 deletions src/Operations/ImplementHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,8 @@ private function land(string $root, string $file, string $plugin, string $class,
'ok' => false,
'error' => 'refused: syntax error in the proposal for ' . substr($file, strlen($root) + 1)
. ' on line ' . $finding['line'] . ': ' . $finding['message']
. ($unchanged ? '; destination preserved, proposal never installed' : '; destination preservation could not be verified'),
. ($unchanged ? '; destination preserved, proposal never installed' : '; destination preservation could not be verified')
. SyntaxFinding::shown($judged, $finding),
'diagnostic' => [
'schema' => 'milpa.authoring-diagnostic/v1',
'phase' => 'syntax',
Expand All @@ -442,8 +443,11 @@ private function land(string $root, string $file, string $plugin, string $class,
// The diagnostic travels whole — it is what the model corrects from. The temp path
// inside it would only mislead, so it is renamed to the file it was meant for.
$detail = str_replace($staged, $file, implode("\n", $lines));
// …and so do the builder's own lines where the check names one (greenhouse evidence/1162).
$named = SyntaxFinding::lineNamedIn($detail);

return ['ok' => false, 'error' => "refused: syntax or compilation check failed (exit {$code}):\n{$detail}"];
return ['ok' => false, 'error' => "refused: syntax or compilation check failed (exit {$code}):\n{$detail}"
. ($named === null ? '' : SyntaxFinding::shownAt($judged, $named))];
}
} finally {
@unlink($staged);
Expand Down
66 changes: 66 additions & 0 deletions src/Operations/SyntaxFinding.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,70 @@ public static function inspect(string $content): ?array
}
return null;
}

/** How much of one line is shown: a generated line can be thousands of characters long. */
private const SHOWN = 240;

/**
* The builder's own lines where the parser stopped, to close the refusal with (greenhouse evidence/1162).
*
* A line number alone sends a builder back to a text it no longer has in front of it — and the house, beside
* the refusal, tells it to repair that text with exact find and replace pairs. The line the parser names is
* where it gave up, which is rarely the line of the mistake: a missing semicolon is reported two lines down. So
* the lines before it travel too, and so does the line a bracket was opened on when the parser names one. After
* the gutter each line is the proposal's, byte for byte: it is what a `find` is copied from.
*
* @param array{message: string, line: int} $finding
*/
public static function shown(string $content, array $finding): string
{
$opened = preg_match("/^Unclosed '.' on line ([1-9][0-9]*)/", $finding['message'], $named) === 1 ? (int) $named[1] : null;

return self::lines($content, $finding['line'], $opened, "The parser stopped at line {$finding['line']}: what it could not read is on that line or just before it.");
}

/**
* The same lines for the check that runs after the parser, to close its refusal with.
*
* A body the parser reads can still fail to compile — a method declared twice, a void function that returns a
* value — and that check answered with what it printed: a line number inside a sentence, and no text. The line
* it names is the line of the mistake itself.
*/
public static function shownAt(string $content, int $line): string
{
return self::lines($content, $line, null, "The check names line {$line}.");
}

/** The line a compile check names in what it printed — it closes its sentence with `on line N` — or null. */
public static function lineNamedIn(string $printed): ?int
{
return preg_match('/ on line ([1-9][0-9]*)$/m', $printed, $named) === 1 ? (int) $named[1] : null;
}

private static function lines(string $content, int $stopped, ?int $opened, string $said): string
{
$lines = explode("\n", $content);
$width = \strlen((string) \count($lines));
$row = static function (int $number, string $mark) use ($lines, $width): string {
$text = $lines[$number - 1];
if (\strlen($text) > self::SHOWN) {
$text = substr($text, 0, self::SHOWN) . ' … ⟨the line goes on: ' . \strlen($text) . ' bytes⟩';
}

return ' ' . $mark . ' ' . str_pad((string) $number, $width, ' ', STR_PAD_LEFT) . ' │ ' . $text . "\n";
};

$block = '';
if ($opened !== null && $opened < $stopped - 3 && $opened <= \count($lines)) {
$said .= " The bracket it names was opened on line {$opened}.";
$block .= $row($opened, '◂') . " …\n";
} elseif ($opened !== null) {
$said .= " The bracket it names was opened on line {$opened}.";
}
for ($number = max(1, $stopped - 3); $number <= min(\count($lines), $stopped + 1); ++$number) {
$block .= $row($number, $number === $stopped ? '▸' : ($number === $opened ? '◂' : ' '));
}

return "\n" . $said . " Your proposal reads there — after «│» every line is yours, byte for byte:\n" . rtrim($block, "\n");
}
}
Loading
Loading