Skip to content
Merged
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Each feature has a focused reference under [`docs/`](docs/) — click through to
| [🔗 Go-to-Definition](docs/go-to-definition.md) | Jump to views, components, routes, config, translations, env, assets, middleware, bindings, Artisan commands — plus query-chain columns / relations / tables and Eloquent magic members |
| [ℹ️ Hover](docs/hover.md) | Intelephense-style summary cards for every recognised pattern, including semantic cards for Eloquent magic (scopes, accessors, relationships, cast-aware column types) |
| [🔍 Find References](docs/find-references.md) | Every call site across the project, vendor packages included — including the magic-member usages Intelephense can't see |
| [✏️ Rename](docs/rename.md) | Atomic rename of routes, configs, translations, env vars, views, components, Livewire, middleware, bindings, PHP classes (models, controllers, jobs, services, form requests), magic members — and database columns, migration included |
| [✏️ Rename](docs/rename.md) | Atomic rename of routes, configs, translations, env vars, views, components, Livewire, middleware, bindings, PHP classes (models, controllers, jobs, services, form requests), magic members, function-local PHP variables — and database columns, migration included |
| [🔢 Code Lens](docs/code-lens.md) | Opt-in reference counts above magic members, routes, config / translation / env keys, and Blade templates — plus an unused-symbol warning |
| [💡 Autocomplete](docs/autocomplete.md) | Cast types, model properties, query chains, builder methods, Blade / loop / slot variables, Pennant flags |
| [❌ Diagnostics](docs/diagnostics.md) | Missing views / components / features, invalid rules, query-chain typos against your real schema |
Expand Down Expand Up @@ -228,10 +228,10 @@ After saving, restart Intelephense (`Cmd+Shift+P → lsp: restart`). For the lic

## 🚧 Planned Features

