Repository navigation
feat: rename — PHP variable rename (function-local, scope-aware) - #87
mikebronner merged 7 commits into
Conversation
Add a pure-PHP, single-file variable rename to the LSP rename engine. Renaming a $variable rewrites every occurrence that resolves to the same lexical binding scope and nothing else. Scope model (src/php_variable_rename.rs): - function / method / closure bodies are hard scopes; an identically-named variable in a sibling or nested non-capturing closure is a distinct binding - arrow functions are transparent for captures (auto-capture by value) but their own parameters shadow - closure use (...) captures cascade the rename across the clause and body - $this and static properties (self::$bar) are excluded; object properties are name nodes and fall out naturally The same resolver runs for the cursor variable and every candidate; an occurrence joins the rename iff it resolves to the same scope. Wired into prepare_rename / rename after the property-oriented column + magic-member checks and before the Laravel-pattern classifier. Docs (README, docs/rename.md) updated to reflect the shipped feature. Fixes #56
There was a problem hiding this comment.
🔄 Changes Requested
✅ What's Good
- The single-rule scope model (
resolve_binding_scope, compared by node id) is genuinely elegant — isolation, arrow-capture inclusion, and shadowing all fall out of one rule with no special-casing. 👏 - Security pass is clean: no panics on the production path, validated identifiers (
normalize_new_var_name), nounsafe, iterative walk so no recursion blow-up. - CI green (LSP test/fmt/clippy), 13 focused tests. The value-form
use ($x)cascade and the arrow-capture-vs-parameter-shadow distinction are both correct and tested. $this,self::$bar, fixed$obj->prop, and even dynamic$this->{$var}/$obj->$varare handled correctly — the last two naturally, since$varthere is a real local and should be renamed.
🛑 Issues Found (blocker)
By-reference closure captures use (&$var) are not detected → silent partial rename that corrupts valid code. — laravel-lsp/src/php_variable_rename.rs:120-134 (scope_captures_use)
scope_captures_use iterates the direct children of anonymous_function_use_clause and matches only v.kind() == "variable_name". In tree-sitter-php 0.24.2 that's right for the value form use ($x), but for the reference form use (&$x) the direct child is a by_ref node that wraps the variable_name (confirmed in the grammar's node-types.json: anonymous_function_use_clause → by_ref, and by_ref → variable_name). The check never fires, scope_captures_use returns false, and the closure's $x is treated as a fresh local.
In a rename — unlike a read-only analysis — that's destructive:
$count = 0;
$inc = function () use (&$count) { $count++; };
// rename $count → $total ⇒
$total = 0;
$inc = function () use (&$count) { $count++; }; // ← severed: closure now captures a dead variableThe outer occurrences are rewritten but the use (&$count) token and the closure body are left behind — silent corruption of valid, idiomatic PHP, which is the one thing an automated rename must never do. The PR body states "use (...) captures cascade in lockstep," so this is the feature falling short of its own contract for the by-ref form.
For context: this faithfully mirrors scope_captures_var in query_chain/flow.rs:235, which has the same blind spot — but there the cost is a read-only undercount; here it's a destructive edit, so it has to be handled.
Fix direction (your call which):
- Preferred — detect by-ref captures: in
scope_captures_use, also match avariable_namenested inside aby_refchild, and ensurecollect_occurrencesrewrites theuse (&$x)token along with the body. - Acceptable fallback — if the cascade is more than you want here, detect the by-ref capture and refuse the rename with a toast (same path as the invalid-name case) rather than emit a partial edit.
Either way, add a use (&$var) test (cascade for (1), refusal for (2)).
📝 Notes (non-blocking)
- A few tests assert occurrence counts + negative offsets but not the positive offsets of the included sites (
nested_closure_without_use_is_isolatedtests.rs:87,arrow_function_captures_outer_variabletests.rs:153). Companion tests cross-cover, so not blocking — but asserting the exact included positions would make a wrong-site regression impossible to miss. - No explicit test for the dynamic-access shapes
$obj->$var/$this->{$var}. Behavior is correct (verified), so this is a coverage note, not a defect — worth a line to lock it in. - AC #4 lists
$this->{$var}under "excluded," which is imprecise: the correct behavior (which you implemented) is to rename the real variable$varwhile leaving the property mechanism alone. No code change needed — flagging so the wording doesn't trip a future reader.
Please address the by-ref capture and re-request review.
…--php-variable-rename-function-local-sc # Conflicts: # README.md # docs/rename.md
`scope_captures_use` only matched a `variable_name` directly under an
`anonymous_function_use_clause`. In tree-sitter-php the reference form
`use (&$x)` wraps the `variable_name` in a `by_ref` node, so the check
never fired: the closure's `$x` was treated as a fresh local and the
outer binding was renamed without its `use (&$x)` capture or closure
body — a silent partial rename that severs the closure and corrupts
otherwise-valid code.
Add `use_clause_capture`, which unwraps the `by_ref` wrapper (the `&` is
an anonymous token, so the captured `variable_name` is `named_child(0)`)
and falls through to the direct `variable_name` for the value form
`use ($x)`. Both forms now resolve to the outer binding, so the rename
cascades across the assignment, the `use (&$x)` token (preserving `&`),
and the body in lockstep.
Tests: add `closure_use_by_reference_cascades` (asserts the capture and
body sites are rewritten and `&` is preserved) and
`dynamic_property_access_renames_the_variable_not_the_property` (locks in
that `$obj->$key` / `$this->{$key}` rename the real local, not the
property mechanism).
Addresses Holmes's review on #56.
|
🔧 Addressed the by-reference capture blocker (option 1 — the cascade fix).
Because Tests added:
On AC #4 wording ( Full suite green locally with the CI fixture setup ( |
There was a problem hiding this comment.
🔄 Changes Requested (round 2)
The round-1 by-reference fix is solid — but it has an unaddressed sibling in the same family. One more blocker, then this is ready.
✅ What's Good
- The round-1 fix lands.
use (&$count)is now detected (by_ref→variable_nameunwrap in the use-clause path) and rewritten correctly —closure_use_by_reference_cascades(tests.rs:152-191) asserts both theuse (&$count)capture site and the body site are edited, and thattarget_text == "$count"(the&is not mangled). That's a real regression test, not a count check. 👏 - The single-rule scope model (
resolve_binding_scope, compared by node id) remains elegant — isolation, arrow auto-capture, value/by-refusecascade, and arrow param shadowing all fall out of one walk. - Property exclusion is correct and the dynamic-access case is handled the right way:
dynamic_property_access_renames_the_variable_not_the_property(tests.rs:194-217) renames the real local$keyin$this->{$key}/$obj->$keywhile leaving$obj/$thisuntouched. - Security is clean on the production path: no panics, validated identifiers, no
unsafe, iterative walk. - CI green (LSP test/fmt/clippy).
🛑 Issues Found (blocker)
global $x is silently partially renamed → severed alias, broken PHP. — laravel-lsp/src/php_variable_rename.rs (is_collectible 199-212, resolve_binding_scope 163-194, collect_occurrences 242-259)
This is the exact same defect class round 1 fixed for use (&$x): a binding that crosses the function boundary, where a naive scope-local rename emits a partial edit that corrupts valid, idiomatic PHP.
is_collectible (199-212) only refuses $this and static-property positions — a variable_name whose parent is global_declaration passes, so prepare_rename returns Some and F2 is enabled on it. resolve_binding_scope's catch-all arm (line 191) routes the $x inside global $x; to the enclosing function_definition — the same scope id as the body occurrences — while the file-level $x resolves to program. So collect_occurrences rewrites the in-function set and skips the global:
$x = 10; // global scope — NOT collected (resolves to `program`)
function f() {
global $x; // collected → renamed (resolves to `function_definition`)
$x = 20; // collected → renamed
return $x; // collected → renamed
}
// rename $x → $y inside f() ⇒
$x = 10; // ← left behind
function f() {
global $y; // ← now aliases a global $y that doesn't exist
$y = 20;
return $y;
}global $y; now binds to a non-existent global; assignments in f() no longer reach the real $x. Silent corruption of valid PHP — the one thing an automated rename must never do. No test covers global (zero hits in tests.rs).
Fix direction (your call which):
- Preferred, and consistent with this issue's scope — the issue explicitly scopes this to function-local rename and defers cross-scope flow. A
global $xvariable is not function-local. So refuse the rename when the variable participates in aglobal_declaration(exclude it inis_collectible, same path as$this), soprepare_renamereturnsNoneand F2 never offers a corrupting edit. - Heavier alternative — treat
global $xas bound toprogramscope and cascade both sides together. More faithful, but it's the cross-scope flow this issue deferred — I'd avoid it here.
Either way, add a global $x test (refusal for (1), or full cascade for (2)).
📝 Notes (non-blocking)
- Constructor-promoted by-ref param
__construct(public &$foo)—scope_declares_param→var_ident(line ~107) strips only$, not&, so aby_ref-wrapped promoted param yields"&foo" ≠ "foo"and the shadow check misses it (a closure/arrow inside the constructor would then mis-resolve$foo). Sameby_refunwrap you already applied in the use-clause path — worth mirroring here, but rare enough that it's not blocking. normalize_new_var_name(264-276) accepts any codepoint>= 0x80, including Cf format chars (U+202E RTL-override and friends) — a "trojan source" vector if a rename request carries an invisible control char. Tightening the guard to Unicode letters/_(excludes Cf) closes it cheaply. Low real-world exposure (needs a malicious LSP client), so a note.- Double parse per rename —
variable_at_cursorandvariable_rename_targetseach callparse_phpon the same content (main.rs path). Passing the already-parsed tree through would halve the parse cost. Perf only. - AC #4 wording (re-flagging from round 1, no code change): the AC lists
$this->{$var}under "excluded," which is imprecise —$varthere is a real local and should be renamed, which you do correctly. The implementation satisfies AC #4's governing clause ("only touches variable names, not properties"). Flagging so the wording doesn't trip a future reader.
Please address the global $x partial rename and re-request review.
A `global $x;` declaration makes the in-scope `$x` an alias of the top-level global, not a function-local. The scope-local rename engine collected only the in-function occurrences and left `global $x;` (or the file-level `$x`) behind — a silent partial edit that severs the alias and corrupts valid PHP, the one thing an automated rename must never do. Refuse the rename outright whenever the variable is connected to a `global` declaration — from prepareRename (so F2 is never offered) and from the rename path, regardless of where the cursor lands. The check is resolution-aware so it never over-refuses a genuine local: a `global $x` in a nested scope binds there, not here; at program scope any `global $x` in the file aliases the one true top-level `$x`. Importing globals is the cross-scope flow this feature defers, so refusing is correct rather than emitting a corrupting edit. Same defect class as the round-1 `use (&$x)` fix: a binding that crosses the function boundary. Tests: global-token + body-occurrence refusal, program-scope sibling refusal, no over-refusal of a real local beside a nested global, and a plain top-level rename still allowed.
|
🔧 Round 2 blocker addressed — Fixed the silent partial-rename corruption (commit Where the guard lives — and why not in Resolution-aware, so no over-refusal. Tests (4 new):
On the non-blocking notes:
Full suite green locally with the CI fixture ( |
There was a problem hiding this comment.
🔄 Changes Requested
The scope engine is genuinely good work — but it ships one silent-corruption path that your own design philosophy says it shouldn't.
🛑 Blocker — silent corruption on compact() / extract() (correctness)
collect_occurrences (php_variable_rename.rs:293–310) renames every variable_name node bound to the target scope. But a compact('user') / extract(...) reference to that same variable lives in a string node, so it's neither rewritten nor detected. Rename $user → $account in a body that also contains compact('user') and you get a partial edit: compact('user') now references a variable that no longer exists — broken PHP, no warning. There is no guard and no documented limitation (module doc lines 40–56 list only $this, static properties, and global).
This breaks two things at once:
- AC #1 — "all references in that lexical scope are updated." A
compact('user')in the same function body is an in-scope reference; it's silently left behind. - Your own principle for
global(lines 47–52): "A scope-local rename would sever the alias (a partial edit …), so the engine refuses the rename outright … not a corruption it should emit."compact/extractstring keys are exactly that class of partial-edit corruption — and in a Laravel codebase (return view('x', compact('user'))) it's everywhere.
My recommendation: extend the scope_aliases_global guard (lines 217–252, commit b38cfbe) — when a compact/extract call with a string literal matching the renamed variable is in scope, refuse the rename (renameable_variable_at → None), exactly as you do for global. It's the consistent, safe choice and reuses machinery you already built. Rewriting the string in-place is a defensible alternative, but refusal matches the established precedent. Either way, add a test.
✅ What's good
- Clean single-rule design (lines 34–38): every occurrence resolves to a binding scope and is included iff it matches by node id. Nested-closure isolation, arrow-capture inclusion, and shadowing all fall out of that one rule — no special-casing.
- Arrow-function auto-capture and closure
use (…)/use (&…)cascades are modeled correctly (lines 169–199); the by-reference capture test uses propercontains(&site)negative-style assertions. - The
global-alias refusal is exactly the right instinct — this review just asks you to apply that same instinct tocompact/extract. - 14 tests, all CI checks green.
$this/self::$bar/ object-property exclusion verified with real negative assertions. The$this->{$var}case (tests.rs:194–217) is handled correctly —$varis a true local variable, so renaming it is right, and the property mechanism is untouched. No change needed there.
📝 Minor (non-blocking — your call)
- Test gap: no sibling-function isolation test (two top-level functions each owning
$user; rename one, assert the other is untouched). Every isolation test uses nested topology, so thefunction_definitionhard boundary is only exercised transitively. One test would close it. - Dead branch:
anonymous_function_creation_expression(lines 71, 184) isn't a node kind in the tree-sitter-php grammar —static function () {}parses asanonymous_function, which you already cover. Harmless, but it implies coverage that isn't real. Drop it or comment why it's there. - Perf:
variable_rename_editre-parses the same content multiple times (variable_at_cursorthenvariable_rename_targetsin main.rs), andcollect_occurrenceswalks all children rather thannamed_children. Neither is a correctness issue — mention only if large-file latency shows up.
Address the compact/extract corruption (a refusal guard is enough) and re-request review.
A `compact('user')` / `extract('user')` call references a local variable
by string literal, not a `variable_name` node, so the scope-aware rename
could not see it. Renaming $user rewrote the variable tokens but left
`compact('user')` pointing at a name that no longer exists — a silent
partial edit that corrupts valid, idiomatic Laravel PHP.
Refuse the rename outright (prepareRename returns None, F2 is never
offered) when the binding scope references the variable via a
compact/extract string literal, mirroring the existing global-alias guard.
The check is resolution-aware, so a compact() in a nested scope with its
own same-named local never over-refuses an enclosing scope's distinct
variable. The dynamic extract($data) form carries no literal name and
remains the deferred cross-scope case.
Also document the dead anonymous_function_creation_expression arm as a
codebase-wide defensive vestige, and add a sibling-function isolation test.
|
🔧 Round 3 blocker addressed — Fixed the corruption you flagged (commit Where the guard lives — same gate as Resolution-aware, so no over-refusal. It reuses Tests (6 new):
On the minor notes:
Full suite green locally with the CI fixture ( |
|
🛑 Escalating to @mikebronner — 3-strike limit reached. This PR has had 3 rounds of changes requested and has hit the bounce limit. By rule I stop reviewing here and hand it to you; I'm not requesting a fourth round. Why it kept bouncing — same defect family, each round a new member:
These are all one class: a binding/reference that crosses the naive scope-local model, where a partial rewrite corrupts valid PHP. The scope engine itself is genuinely good — the issue is the boundary set keeps growing one case at a time. Your call on how to break the cycle (this is why it's with you, not Watson):
The work is close and the design is sound — it just needs a human decision on where the line sits rather than another discover-one-more-case round. PR #87, head |
Names the conservative safety boundary the rename engine enforces — global-aliased and compact()/extract() string-keyed locals are refused rather than half-renamed — and flags the exotic dynamic shapes not yet detected. Mapping the full set is tracked as a discovery follow-up. Refs #96
Summary
Implements #56 — function-local, scope-aware rename of plain PHP variables in the LSP rename engine. The existing engine handled Laravel symbols (routes, config, views, magic members, columns, model classes) but deliberately skipped plain $variable rename because it needs real lexical-scope analysis. This adds that analysis.
Renaming a
$variablerewrites every occurrence that resolves to the same lexical binding scope — and nothing else. Bindings that cross the function boundary (use (&$x)by-ref captures,global $ximports) are recognised so the engine never emits a corrupting partial edit.Changes
laravel-lsp/src/php_variable_rename.rs— the scope-aware engine. A single recursiveresolve_binding_scoperuns for both the cursor variable and every candidate occurrence; an occurrence joins the rename iff it resolves to the same scope (compared by node id). That one rule yields isolation, capture, and shadowing without special cases.function/method/anonymous_functionbodies are hard scopes — a same-named variable in a sibling or nested non-capturing closure is a distinct binding.use (...)captures cascade the rename across theuseclause and the closure body in lockstep — both the value formuse ($x)and the by-reference formuse (&$x).$this, static properties (self::$bar,Foo::$bar). Object properties ($this->foo) arenamenodes and fall out naturally.global $xrefusal — aglobal $x;declaration makes the in-scope$xan alias of the top-level global, not a function-local. The rename is refused outright (prepareRename returnsNone, so F2 is never offered) rather than emitting a partial edit that severs the alias. The check is resolution-aware: an unrelatedglobalin a nested scope never over-refuses a genuine local; at program scope anyglobal $xin the file aliases the one true top-level$x. Importing globals is the cross-scope flow this issue defers.main.rs—variable_rename_at(prepare) andvariable_rename_edit(rename), placed after the property-oriented column + magic-member checks (so$user->emailstill routes there) and before the Laravel-pattern classifier (which never classifies a bare$variable). Invalid new names surface a toast.README.mdanddocs/rename.mdupdated; the feature moved out of Planned Features, and class-property rename noted as the remaining follow-up.Acceptance Criteria
$this->foo,$obj->prop,self::$bar) are excluded — variable rename only touches variable names, not properties.Test Plan
cargo test --all-features— 1621 + 232 + 80 tests pass, including thephp_variable_renamesuite (simple rename, nested-closure isolation,use-clause value + by-ref cascade, arrow capture, arrow param shadow, property exclusion, dynamic-access,$thisrefusal, invalid-name error, no-op, prepare-range, and the newglobal-alias refusal cases).cargo fmt --checkclean.cargo clippy --all-targets -- -D warningsclean.Notes
__construct(private $name)) are treated as ordinary locals within the constructor — the property side of promotion belongs to the deferred class-property rename pass.compact()/extract()string references (review round 3) —compact('user')/extract('user')name a local by string literal, a reference that lives in astringnode the rename can't follow. Renaming the variable while leaving the string behind is a silent partial edit that corrupts valid, ubiquitous Laravel PHP (return view('x', compact('user'))). The engine now refuses such a rename (prepareRename→None), mirroring the existingglobal-alias guard — resolution-aware, so acompact()in a nested scope with its own same-named local never over-refuses an enclosing scope's distinct variable. The dynamicextract($data)form carries no source-literal name and remains the deferred cross-scope case. The deadanonymous_function_creation_expressionmatch arm is now documented as a codebase-wide defensive vestige, and a sibling-function isolation test was added.Fixes #56