diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 0c5f06510af..1775a710833 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -970,7 +970,7 @@ jobs: - script: | cd e2e/fix composer install - rm -rf src cycle tmp && cp -r original src && cp -r cycle-original cycle + rm -rf src cycle tmp && cp -r original src && cp -r cycle-original cycle && cp ignore-original.neon ignore.neon OUTPUT=$(AI_AGENT=1 ../bashunit assert exit_code "1" "../../bin/phpstan analyse --error-format raw") echo "$OUTPUT" ../bashunit assert contains '7 of the errors can be fixed automatically. Run PHPStan again with --fix' "$OUTPUT" @@ -984,9 +984,11 @@ jobs: diff -u expected/Ignored-fix.php src/Ignored.php OUTPUT=$(../bashunit assert exit_code "0" "../../bin/phpstan analyse --fix-ignored") echo "$OUTPUT" - ../bashunit assert contains '[OK] 2 errors fixed' "$OUTPUT" + # the last round removes the ignoreErrors entry, it does not match anything after the fixes + ../bashunit assert contains '[OK] 3 errors fixed in 2 files' "$OUTPUT" diff -u expected/Foo.php src/Foo.php diff -u expected/Ignored-fix-ignored.php src/Ignored.php + diff -u ignore-expected.neon ignore.neon OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse --fix --fix-ignored") echo "$OUTPUT" ../bashunit assert contains 'No fixable errors found' "$OUTPUT" @@ -997,28 +999,50 @@ jobs: cd e2e/fix-baseline composer install mkdir -p vendor/acme/rules && cp vendor-baseline.php vendor/acme/rules/baseline.php - rm -rf tmp && cp baseline-original.php baseline.php + rm -rf tmp && cp baseline-original.php baseline.php && cp baseline-original.neon baseline.neon OUTPUT=$(AI_AGENT=1 ../bashunit assert exit_code "1" "../../bin/phpstan analyse --error-format raw") echo "$OUTPUT" - # two unmatched messages and an unmatched entry of the PHP baseline, and a count higher than the occurrences; - # not the entries in NEON and in vendor/, and not a count lower than the occurrences (--generate-baseline does that) - ../bashunit assert contains '4 of the errors can be fixed automatically. Run PHPStan again with --fix' "$OUTPUT" + # two unmatched messages, an unmatched entry, and a count higher than the occurrences in the PHP baseline, + # an unmatched message, an unmatched entry, and a count higher than the occurrences in the NEON baseline; + # not the entry in vendor/, and not a count lower than the occurrences (--generate-baseline does that) + ../bashunit assert contains '7 of the errors can be fixed automatically. Run PHPStan again with --fix' "$OUTPUT" OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse --fix --generate-baseline") echo "$OUTPUT" ../bashunit assert contains 'Errors cannot be fixed when generating the baseline.' "$OUTPUT" OUTPUT=$(../bashunit assert exit_code "0" "../../bin/phpstan analyse --fix") echo "$OUTPUT" - # removing the last two messages of the list conflicts, the second one goes in the next round - ../bashunit assert contains 'Round 2: fixed 1 error in 1 file' "$OUTPUT" - ../bashunit assert contains '[OK] 4 errors fixed in 1 file' "$OUTPUT" + # removing the last two messages of the PHP list conflicts, and so does removing the NEON entry + # and the first message of the entry after it, the second one goes in the next round + ../bashunit assert contains 'Round 2: fixed 2 errors in 2 files' "$OUTPUT" + ../bashunit assert contains '[OK] 7 errors fixed in 2 files' "$OUTPUT" diff -u baseline-expected.php baseline.php + diff -u baseline-expected.neon baseline.neon OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse --error-format raw") echo "$OUTPUT" - ../bashunit assert contains 'Ignored error pattern #^Unmatched in NEON$#' "$OUTPUT" ../bashunit assert contains 'Ignored error pattern #^Unmatched in vendor$#' "$OUTPUT" ../bashunit assert contains 'is expected to occur 1 time, but occurred 2 times.' "$OUTPUT" ../bashunit assert not_contains 'Unmatched one' "$OUTPUT" + ../bashunit assert not_contains 'NEON' "$OUTPUT" ../bashunit assert not_contains 'occurred only' "$OUTPUT" + - script: | + cd e2e/fix-ignored-entries + composer install + rm -rf src tmp && cp -r original src && cp baseline-original.php baseline.php && cp baseline-original.neon baseline.neon + OUTPUT=$(../bashunit assert exit_code "0" "../../bin/phpstan analyse --fix-ignored") + echo "$OUTPUT" + # the ignored errors in the first round, the ignores that matched them in the second round: + # the PHP baseline entry, the too high count of the NEON baseline entry and the @phpstan-ignore comment + ../bashunit assert contains 'Round 1: fixed 5 errors in 3 files' "$OUTPUT" + ../bashunit assert contains 'Round 2: fixed 3 errors in 3 files' "$OUTPUT" + ../bashunit assert contains '[OK] 8 errors fixed in 5 files' "$OUTPUT" + for file in InComment.php InNeonBaseline.php InPhpBaseline.php; do diff -u expected/$file src/$file; done + diff -u expected/baseline.php baseline.php + diff -u expected/baseline.neon baseline.neon + OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse --error-format raw") + echo "$OUTPUT" + # the entry that was stale before, in a file --fix-ignored did not change, is left alone + ../bashunit assert contains 'Untouched.php:?:Ignored error pattern #^Stale$#' "$OUTPUT" + ../bashunit assert not_contains 'src/In' "$OUTPUT" - script: | cd e2e/editor-mode OUTPUT=$(../bashunit assert exit_code "1" "../../bin/phpstan analyse -vv --error-format raw") diff --git a/build/phpstan.neon b/build/phpstan.neon index e4c1650ff6a..44b3deae353 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -191,6 +191,14 @@ parameters: message: '#^Call to PHPStan\\Cache\\ArenaCache\:\:attach\(\) on a separate line has no effect\.$#' count: 1 path: ../src/Command/WorkerCommand.php + - + # nette/neon 3 marks its syntax tree, lexer and parser @internal. The format-preserving printer + # and the ignoreErrors fixer build on them, composer.json pins nette/neon to one version. + message: '#internal class Nette\\Neon\\#' + paths: + - ../src/Neon/* + - ../src/Analyser/Ignore/IgnoreErrorsConfigFixer.php + - ../tests/PHPStan/Neon/* - rawMessage: 'Property PHPStan\Command\CommandHelper::$reservedMemory is never read' # intentional reportUnmatched: false diff --git a/e2e/fix-baseline/.gitignore b/e2e/fix-baseline/.gitignore index 0685d4cefe4..09801b37eb5 100644 --- a/e2e/fix-baseline/.gitignore +++ b/e2e/fix-baseline/.gitignore @@ -2,3 +2,4 @@ /composer.lock /tmp /baseline.php +/baseline.neon diff --git a/e2e/fix-baseline/baseline-expected.neon b/e2e/fix-baseline/baseline-expected.neon new file mode 100644 index 00000000000..e896dbff049 --- /dev/null +++ b/e2e/fix-baseline/baseline-expected.neon @@ -0,0 +1,12 @@ +parameters: + ignoreErrors: + - + message: '#^Undefined variable\: \$g$#' + identifier: variable.undefined + count: 3 # lowered to 3 + path: src/Foo.php + + - + messages: + - '#^Undefined variable\: \$h$#' + path: src/Foo.php diff --git a/e2e/fix-baseline/baseline-original.neon b/e2e/fix-baseline/baseline-original.neon new file mode 100644 index 00000000000..b31a8187b86 --- /dev/null +++ b/e2e/fix-baseline/baseline-original.neon @@ -0,0 +1,18 @@ +parameters: + ignoreErrors: + - + message: '#^Undefined variable\: \$g$#' + identifier: variable.undefined + count: 5 # lowered to 3 + path: src/Foo.php + + # removed together with this comment + - + message: '#^Unmatched in NEON$#' + path: src/Foo.php + + - + messages: + - '#^Unmatched in a NEON list$#' + - '#^Undefined variable\: \$h$#' + path: src/Foo.php diff --git a/e2e/fix-baseline/phpstan.neon b/e2e/fix-baseline/phpstan.neon index 74b50d8f290..2faab5f1b94 100644 --- a/e2e/fix-baseline/phpstan.neon +++ b/e2e/fix-baseline/phpstan.neon @@ -1,5 +1,6 @@ includes: - baseline.php + - baseline.neon # a config file of an installed package is never fixed - vendor/acme/rules/baseline.php @@ -8,8 +9,3 @@ parameters: paths: - src tmpDir: tmp - ignoreErrors: - # NEON config files are not fixed yet - - - message: '#^Unmatched in NEON$#' - path: src/Foo.php diff --git a/e2e/fix-baseline/src/Foo.php b/e2e/fix-baseline/src/Foo.php index 345d9d28857..dca3c54eacf 100644 --- a/e2e/fix-baseline/src/Foo.php +++ b/e2e/fix-baseline/src/Foo.php @@ -25,3 +25,11 @@ function qux(): void echo $f; echo $f; } + +function quux(): void +{ + echo $g; + echo $g; + echo $g; + echo $h; +} diff --git a/e2e/fix-ignored-entries/.gitignore b/e2e/fix-ignored-entries/.gitignore new file mode 100644 index 00000000000..427f944cac2 --- /dev/null +++ b/e2e/fix-ignored-entries/.gitignore @@ -0,0 +1,6 @@ +/src +/tmp +/vendor +/composer.lock +/baseline.php +/baseline.neon diff --git a/e2e/fix-ignored-entries/baseline-original.neon b/e2e/fix-ignored-entries/baseline-original.neon new file mode 100644 index 00000000000..eedd94cd1dd --- /dev/null +++ b/e2e/fix-ignored-entries/baseline-original.neon @@ -0,0 +1,7 @@ +parameters: + ignoreErrors: + - + message: '#^Call modern\(\) instead\.$#' + identifier: fixIgnoredEntriesE2E.rename + count: 3 + path: src/InNeonBaseline.php diff --git a/e2e/fix-ignored-entries/baseline-original.php b/e2e/fix-ignored-entries/baseline-original.php new file mode 100644 index 00000000000..fc4f2e1b37b --- /dev/null +++ b/e2e/fix-ignored-entries/baseline-original.php @@ -0,0 +1,16 @@ + '#^Call modern\(\) instead\.$#', + 'identifier' => 'fixIgnoredEntriesE2E.rename', + 'count' => 2, + 'path' => __DIR__ . '/src/InPhpBaseline.php', +]; +// stale before --fix-ignored, in a file it does not change, so it is left alone +$ignoreErrors[] = [ + 'message' => '#^Stale$#', + 'path' => __DIR__ . '/src/Untouched.php', +]; + +return ['parameters' => ['ignoreErrors' => $ignoreErrors]]; diff --git a/e2e/fix-ignored-entries/composer.json b/e2e/fix-ignored-entries/composer.json new file mode 100644 index 00000000000..12a1404d2e9 --- /dev/null +++ b/e2e/fix-ignored-entries/composer.json @@ -0,0 +1,7 @@ +{ + "autoload-dev": { + "classmap": [ + "rules/" + ] + } +} diff --git a/e2e/fix-ignored-entries/expected/InComment.php b/e2e/fix-ignored-entries/expected/InComment.php new file mode 100644 index 00000000000..557582a95a8 --- /dev/null +++ b/e2e/fix-ignored-entries/expected/InComment.php @@ -0,0 +1,5 @@ + '#^Stale$#', + 'path' => __DIR__ . '/src/Untouched.php', +]; + +return ['parameters' => ['ignoreErrors' => $ignoreErrors]]; diff --git a/e2e/fix-ignored-entries/original/InComment.php b/e2e/fix-ignored-entries/original/InComment.php new file mode 100644 index 00000000000..deff00b044c --- /dev/null +++ b/e2e/fix-ignored-entries/original/InComment.php @@ -0,0 +1,5 @@ + + */ +final class RenameRule implements Rule +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name || $node->name->toString() !== 'legacy') { + return []; + } + + $error = RuleErrorBuilder::message('Call modern() instead.')->identifier('fixIgnoredEntriesE2E.rename'); + if ($node->getArgs() === []) { + $error->fixNode($node, static function (FuncCall $node): FuncCall { + $node->name = new Node\Name('modern'); + return $node; + }); + } + + return [$error->build()]; + } + +} diff --git a/e2e/fix/.gitignore b/e2e/fix/.gitignore index 67962ade64f..f43aa86dc5a 100644 --- a/e2e/fix/.gitignore +++ b/e2e/fix/.gitignore @@ -3,3 +3,4 @@ /tmp /vendor /composer.lock +/ignore.neon diff --git a/e2e/fix/ignore-expected.neon b/e2e/fix/ignore-expected.neon new file mode 100644 index 00000000000..aab49911587 --- /dev/null +++ b/e2e/fix/ignore-expected.neon @@ -0,0 +1,2 @@ +parameters: + ignoreErrors: [] diff --git a/e2e/fix/ignore-original.neon b/e2e/fix/ignore-original.neon new file mode 100644 index 00000000000..c4e701d32f5 --- /dev/null +++ b/e2e/fix/ignore-original.neon @@ -0,0 +1,5 @@ +parameters: + ignoreErrors: + - + identifier: fixE2E.step + path: src/Ignored.php diff --git a/e2e/fix/phpstan.neon b/e2e/fix/phpstan.neon index c08b0af1a57..6c0f57e55dd 100644 --- a/e2e/fix/phpstan.neon +++ b/e2e/fix/phpstan.neon @@ -1,12 +1,11 @@ +includes: + - ignore.neon + parameters: level: 0 paths: - src tmpDir: tmp - ignoreErrors: - - - identifier: fixE2E.step - path: src/Ignored.php rules: - FixE2E\StepRule diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 166d7e965f3..c092e1db7ad 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -57,7 +57,7 @@ parameters: - rawMessage: 'Call to static method expand() of internal class Nette\DI\Helpers from outside its root namespace Nette.' identifier: staticMethod.internalClass - count: 1 + count: 2 path: src/Analyser/Ignore/IgnoreErrorsConfigFixer.php - diff --git a/src/Analyser/Ignore/IgnoreErrorsConfigFixer.php b/src/Analyser/Ignore/IgnoreErrorsConfigFixer.php index 202dd798408..e16d619c194 100644 --- a/src/Analyser/Ignore/IgnoreErrorsConfigFixer.php +++ b/src/Analyser/Ignore/IgnoreErrorsConfigFixer.php @@ -4,6 +4,14 @@ use Nette\DI\Helpers; use Nette\InvalidArgumentException; +use Nette\Neon\Exception as NeonException; +use Nette\Neon\Lexer as NeonLexer; +use Nette\Neon\Node as NeonNode; +use Nette\Neon\Node\ArrayItemNode as NeonArrayItemNode; +use Nette\Neon\Node\ArrayNode as NeonArrayNode; +use Nette\Neon\Node\LiteralNode as NeonLiteralNode; +use Nette\Neon\Parser as NeonParser; +use Nette\Neon\TokenStream as NeonTokenStream; use Nette\Utils\Strings; use PhpParser\ConstExprEvaluationException; use PhpParser\ConstExprEvaluator; @@ -17,13 +25,18 @@ use PHPStan\Dependency\PackageDependencyResolver; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\DependencyInjection\NeonAdapter; use PHPStan\File\CouldNotReadFileException; use PHPStan\File\FileHelper; use PHPStan\File\FileReader; +use PHPStan\Neon\Printer as NeonPrinter; use function array_key_exists; use function array_keys; use function array_map; +use function array_pop; use function array_search; +use function array_slice; +use function array_values; use function count; use function dirname; use function hash; @@ -36,6 +49,7 @@ use function spl_object_id; use function sprintf; use function str_ends_with; +use function str_replace; use function str_starts_with; use function strlen; use function strpos; @@ -55,19 +69,30 @@ * * The entry is looked for in the whole config cascade, except for files of installed Composer packages. * The ignoreErrors parameter is merged from all of them, so the entry can come from more than one file. - * Only PHP config files, like PHP baselines, are fixed so far. * - * Baselines can have tens of thousands of entries and each fixed error needs its own diff, - * so a fix is an edit of the source range of the parsed node and its diff covers only the changed lines. - * Printing the whole file for every fix would make fixing a large baseline take hours. + * Baselines can have tens of thousands of entries and each fixed error needs its own diff, which covers + * only the changed lines. In a PHP config file, a fix is an edit of the source range of the parsed node, + * printing the whole file with the format-preserving printer for every fix would make fixing a large baseline + * take hours. A NEON config file is printed with PHPStan\Neon\Printer, which copies the unchanged parts + * of the file as they are. * * @phpstan-import-type ExpandedIgnoredErrorData from IgnoredErrorHelperResult - * @phpstan-type ConfigEntry = array{ + * @phpstan-type PhpConfigEntry = array{ * file: string, * values: array, + * format: 'php', * items: array, * removableNode: Node|null, * } + * @phpstan-type NeonConfigEntry = array{ + * file: string, + * values: array, + * format: 'neon', + * items: array, + * path: non-empty-list, + * entryValue: NeonArrayNode, + * } + * @phpstan-type ConfigEntry = PhpConfigEntry|NeonConfigEntry * @phpstan-type Edit = array{start: int, end: int, replacement: string, line: int} */ #[AutowiredService] @@ -88,6 +113,9 @@ final class IgnoreErrorsConfigFixer /** @var array contents of the config files the entries were found in */ private array $files = []; + /** @var array parsed NEON config files */ + private array $neonFiles = []; + /** * @param list $allConfigFiles */ @@ -95,6 +123,7 @@ public function __construct( #[AutowiredParameter(ref: '@currentPhpVersionPhpParser')] private Parser $parser, private PackageDependencyResolver $packageDependencyResolver, + private NeonPrinter $neonPrinter, private FileHelper $fileHelper, #[AutowiredParameter] private array $allConfigFiles, @@ -113,9 +142,26 @@ public function __construct( public function removeUnmatched(array $ignoreError): array { $editsByFile = []; - foreach ($this->findEntries($ignoreError) as [$entry, $listItem, $listNode]) { + $neonChangesByFile = []; + foreach ($this->findEntries($ignoreError) as [$entry, $listField, $position]) { + if ($entry['format'] === 'neon') { + if ($listField === null || $position === null) { + $neonChangesByFile[$entry['file']][] = [$entry['path'], null]; + continue; + } + + $listItemNode = $entry['items'][$listField]; + $listNode = $listItemNode->value; + if (!$listNode instanceof NeonArrayNode || count($listNode->items) !== count($entry['values'][$listField])) { + return []; + } + + $neonChangesByFile[$entry['file']][] = [[...$entry['path'], $entry['entryValue'], $listItemNode, $listNode, array_values($listNode->items)[$position]], null]; + continue; + } + $code = $this->files[$entry['file']]['code']; - if ($listItem === null || $listNode === null) { + if ($listField === null || $position === null) { if ($entry['removableNode'] === null) { return []; } @@ -124,10 +170,16 @@ public function removeUnmatched(array $ignoreError): array continue; } + $listNode = $entry['items'][$listField]->value; + if (!$listNode instanceof Expr\Array_ || count($listNode->items) !== count($entry['values'][$listField])) { + return []; + } + // The edit replaces the whole list, so that removing another item of it conflicts. // Otherwise all items could be removed in one round, leaving an entry without messages. // The next round removes the whole entry once its last item is unmatched. - $itemEdit = $this->createRemovalEdit($code, $listItem); + // The NEON printer prints the whole list too. + $itemEdit = $this->createRemovalEdit($code, $listNode->items[$position]); $listStart = $listNode->getStartFilePos(); $listEnd = $listNode->getEndFilePos() + 1; $editsByFile[$entry['file']][] = [ @@ -138,6 +190,15 @@ public function removeUnmatched(array $ignoreError): array ]; } + foreach ($neonChangesByFile as $file => $changes) { + $edit = $this->createNeonEdit($file, $changes); + if ($edit === null) { + return []; + } + + $editsByFile[$file][] = $edit; + } + return $this->createDiffs($editsByFile); } @@ -152,17 +213,34 @@ public function lowerCount(array $ignoreError, int $count): array return []; } - [$entry, $listItem] = $entries[0]; + [$entry, $listField] = $entries[0]; if ( - $listItem !== null - || !isset($entry['items']['count']) - // another entry of the same error is somewhere this class does not look into, like a NEON file - || $entry['values']['count'] !== ($ignoreError['count'] ?? null) + $listField !== null + // another entry of the same error is somewhere this class does not look into + || ($entry['values']['count'] ?? null) !== ($ignoreError['count'] ?? null) ) { return []; } - $countNode = $entry['items']['count']->value; + if ($entry['format'] === 'neon') { + $countItem = $entry['items']['count'] ?? null; + if ($countItem === null) { + return []; + } + + $edit = $this->createNeonEdit($entry['file'], [ + [[...$entry['path'], $entry['entryValue'], $countItem, $countItem->value], new NeonLiteralNode($count)], + ]); + + return $edit !== null ? $this->createDiffs([$entry['file'] => [$edit]]) : []; + } + + $countItem = $entry['items']['count'] ?? null; + if ($countItem === null) { + return []; + } + + $countNode = $countItem->value; return $this->createDiffs([ $entry['file'] => [[ @@ -283,7 +361,7 @@ private function createDiff(string $code, array $edits): ?string * like `messages` that has other items too, only that item can then be removed. * * @param ExpandedIgnoredErrorData $ignoreError - * @return list + * @return list the entry, and the list field and position of the item */ private function findEntries(array $ignoreError): array { @@ -294,12 +372,12 @@ private function findEntries(array $ignoreError): array $found = []; foreach ($this->getEntriesByPath()[$this->normalizePath($path)] ?? [] as $entry) { - $listItem = null; - $listNode = null; - foreach (self::MATCHED_FIELDS as $field => $listField) { + $listField = null; + $position = null; + foreach (self::MATCHED_FIELDS as $field => $matchedListField) { $expected = $ignoreError[$field] ?? null; if ($expected === null) { - if (array_key_exists($field, $entry['values']) || array_key_exists($listField, $entry['values'])) { + if (array_key_exists($field, $entry['values']) || array_key_exists($matchedListField, $entry['values'])) { continue 2; } continue; @@ -309,7 +387,7 @@ private function findEntries(array $ignoreError): array continue; } - $list = $entry['values'][$listField] ?? null; + $list = $entry['values'][$matchedListField] ?? null; if (!is_array($list)) { continue 2; } @@ -323,20 +401,14 @@ private function findEntries(array $ignoreError): array continue; } - $listNode = $entry['items'][$listField]->value; - if (!$listNode instanceof Expr\Array_ || count($listNode->items) !== count($list)) { - return []; - } - $position = array_search($index, array_keys($list), true); if ($position === false) { return []; } - - $listItem = $listNode->items[$position]; + $listField = $matchedListField; } - $found[] = [$entry, $listItem, $listNode]; + $found[] = [$entry, $listField, $position]; } return $found; @@ -357,12 +429,8 @@ private function getEntriesByPath(): array continue; } - // NEON config files are not fixed yet - if (!str_ends_with($configFile, '.php')) { - continue; - } - - foreach ($this->findPhpEntries($configFile) as $entry) { + $entries = str_ends_with($configFile, '.php') ? $this->findPhpEntries($configFile) : $this->findNeonEntries($configFile); + foreach ($entries as $entry) { $path = $entry['values']['path'] ?? null; if (!is_string($path)) { continue; @@ -406,11 +474,7 @@ private function findPhpEntries(string $file): array throw new ConstExprEvaluationException(); }); - $parameters = [ - 'rootDir' => $this->rootDir, - 'currentWorkingDirectory' => $this->currentWorkingDirectory, - 'env' => Environment::getCleanedArray(), - ]; + $parameters = $this->getParameters(); $removableNodes = []; $entries = []; @@ -459,6 +523,7 @@ private function findPhpEntries(string $file): array $entries[] = [ 'file' => $file, 'values' => $values, + 'format' => 'php', 'items' => $items, 'removableNode' => $removableNodes[spl_object_id($node)] ?? null, ]; @@ -467,6 +532,232 @@ private function findPhpEntries(string $file): array return $entries; } + /** + * Entries in the ignoreErrors parameter, which can be removed from its list. + * + * @return list + */ + private function findNeonEntries(string $file): array + { + try { + $code = FileReader::read($file); + $tokens = (new NeonLexer())->tokenize($code); + $root = (new NeonParser())->parse($tokens); + } catch (CouldNotReadFileException | NeonException) { + return []; + } + + $parametersItem = self::findNeonItem($root, 'parameters'); + if ($parametersItem === null) { + return []; + } + $ignoreErrorsItem = self::findNeonItem($parametersItem->value, 'ignoreErrors'); + if ($ignoreErrorsItem === null || !$ignoreErrorsItem->value instanceof NeonArrayNode) { + return []; + } + + $this->files[$file] = ['code' => $code, 'hash' => hash('sha256', $code)]; + $codeWithoutCr = str_replace("\r", '', $code); + $this->neonFiles[$file] = [ + 'tokens' => $tokens, + 'root' => $root, + 'codeWithoutCr' => $codeWithoutCr, + // nette/neon removes \r, it is put back when every line break had it + 'crlf' => $codeWithoutCr !== $code, + 'mixedLineBreaks' => $codeWithoutCr !== $code && str_replace("\n", "\r\n", $codeWithoutCr) !== $code, + ]; + + $parameters = $this->getParameters(); + // relative paths are resolved like when the config file is loaded + $adapter = new NeonAdapter(['[parameters][ignoreErrors][][path]', '[parameters][ignoreErrors][][paths][]'], $parameters); + $entries = []; + foreach ($ignoreErrorsItem->value->items as $entryItem) { + if (!$entryItem->value instanceof NeonArrayNode) { + continue; + } + + $items = []; + foreach ($entryItem->value->items as $item) { + $key = $item->key !== null ? $item->key->toValue() : null; + if (!is_string($key) || !in_array($key, self::ENTRY_KEYS, true)) { + continue 2; + } + + $items[$key] = $item; + } + + try { + $processed = $adapter->process(['parameters' => ['ignoreErrors' => [$entryItem->value->toValue()]]], '', $file); + // %% is escaped % in parameters, and other parameters can be used as well + $values = Helpers::expand($processed['parameters']['ignoreErrors'][0], $parameters); + } catch (InvalidArgumentException) { + continue; + } + + if (!is_array($values) || !isset($values['path']) || (isset($values['count']) && !is_int($values['count']))) { + continue; + } + + $entries[] = [ + 'file' => $file, + 'values' => $values, + 'format' => 'neon', + 'items' => $items, + 'path' => [$root, $parametersItem, $parametersItem->value, $ignoreErrorsItem, $ignoreErrorsItem->value, $entryItem], + 'entryValue' => $entryItem->value, + ]; + } + + return $entries; + } + + private static function findNeonItem(NeonNode $node, string $key): ?NeonArrayItemNode + { + if (!$node instanceof NeonArrayNode) { + return null; + } + + foreach ($node->items as $item) { + if ($item->key !== null && $item->key->toValue() === $key) { + return $item; + } + } + + return null; + } + + /** + * Prints the NEON file with the changes and turns it into one edit of its changed part. + * + * @param list, NeonNode|null}> $changes path from the root to the changed node, + * and its replacement, or null to remove it + * @return Edit|null + */ + private function createNeonEdit(string $file, array $changes): ?array + { + ['tokens' => $tokens, 'root' => $root, 'codeWithoutCr' => $codeWithoutCr, 'crlf' => $crlf, 'mixedLineBreaks' => $mixedLineBreaks] = $this->neonFiles[$file]; + if ($mixedLineBreaks) { + return null; + } + $newRoot = self::changeNeonTree($root, $changes); + if ($newRoot === null) { + return null; + } + + $printedChanges = $this->neonPrinter->printChanges($newRoot, $root, $tokens); + if ($printedChanges === []) { + return null; + } + + // one edit from the first change to the last one + $start = $printedChanges[0]['start']; + $replacement = ''; + $offset = $start; + foreach ($printedChanges as $printedChange) { + $replacement .= substr($codeWithoutCr, $offset, $printedChange['start'] - $offset) . $printedChange['replacement']; + $offset = $printedChange['end']; + } + $end = $offset; + if ($crlf) { + $start += substr_count($codeWithoutCr, "\n", length: $start); + $end += substr_count($codeWithoutCr, "\n", length: $end); + $replacement = str_replace("\n", "\r\n", $replacement); + } + + return [ + 'start' => $start, + 'end' => $end, + 'replacement' => $replacement, + 'line' => substr_count($this->files[$file]['code'], "\n", length: $start) + 1, + ]; + } + + /** + * Replaces the changed nodes and clones their ancestors, the original tree stays as it is. + * The clones keep the token positions of the original nodes, so the printer knows what they stand for. + * + * @param list, NeonNode|null}> $changes + */ + private static function changeNeonTree(NeonNode $root, array $changes): ?NeonNode + { + /** @var array $clones clones by spl_object_id() of the original node */ + $clones = []; + foreach ($changes as [$path, $replacement]) { + $target = array_pop($path); + if ($path === []) { + return null; + } + + $parentClones = []; + foreach ($path as $node) { + $parentClones[] = $clones[spl_object_id($node)] ??= clone $node; + } + $children = [...array_slice($parentClones, 1), $replacement]; + $originalChildren = [...array_slice($path, 1), $target]; + foreach ($parentClones as $i => $parentClone) { + $originalChild = $originalChildren[$i]; + if (!self::replaceNeonChild($parentClone, [$originalChild, $clones[spl_object_id($originalChild)] ?? $originalChild], $children[$i])) { + return null; + } + } + } + + return $clones[spl_object_id($root)] ?? $root; + } + + /** + * @param array{NeonNode, NeonNode} $oldChildren the original child and the node in its place + */ + private static function replaceNeonChild(NeonNode $parent, array $oldChildren, ?NeonNode $newChild): bool + { + if ($parent instanceof NeonArrayNode) { + foreach ($parent->items as $i => $item) { + if (!in_array($item, $oldChildren, true)) { + continue; + } + + if ($newChild === null) { + unset($parent->items[$i]); + $parent->items = array_values($parent->items); + return true; + } + if (!$newChild instanceof NeonArrayItemNode) { + return false; + } + + $parent->items[$i] = $newChild; + return true; + } + + return false; + } + + if ($parent instanceof NeonArrayItemNode && $newChild !== null) { + if (in_array($parent->value, $oldChildren, true)) { + $parent->value = $newChild; + return true; + } + if ($parent->key !== null && in_array($parent->key, $oldChildren, true)) { + $parent->key = $newChild; + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function getParameters(): array + { + return [ + 'rootDir' => $this->rootDir, + 'currentWorkingDirectory' => $this->currentWorkingDirectory, + 'env' => Environment::getCleanedArray(), + ]; + } + private function normalizePath(string $path): string { return $this->fileHelper->normalizePath($this->fileHelper->absolutizePath($path)); diff --git a/src/Command/AnalyseCommand.php b/src/Command/AnalyseCommand.php index 71e09a67492..114757add92 100644 --- a/src/Command/AnalyseCommand.php +++ b/src/Command/AnalyseCommand.php @@ -43,15 +43,18 @@ use Symfony\Component\Console\Output\StreamOutput; use Symfony\Component\Process\Process; use Throwable; +use function array_fill_keys; use function array_filter; use function array_intersect; use function array_key_exists; +use function array_keys; use function array_map; use function array_merge; use function array_reverse; use function array_values; use function count; use function dirname; +use function escapeshellarg; use function filesize; use function fopen; use function get_class; @@ -67,6 +70,7 @@ use function rewind; use function sprintf; use function str_contains; +use function str_starts_with; use function stream_get_contents; use function strlen; use function substr; @@ -98,6 +102,8 @@ final class AnalyseCommand extends Command private const OPTION_FIX_ROUND = 'fix-round'; + private const OPTION_FIXED_IGNORED_IN = 'fixed-ignored-in'; + /** * @param string[] $composerAutoloaderProjectPaths */ @@ -131,6 +137,7 @@ protected function configure(): void new InputOption('fix', mode: InputOption::VALUE_NONE, description: 'Fix auto-fixable errors (experimental)'), new InputOption('fix-ignored', mode: InputOption::VALUE_NONE, description: 'Fix auto-fixable ignored errors, can be combined with --fix (experimental)'), new InputOption(self::OPTION_FIX_ROUND, mode: InputOption::VALUE_NONE, description: '(Internal) Run one round of --fix and print its counts as JSON'), + new InputOption(self::OPTION_FIXED_IGNORED_IN, mode: InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, description: '(Internal) Files changed by the previous rounds of --fix-ignored'), new InputOption('watch', mode: InputOption::VALUE_NONE, description: 'Launch PHPStan Pro'), new InputOption('pro', mode: InputOption::VALUE_NONE, description: 'Launch PHPStan Pro'), new InputOption('fail-without-result-cache', mode: InputOption::VALUE_NONE, description: 'Return non-zero exit code when result cache is not used'), @@ -578,6 +585,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int foreach ($analysisResult->getFixableIgnoredErrors() as $fixableIgnoredError) { $fixableErrors[] = $fixableIgnoredError; } + if (!$fixReported) { + // a fixed ignored error leaves behind the ignore that matched it, like an unmatched baseline entry + // or a count that is too high, those are reported in the files the previous rounds changed + $fixedIgnoredIn = array_fill_keys($input->getOption(self::OPTION_FIXED_IGNORED_IN), true); + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + if ( + !$fileSpecificError->isFixable() + || !str_starts_with($fileSpecificError->getIdentifier() ?? '', 'ignore.') + || !isset($fixedIgnoredIn[$fileSpecificError->getFilePath()]) + ) { + continue; + } + + $fixableErrors[] = $fileSpecificError; + } + } } $errorFixer = $container->getByType(ErrorFixer::class); @@ -702,6 +725,7 @@ private function fixInRounds( (bool) $input->getOption(ErrorsConsoleStyle::OPTION_NO_PROGRESS) ? ['--' . ErrorsConsoleStyle::OPTION_NO_PROGRESS] : [], (bool) $input->getOption('fix') ? ['--fix'] : [], (bool) $input->getOption('fix-ignored') ? ['--fix-ignored'] : [], + array_map(static fn (string $file): string => '--' . self::OPTION_FIXED_IGNORED_IN . '=' . escapeshellarg($file), array_keys($changedFiles)), ), $input, ); diff --git a/src/Neon/Printer.php b/src/Neon/Printer.php new file mode 100644 index 00000000000..fa79a85f3c6 --- /dev/null +++ b/src/Neon/Printer.php @@ -0,0 +1,902 @@ +, tokens: list}> + */ + private array $streams = []; + + /** + * Prepared original trees by spl_object_id(), with the root node for the same reason. + * + * @var array, ends: array, indent: string}> + */ + private array $trees = []; + + private string $code = ''; + + private bool $useCopies = true; + + /** @var list byte offset of each token, and the length of the code at the end */ + private array $offsets = []; + + /** @var list */ + private array $tokens = []; + + /** @var array original nodes by class and position, false when ambiguous */ + private array $originals = []; + + /** @var array effective end token of original nodes by spl_object_id() */ + private array $ends = []; + + private string $indentUnit = self::DEFAULT_INDENT; + + /** @var Differ */ + private Differ $differ; + + public function __construct() + { + $this->differ = new Differ(fn (Node $a, Node $b): bool => $a === $b || $this->findOriginal($b) === $a); + } + + /** + * Prints $node, which is $originalNode or a changed copy of it. + * The original tree has to be parsed from $originalTokens, and stay unchanged. + */ + public function printFormatPreserving(Node $node, Node $originalNode, TokenStream $originalTokens): string + { + $pieces = Strings::split($this->printWithCopies($node, $originalNode, $originalTokens), self::COPY_PATTERN); + $result = ''; + for ($i = 0; $i < count($pieces); $i += 3) { + $result .= $pieces[$i]; + if (!isset($pieces[$i + 2])) { + continue; + } + + $result .= $this->textRange((int) $pieces[$i + 1], (int) $pieces[$i + 2]); + } + + return $result; + } + + /** + * Prints $node like printFormatPreserving(), as the changes of the original code. + * Unchanged code is not copied, so the time depends on the size of the changes, not of the code. + * + * @return list byte offsets in the original code without \r, + * the end is exclusive + */ + public function printChanges(Node $node, Node $originalNode, TokenStream $originalTokens): array + { + $pieces = Strings::split($this->printWithCopies($node, $originalNode, $originalTokens), self::COPY_PATTERN); + $changes = []; + $offset = 0; + $replacement = ''; + for ($i = 0; $i < count($pieces); $i += 3) { + $replacement .= $pieces[$i]; + if (!isset($pieces[$i + 2])) { + break; + } + + $from = (int) $pieces[$i + 1]; + $to = (int) $pieces[$i + 2]; + if ($this->offsets[$from] < $offset) { + // a moved node, the code before is already used + $replacement .= $this->textRange($from, $to); + continue; + } + + $this->addChange($changes, $offset, $this->offsets[$from], $replacement); + $offset = $this->offsets[$to + 1]; + $replacement = ''; + } + $this->addChange($changes, $offset, strlen($this->code), $replacement); + + return $changes; + } + + /** + * @param list $changes + */ + private function addChange(array &$changes, int $start, int $end, string $replacement): void + { + if ($end - $start === strlen($replacement) && substr($this->code, $start, $end - $start) === $replacement) { + return; + } + + $changes[] = ['start' => $start, 'end' => $end, 'replacement' => $replacement]; + } + + /** + * The printed code with unchanged ranges of the original code as references to its tokens, + * see copy(). Unless the code has the character of the references, then they are the code itself. + */ + private function printWithCopies(Node $node, Node $originalNode, TokenStream $originalTokens): string + { + $this->prepare($originalNode, $originalTokens); + + $start = $this->start($originalNode); + $end = $this->end($originalNode); + + $result = $this->copy(0, $start - 1) + . $this->p($node, $this->indentAt($start)) + . $this->copy($end + 1, count($this->tokens) - 1); + + $this->trees[spl_object_id($originalNode)]['ends'] = $this->ends; + + return $result; + } + + private function prepare(Node $originalNode, TokenStream $originalTokens): void + { + $streamId = spl_object_id($originalTokens); + if (!isset($this->streams[$streamId])) { + $tokens = array_values($originalTokens->getTokens()); + $offsets = []; + $offset = 0; + $code = ''; + foreach ($tokens as $token) { + $offsets[] = $offset; + $offset += strlen($token->value); + $code .= $token->value; + } + $offsets[] = $offset; + $this->streams[$streamId] = ['stream' => $originalTokens, 'code' => $code, 'offsets' => $offsets, 'tokens' => $tokens]; + } + + ['code' => $this->code, 'offsets' => $this->offsets, 'tokens' => $this->tokens] = $this->streams[$streamId]; + $this->useCopies = !str_contains($this->code, self::COPY); + + $treeId = spl_object_id($originalNode); + if (!isset($this->trees[$treeId])) { + $this->originals = []; + $this->ends = []; + $this->indentUnit = ''; + $this->indexOriginals($originalNode); + $this->trees[$treeId] = [ + 'root' => $originalNode, + 'originals' => $this->originals, + 'ends' => [], + 'indent' => $this->indentUnit !== '' ? $this->indentUnit : self::DEFAULT_INDENT, + ]; + } + + $tree = $this->trees[$treeId]; + $this->originals = $tree['originals']; + $this->ends = $tree['ends']; + $this->indentUnit = $tree['indent']; + } + + private function indexOriginals(Node $node): void + { + $key = self::getKey($node); + if ($key !== null) { + $this->originals[$key] = isset($this->originals[$key]) ? false : $node; + } + if ($this->indentUnit === '' && $node instanceof BlockArrayNode && $node->indentation !== '' && $key !== null) { + $this->indentUnit = $node->indentation; + } + + foreach ($node as $child) { + $this->indexOriginals($child); + } + } + + private static function getKey(Node $node): ?string + { + if ($node->startTokenPos === null || $node->endTokenPos === null) { + return null; + } + + return get_class($node) . ':' . $node->startTokenPos . ':' . $node->endTokenPos; + } + + private function findOriginal(Node $node): ?Node + { + $key = self::getKey($node); + if ($key === null) { + return null; + } + + $original = $this->originals[$key] ?? false; + + return $original === false ? null : $original; + } + + /** + * @param string $indent indentation of the line the node starts on + */ + private function p(Node $node, string $indent): string + { + $original = $this->findOriginal($node); + if ($original === null) { + return $this->printNew($node, $indent); + } + if ($node === $original) { + return $this->text($original); + } + + if ($node instanceof LiteralNode && $original instanceof LiteralNode) { + if ( + $node->value === $original->value + || ( + $node->value instanceof DateTimeInterface + && $original->value instanceof DateTimeInterface + && $node->value->format(self::DATE_FORMAT) === $original->value->format(self::DATE_FORMAT) + ) + ) { + return $this->text($original); + } + + return $this->printNew($node, $indent); + } + + if ($node instanceof StringNode && $original instanceof StringNode) { + return $node->value === $original->value ? $this->text($original) : $this->printNew($node, $indent); + } + + if ($node instanceof ArrayItemNode && $original instanceof ArrayItemNode) { + return $this->printItem($node, $original, $indent); + } + + if ($node instanceof BlockArrayNode && $original instanceof BlockArrayNode) { + return $this->printBlockItems($node, $original); + } + + if ($node instanceof InlineArrayNode && $original instanceof InlineArrayNode) { + if ($node->bracket !== $original->bracket) { + return $this->printNew($node, $indent); + } + + return $this->printInlineItems($node->items, $original->items, $original->startTokenPos ?? 0, $original->endTokenPos ?? 0, $indent); + } + + if ($node instanceof EntityNode && $original instanceof EntityNode) { + $open = $this->end($original->value) + 1; + while (isset($this->tokens[$open]) && $this->tokens[$open]->type !== '(') { + $open++; + } + + return $this->copy($this->start($original), $this->start($original->value) - 1) + . $this->p($node->value, $indent) + . $this->copy($this->end($original->value) + 1, $open - 1) + . $this->printInlineItems($node->attributes, $original->attributes, $open, $original->endTokenPos ?? 0, $indent); + } + + if ($node instanceof EntityChainNode && $original instanceof EntityChainNode) { + if (count($node->chain) !== count($original->chain)) { + return $this->printNew($node, $indent); + } + + $pairs = []; + foreach (array_values($original->chain) as $i => $originalEntity) { + $pairs[] = [$originalEntity, array_values($node->chain)[$i]]; + } + + return $this->splice($original, $pairs); + } + + return $this->printNew($node, $indent); + } + + private function printItem(ArrayItemNode $node, ArrayItemNode $original, string $indent): string + { + if (($node->key === null) !== ($original->key === null)) { + return $this->printNew($node, $indent); + } + + $head = $node->key !== null ? $this->p($node->key, $indent) . ':' : '-'; + $originalValue = $original->value; + $value = $node->value; + if ($this->isEmptyValue($originalValue)) { + if ($value === $originalValue || $this->findOriginal($value) === $originalValue) { + return $this->text($original); + } + + return $head . $this->printValueAfterHead($value, $indent); + } + + $isOriginalBlock = $originalValue instanceof BlockArrayNode; + $isBlock = $value instanceof BlockArrayNode && count($value->items) > 0; + if ($isOriginalBlock !== $isBlock) { + return $head . $this->printValueAfterHead($value, $indent); + } + + $pairs = []; + if ($node->key !== null && $original->key !== null) { + $pairs[] = [$original->key, $node->key]; + } + $pairs[] = [$originalValue, $value]; + + return $this->splice($original, $pairs); + } + + /** + * Prints the original node with its children replaced. + * + * @param list $pairs original child and its replacement, in the order of the source + */ + private function splice(Node $original, array $pairs): string + { + $result = ''; + $pos = $this->start($original); + foreach ($pairs as [$originalChild, $child]) { + $childStart = $this->start($originalChild); + $result .= $this->copy($pos, $childStart - 1) . $this->p($child, $this->indentAt($childStart)); + $pos = $this->end($originalChild) + 1; + } + + return $result . $this->copy($pos, $this->end($original)); + } + + /** + * Every item of a block array is on its own lines. The comment at the end of the last line of an item + * belongs to the item, and so do the lines between the previous item and the item, like comments. + */ + private function printBlockItems(BlockArrayNode $node, BlockArrayNode $original): string + { + $items = array_values($node->items); + if ($items === []) { + return '[]'; + } + + $originalItems = array_values($original->items); + $itemIndent = $this->getBlockItemIndent($originalItems); + + $result = ''; + $first = true; + $runStart = null; + $runEnd = null; + foreach ($this->diff($originalItems, $items) as [$item, $i, $last]) { + if ($i !== null) { + $originalItem = $originalItems[$i]; + if ($item === $originalItem && $runEnd !== null && $runEnd === $i - 1) { + // unchanged items next to each other are printed with the text between them in one go + $runEnd = $last; + continue; + } + + $result .= $this->printBlockRun($originalItems, $runStart, $runEnd); + $runStart = $runEnd = null; + + if ($first) { + // the indentation of the first item is before the array, and empty lines above it are dropped + $result .= ltrim($this->getBlockItemLead($original, $originalItems, $i, $itemIndent), " \t\n"); + } else { + $result .= "\n" . $this->getBlockItemLead($original, $originalItems, $i, $itemIndent); + } + + if ($item === $originalItem) { + $runStart = $i; + $runEnd = $last; + } else { + $result .= $this->p($item, $itemIndent) . $this->getBlockItemTrail($originalItems, $i); + } + $first = false; + continue; + } + + $result .= $this->printBlockRun($originalItems, $runStart, $runEnd); + $runStart = $runEnd = null; + if (!$first) { + $result .= "\n" . $itemIndent; + } + $result .= $this->printListItem($item, $itemIndent, false); + $first = false; + } + + return $result . $this->printBlockRun($originalItems, $runStart, $runEnd); + } + + /** + * @param list $originalItems + */ + private function printBlockRun(array $originalItems, ?int $runStart, ?int $runEnd): string + { + if ($runStart === null || $runEnd === null) { + return ''; + } + + return $this->copy($originalItems[$runStart]->startTokenPos ?? 0, $this->end($originalItems[$runEnd])) + . $this->getBlockItemTrail($originalItems, $runEnd); + } + + /** + * The comment and whitespace after the item on its last line. + * + * @param list $originalItems + */ + private function getBlockItemTrail(array $originalItems, int $i): string + { + $start = $this->end($originalItems[$i]) + 1; + + return $this->textRange($start, $this->findLineBreak($start) - 1); + } + + /** + * The lines before the item after the line break of the previous item, like empty lines and comments, + * with the indentation of the item. + * + * @param list $originalItems + * @param int<0, max> $i + */ + private function getBlockItemLead(BlockArrayNode $original, array $originalItems, int $i, string $itemIndent): string + { + if ($i === 0) { + // the indentation of the comments above the first item is before the array + return $itemIndent . $this->textRange($this->start($original), $this->start($originalItems[0]) - 1); + } + + $lineBreak = $this->findLineBreak($this->end($originalItems[$i - 1]) + 1); + + // the lexer puts the empty lines into the token of the line break + return substr($this->tokens[$lineBreak]->value, 1) . $this->textRange($lineBreak + 1, $this->start($originalItems[$i]) - 1); + } + + /** + * Position of the first line break from $pos, or the end of the code. + */ + private function findLineBreak(int $pos): int + { + while (isset($this->tokens[$pos]) && $this->tokens[$pos]->type !== Token::Newline) { + $pos++; + } + + return $pos; + } + + /** + * @param list $originalItems + */ + private function getBlockItemIndent(array $originalItems): string + { + $first = $this->start($originalItems[0]); + if ($this->isFirstOnLine($first)) { + return $this->indentAt($first); + } + if (isset($originalItems[1])) { + return $this->indentAt($this->start($originalItems[1])); + } + + // a block opened on the line of a dash, like `- key: value` + return $this->indentAt($first) . ' '; + } + + /** + * Items of an inline array, like `[a, b]`, and attributes of an entity, like `Foo(a, b)`. + * + * @param array $items + * @param array $originalItems + * @param int $open position of the opening bracket + * @param int $close position of the closing bracket + */ + private function printInlineItems(array $items, array $originalItems, int $open, int $close, string $indent): string + { + $items = array_values($items); + $originalItems = array_values($originalItems); + $openText = $this->textRange($open, $open); + $closeText = $this->textRange($close, $close); + if ($items === []) { + return $openText . $closeText; + } + if ($originalItems === []) { + $printed = []; + foreach ($items as $item) { + $printed[] = $this->printListItem($item, $indent, true); + } + + return $openText . implode(', ', $printed) . $closeText; + } + + $defaultSeparator = isset($originalItems[1]) ? $this->getInlineSeparator($originalItems, 1) : ', '; + + $result = $this->textRange($open, $this->start($originalItems[0]) - 1); + $first = true; + $previous = null; + $runStart = null; + $runEnd = null; + foreach ($this->diff($originalItems, $items) as [$item, $i, $last]) { + if ($i !== null) { + $originalItem = $originalItems[$i]; + if ($item === $originalItem && $runEnd !== null && $runEnd === $i - 1) { + $runEnd = $last; + $previous = $last; + continue; + } + + $result .= $this->printInlineRun($originalItems, $runStart, $runEnd); + $runStart = $runEnd = null; + if (!$first) { + if ($previous !== null) { + // the separator after an item has its trailing comment in a multi-line array + $result .= $this->getInlineSeparator($originalItems, $previous + 1); + } else { + $result .= $i > 0 ? $this->getInlineSeparator($originalItems, $i) : $defaultSeparator; + } + } + $previous = $i; + if ($item === $originalItem) { + $runStart = $i; + $runEnd = $previous = $last; + } else { + $result .= $this->p($item, $indent); + } + $first = false; + continue; + } + + $result .= $this->printInlineRun($originalItems, $runStart, $runEnd); + $runStart = $runEnd = null; + if (!$first) { + $result .= $defaultSeparator; + } + $result .= $this->printListItem($item, $indent, true); + $previous = null; + $first = false; + } + + return $result + . $this->printInlineRun($originalItems, $runStart, $runEnd) + . $this->textRange($this->end($originalItems[count($originalItems) - 1]) + 1, $close); + } + + /** + * @param list $originalItems + */ + private function printInlineRun(array $originalItems, ?int $runStart, ?int $runEnd): string + { + if ($runStart === null || $runEnd === null) { + return ''; + } + + return $this->copy($this->start($originalItems[$runStart]), $this->end($originalItems[$runEnd])); + } + + /** + * @param list $originalItems + */ + private function getInlineSeparator(array $originalItems, int $i): string + { + return $this->textRange($this->end($originalItems[$i - 1]) + 1, $this->start($originalItems[$i]) - 1); + } + + /** + * Kept items with their original index, and added items without it. Unchanged items at the start + * and at the end are common, like after removing one item from a long list. They are not left + * to the differ, and each of the two runs is one element with the index of its last item, + * so that the time does not grow with the length of the list. + * + * @param list $old + * @param list $new + * @return list, int<0, max>}|array{Node, null, null}> + */ + private function diff(array $old, array $new): array + { + $oldCount = count($old); + $newCount = count($new); + $minCount = $oldCount < $newCount ? $oldCount : $newCount; + $startCount = 0; + while ($startCount < $minCount && $old[$startCount] === $new[$startCount]) { + $startCount++; + } + $endCount = 0; + while ($endCount < $minCount - $startCount && $old[$oldCount - 1 - $endCount] === $new[$newCount - 1 - $endCount]) { + $endCount++; + } + + $result = []; + if ($startCount > 0) { + $result[] = [$new[0], 0, $startCount - 1]; + } + $i = $startCount; + $j = $startCount; + foreach ($this->differ->diff( + array_slice($old, $startCount, $oldCount - $startCount - $endCount), + array_slice($new, $startCount, $newCount - $startCount - $endCount), + ) as $diffElem) { + if ($diffElem->type === DiffElem::TYPE_REMOVE) { + $i++; + continue; + } + if ($diffElem->type === DiffElem::TYPE_ADD) { + $result[] = [$new[$j], null, null]; + } else { + $result[] = [$new[$j], $i, $i]; + $i++; + } + $j++; + } + if ($endCount > 0) { + $result[] = [$new[$j], $i, $i + $endCount - 1]; + } + + return $result; + } + + private function printListItem(Node $item, string $indent, bool $inline): string + { + if (!$item instanceof ArrayItemNode || $this->findOriginal($item) !== null) { + return $this->p($item, $indent); + } + + if ($inline) { + return ($item->key !== null ? $this->p($item->key, $indent) . ': ' : '') . $this->p($item->value, $indent); + } + + return ($item->key !== null ? $this->p($item->key, $indent) . ':' : '-') . $this->printValueAfterHead($item->value, $indent); + } + + /** + * The value of a block item after its key and colon, or after its dash. + */ + private function printValueAfterHead(Node $value, string $indent): string + { + if ($value instanceof BlockArrayNode) { + if (count($value->items) === 0) { + return ' []'; + } + + $childIndent = $indent . ($value->indentation !== '' ? $value->indentation : $this->indentUnit); + + return "\n" . $childIndent . $this->p($value, $childIndent); + } + + return ' ' . $this->p($value, $indent); + } + + private function printNew(Node $node, string $indent): string + { + if ($node instanceof BlockArrayNode) { + if (count($node->items) === 0) { + return '[]'; + } + + $printed = []; + foreach ($node->items as $item) { + $printed[] = $this->printListItem($item, $indent, false); + } + + return implode("\n" . $indent, $printed); + } + + if ($node instanceof ArrayItemNode) { + return $this->printListItem($node, $indent, false); + } + + if ($node instanceof InlineArrayNode) { + $printed = []; + foreach ($node->items as $item) { + $printed[] = $this->printListItem($item, $indent, true); + } + + return $node->bracket . implode(', ', $printed) . (['[' => ']', '{' => '}', '(' => ')'][$node->bracket] ?? ']'); + } + + if ($node instanceof EntityNode) { + $printed = []; + foreach ($node->attributes as $item) { + $printed[] = $this->printListItem($item, $indent, true); + } + + return $this->p($node->value, $indent) . '(' . implode(', ', $printed) . ')'; + } + + if ($node instanceof EntityChainNode) { + $result = ''; + foreach ($node->chain as $entity) { + $result .= $this->p($entity, $indent); + } + + return $result; + } + + return $node->toString(); + } + + /** + * A key without a value, like `key:`, has a null value positioned at the token after the colon. + */ + private function isEmptyValue(Node $node): bool + { + return $node instanceof LiteralNode + && $node->value === null + && $node->startTokenPos !== null + && ($this->tokens[$node->startTokenPos]->type ?? null) !== Token::Literal; + } + + /** + * The first token of the original node. A block array starts with the comment lines right above its first item, + * they belong to the item like the lines between two items belong to the second one. + */ + private function start(Node $original): int + { + $start = $original->startTokenPos ?? 0; + if (!$original instanceof BlockArrayNode || $original->items === [] || !$this->isFirstOnLine($start)) { + return $start; + } + + $pos = $start - 1; + if ($pos >= 0 && $this->tokens[$pos]->type === Token::Whitespace) { + $pos--; + } + // an empty line separates the comments above it, like a header of the file + while ($pos > 0 && $this->tokens[$pos]->type === Token::Newline && $this->tokens[$pos]->value === "\n") { + $commentPos = $pos - 1; + if ($this->tokens[$commentPos]->type !== Token::Comment || !$this->isFirstOnLine($commentPos)) { + break; + } + + $start = $commentPos; + $pos = $commentPos - 1; + if ($pos < 0 || $this->tokens[$pos]->type !== Token::Whitespace) { + continue; + } + + $pos--; + } + + return $start; + } + + /** + * The last token of the original node. A node ending with an empty value ends before it, + * its end position is after it. A block array ends at the end of its last line. + */ + private function end(Node $original): int + { + $id = spl_object_id($original); + if (isset($this->ends[$id])) { + return $this->ends[$id]; + } + + if ($this->isEmptyValue($original)) { + $end = $this->start($original) - 1; + } elseif ($original instanceof ArrayItemNode) { + if ($this->isEmptyValue($original->value)) { + $end = $this->start($original->value) - 1; + while ($end > 0 && in_array($this->tokens[$end]->type, self::TRIVIA_TOKENS, true)) { + $end--; + } + } else { + $end = $this->end($original->value); + } + } elseif ($original instanceof BlockArrayNode && $original->items !== []) { + // the comment on the last line belongs to the last item, not to what contains the array + $items = array_values($original->items); + $end = $this->findLineBreak($this->end($items[count($items) - 1]) + 1) - 1; + } elseif ($original instanceof EntityChainNode && $original->chain !== []) { + $chain = array_values($original->chain); + $end = $this->end($chain[count($chain) - 1]); + } else { + $end = $original->endTokenPos ?? 0; + } + + return $this->ends[$id] = $end; + } + + /** + * The unchanged original node, see copy(). + */ + private function text(Node $original): string + { + return $this->copy($this->start($original), $this->end($original)); + } + + /** + * Unchanged code of the tokens from $from to $to, inclusive, to be put into the printed code as it is. + * The text is not there yet, use textRange() to look at it. + */ + private function copy(int $from, int $to): string + { + if ($to < $from) { + return ''; + } + if (!$this->useCopies) { + return $this->textRange($from, $to); + } + + return self::COPY . $from . '-' . $to . self::COPY; + } + + /** + * Code of the tokens from $from to $to, inclusive. + */ + private function textRange(int $from, int $to): string + { + if ($to < $from) { + return ''; + } + + return substr($this->code, $this->offsets[$from], $this->offsets[$to + 1] - $this->offsets[$from]); + } + + private function isFirstOnLine(int $pos): bool + { + $pos--; + if ($pos >= 0 && $this->tokens[$pos]->type === Token::Whitespace) { + $pos--; + } + + return $pos < 0 || $this->tokens[$pos]->type === Token::Newline; + } + + /** + * Indentation of the line of the token. + */ + private function indentAt(int $pos): string + { + $pos--; + while ($pos >= 0 && $this->tokens[$pos]->type !== Token::Newline) { + $pos--; + } + + $lineStart = $this->tokens[$pos + 1] ?? null; + if ($lineStart === null || $lineStart->type !== Token::Whitespace) { + return ''; + } + + return $lineStart->value; + } + +} diff --git a/tests/PHPStan/Analyser/Ignore/IgnoreErrorsConfigFixerTest.php b/tests/PHPStan/Analyser/Ignore/IgnoreErrorsConfigFixerTest.php index 6c37694a3c7..0f8c91ddf65 100644 --- a/tests/PHPStan/Analyser/Ignore/IgnoreErrorsConfigFixerTest.php +++ b/tests/PHPStan/Analyser/Ignore/IgnoreErrorsConfigFixerTest.php @@ -2,12 +2,14 @@ namespace PHPStan\Analyser\Ignore; +use Closure; use Generator; use Override; use PHPStan\Analyser\FixedErrorDiff; use PHPStan\Dependency\PackageDependencyResolver; use PHPStan\File\FileHelper; use PHPStan\Fixable\Patcher; +use PHPStan\Neon\Printer as NeonPrinter; use PHPStan\Testing\PHPStanTestCase; use PHPUnit\Framework\Attributes\DataProvider; use function file_put_contents; @@ -214,17 +216,104 @@ public function testNotFixable(string $code, array $ignoreError, bool $lowerCoun $this->assertSame([], $lowerCount ? $fixer->lowerCount($ignoreError, 1) : $fixer->removeUnmatched($ignoreError)); } - public function testNeonIsNotFixed(): void + public static function dataNeon(): Generator { - $file = __DIR__ . '/' . uniqid('baseline-') . '.neon'; - file_put_contents($file, "parameters:\n\tignoreErrors:\n\t\t-\n\t\t\tmessage: '#^A$#'\n\t\t\tpath: Foo.php\n"); - try { - $fixer = $this->createFixerForFiles([$file]); - $path = __DIR__ . '/Foo.php'; - $this->assertSame([], $fixer->removeUnmatched(['message' => '#^A$#', 'path' => $path, 'realPath' => $path])); - } finally { - @unlink($file); - } + $entries = "parameters:\n\tignoreErrors:\n" + . "\t\t# A\n\t\t-\n\t\t\tmessage: '#^A$#'\n\t\t\tcount: 5\n\t\t\tpath: Foo.php\n" + . "\n\t\t-\n\t\t\tmessage: '#^100%% sure$#' # percent\n\t\t\tpath: Foo.php\n" + . "\n\t\t-\n\t\t\tmessages:\n\t\t\t\t- '#^C$#'\n\t\t\t\t- '#^D$#'\n\t\t\tpath: Foo.php\n" + . "\tlevel: 8\n"; + + yield 'entry with the comment above it' => [ + $entries, + static fn (IgnoreErrorsConfigFixer $fixer, string $path) => $fixer->removeUnmatched(['message' => '#^A$#', 'count' => 5, 'path' => $path, 'realPath' => $path]), + "parameters:\n\tignoreErrors:\n" + . "\t\t-\n\t\t\tmessage: '#^100%% sure$#' # percent\n\t\t\tpath: Foo.php\n" + . "\n\t\t-\n\t\t\tmessages:\n\t\t\t\t- '#^C$#'\n\t\t\t\t- '#^D$#'\n\t\t\tpath: Foo.php\n" + . "\tlevel: 8\n", + ]; + + yield 'escaped percent sign in the message' => [ + $entries, + static fn (IgnoreErrorsConfigFixer $fixer, string $path) => $fixer->removeUnmatched(['message' => '#^100% sure$#', 'path' => $path, 'realPath' => $path]), + "parameters:\n\tignoreErrors:\n" + . "\t\t# A\n\t\t-\n\t\t\tmessage: '#^A$#'\n\t\t\tcount: 5\n\t\t\tpath: Foo.php\n" + . "\n\t\t-\n\t\t\tmessages:\n\t\t\t\t- '#^C$#'\n\t\t\t\t- '#^D$#'\n\t\t\tpath: Foo.php\n" + . "\tlevel: 8\n", + ]; + + yield 'item of a list' => [ + $entries, + static fn (IgnoreErrorsConfigFixer $fixer, string $path) => $fixer->removeUnmatched(['message' => '#^D$#', 'path' => $path, 'realPath' => $path]), + "parameters:\n\tignoreErrors:\n" + . "\t\t# A\n\t\t-\n\t\t\tmessage: '#^A$#'\n\t\t\tcount: 5\n\t\t\tpath: Foo.php\n" + . "\n\t\t-\n\t\t\tmessage: '#^100%% sure$#' # percent\n\t\t\tpath: Foo.php\n" + . "\n\t\t-\n\t\t\tmessages:\n\t\t\t\t- '#^C$#'\n\t\t\tpath: Foo.php\n" + . "\tlevel: 8\n", + ]; + + yield 'lower count' => [ + $entries, + static fn (IgnoreErrorsConfigFixer $fixer, string $path) => $fixer->lowerCount(['message' => '#^A$#', 'count' => 5, 'path' => $path, 'realPath' => $path], 2), + "parameters:\n\tignoreErrors:\n" + . "\t\t# A\n\t\t-\n\t\t\tmessage: '#^A$#'\n\t\t\tcount: 2\n\t\t\tpath: Foo.php\n" + . "\n\t\t-\n\t\t\tmessage: '#^100%% sure$#' # percent\n\t\t\tpath: Foo.php\n" + . "\n\t\t-\n\t\t\tmessages:\n\t\t\t\t- '#^C$#'\n\t\t\t\t- '#^D$#'\n\t\t\tpath: Foo.php\n" + . "\tlevel: 8\n", + ]; + + yield 'only entry' => [ + "parameters:\n\tignoreErrors:\n\t\t-\n\t\t\tidentifier: foo.bar\n\t\t\tpath: Foo.php\n", + static fn (IgnoreErrorsConfigFixer $fixer, string $path) => $fixer->removeUnmatched(['identifier' => 'foo.bar', 'path' => $path, 'realPath' => $path]), + "parameters:\n\tignoreErrors: []\n", + ]; + + yield 'CRLF line breaks' => [ + "parameters:\r\n\tignoreErrors:\r\n\t\t-\r\n\t\t\tmessage: '#^A$#'\r\n\t\t\tpath: Foo.php\r\n\tlevel: 8\r\n", + static fn (IgnoreErrorsConfigFixer $fixer, string $path) => $fixer->removeUnmatched(['message' => '#^A$#', 'path' => $path, 'realPath' => $path]), + "parameters:\r\n\tignoreErrors: []\r\n\tlevel: 8\r\n", + ]; + } + + /** + * @param Closure(IgnoreErrorsConfigFixer, string): list $fix + */ + #[DataProvider('dataNeon')] + public function testNeon(string $code, Closure $fix, string $expected): void + { + [$fixer, $file] = $this->createFixer($code, 'neon'); + + $this->assertSame($expected, $this->apply($file, $code, $fix($fixer, __DIR__ . '/Foo.php'))); + } + + public function testNeonCountOfDuplicateEntriesIsNotLowered(): void + { + [$fixer] = $this->createFixer( + "parameters:\n\tignoreErrors:\n\t\t-\n\t\t\tmessage: '#^A$#'\n\t\t\tcount: 2\n\t\t\tpath: Foo.php\n\t\t-\n\t\t\tmessage: '#^A$#'\n\t\t\tcount: 3\n\t\t\tpath: Foo.php\n", + 'neon', + ); + $path = __DIR__ . '/Foo.php'; + + $this->assertSame([], $fixer->lowerCount(['message' => '#^A$#', 'count' => 5, 'path' => $path, 'realPath' => $path], 1)); + } + + public function testNeonRemovingAllItemsOfListTakesTwoRounds(): void + { + $code = "parameters:\n\tignoreErrors:\n\t\t-\n\t\t\tmessages:\n\t\t\t\t- '#^A$#'\n\t\t\t\t- '#^B$#'\n\t\t\tpath: Foo.php\n\t\t-\n\t\t\tmessage: '#^C$#'\n\t\t\tpath: Foo.php\n\t\t-\n\t\t\tmessage: '#^D$#'\n\t\t\tpath: Foo.php\n"; + [$fixer, $file] = $this->createFixer($code, 'neon'); + $path = __DIR__ . '/Foo.php'; + + $result = self::getContainer()->getByType(Patcher::class)->apply([$file => $code], [ + $fixer->removeUnmatched(['message' => '#^A$#', 'path' => $path, 'realPath' => $path]), + $fixer->removeUnmatched(['message' => '#^B$#', 'path' => $path, 'realPath' => $path]), + $fixer->removeUnmatched(['message' => '#^C$#', 'path' => $path, 'realPath' => $path]), + $fixer->removeUnmatched(['message' => '#^D$#', 'path' => $path, 'realPath' => $path]), + ]); + $this->assertSame(2, $result['appliedCount']); + $this->assertSame( + "parameters:\n\tignoreErrors:\n\t\t-\n\t\t\tmessages:\n\t\t\t\t- '#^B$#'\n\t\t\tpath: Foo.php\n\t\t-\n\t\t\tmessage: '#^D$#'\n\t\t\tpath: Foo.php\n", + $result['contents'][$file], + ); } /** @@ -232,9 +321,9 @@ public function testNeonIsNotFixed(): void * * @return array{IgnoreErrorsConfigFixer, string} */ - private function createFixer(string $code): array + private function createFixer(string $code, string $extension = 'php'): array { - $file = __DIR__ . '/' . uniqid('baseline-') . '.php'; + $file = __DIR__ . '/' . uniqid('baseline-') . '.' . $extension; file_put_contents($file, $code); $this->filesToDelete[] = $file; @@ -264,6 +353,7 @@ private function createFixerForFiles(array $configFiles): IgnoreErrorsConfigFixe return new IgnoreErrorsConfigFixer( $container->getService('currentPhpVersionPhpParser'), $container->getByType(PackageDependencyResolver::class), + new NeonPrinter(), $container->getByType(FileHelper::class), $configFiles, sys_get_temp_dir(), diff --git a/tests/PHPStan/Neon/PrinterTest.php b/tests/PHPStan/Neon/PrinterTest.php new file mode 100644 index 00000000000..520d3cb7898 --- /dev/null +++ b/tests/PHPStan/Neon/PrinterTest.php @@ -0,0 +1,201 @@ +items = array_values(array_filter($node->items, static fn (ArrayItemNode $item): bool => !$predicate($item->value->toValue()))); + } + return null; + }; + } + + /** + * @return Closure(Node): ?Node + */ + private static function replaceLiteral(mixed $old, mixed $new): Closure + { + return static fn (Node $node): ?Node => $node instanceof LiteralNode && $node->value === $old ? new LiteralNode($new) : null; + } + + private static function item(?string $key, Node $value): ArrayItemNode + { + $item = new ArrayItemNode(); + $item->key = $key !== null ? new LiteralNode($key) : null; + $item->value = $value; + + return $item; + } + + public static function dataPrintFormatPreserving(): Generator + { + $noChange = static fn (Node $node) => null; + + yield 'unchanged' => [ + "# header\nparameters:\n\tlevel: 8 # max\n\tpaths: [src, tests]\n\n\tfoo: Foo( 1 , 2 )\n", + $noChange, + "# header\nparameters:\n\tlevel: 8 # max\n\tpaths: [src, tests]\n\n\tfoo: Foo( 1 , 2 )\n", + ]; + + $entries = "parameters:\n\tignoreErrors:\n" + . "\t\t# first\n\t\t-\n\t\t\tmessage: '#^A$#'\n\t\t\tpath: A.php\n" + . "\n\t\t# second\n\t\t-\n\t\t\tmessage: '#^B$#' # b\n\t\t\tpath: B.php\n" + . "\t\t-\n\t\t\tmessage: '#^C$#'\n\t\t\tpath: C.php\n" + . "\tlevel: 8\n"; + $isEntry = static fn (string $message) => static fn (mixed $value): bool => is_array($value) && ($value['message'] ?? null) === $message; + + yield 'first block item with the comments above it' => [ + $entries, + self::removeItems($isEntry('#^A$#')), + "parameters:\n\tignoreErrors:\n" + . "\t\t# second\n\t\t-\n\t\t\tmessage: '#^B$#' # b\n\t\t\tpath: B.php\n" + . "\t\t-\n\t\t\tmessage: '#^C$#'\n\t\t\tpath: C.php\n" + . "\tlevel: 8\n", + ]; + + yield 'middle block item with the lines above it' => [ + $entries, + self::removeItems($isEntry('#^B$#')), + "parameters:\n\tignoreErrors:\n" + . "\t\t# first\n\t\t-\n\t\t\tmessage: '#^A$#'\n\t\t\tpath: A.php\n" + . "\t\t-\n\t\t\tmessage: '#^C$#'\n\t\t\tpath: C.php\n" + . "\tlevel: 8\n", + ]; + + yield 'last block item' => [ + $entries, + self::removeItems($isEntry('#^C$#')), + "parameters:\n\tignoreErrors:\n" + . "\t\t# first\n\t\t-\n\t\t\tmessage: '#^A$#'\n\t\t\tpath: A.php\n" + . "\n\t\t# second\n\t\t-\n\t\t\tmessage: '#^B$#' # b\n\t\t\tpath: B.php\n" + . "\tlevel: 8\n", + ]; + + yield 'all block items' => [ + $entries, + self::removeItems(static fn (mixed $value): bool => is_array($value) && isset($value['message'])), + "parameters:\n\tignoreErrors: []\n\tlevel: 8\n", + ]; + + yield 'item of a block list in a block opened on the line of a dash' => [ + "- messages:\n - a\n - b # b\n path: A.php\n", + self::removeItems(static fn (mixed $value): bool => $value === 'b'), + "- messages:\n - a\n path: A.php\n", + ]; + + yield 'scalar with a trailing comment' => [ + "count: 2 # two\nlevel: 2\n", + self::replaceLiteral(2, 1), + "count: 1 # two\nlevel: 1\n", + ]; + + yield 'first inline item' => [ + "paths: [a, b, c] # paths\n", + self::removeItems(static fn (mixed $value): bool => $value === 'a'), + "paths: [b, c] # paths\n", + ]; + + yield 'last inline item' => [ + "paths: [a, b, c] # paths\n", + self::removeItems(static fn (mixed $value): bool => $value === 'c'), + "paths: [a, b] # paths\n", + ]; + + yield 'item of a multi-line inline list' => [ + "paths: [\n\ta, # a\n\tb,\n\tc,\n]\n", + self::removeItems(static fn (mixed $value): bool => $value === 'b'), + "paths: [\n\ta, # a\n\tc,\n]\n", + ]; + + yield 'entity attribute' => [ + "services:\n\t- Foo( 1 , 2 )\n", + static function (Node $node) { + if ($node instanceof EntityNode) { + $node->attributes = [$node->attributes[0]]; + } + return null; + }, + "services:\n\t- Foo( 1 )\n", + ]; + + yield 'new block item with the indentation of the others' => [ + "paths:\n - a\nlevel: 8\n", + static function (Node $node) { + if ($node instanceof BlockArrayNode && $node->items[0]->key === null) { + $node->items[] = self::item(null, new StringNode('b c')); + } + return null; + }, + "paths:\n - a\n - 'b c'\nlevel: 8\n", + ]; + + yield 'new block value of an empty key' => [ + "parameters:\n\tpaths:\n\tlevel: 8\n", + static function (Node $node) { + if ($node instanceof ArrayItemNode && $node->key instanceof LiteralNode && $node->key->value === 'paths') { + $list = new BlockArrayNode(); + $list->items = [self::item(null, new LiteralNode('a')), self::item(null, new LiteralNode('b'))]; + $node->value = $list; + } + return null; + }, + "parameters:\n\tpaths:\n\t\t- a\n\t\t- b\n\tlevel: 8\n", + ]; + } + + /** + * @param Closure(Node): ?Node $change called when leaving each node of a copy of the parsed tree + */ + #[DataProvider('dataPrintFormatPreserving')] + public function testPrintFormatPreserving(string $code, Closure $change, string $expected): void + { + $tokens = (new Lexer())->tokenize($code); + $originalNode = (new Parser())->parse($tokens); + $node = (new Traverser())->traverse($originalNode, static fn (Node $node): Node => clone $node, $change); + + $printer = new Printer(); + $printed = $printer->printFormatPreserving($node, $originalNode, $tokens); + $this->assertSame($expected, $printed); + $this->assertEquals($node->toValue(), Neon::decode($printed)); + + $changed = $code; + foreach (array_reverse($printer->printChanges($node, $originalNode, $tokens)) as $change) { + $changed = substr_replace($changed, $change['replacement'], $change['start'], $change['end'] - $change['start']); + } + $this->assertSame($expected, $changed); + + // the original tree prints the original code + $this->assertSame($code, (new Printer())->printFormatPreserving($originalNode, $originalNode, $tokens)); + } + +}