Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 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, model classes, 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 — 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,9 +228,8 @@ After saving, restart Intelephense (`Cmd+Shift+P → lsp: restart`). For the lic

## 🚧 Planned Features

**Rename — remaining work** (the class-backed kinds, the Eloquent model-class engine, 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, and database columns shipped; variables didn't):

- ✏️ **More PHP class kinds** — the FQCN rename engine (use-statement updates, type-hint / static-call / `new` / `::class` rewrites, docblocks, file move) now powers Eloquent model rename; extending it to controllers, jobs, services, and form requests as first-class symbols is the follow-up.
- 📝 **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.

Expand Down
4 changes: 2 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, Eloquent model 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), 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.

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 @@ -80,7 +80,7 @@ return view('users.account');

**Container bindings** follow the same shape as middleware aliases: the quoted name at the registration site PLUS every `app('x')`, `resolve('x')`, `app()->make('x')` call site.

**Eloquent model classes** rename project-wide. Press `F2` on a model class name and every reference rewrites in one pass — `use` imports, `User::` static calls, `new User`, type hints, `::class` references, `extends`/`implements`, `instanceof`, and `@param`/`@return`/`@var` docblocks — and the backing `.php` file is renamed alongside. Aliased imports are respected (`use App\Models\User as U;` keeps `U`), and members that just happen to share the class's name are left untouched. Same-namespace renames only — moving a class to a different namespace returns a status message rather than a half-applied move.
**PHP classes** rename project-wide — Eloquent models, controllers, jobs, services, form requests, and any other first-class project class share one FQCN rename engine. Press `F2` on a class name and every reference rewrites in one pass — `use` imports, `User::`/`UserController::` static calls, `new User`, type hints (constructor injection, action arguments, return types), `::class` references (including `[UserController::class, 'index']` route actions), `extends`/`implements`, `instanceof`, and `@param`/`@return`/`@var` docblocks — and the backing `.php` file is renamed alongside (same directory, basename swapped). Aliased imports are respected (`use App\Models\User as U;` keeps `U`), and members that just happen to share the class's name are left untouched. Vendor-located classes refuse to rename. Same-namespace renames only — moving a class to a different namespace returns a status message rather than a half-applied move.

**Eloquent magic members** rename from their usage sites. Press `F2` on a relationship, scope, or accessor usage and the declaring method renames with the *inverse name transform* applied — every cached usage follows:

Expand Down
13 changes: 13 additions & 0 deletions laravel-lsp/src/class_rename.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ pub fn is_dependency_path(path: &Path) -> bool {
})
}

/// The new path for a renamed class's declaring file: same directory, basename
/// swapped to `new_basename`, `.php` extension preserved.
///
/// PSR-4 puts one class per file with the file basename equal to the class
/// basename, so renaming `App\Http\Controllers\UserController` →
/// `AdminController` moves `app/Http/Controllers/UserController.php` →
/// `app/Http/Controllers/AdminController.php` — the class kind (controller, job,
/// service, form request, model) doesn't change the rule. Same-directory only:
/// a same-namespace rename never crosses directories.
pub fn renamed_file_path(decl_path: &Path, new_basename: &str) -> PathBuf {
decl_path.with_file_name(format!("{new_basename}.php"))
}

/// A resolved class-name occurrence: the FQCN it refers to and the byte span of
/// the **basename segment** to rewrite (just `User` in `App\Models\User`).
#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down
289 changes: 288 additions & 1 deletion laravel-lsp/src/class_rename/tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use super::{class_at_cursor, is_dependency_path, project_php_files, reference_spans};
use super::{
class_at_cursor, is_dependency_path, project_php_files, reference_spans, renamed_file_path,
};
use std::path::Path;

/// Replace every span (right-to-left so earlier offsets stay valid) — mimics
Expand Down Expand Up @@ -263,3 +265,288 @@ fn is_dependency_path_flags_vendor() {
)));
assert!(!is_dependency_path(Path::new("/proj/app/Models/User.php")));
}

// ---- file move target -----------------------------------------------------

