Skip to content

Run indentation detection, node cloning and node replacing in a single AST traversal and cache per-file fixing data#6101

Merged
staabm merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-3puj8ho
Jul 26, 2026
Merged

Run indentation detection, node cloning and node replacing in a single AST traversal and cache per-file fixing data#6101
staabm merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-3puj8ho

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

RuleErrorTransformer->transform() walked the whole file AST three times and re-read, re-parsed and re-tokenized the file for every single fixable error. On files with many fixable errors this dominated the run.

The three NodeVisitor implementations now share a single NodeTraverser pass, and everything that only depends on the file being fixed (contents, tokens, hash, detected indentation, printer) is computed once per file instead of once per error.

Measured on a stress case (200 fixable errors in src/Analyser/MutatingScope.php, ~5k lines): 59.8s → 41.4s, with the AST-traversal portion dropping from ~22s to 3.3s. On a normal-sized file (src/Rules/Methods/OverridingMethodRule.php, 200 errors): 2.31s → 1.20s.

Changes

  • src/Analyser/RuleErrorTransformer.php
    • Extracted the fixing logic into a private fix() method.
    • The indentation detector, CloningVisitor and ReplacingNodeVisitor are registered on one NodeTraverser instead of three.
    • Added a FIFO cache (PARSED_FILES_LIMIT) holding the file contents, its sha256 hash, its tokens and the detected indentation, so FileReader::read() and Parser::parse() run once per file.
    • Added a PhpPrinter cache keyed by the indentation string (PRINTERS_LIMIT).
    • Return early when $fileNodes is empty — nothing can be replaced, so reading and parsing the file was pure waste.
  • src/Fixable/ReplacingNodeVisitor.php — replacement moved from enterNode() to leaveNode().
  • src/Fixable/PhpPrinterIndentationDetectorVisitor.php — replaced NodeVisitor::STOP_TRAVERSAL with a public found flag and an early return.

Analogous cases probed

  • src/Fixable/PhpDoc/PhpDocEditor::edit() — structurally the same two-pass shape (CloningVisitor traversal followed by a CallbackVisitor traversal), but over a PHPDoc AST. Deliberately left unchanged: the same leaveNode() trick would turn the third-party callback from pre-order into post-order on an @api-tagged class, and the ASTs involved are a handful of nodes, so there is nothing to gain.
  • src/Fixable/Patcher::applyDiffs() — also does repeated splitting/diffing, but it runs once per file at the end of a --fix run, not once per error, so it is not on the hot path.
  • src/Parser/RichParser, src/Node/DeepNodeCloner, src/Dependency/ExportedNodeFetcher — checked; their traversals are either already merged into one pass or ordered by necessity, and none of them run per error.

Also worth recording for a follow-up (intentionally not changed here): with the traversals fixed, the remaining cost of transform() is dominated by SebastianBergmann\Diff\Differ::diffToArray(), whose getArrayDiffParted() builds the common suffix with $end = [$k => $v] + $end — quadratic in the number of lines after the change (154ms for a change near the top of a 5k-line file vs 14ms near the bottom). Working around it would mean trimming the input and rewriting the @@ hunk headers, which is too risky to bundle into this change since those diffs get applied to users' files.

Root cause

The pattern is per-error work that only depends on the file. Every call to transform() for a FixableNodeRuleError redid all of it:

  1. FileReader::read($fixingFile) — file I/O.
  2. $this->parser->parse($oldCode) — a full re-parse of the file, only to get getTokens().
  3. new TokenStream($oldTokens, ...) — an indentation map over every token.
  4. A traversal of the whole AST just to detect the file's indentation.
  5. A second traversal to clone the AST.
  6. A third traversal to find and replace the node.
  7. new PhpPrinter(...)printFormatPreserving() memoizes eight lookup tables (fixup map, removal map, insertion maps, modifier change map, …) on the printer instance, so a fresh instance per error rebuilt all of them.

Only steps 5 and 6 genuinely depend on the individual error, and even those can share one traversal.

Merging 5 and 6 naively is unsafe: CloningVisitor::enterNode() clones a node before its children are cloned, so a replacement callable running in enterNode() would see un-cloned children, and any brand-new node returned by the callable would get its children cloned and tagged with an origNode attribute pointing at nodes with no token positions — which breaks format-preserving printing. Doing the replacement in leaveNode() avoids this entirely: by then the traverser has already descended into the node and cloned its whole subtree, which is exactly the state the callable used to receive from the separate second pass.

Merging 4 in required dropping STOP_TRAVERSAL from the indentation detector, since stopping would abort cloning as well.

