perf(hir): O(1) scope resolution — index local bindings by name (#5267)#5270
Conversation
Expression/declaration lowering resolved every identifier by a reverse linear scan of the per-scope `locals` stack (`iter().rev().find(name)` / `rposition`), so a scope with N bindings and N references lowered in O(n²). Real minified/bundled JS puts tens of thousands of bindings and references in one module/function scope, so `check-lower` stalled for minutes and got killed with no diagnostic on large bundles. Wrap `locals` in a `Locals` newtype (crates/perry-hir/src/lower/locals.rs) that keeps the same ordered `(name, id, type)` stack plus a side `name -> ascending positions` index. The innermost binding for a name is the back of its position list, so `lookup` / `lookup_index` / `lookup_type` are O(1). Every position-changing op (push, drain, extend, remove) maintains the index; read/in-place ops reach the slice via `Deref`/`DerefMut` (which hands out `&mut [..]`, not `&mut Vec`, so the index can't be bypassed). Also fix the per-declaration linear scans that the var/let-hoisting and var-redeclaration paths ran: - `predefine_var_bindings_in_function_body` (block.rs) and the function- body var/fn pre-passes (expr_function.rs) used `iter().enumerate().rev().any(|(idx, ..)| name && idx >= mark)` per binding; replaced with O(1) `lookup_index_in_scope` (max-position == innermost, so "exists idx >= mark" iff "max >= mark"). - the var-redeclaration reuse (var_decl.rs / stmt.rs) found the binding by name then re-found it by id to patch its type; now `iter_named` yields the position and `type_mut_at` patches it in place. Lowering of a single large scope is now linear: a 160k-binding/160k-ref function checks in ~0.5s (was killed >600s extrapolated); the issue's 40k repro drops from 2.2s to 0.12s. All perry-hir (200+) and perry CLI (560+) tests pass; var hoisting/redeclaration/shadowing/closure-capture output verified byte-identical to `node --experimental-strip-types`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughIntroduces a new ChangesO(1) Scope-Local Binding Lookup
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/perry-hir/src/lower/locals.rs`:
- Around line 171-174: The DerefMut implementation for Locals currently exposes
mutable access to the entries slice, allowing callers to reorder entries via
swap operations or modify string names directly. This breaks the synchronization
between the entries vector and the index map, causing lookup methods to return
stale or incorrect bindings. Remove the DerefMut implementation for Locals
entirely, or if mutable access is needed, provide explicit controlled methods on
Locals that update both entries and index together, ensuring all mutations that
could affect the index invariant go through Locals methods only.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: d7fdcb5d-f2b6-478f-afd5-922ce0a4d0e7
📒 Files selected for processing (8)
crates/perry-hir/src/destructuring/var_decl.rscrates/perry-hir/src/lower/context.rscrates/perry-hir/src/lower/expr_function.rscrates/perry-hir/src/lower/locals.rscrates/perry-hir/src/lower/lowering_context.rscrates/perry-hir/src/lower/mod.rscrates/perry-hir/src/lower/stmt.rscrates/perry-hir/src/lower_decl/block.rs
| impl DerefMut for Locals { | ||
| fn deref_mut(&mut self) -> &mut Self::Target { | ||
| &mut self.entries | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Lock down mutable slice access to preserve Locals index invariants.
Line 171 currently exposes &mut [(String, LocalId, Type)]. That still permits reordering (swap/slice ops) and renaming (entry.0), which can desynchronize index from entries and make lookup* return incorrect bindings. Please keep all index-affecting mutations behind Locals methods only.
Proposed direction
-impl DerefMut for Locals {
- fn deref_mut(&mut self) -> &mut Self::Target {
- &mut self.entries
- }
-}
+// Remove DerefMut to prevent index-bypassing mutations.
+// Keep explicit mutation APIs (`push`, `drain_from`, `extend`, `remove`,
+// `lookup_type_mut`, `type_mut_at`) as the only writable surface.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/perry-hir/src/lower/locals.rs` around lines 171 - 174, The DerefMut
implementation for Locals currently exposes mutable access to the entries slice,
allowing callers to reorder entries via swap operations or modify string names
directly. This breaks the synchronization between the entries vector and the
index map, causing lookup methods to return stale or incorrect bindings. Remove
the DerefMut implementation for Locals entirely, or if mutable access is needed,
provide explicit controlled methods on Locals that update both entries and index
together, ensuring all mutations that could affect the index invariant go
through Locals methods only.
Summary
Fixes #5267. Lowering resolved every identifier by a reverse linear scan of the per-scope
localsstack, so a scope with N bindings and N references lowered in O(n²). Large minified/bundled JS (tens of thousands of bindings/refs in one module or wrapper-function scope) stalledcheck-lowerfor minutes and got killed with no diagnostic.Change
Localsnewtype (crates/perry-hir/src/lower/locals.rs) wraps the ordered(name, id, type)stack with a sidename → ascending positionsindex. The innermost binding for a name is the back of its position list, solookup/lookup_index/lookup_typeare O(1). Position-changing ops (push/drain_from/extend/remove) maintain the index; read/in-place ops reach the slice viaDeref/DerefMut(&mut [..], not&mut Vec, so the index can't be bypassed by callers).block.rs,expr_function.rs,var_decl.rs,stmt.rs) with O(1)lookup_index_in_scope(max position == innermost binding, so "exists idx ≥ mark" iff "max ≥ mark") and aniter_named+type_mut_atpatch-in-place (no second re-find by id).Measured (
perry check, single large scope)var)var)Scaling is now linear (40k→80k→160k = 0.13→0.25→0.52 s) for both
varandlet/const.Tests
perry-hirunit tests (200+) andperryCLI integration tests (560+) pass.node --experimental-strip-types.Per request, this PR intentionally omits the version bump and CHANGELOG entry.
🤖 Generated with Claude Code
Summary by CodeRabbit