diff --git a/src/Operations/EditPairs.php b/src/Operations/EditPairs.php index db352ef..348d1f9 100644 --- a/src/Operations/EditPairs.php +++ b/src/Operations/EditPairs.php @@ -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); + } } diff --git a/src/Operations/ImplementHandler.php b/src/Operations/ImplementHandler.php index d78c807..22b41f1 100644 --- a/src/Operations/ImplementHandler.php +++ b/src/Operations/ImplementHandler.php @@ -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', @@ -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); diff --git a/src/Operations/SyntaxFinding.php b/src/Operations/SyntaxFinding.php index c611978..17c1aab 100644 --- a/src/Operations/SyntaxFinding.php +++ b/src/Operations/SyntaxFinding.php @@ -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"); + } } diff --git a/tests/Operations/ALandingRefusalCarriesWhatTheNextCallNeedsTest.php b/tests/Operations/ALandingRefusalCarriesWhatTheNextCallNeedsTest.php new file mode 100644 index 0000000..01a812b --- /dev/null +++ b/tests/Operations/ALandingRefusalCarriesWhatTheNextCallNeedsTest.php @@ -0,0 +1,412 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/devtools + */ + +declare(strict_types=1); + +namespace Milpa\DevTools\Tests\Operations; + +use Milpa\DevTools\Operations\EditPairs; +use Milpa\DevTools\Operations\ImplementHandler; +use Milpa\DevTools\Operations\SyntaxFinding; +use Milpa\DevTools\Support\RootResolver; +use PHPUnit\Framework\TestCase; + +/** + * A LANDING REFUSAL CARRIES THE TEXT THE NEXT CALL NEEDS (greenhouse evidence/1162). + * + * Three refusals are about the text itself, and each told a builder that something was wrong without the words to + * put it right. A body with a syntax error got one line — a line number, and it is the line where the parser gave + * up, which is rarely the line of the mistake — beside a hint to repair the proposal with exact find and replace + * pairs: exact pairs over a text the answer did not show. An edit whose `find` was not in the file got the whole + * file back and nothing about where the two part ways. An edit whose `find` was there several times got a count. + * + * So each says what the next call is written from: the builder's own lines where the parser stopped; the line where + * an edit stops matching, how the file reads there, and the file's own text over those lines; the lines an + * ambiguous text sits on. + */ +final class ALandingRefusalCarriesWhatTheNextCallNeedsTest extends TestCase +{ + private const FILE = <<<'PHP' + root = sys_get_temp_dir() . '/milpa-refusal-' . bin2hex(random_bytes(4)); + mkdir($this->root . '/src/Plugins/Demo/Services', 0o775, true); + file_put_contents($this->file(), "root)); + } + + // ── a body with a syntax error ─────────────────────────────────────────────────────────────── + + public function testASyntaxRefusalShowsTheBuildersOwnLinesWhereTheParserStopped(): void + { + $proposal = str_replace('$clean = trim($name);', '$clean = trim($name)', self::FILE); + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertSame('syntax', $r['diagnostic']['phase']); + self::assertStringStartsWith('refused: syntax error in the proposal for src/Plugins/Demo/Services/GreeterService.php on line 15: ', $r['error']); + // The parser stopped two lines after the mistake: both are in what is shown, and the one it names is marked. + self::assertStringContainsString(" 13 │ \$clean = trim(\$name)\n", $r['error']); + self::assertStringContainsString(" ▸ 15 │ return 'hola ' . \$clean;\n", $r['error']); + self::assertStringContainsString('The parser stopped at line 15: what it could not read is on that line or just before it.', $r['error']); + } + + /** What is shown is copied into a `find`: after the gutter, every line is the proposal's, byte for byte. */ + public function testTheLinesShownAreTheProposalsByteForByte(): void + { + $proposal = str_replace(" \$clean = trim(\$name);\n", "\t\$clean = trim(\$name) \n", self::FILE); + + $r = $this->implement($proposal); + + $lines = explode("\n", $proposal); + self::assertSame(1, preg_match_all('/^ [ ▸]\s*(\d+) │ (.*)$/mu', $r['error'], $shown, PREG_SET_ORDER) > 0 ? 1 : 0, $r['error']); + self::assertGreaterThanOrEqual(4, \count($shown)); + foreach ($shown as [, $number, $text]) { + self::assertSame($lines[(int) $number - 1], $text, "line {$number}"); + } + } + + public function testAnUnclosedBracketShowsWhereItWasOpenedToo(): void + { + $proposal = str_replace(" return 'adiós ' . trim(\$name);\n }\n}\n", " return 'adiós ' . trim(\$name);\n}\n", self::FILE) . "\n\n\n\n\n\n// the end\n"; + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertMatchesRegularExpression("/Unclosed '\\{' on line (\\d+)/", $r['error']); + self::assertStringContainsString('The bracket it names was opened on line 10.', $r['error']); + self::assertStringContainsString(" ◂ 10 │ {\n …\n", $r['error'], 'far from where the parser stopped, it is shown apart'); + self::assertMatchesRegularExpression('/^ ▸ 29 │ $/mu', $r['error']); + } + + /** Opened close by, the bracket's line is among the lines shown: it is marked there, and nothing is set apart. */ + public function testABracketOpenedCloseByIsMarkedAmongTheLinesShown(): void + { + $proposal = rtrim(self::FILE) . "\n\nfunction more(): void\n{\n return;\n"; + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertStringContainsString('The bracket it names was opened on line 25.', $r['error']); + self::assertMatchesRegularExpression('/^ ◂ 25 │ \{$/mu', $r['error']); + self::assertMatchesRegularExpression('/^ ▸ 27 │ $/mu', $r['error']); + self::assertStringNotContainsString(" …\n", $r['error']); + } + + public function testASyntaxErrorOnTheLastLineShowsTheEndOfTheProposal(): void + { + $proposal = rtrim(self::FILE) . "\n\nfunction ("; + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertStringEndsWith(" ▸ 24 │ function (", $r['error'], 'the last line of the proposal is the last line shown'); + self::assertStringContainsString(" 21 │ }\n", $r['error']); + } + + public function testAVeryLongLineIsCutAndSaysSo(): void + { + $proposal = str_replace('$clean = trim($name);', '$clean = trim($name) . \'' . str_repeat('x', 600) . '\'', self::FILE); + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertStringContainsString(str_repeat('x', 100) . ' … ⟨the line goes on: 6', $r['error']); + self::assertLessThan(1600, \strlen($r['error'])); + } + + /** What nothing else reads stays as it was: the first sentence, and the finding the diagnostic carries. */ + public function testTheFindingItselfIsWhatItWas(): void + { + $proposal = str_replace('$clean = trim($name);', '$clean = trim($name)', self::FILE); + + $r = $this->implement($proposal); + + self::assertSame(SyntaxFinding::inspect($proposal), $r['diagnostic']['result']); + self::assertSame(['parser', 'message', 'line', 'fingerprint'], array_keys($r['diagnostic']['result'])); + self::assertStringContainsString('; destination preserved, proposal never installed', explode("\n", $r['error'])[0]); + } + + // ── a body the parser reads and the compile check refuses ──────────────────────────────────── + + /** + * The parser is not the only door. A body it reads can still fail to compile — a void function that returns a + * value, a method declared twice — and the check that says so has a sentence of its own. A resident met it on + * a house that carried the rest of this: 300 characters, a line number, no text. + */ + public function testABodyThatDoesNotCompileIsShownItsOwnLinesToo(): void + { + $proposal = str_replace('part(string $name): string', 'part(string $name): void', self::FILE); + + $r = $this->implement($proposal); + + self::assertFalse($r['ok']); + self::assertStringStartsWith("refused: syntax or compilation check failed (exit 255):\n", $r['error']); + // What the check printed, in the engine's own words — which change with its version («A void function…», + // «A void method…»): only what both say is asked for. + self::assertStringContainsString(' must not return a value in ', $r['error']); + self::assertStringEndsWith( + "\nThe check names line 20. Your proposal reads there — after «│» every line is yours, byte for byte:\n" + . " 17 │ \n" + . " 18 │ public function part(string \$name): void\n" + . " 19 │ {\n" + . " ▸ 20 │ return 'adiós ' . trim(\$name);\n" + . ' 21 │ }', + $r['error'], + ); + } + + /** The line it names is the mistake's own, whatever the mistake: here the second declaration of a method. */ + public function testTheLineTheCompileCheckNamesIsTheOneMarked(): void + { + $r = $this->implement(str_replace('function part(', 'function greet(', self::FILE)); + + self::assertFalse($r['ok']); + self::assertStringContainsString('Cannot redeclare ', $r['error']); + self::assertStringContainsString('The check names line 18.', $r['error']); + self::assertMatchesRegularExpression('/^ ▸ 18 │ public function greet\(string \$name\): string$/mu', $r['error']); + } + + /** What the check printed is still there whole, under the name of the file it was meant for. */ + public function testWhatTheCompileCheckPrintedIsStillThere(): void + { + $r = $this->implement(str_replace('part(string $name): string', 'part(string $name): void', self::FILE)); + + self::assertStringContainsString(' in ' . $this->file() . ' on line 20', $r['error']); + self::assertStringContainsString('Errors parsing ' . $this->file(), $r['error']); + self::assertStringNotContainsString('milpa-implement-', $r['error']); + self::assertArrayNotHasKey('diagnostic', $r); + } + + /** The line is read where the check closes its sentence with it; a check that names none adds nothing. */ + public function testTheLineIsReadFromWhatTheCheckPrinted(): void + { + self::assertSame(41, SyntaxFinding::lineNamedIn("PHP Fatal error: A void function must not return a value in /app/src/A.php on line 41\nErrors parsing /app/src/A.php")); + // A folder can be named anything: the line is the one that closes the sentence, not the first that looks like one. + self::assertSame(7, SyntaxFinding::lineNamedIn("Fatal error: Cannot redeclare A::b() in /app/notes on line 3/A.php on line 7\nErrors parsing /app/notes on line 3/A.php")); + self::assertNull(SyntaxFinding::lineNamedIn('Errors parsing /app/src/A.php')); + self::assertNull(SyntaxFinding::lineNamedIn("sh: 1: php: not found\n")); + } + + // ── an edit whose text is not in the file ──────────────────────────────────────────────────── + + public function testAnEditWhoseTextIsNotThereIsToldWhereItStopsMatching(): void + { + $find = " public function greet(string \$name): string\n {\n return 'hola ' . \$name;"; + + $r = EditPairs::apply(self::FILE, [['find' => $find, 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringStartsWith("edit #1 matches nothing — this `find` does not appear in the file:\n{$find}\n", $r['error']); + self::assertStringContainsString("It matches the CURRENT file up to line 13 and stops there. There the file reads:\n \$clean = trim(\$name);\nand your `find` reads:\n return 'hola ' . \$name;\n", $r['error']); + } + + /** The file's own text over those lines is handed over as the `find` that matches — and it does. */ + public function testTheFilesOwnLinesAreGivenAndTheyMatch(): void + { + $find = " public function greet(string \$name): string\n {\n return 'hola ' . \$name;"; + + $r = EditPairs::apply(self::FILE, [['find' => $find, 'replace' => 'x']]); + + self::assertSame(1, preg_match('/as a `find` it matches exactly once:\n(.*?)\nThe CURRENT file is:/s', $r['error'], $given), $r['error']); + self::assertSame(" public function greet(string \$name): string\n {\n \$clean = trim(\$name);", $given[1]); + self::assertTrue(EditPairs::apply(self::FILE, [['find' => $given[1], 'replace' => 'x']])['ok']); + } + + /** The commonest miss: the words are right and the spacing is not — a blank line, an indentation. */ + public function testWhenOnlyItsSpacingDiffersItSaysSoAndGivesTheExactText(): void + { + $find = " \$clean = trim(\$name);\n return 'hola ' . \$clean;"; + + $r = EditPairs::apply(self::FILE, [['find' => $find, 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringContainsString('Only its spacing differs from the file', $r['error']); + self::assertSame(1, preg_match('/as a `find` it matches exactly once:\n(.*?)\nThe CURRENT file is:/s', $r['error'], $given), $r['error']); + self::assertSame(" \$clean = trim(\$name);\n\n return 'hola ' . \$clean;", $given[1]); + } + + public function testWhenNothingOfItIsThereItSaysSo(): void + { + $r = EditPairs::apply(self::FILE, [['find' => " private function shout(): string\n {", 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringContainsString('Nothing of its first line is in the CURRENT file.', $r['error']); + self::assertStringNotContainsString('stops there', $r['error']); + self::assertStringNotContainsString('matches exactly once', $r['error']); + } + + /** A text over several places is not handed back as a `find`: it would be the next refusal. */ + public function testLinesThatAreInTheFileMoreThanOnceAreNotOfferedAsAFind(): void + { + $r = EditPairs::apply(self::FILE, [['find' => " {\n zzz();", 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringContainsString('It matches the CURRENT file up to line 13 and stops there (the first of 2 places it matches that far).', $r['error']); + self::assertStringNotContainsString('matches exactly once', $r['error']); + } + + /** A second pair is judged against the text the first one left — and the answer says that is the text it shows. */ + public function testALaterPairIsShownTheTextTheEarlierOnesLeft(): void + { + $r = EditPairs::apply(self::FILE, [['find' => "'hola '", 'replace' => "'buenas '"], ['find' => "return 'hola ' . \$clean;", 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringStartsWith('edit #2 matches nothing', $r['error']); + self::assertStringContainsString("The CURRENT file, with edit #1 of this call applied, is:\n---\n", $r['error']); + self::assertStringContainsString("return 'buenas ' . \$clean;", $r['error']); + self::assertStringContainsString('It matches the text the earlier edits of this call left up to line 15 and stops there', $r['error']); + self::assertStringContainsString("There the file reads:\n return 'buenas ' . \$clean;\nand your `find` reads:\nreturn 'hola ' . \$clean;\n", $r['error']); + } + + public function testAThirdPairSaysWhichOnesCameBeforeIt(): void + { + $r = EditPairs::apply(self::FILE, [['find' => "'hola '", 'replace' => "'buenas '"], ['find' => "'adiós '", 'replace' => "'chao '"], ['find' => 'absent', 'replace' => 'x']]); + + self::assertStringStartsWith('edit #3 matches nothing', $r['error']); + self::assertStringContainsString("The CURRENT file, with edits #1–#2 of this call applied, is:\n---\n", $r['error']); + self::assertStringContainsString('Nothing of its first line is in the text the earlier edits of this call left.', $r['error']); + } + + /** + * What the answer costs is the cost of the stretch it speaks of, not of the file: the search for the stretch + * that says the same words ends at the first line that says another. Without that it reads the rest of the + * file once per line — measured, 22 seconds on 8,000 lines. + */ + public function testALongFileIsAnsweredAsSoonAsAShortOne(): void + { + $lines = [' " public function first(): int\n {\n return 2;", 'replace' => 'x']]); + $seconds = (hrtime(true) - $started) / 1e9; + + self::assertStringContainsString("as a `find` it matches exactly once:\n public function first(): int\n {\n return 1;\n", $r['error']); + self::assertLessThan(2.0, $seconds, 'a refused edit on a long file took ' . round($seconds, 1) . ' s to answer'); + } + + /** The same for a text that is on every line of a long file: each place is counted from the one before it. */ + public function testATextOnEveryLineOfALongFileIsCountedOnce(): void + { + $lines = []; + for ($i = 0; $i < 80000; ++$i) { + $lines[] = " private int \$field{$i} = {$i}; // a line of ordinary length, as a class has them"; + } + + $started = hrtime(true); + $r = EditPairs::apply(implode("\n", $lines) . "\n", [['find' => ';', 'replace' => 'x']]); + $seconds = (hrtime(true) - $started) / 1e9; + + self::assertStringContainsString('It is on lines 1, 2, 3, 4, 5, 6, 7, 8 and 79992 more of the CURRENT file:', $r['error']); + self::assertLessThan(2.0, $seconds, 'an ambiguous edit on a long file took ' . round($seconds, 1) . ' s to answer'); + } + + /** Nothing is taken away: the whole text still travels, under the name it was given. */ + public function testTheWholeTextStillTravels(): void + { + $r = EditPairs::apply(self::FILE, [['find' => 'absent', 'replace' => 'x']], 'RECORDED proposal'); + + self::assertStringContainsString("The RECORDED proposal is:\n---\n" . self::FILE . "\n---\nBuild your pairs against that text exactly.", $r['error']); + self::assertStringContainsString('Nothing of its first line is in the RECORDED proposal.', $r['error']); + } + + // ── an edit whose text is there more than once ─────────────────────────────────────────────── + + public function testAnAmbiguousEditIsToldTheLinesItsTextSitsOn(): void + { + $r = EditPairs::apply(self::FILE, [['find' => 'trim($name)', 'replace' => 'x']]); + + self::assertFalse($r['ok']); + self::assertStringStartsWith('edit #1 is ambiguous: `find` appears 2 times and must appear exactly once — widen it with surrounding lines', $r['error']); + self::assertStringContainsString("It is on lines 13 and 20 of the CURRENT file:\n 13 │ \$clean = trim(\$name);\n 20 │ return 'adiós ' . trim(\$name);", $r['error']); + } + + public function testALineItSitsOnTwiceIsOneLine(): void + { + $r = EditPairs::apply(self::FILE, [['find' => 'string', 'replace' => 'x']]); + + self::assertStringContainsString('appears 4 times', $r['error']); + self::assertStringContainsString("It is on lines 11 and 18 of the CURRENT file:\n 11 │ public function greet(string \$name): string\n 18 │ public function part(string \$name): string", $r['error']); + + $one = EditPairs::apply("a x x b\n", [['find' => 'x', 'replace' => 'y']]); + self::assertStringContainsString("It is on line 1, more than once, of the CURRENT file:\n 1 │ a x x b", $one['error']); + } + + public function testManyPlacesAreCounted(): void + { + $r = EditPairs::apply(str_repeat("same\n", 40), [['find' => 'same', 'replace' => 'x']]); + + self::assertStringContainsString('appears 40 times', $r['error']); + self::assertStringContainsString('It is on lines 1, 2, 3, 4, 5, 6, 7, 8 and 32 more of the CURRENT file:', $r['error']); + self::assertSame(8, preg_match_all('/^\s+\d+ │ same$/mu', $r['error'])); + } + + /** Through the door a builder uses, the same answer arrives. */ + public function testTheEditDoorAnswersWithIt(): void + { + file_put_contents($this->file(), self::FILE); + + $r = (new ImplementHandler(new RootResolver($this->root)))->edit(['plugin' => 'Demo', 'class' => 'GreeterService', + 'edits' => [['find' => " return 'hola ' . \$name;", 'replace' => 'x']]]); + + self::assertFalse($r['ok']); + self::assertStringContainsString("It matches the CURRENT file up to line 15 and stops there. There the file reads:\n return 'hola ' . \$clean;\n", $r['error']); + self::assertSame(self::FILE, (string) file_get_contents($this->file()), 'and the file is as it was'); + } + + private function file(): string + { + return $this->root . '/src/Plugins/Demo/Services/GreeterService.php'; + } + + /** @return array */ + private function implement(string $content): array + { + return (new ImplementHandler(new RootResolver($this->root)))->handle(['plugin' => 'Demo', 'class' => 'GreeterService', 'content' => $content]); + } +}