Test

  • tests/PHPStan/Analyser/RuleErrorTransformerTest.php (new), with tests/PHPStan/Analyser/CountingParser.php (a PhpParser\Parser decorator counting parse() calls) and data files under tests/PHPStan/Analyser/data/rule-error-transformer/:
    • testFileIsParsedOnceForMultipleFixableErrors() — 10 fixable errors in one file must parse the file once. Fails before the fix (10 parses).
    • testNoFileNodesDoesNotReadTheFile() — a fixable error transformed with no file nodes must not touch the parser at all. Fails before the fix (1 parse).
    • testCachingDoesNotChangeProducedDiffs() — every diff produced by a cache-warm transformer is byte-identical (hash and diff) to the one produced by a fresh transformer, for both a space-indented and a tab-indented file.
    • testInterleavedFilesDoNotShareIndentation() — alternating between a space-indented and a tab-indented file keeps both files' diffs correct, guarding the new per-file indentation cache.
  • The existing fix tests cover the merged traversal end to end and all still pass, including multi-error files and tab indentation: FixIssetRuleTest, NamedArgumentsRuleTest, AttributeNamedArgumentsRuleTest, FinalClassRuleTest, MemoizationPropertyRuleTest, OrChainIdenticalComparisonToInArrayRuleTest, OverridingMethodRuleTest (testFixOverrideAttribute, testFixWithTabs), OverridingPropertyRuleTest, BacktickRuleTest.
  • Full suite (make tests, 17628 tests) and make phpstan are green. make name-collision fails on tests/PHPStan/Build/data/final-class-rule-pipe.php both with and without this change (the collision detector's parser does not understand the pipe operator).

Fixes phpstan/phpstan#14996

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove changes in tests/

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done — pushed as a1de80abf.

Change: removed all four files the PR added under tests/ (they were all new files; no existing test was modified):

  • tests/PHPStan/Analyser/RuleErrorTransformerTest.php
  • tests/PHPStan/Analyser/CountingParser.php
  • tests/PHPStan/Analyser/data/rule-error-transformer/{spaces,tabs}.php

The PR is now source-only: RuleErrorTransformer.php, ReplacingNodeVisitor.php, PhpPrinterIndentationDetectorVisitor.php.

Verification:

  • make tests — 17622 tests, 90012 assertions, 97 skipped, OK (exit 0)
  • make phpstan — no errors

One thing worth flagging for your call: the merged-traversal behaviour is still covered end to end by the existing fix tests (FixIssetRuleTest, OverridingMethodRuleTest::testFixOverrideAttribute / testFixWithTabs, NamedArgumentsRuleTest, etc.), so correctness of the produced diffs is not untested. What's gone with the removal is the coverage specific to the new caching — that the file is parsed once per file rather than once per error, that an empty $fileNodes skips the read entirely, and that interleaving a space-indented and a tab-indented file doesn't leak the cached indentation between them. That last one is the only new failure mode this PR introduces that no other test exercises.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create a self contained code sample in tests/bench/data which when analyzed by this PR gets significant faster than before

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

The last monitor has ended. Everything is finished: the review request is addressed by 6a094cbb5 (new bench case tests/bench/data/bug-14996.php, 3.100s → 1.926s and 303 MB → 84 MB peak), both make tests and make phpstan are green, and the commit is pushed to create-pull-request/patch-3puj8ho.

@staabm

staabm commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

before this PR

➜  phpstan-src git:(2.2.x) ✗ hyperfine 'php bin/phpstan analyze bug-14996.php -v --debug' -i
Benchmark 1: php bin/phpstan analyze bug-14996.php -v --debug
  Time (mean ± σ):      1.966 s ±  0.016 s    [User: 1.724 s, System: 0.237 s]
  Range (min … max):    1.940 s …  1.997 s    10 runs

after this PR

➜  phpstan-src git:(pr/6101) ✗ hyperfine 'php bin/phpstan analyze bug-14996.php -v --debug' -i
Benchmark 1: php bin/phpstan analyze bug-14996.php -v --debug
  Time (mean ± σ):      1.554 s ±  0.014 s    [User: 1.328 s, System: 0.222 s]
  Range (min … max):    1.538 s …  1.583 s    10 runs

@staabm
staabm requested a review from VincentLanglet July 26, 2026 08:40
staabm and others added 3 commits July 26, 2026 14:36
…e AST traversal and cache per-file fixing data

* `RuleErrorTransformer::transform()` used to run three separate `NodeTraverser` passes over the whole file AST for every fixable error (`PhpPrinterIndentationDetectorVisitor`, `CloningVisitor`, `ReplacingNodeVisitor`). They now share one traversal.
* `ReplacingNodeVisitor` does the replacement in `leaveNode()` instead of `enterNode()`, so the callable still receives a node whose children have already been cloned by `CloningVisitor` - keeping the produced diffs byte-identical while allowing both visitors in one pass.
* `PhpPrinterIndentationDetectorVisitor` no longer returns `STOP_TRAVERSAL` (which would have aborted the shared traversal); it exposes a `found` flag and skips further nodes once the indentation is known.
* The fixed file is read, parsed and tokenized once per file instead of once per error. `FileReader::read()`, `Parser::parse()`, the sha256 hash and the detected indentation are kept in a small FIFO cache bounded by `PARSED_FILES_LIMIT`.
* `PhpPrinter` instances are reused per indentation string (bounded by `PRINTERS_LIMIT`) - `printFormatPreserving()` memoizes eight sizeable lookup tables on first use, which were rebuilt for every single error before.
* Fixing is skipped early when no file nodes are available (`AnalyserResultFinalizer` and `ConstantConditionInTraitHelper` pass an empty array), which previously still read and parsed the file just to find nothing to replace.
* Probed `PHPStan\Fixable\PhpDoc\PhpDocEditor`, which has the same two-pass clone-then-callback shape over the PHPDoc AST. Left unchanged on purpose: merging the passes would flip the user callback from pre-order to post-order on an `@api` class, and the PHPDoc ASTs involved are tiny.

Closes phpstan/phpstan#14996
The behaviour is already covered end to end by the existing fix tests
(FixIssetRuleTest, OverridingMethodRuleTest::testFixWithTabs, ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 methods wrongly marked with #[\Override] produce 100 fixable errors in
a single ~1000 line file, which is the shape that makes
RuleErrorTransformer::transform() dominate the analysis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@staabm
staabm force-pushed the create-pull-request/patch-3puj8ho branch from 6a094cb to 3747595 Compare July 26, 2026 12:36

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also worth recording for a follow-up (intentionally not changed here): with the traversals fixed, the remaining cost of transform() is dominated by SebastianBergmann\Diff\Differ::diffToArray(), whose getArrayDiffParted() builds the common suffix with $end = [$k => $v] + $end — quadratic in the number of lines after the change (154ms for a change near the top of a 5k-line file vs 14ms near the bottom)

@phpstan-bot suggest how getArrayDiffParted() can be improved for performance, without changing any files in the PR. just send a diff in a comment.

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Nothing in the PR changed — the reviewer asked for a proposal only. Working tree is clean, vendor restored to pristine.

Here's what I found and verified:

Comment for the PR

@staabm — investigated. Turns out both loops in getArrayDiffParted() are quadratic, not just the suffix one I mentioned in the description. Nothing to change in this PR; this is an upstream patch for sebastianbergmann/diff (installed here: 6.0.2).

Two independent O(n²) loops

1. Suffix loop$end = [$fromK => $from[$fromK]] + $end; reallocates and copies the whole accumulator on every iteration. n lines of common suffix ⇒ n²/2 element copies.

2. Prefix loop — subtler and previously unnoticed. unset($to[$k]) leaves an UNDEF hole at the internal array pointer, and the pointer is never advanced; key($to) therefore re-scans forward past all previously-unset buckets on every call. Proof — same loop, but advancing with next($to) instead of relying on unset():

n current (unset only) with next($to) index-based
2000 2.578 ms 0.165 ms 0.117 ms
4000 9.751 ms 0.317 ms 0.234 ms
8000 37.928 ms 0.646 ms 0.480 ms

Both loops are quadratic in the length of the unchanged region — which for a one-node fix is essentially the whole file. That's the asymmetry I measured: a change near the top pays it in the suffix loop, near the bottom in the prefix loop.

Proposed patch

Compute the common prefix/suffix lengths by integer index and slice once. No internal array pointer, no incremental array unions.

--- a/src/Differ.php
+++ b/src/Differ.php
@@ -12,17 +12,15 @@
 use const PHP_INT_SIZE;
 use const PREG_SPLIT_DELIM_CAPTURE;
 use const PREG_SPLIT_NO_EMPTY;
+use function array_keys;
 use function array_shift;
+use function array_slice;
 use function array_unshift;
 use function array_values;
 use function count;
-use function current;
-use function end;
 use function is_string;
-use function key;
 use function min;
 use function preg_split;
-use function prev;
 use function reset;
 use function str_ends_with;
 use function substr;
@@ -207,41 +205,40 @@
 
     private static function getArrayDiffParted(array &$from, array &$to): array
     {
-        $start = [];
-        $end   = [];
+        $fromKeys   = array_keys($from);
+        $toKeys     = array_keys($to);
+        $fromLength = count($fromKeys);
+        $toLength   = count($toKeys);
+        $shortest   = min($fromLength, $toLength);
 
-        reset($to);
-
-        foreach ($from as $k => $v) {
-            $toK = key($to);
+        $prefixLength = 0;
 
-            if ($toK === $k && $v === $to[$k]) {
-                $start[$k] = $v;
+        while ($prefixLength < $shortest) {
+            $key = $fromKeys[$prefixLength];
 
-                unset($from[$k], $to[$k]);
-            } else {
+            if ($toKeys[$prefixLength] !== $key || $from[$key] !== $to[$key]) {
                 break;
             }
-        }
 
-        end($from);
-        end($to);
+            $prefixLength++;
+        }
 
-        do {
-            $fromK = key($from);
-            $toK   = key($to);
+        $suffixLength = 0;
 
-            if (null === $fromK || null === $toK || current($from) !== current($to)) {
+        while ($suffixLength < $shortest - $prefixLength) {
+            if ($from[$fromKeys[$fromLength - $suffixLength - 1]] !== $to[$toKeys[$toLength - $suffixLength - 1]]) {
                 break;
             }
 
-            prev($from);
-            prev($to);
+            $suffixLength++;
+        }
 
-            $end = [$fromK => $from[$fromK]] + $end;
-            unset($from[$fromK], $to[$toK]);
-        } while (true);
+        $start = array_slice($from, 0, $prefixLength, true);
+        $end   = array_slice($from, $fromLength - $suffixLength, $suffixLength, true);
+        $from  = array_slice($from, $prefixLength, $fromLength - $prefixLength - $suffixLength, true);
+        $to    = array_slice($to, $prefixLength, $toLength - $prefixLength - $suffixLength, true);
 
         return [$from, $to, $start, $end];
     }
 }

array_slice(…, true) preserves the original keys, so $start/$end and the two by-ref outputs are identical to before, not just equivalent.

Numbers

getArrayDiffParted() alone, 5000-line file, one line changed:

change position current patched
top 142.5 ms 0.25 ms 565×
middle 39.0 ms 0.27 ms 144×
bottom 15.3 ms 0.26 ms 59×

Scaling (change on line 0) — clean O(n²) → O(n):

n current patched
1 000 5.5 ms 0.05 ms
2 000 22.4 ms 0.10 ms
4 000 88.8 ms 0.20 ms
8 000 366.3 ms 0.41 ms
16 000 1493.7 ms 1.01 ms

Full Differ->diff() on real sources, e.g. src/Analyser/NodeScopeResolver.php (5292 lines), change at top: 170.6 ms → 2.3 ms; at bottom: 18.7 ms → 2.3 ms.

PHPStan end-to-end, on top of this PR:

input current patched
5008-line class, 500 fixable errors 63.3 s 37.4 s
tests/bench/data/bug-14996.php (1014 lines, 100 errors) 2.62 s 2.42 s

The gain scales with file length, so it's small on the 1k-line bench case and dominant on real large files.

Correctness checks

  • 300 000 randomized input pairs (lists, gappy int keys, string keys, empty arrays, duplicate values, unequal lengths) — old and new return byte-identical [$from, $to, $start, $end] and leave the by-ref $from/$to identical. Zero mismatches.
  • Every diff produced by Differ->diff() over insert/replace/delete at top/middle/bottom of four real PHPStan source files — byte-identical.
  • Patched into vendor/ and ran the full PHPStan suite: 17626 tests, 90020 assertions, OK (sebastian/diff is also PHPUnit's own diff engine, so this exercises it twice over — and would speed up large assertion-failure diffs too).

Happy to open the PR against sebastianbergmann/diff if you want.


Note on scope: no commits — the review asked for a suggestion without touching the PR. The vendor/ patching above was temporary and has been reverted; git status is clean and HEAD is still 3747595ae.

@staabm
staabm merged commit 5b9c8d4 into phpstan:2.2.x Jul 26, 2026
1094 of 1101 checks passed
@staabm
staabm deleted the create-pull-request/patch-3puj8ho branch July 26, 2026 14:48
staabm

This comment was marked as off-topic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RuleErrorTransformer->transform() is running multiple pass over tokens

3 participants