From bcab1b6cfa9d2f060e6a5f44f359a5b84087de89 Mon Sep 17 00:00:00 2001 From: sidux Date: Tue, 4 Aug 2026 19:26:03 +0200 Subject: [PATCH 1/2] fix(laravel): bound migration discovery traversal Default migration discovery recursively followed ignored and generated directories, including symlinked trees. This could keep server initialization busy indefinitely and block every LSP request. Use the gitignore-aware walker without following symlinks, while preserving direct migration discovery. --- docs/CHANGELOG.md | 1 + .../laravel/database_schema.rs | 63 +++++++++++++++---- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c739332d..922243d7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -75,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Laravel migration discovery no longer gets stuck in generated or symlinked directories.** Default discovery now respects ignore files, skips hidden and vendor directories, and does not follow directory symlinks, so editor features remain available in projects with generated dependency trees or link cycles. Contributed by @sidux. - **Every feature now resolves a type as well as hover does.** Three parts of the type engine were switched on for hover, completion, and diagnostics only: inferring a return type from a method body when nothing declares one, resolving the model a Laravel auth guard is configured with, and reading the array shape a validation rules array describes. Every other feature asked the type engine the same question with those parts absent and got a poorer answer for the identical code, so hovering `auth('admin')->user()->email` named the model's property while go-to-definition on that same `email` had nothing to jump to. They are now active for every request, so go-to-definition, find-references, signature help, code actions, rename, and inlay hints see what hover sees. - **"Promote to constructor property" keeps the property's attributes.** An attribute on the property being promoted (`#[SomeAttr] private int $bar;`) was deleted along with the declaration and never re-emitted, so the refactor quietly removed executable metadata that ORMs, validators, and serializers read at runtime. The attributes now move onto the promoted parameter, ahead of the visibility keyword, one `#[…]` group each so a grouped `#[First, Second]` still reads clearly. Arguments carry over verbatim, and an attribute the parameter already has is not repeated. - **Override completion writes `static`, not `$this`, as the return type.** Completing an override of a method whose return type only exists in PHPDoc as `@return $this` generated `public function withTitle(string $title): $this`, which PHP rejects: `$this` is not a native type hint. The generated signature now uses `: static`, the native spelling of a fluent return, and the same applies to the "Implement missing methods" quickfix and to unions like `$this|null`. A `@template` param is no longer emitted as a hint either: `@return T` used to generate `: T`, which PHP reads as a return of the nonexistent class `T`. Completing an override of a trait method now also restates the trait's docblock-only `@param` and `@return` types (and the `@template` params they use) above the new declaration, since PHP inherits PHPDoc from parent classes and interfaces but not from traits. Only the types the generated signature cannot express are restated, so an override of a plainly typed trait method still comes out bare. diff --git a/src/virtual_members/laravel/database_schema.rs b/src/virtual_members/laravel/database_schema.rs index 6af2095b..405f7faa 100644 --- a/src/virtual_members/laravel/database_schema.rs +++ b/src/virtual_members/laravel/database_schema.rs @@ -402,7 +402,7 @@ fn discover_migration_files( ) -> std::io::Result> { let mut files = Vec::new(); if config.paths.is_empty() { - collect_default_migration_files(workspace_root, workspace_root, &mut files)?; + collect_default_migration_files(workspace_root, &mut files)?; } else { for configured in &config.paths { collect_configured_migration_files( @@ -489,20 +489,36 @@ fn collect_configured_migration_files( fn collect_default_migration_files( workspace_root: &Path, - path: &Path, files: &mut Vec, ) -> std::io::Result<()> { - if !path.is_dir() || is_vendor_path(workspace_root, path) { - return Ok(()); - } - if is_database_migrations_dir(path) { - return collect_configured_migration_files(path, files); - } - for entry in std::fs::read_dir(path)? { - let entry = entry?; + let root = workspace_root.to_path_buf(); + let walker = ignore::WalkBuilder::new(workspace_root) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .hidden(true) + .parents(true) + .ignore(true) + .follow_links(false) + .filter_entry(move |entry| { + let path = entry.path(); + if is_vendor_path(&root, path) { + return false; + } + + !entry.file_type().is_some_and(|kind| kind.is_dir()) + || !path.parent().is_some_and(is_database_migrations_dir) + }) + .build(); + + for entry in walker { + let entry = entry.map_err(std::io::Error::other)?; let path = entry.path(); - if path.is_dir() { - collect_default_migration_files(workspace_root, &path, files)?; + if entry.file_type().is_some_and(|kind| kind.is_file()) + && path.parent().is_some_and(is_database_migrations_dir) + && path.extension().and_then(|extension| extension.to_str()) == Some("php") + { + files.push(path.to_path_buf()); } } Ok(()) @@ -2050,6 +2066,29 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn default_migration_discovery_skips_ignored_and_symlinked_directories() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let migration = root.join("database/migrations/2024_01_01_000000_create_posts.php"); + std::fs::create_dir_all(migration.parent().unwrap()).unwrap(); + std::fs::write(&migration, " Date: Tue, 4 Aug 2026 19:34:37 +0200 Subject: [PATCH 2/2] fix(laravel): gate schema work by project type Schema and migration discovery ran before Composer classified the workspace, so non-Laravel projects paid for Laravel-only startup scans and file watchers. Start and refresh those features only after Laravel or Illuminate dependencies are detected. --- docs/CHANGELOG.md | 2 +- src/indexing/watch.rs | 28 +++++++++- src/server.rs | 123 +++++++++++++++++++++++------------------- 3 files changed, 97 insertions(+), 56 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 922243d7..0844f538 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -75,7 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **Laravel migration discovery no longer gets stuck in generated or symlinked directories.** Default discovery now respects ignore files, skips hidden and vendor directories, and does not follow directory symlinks, so editor features remain available in projects with generated dependency trees or link cycles. Contributed by @sidux. +- **Non-Laravel projects no longer start Laravel schema discovery.** PHPantom now checks Composer dependencies before loading or watching Laravel schema dumps and migrations. In Laravel projects, default migration discovery also respects ignore files, skips hidden and vendor directories, and does not follow directory symlinks, so generated dependency trees or link cycles cannot block editor features. Contributed by @sidux. - **Every feature now resolves a type as well as hover does.** Three parts of the type engine were switched on for hover, completion, and diagnostics only: inferring a return type from a method body when nothing declares one, resolving the model a Laravel auth guard is configured with, and reading the array shape a validation rules array describes. Every other feature asked the type engine the same question with those parts absent and got a poorer answer for the identical code, so hovering `auth('admin')->user()->email` named the model's property while go-to-definition on that same `email` had nothing to jump to. They are now active for every request, so go-to-definition, find-references, signature help, code actions, rename, and inlay hints see what hover sees. - **"Promote to constructor property" keeps the property's attributes.** An attribute on the property being promoted (`#[SomeAttr] private int $bar;`) was deleted along with the declaration and never re-emitted, so the refactor quietly removed executable metadata that ORMs, validators, and serializers read at runtime. The attributes now move onto the promoted parameter, ahead of the visibility keyword, one `#[…]` group each so a grouped `#[First, Second]` still reads clearly. Arguments carry over verbatim, and an attribute the parameter already has is not repeated. - **Override completion writes `static`, not `$this`, as the return type.** Completing an override of a method whose return type only exists in PHPDoc as `@return $this` generated `public function withTitle(string $title): $this`, which PHP rejects: `$this` is not a native type hint. The generated signature now uses `: static`, the native spelling of a fluent return, and the same applies to the "Implement missing methods" quickfix and to unions like `$this|null`. A `@template` param is no longer emitted as a hint either: `@return T` used to generate `: T`, which PHP reads as a return of the nonexistent class `T`. Completing an override of a trait method now also restates the trait's docblock-only `@param` and `@return` types (and the `@template` params they use) above the new declaration, since PHP inherits PHPDoc from parent classes and interfaces but not from traits. Only the types the generated signature cannot express are restated, so an override of a plainly typed trait method still comes out bare. diff --git a/src/indexing/watch.rs b/src/indexing/watch.rs index 3716d1f5..1721fc01 100644 --- a/src/indexing/watch.rs +++ b/src/indexing/watch.rs @@ -39,6 +39,7 @@ impl Backend { let mut schema_full_rebuild = false; let mut migration_changes: Vec<(PathBuf, FileChangeType)> = Vec::new(); let mut php_changes: Vec<(String, PathBuf, FileChangeType)> = Vec::new(); + let is_laravel = self.resolved_class_cache.read().is_laravel(); { let open = self.open_files.read(); let parsed = self.parsed_uris.read(); @@ -49,7 +50,8 @@ impl Backend { composer_changed = true; continue; } - if let Ok(file_path) = change.uri.to_file_path() + if is_laravel + && let Ok(file_path) = change.uri.to_file_path() && crate::virtual_members::laravel::database_schema::SchemaIndex::watched_path_affects_schema( root, &laravel_config, @@ -141,3 +143,27 @@ impl Backend { true } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_laravel_projects_ignore_schema_watch_changes() { + let dir = tempfile::tempdir().unwrap(); + let schema = dir.path().join("database/schema/default-schema.sql"); + std::fs::create_dir_all(schema.parent().unwrap()).unwrap(); + std::fs::write(&schema, "CREATE TABLE users (id bigint);").unwrap(); + + let backend = Backend::new_test(); + backend.resolved_class_cache.write().set_laravel(false); + let params = DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: Url::from_file_path(&schema).unwrap(), + typ: FileChangeType::CREATED, + }], + }; + + assert!(!backend.apply_watched_file_changes(¶ms, dir.path())); + } +} diff --git a/src/server.rs b/src/server.rs index 5a839b51..6e57f533 100644 --- a/src/server.rs +++ b/src/server.rs @@ -295,30 +295,6 @@ impl LanguageServer for Backend { } } - let laravel_config = self.config().laravel; - if laravel_config.schema.enabled() || laravel_config.migrations.enabled() { - let bp_macros = self.laravel_macros.read().blueprint_macro_closures(); - match crate::virtual_members::laravel::database_schema::load_schema_index( - &root, - &laravel_config, - &bp_macros, - ) { - Ok(index) => { - self.resolved_class_cache - .write() - .set_schema_index(index.clone()); - *self.schema_index.write() = index; - } - Err(e) => { - self.log( - MessageType::WARNING, - format!("Failed to load Laravel schema dumps: {}", e), - ) - .await; - } - } - } - // Parse composer.json once up front. The result is used for // PHP version detection and passed into init_single_project // so the file is never re-read during startup. @@ -373,9 +349,36 @@ impl LanguageServer for Backend { } } + let is_laravel = self.resolved_class_cache.read().is_laravel(); + if is_laravel { + let laravel_config = self.config().laravel; + if laravel_config.schema.enabled() || laravel_config.migrations.enabled() { + let bp_macros = self.laravel_macros.read().blueprint_macro_closures(); + match crate::virtual_members::laravel::database_schema::load_schema_index( + &root, + &laravel_config, + &bp_macros, + ) { + Ok(index) => { + self.resolved_class_cache + .write() + .set_schema_index(index.clone()); + *self.schema_index.write() = index; + } + Err(e) => { + self.log( + MessageType::WARNING, + format!("Failed to load Laravel schema dumps: {}", e), + ) + .await; + } + } + } + } + // Warm the Eloquent Builder resolution cache only for Laravel // projects; a non-Laravel workspace has nothing to warm. - if self.resolved_class_cache.read().is_laravel() { + if is_laravel { progress.set_percentage(90, "Warming Laravel completions"); let warmed = self.warm_laravel_completion_cache(); if warmed > 0 { @@ -460,39 +463,43 @@ impl LanguageServer for Backend { // Register file watchers for staleness detection. The client // will notify us when PHP files or composer files change on disk // (even outside the editor), so we can refresh our indices. + let mut watchers = vec![ + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.php".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/composer.json".to_string()), + kind: Some(WatchKind::Change), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/composer.lock".to_string()), + kind: Some(WatchKind::Change), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/.phpantom.toml".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + ]; + if self.resolved_class_cache.read().is_laravel() { + watchers.extend([ + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.sql".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/config/database.php".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + ]); + } + registrations.push(Registration { id: "workspace/didChangeWatchedFiles".to_string(), method: "workspace/didChangeWatchedFiles".to_string(), register_options: Some( - serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { - watchers: vec![ - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/*.php".to_string()), - kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/composer.json".to_string()), - kind: Some(WatchKind::Change), - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/composer.lock".to_string()), - kind: Some(WatchKind::Change), - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/*.sql".to_string()), - kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/config/database.php".to_string()), - kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/.phpantom.toml".to_string()), - kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), - }, - ], - }) - .unwrap(), + serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }) + .unwrap(), ), }); @@ -2679,6 +2686,10 @@ impl Backend { } pub(crate) fn reload_laravel_schema_index(&self, root: &std::path::Path) { + if !self.resolved_class_cache.read().is_laravel() { + return; + } + if let Ok(cfg) = crate::config::load_config(root) { *self.workspace.config.lock() = cfg; } @@ -2710,6 +2721,10 @@ impl Backend { } pub(crate) fn update_laravel_migrations(&self, changes: &[(PathBuf, FileChangeType)]) { + if !self.resolved_class_cache.read().is_laravel() { + return; + } + let mut index = self.schema_index.write(); let mut any_changed = false; for (path, change_type) in changes {