#[test]
fn renamed_file_path_swaps_basename_same_dir_for_every_kind() {
// The declaring file moves within its own directory, basename swapped,
// `.php` preserved — identical rule for every class kind (PSR-4: one class
// per file, basename == class basename).
let cases = [
(
"/proj/app/Http/Controllers/UserController.php",
"AdminController",
"/proj/app/Http/Controllers/AdminController.php",
),
(
"/proj/app/Jobs/SendWelcomeEmail.php",
"SendGreeting",
"/proj/app/Jobs/SendGreeting.php",
),
(
"/proj/app/Services/PaymentService.php",
"BillingService",
"/proj/app/Services/BillingService.php",
),
(
"/proj/app/Http/Requests/StorePostRequest.php",
"CreatePostRequest",
"/proj/app/Http/Requests/CreatePostRequest.php",
),
// Regression guard: models follow the very same rule.
(
"/proj/app/Models/User.php",
"Customer",
"/proj/app/Models/Customer.php",
),
];
for (decl, new_basename, expected) in cases {
assert_eq!(
renamed_file_path(Path::new(decl), new_basename),
Path::new(expected),
"rename {decl} → {new_basename}"
);
}
}

// ---- controllers ----------------------------------------------------------

#[test]
fn renames_controller_class_declaration_and_route_references() {
// Declaration site: `class UserController extends Controller`.
let decl = r#"<?php
namespace App\Http\Controllers;
class UserController extends Controller
{
public function index() {}
}
"#;
let out = rename(
decl,
"App\\Http\\Controllers\\UserController",
"UserController",
"AdminController",
);
assert!(
out.contains("class AdminController extends Controller"),
"declaration\n{out}"
);
// The base `Controller` is untouched.
assert!(out.contains("extends Controller"), "base class\n{out}");

// Consumer site: a routes file referencing the controller via `::class`.
let routes = r#"<?php
use App\Http\Controllers\UserController;
Route::get('/users', [UserController::class, 'index']);
Route::resource('users', UserController::class);
"#;
let out = rename(
routes,
"App\\Http\\Controllers\\UserController",
"UserController",
"AdminController",
);
assert!(
out.contains("use App\\Http\\Controllers\\AdminController;"),
"use import\n{out}"
);
assert!(
out.contains("[AdminController::class, 'index']"),
"route array action\n{out}"
);
assert!(
out.contains("Route::resource('users', AdminController::class)"),
"resource action\n{out}"
);
}

#[test]
fn cursor_on_controller_declaration_resolves_class() {
let src =
"<?php\nnamespace App\\Http\\Controllers;\nclass UserController extends Controller {}\n";
let byte = src.find("class UserController").unwrap() + "class User".len();
let (fqcn, _span) = class_at_cursor(src, byte).expect("class at cursor");
assert_eq!(fqcn, "App\\Http\\Controllers\\UserController");
}

// ---- jobs -----------------------------------------------------------------

#[test]
fn renames_job_class_declaration_and_dispatch_references() {
let decl = r#"<?php
namespace App\Jobs;
class SendWelcomeEmail implements ShouldQueue
{
public function handle() {}
}
"#;
let out = rename(
decl,
"App\\Jobs\\SendWelcomeEmail",
"SendWelcomeEmail",
"SendGreeting",
);
assert!(
out.contains("class SendGreeting implements ShouldQueue"),
"declaration\n{out}"
);

// Consumer: dispatch via static call, `new`, and `dispatch(new …)`.
let consumer = r#"<?php
namespace App\Http\Controllers;
use App\Jobs\SendWelcomeEmail;
class RegisterController extends Controller {
public function store() {
SendWelcomeEmail::dispatch($user);
dispatch(new SendWelcomeEmail($user));
$job = new SendWelcomeEmail($user);
return $job;
}
}
"#;
let out = rename(
consumer,
"App\\Jobs\\SendWelcomeEmail",
"SendWelcomeEmail",
"SendGreeting",
);
assert!(
out.contains("use App\\Jobs\\SendGreeting;"),
"use import\n{out}"
);
assert!(
out.contains("SendGreeting::dispatch($user)"),
"static dispatch\n{out}"
);
assert_eq!(
out.matches("new SendGreeting($user)").count(),
2,
"both `new` sites\n{out}"
);
// The enclosing controller is untouched.
assert!(out.contains("class RegisterController extends Controller"));
}