**Rename — remaining work** (the class-backed kinds, the FQCN class-rename engine across all common PHP class kinds, magic members, and database columns shipped; variables didn't):
**Rename — remaining work** (the class-backed kinds, the FQCN class-rename engine across all common PHP class kinds, magic members, database columns, and function-local PHP variables shipped):

- 📝 **Blade variable rename** — scope-aware within a template (`@foreach`, `@php`, etc.), plus cross-file via the `view('x', ['key' => …])` / `compact('key')` linkage from controller into view.
- 🔧 **PHP variable rename** — scope-aware function-local. Class properties (`$this->foo`) are out of scope for this round and folded into a future class-property rename.
- 🔧 **Class-property rename** — `$this->foo`, `self::$bar`, and dynamic property access have different reference shapes than local variables, so they get their own scope-aware pass. (Function-local PHP variable rename shipped.)

**Framework integrations:**

Expand Down
24 changes: 22 additions & 2 deletions docs/rename.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[← Back to README](../README.md)

Press `F2` (or right-click → **"Rename Symbol"**) on a route name, config key, translation key, environment variable, view, Blade component, Livewire component, middleware alias, container binding, PHP class (Eloquent model, controller, job, service, form request, or any other project class), magic member (relationship / scope / accessor), or database column. The extension rewrites every call site AND the declaration site (or moves the backing file, or generates the migration) in one atomic operation.
Press `F2` (or right-click → **"Rename Symbol"**) on a route name, config key, translation key, environment variable, view, Blade component, Livewire component, middleware alias, container binding, PHP class (Eloquent model, controller, job, service, form request, or any other project class), magic member (relationship / scope / accessor), database column, or a function-local PHP variable. The extension rewrites every call site AND the declaration site (or moves the backing file, or generates the migration) in one atomic operation.

You can also right-click a `.blade.php` file in Zed's file explorer → **Rename** → call sites update atomically with the file move.

Expand Down Expand Up @@ -110,4 +110,24 @@ The transforms run both ways: `active` ↔ `scopeActive`, `full_name` ↔ `getFu

**Vendor-located files refuse to rename** — never moves a Composer-installed view, component, or Livewire class, and never rewrites a middleware alias or binding registered inside `vendor/`. You'll see a toast explaining why instead of a silent no-op.

**Not yet renameable** (out of scope for this round, planned follow-up): Blade variables (`@foreach`, `@php` locals + the `view('x', ['key' => …])` / `compact('key')` linkage), PHP function-local variables. `prepare_rename` returns nothing for these so F2 silently does nothing.
**Function-local PHP variables** rename scope-aware, within a single file. Press `F2` on a `$variable` and every occurrence that resolves to the *same lexical binding* is rewritten — and nothing else:

```php
function greet($user) { // F2 on any $user here…
$user = trim($user);
return "Hello " . $user; // …renames all four occurrences to $account
}
```

Scope boundaries are respected exactly:

- **Nested closures isolate.** A `function () { … }` does not auto-capture, so an identically-named `$user` inside it is a separate binding — renaming the outer one leaves it alone (and vice versa). A `use ($user)` capture *does* tie the two together, so the rename cascades across the `use (…)` clause and the closure body in lockstep.
- **Arrow functions auto-capture.** `fn () => $base` shares `$base` with the enclosing scope, so renaming `$base` reaches inside the arrow body. The arrow's *own* parameters still shadow: `fn ($user) => $user` is its own binding.
- **Properties are never touched.** `$this->user` and `$obj->prop` are property accesses, not variables; `self::$bar` / `Foo::$bar` are class properties even though they're spelled with a `$`. `$this` itself is never renameable. Renaming a class property is a separate, scope-different operation tracked as a follow-up.
- **Ambiguous references are refused, never half-renamed.** Some shapes name a local by something other than a plain `$variable` token — a string key, or a cross-scope alias — that a single-file, scope-local rewrite can't follow. Rather than emit a corrupting partial edit, the engine refuses outright (`F2` does nothing):
- **`compact('user')` / `extract('user')`** name the local *by string literal* — ubiquitous in Laravel (`return view('x', compact('user'))`). Renaming `$user` would orphan the `'user'` string.
- **`global $user`** aliases the in-scope variable to the top-level global; a scope-local rewrite would sever the alias.

This safety boundary is deliberately conservative and not yet exhaustive — more exotic dynamic shapes (`${'user'}`, `$GLOBALS['user']`, `extract($runtimeArray)`, `get_defined_vars()`) reference a local without a plain `$variable` token and aren't all detected yet. Mapping the complete set, and deciding refuse-vs-rewrite for each, is tracked in [#96](https://github.com/mike-bronner/zed-laravel/issues/96).

**Not yet renameable** (out of scope for this round, planned follow-up): Blade variables (`@foreach`, `@php` locals + the `view('x', ['key' => …])` / `compact('key')` linkage), and class properties (`$this->foo`, `self::$bar`). `prepare_rename` returns nothing for these so F2 silently does nothing.
1 change: 1 addition & 0 deletions laravel-lsp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub mod pattern_disk_cache;
pub mod pattern_indexer;
pub mod php_class;
// php_outline was consolidated into `laravel_introspector::walker`.
pub mod php_variable_rename;
pub mod queries;
pub mod query_chain;
pub mod references;
Expand Down
84 changes: 84 additions & 0 deletions laravel-lsp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14835,6 +14835,69 @@ return [
}))
}

/// prepare-rename for a plain PHP local variable (`$user`): return the
/// range of the `$name` token under the cursor, or `None` if the cursor
/// isn't on a renameable local variable. Pure-PHP, single-file —
/// scope-aware analysis lives in [`laravel_lsp::php_variable_rename`].
async fn variable_rename_at(&self, uri: &Url, position: Position) -> Option<Range> {
use laravel_lsp::query_chain::position_to_byte_offset;
let content = self
.documents
.read()
.await
.get(uri)
.map(|(c, _)| c.clone())?;
let byte = position_to_byte_offset(&content, position.line, position.character)?;
let (line, start, end) =
laravel_lsp::php_variable_rename::variable_at_cursor(&content, byte)?;
Some(Range {
start: Position {
line,
character: start,
},
end: Position {
line,
character: end,
},
})
}

/// rename for a plain PHP local variable: rewrite every occurrence that
/// resolves to the same binding scope (function-local, scope-aware).
///
/// Returns `Ok(None)` when the cursor isn't on a local variable, so the
/// caller can fall through to the Laravel-pattern / class rename pipeline.
/// Returns `Err` only when the cursor *is* on a variable but the new name
/// isn't a legal PHP identifier — surfaced to the user as a toast.
async fn variable_rename_edit(
&self,
uri: &Url,
position: Position,
new_name: &str,
) -> jsonrpc::Result<Option<WorkspaceEdit>> {
use laravel_lsp::query_chain::position_to_byte_offset;
let Some(content) = self.documents.read().await.get(uri).map(|(c, _)| c.clone()) else {
return Ok(None);
};
let Ok(file_path) = uri.to_file_path() else {
return Ok(None);
};
let Some(byte) = position_to_byte_offset(&content, position.line, position.character)
else {
return Ok(None);
};
// Not on a local variable → let the caller continue the pipeline.
if laravel_lsp::php_variable_rename::variable_at_cursor(&content, byte).is_none() {
return Ok(None);
}
match laravel_lsp::php_variable_rename::variable_rename_targets(
&content, &file_path, byte, new_name,
) {
Ok(targets) => Ok(laravel_lsp::rename::build_rename_edit(&targets)),
Err(msg) => Err(laravel_lsp::rename::rename_error(msg)),
}
}

/// Create a goto location for a url('path') call
/// Navigates to the file in public directory if it exists
async fn create_url_location_from_salsa(
Expand Down Expand Up @@ -20071,6 +20134,15 @@ impl LanguageServer for LaravelLanguageServer {
}
}

// Plain PHP local variable (`$user`): pure-PHP, single-file,
// scope-aware rename. Checked after the property-oriented magic /
// column member renames above (so `$user->email` still routes there)
// and before the Laravel-pattern classifier, which never classifies a
// bare `$variable`.
if let Some(range) = self.variable_rename_at(uri, position).await {
return Ok(Some(PrepareRenameResponse::Range(range)));
}

let root_path = self.root_path.read().await.clone();
let symbol = match classify_with_decl_fallback(
self,
Expand Down Expand Up @@ -20180,6 +20252,18 @@ impl LanguageServer for LaravelLanguageServer {
return Ok(Some(edit));
}

// Plain PHP local variable (`$user`): function-local, scope-aware
// rewrite of every in-scope occurrence. Checked after the
// property-oriented column / magic-member renames (so `$user->email`
// still routes there) and before the Laravel-pattern classifier, which
// never classifies a bare `$variable`. `Ok(None)` here means "not a
// local variable" — fall through to the rest of the pipeline.
match self.variable_rename_edit(uri, position, &new_name).await {
Ok(Some(edit)) => return Ok(Some(edit)),
Err(e) => return Err(e),
Ok(None) => {}
}

let root_path = self.root_path.read().await.clone();
let symbol = match classify_with_decl_fallback(
self,
Expand Down
Loading