// ---- services -------------------------------------------------------------

#[test]
fn renames_service_class_declaration_and_injection_references() {
let decl = r#"<?php
namespace App\Services;
class PaymentService
{
public function charge() {}
}
"#;
let out = rename(
decl,
"App\\Services\\PaymentService",
"PaymentService",
"BillingService",
);
assert!(out.contains("class BillingService"), "declaration\n{out}");

// Consumer: constructor-injected + method type-hint + return type + `new`.
let consumer = r#"<?php
namespace App\Http\Controllers;
use App\Services\PaymentService;
class CheckoutController extends Controller {
public function __construct(private PaymentService $payments) {}
public function build(PaymentService $service): PaymentService {
return new PaymentService();
}
}
"#;
let out = rename(
consumer,
"App\\Services\\PaymentService",
"PaymentService",
"BillingService",
);
assert!(
out.contains("use App\\Services\\BillingService;"),
"use import\n{out}"
);
assert!(
out.contains("__construct(private BillingService $payments)"),
"constructor injection\n{out}"
);
assert!(
out.contains("build(BillingService $service): BillingService"),
"param + return type\n{out}"
);
assert!(out.contains("new BillingService()"), "new\n{out}");
assert!(out.contains("class CheckoutController extends Controller"));
}

// ---- form requests --------------------------------------------------------

#[test]
fn renames_form_request_class_declaration_and_type_hint_references() {
let decl = r#"<?php
namespace App\Http\Requests;
class StorePostRequest extends FormRequest
{
public function rules(): array { return []; }
}
"#;
let out = rename(
decl,
"App\\Http\\Requests\\StorePostRequest",
"StorePostRequest",
"CreatePostRequest",
);
assert!(
out.contains("class CreatePostRequest extends FormRequest"),
"declaration\n{out}"
);

// Consumer: a controller type-hinting the request as an action argument,
// plus a docblock reference.
let consumer = r#"<?php
namespace App\Http\Controllers;
use App\Http\Requests\StorePostRequest;
class PostController extends Controller {
public function store(StorePostRequest $request) {
return $request->validated();
}
/**
* @param StorePostRequest $request
*/
public function update(StorePostRequest $request) {}
}
"#;
let out = rename(
consumer,
"App\\Http\\Requests\\StorePostRequest",
"StorePostRequest",
"CreatePostRequest",
);
assert!(
out.contains("use App\\Http\\Requests\\CreatePostRequest;"),
"use import\n{out}"
);
assert!(
out.contains("store(CreatePostRequest $request)"),
"action type hint\n{out}"
);
assert!(
out.contains("update(CreatePostRequest $request)"),
"second type hint\n{out}"
);
assert!(
out.contains("@param CreatePostRequest $request"),
"docblock\n{out}"
);
assert!(out.contains("class PostController extends Controller"));
}

#[test]
fn cursor_on_job_static_dispatch_resolves_class() {
let src = "<?php\nnamespace App\\Http\\Controllers;\nuse App\\Jobs\\SendWelcomeEmail;\nSendWelcomeEmail::dispatch($u);\n";
let byte = src.find("SendWelcomeEmail::dispatch").unwrap() + 1; // inside the class name
let (fqcn, span) = class_at_cursor(src, byte).expect("class at cursor");
assert_eq!(fqcn, "App\\Jobs\\SendWelcomeEmail");
assert_eq!(&src[span.0..span.1], "SendWelcomeEmail");
}
2 changes: 1 addition & 1 deletion laravel-lsp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14808,7 +14808,7 @@ return [

// Rename the declaring file (same dir, new basename). Emitted AFTER the
// text edits so the `class User → class NewName` edit lands first.
let new_path = decl_path.with_file_name(format!("{new_basename}.php"));
let new_path = laravel_lsp::class_rename::renamed_file_path(&decl_path, &new_basename);
if new_path != decl_path {
if let (Ok(old_uri), Ok(new_uri)) = (
Url::from_file_path(&decl_path),
Expand Down