Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **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.
Expand Down
28 changes: 27 additions & 1 deletion src/indexing/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand Down Expand Up @@ -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(&params, dir.path()));
}
}
123 changes: 69 additions & 54 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
),
});

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down
63 changes: 51 additions & 12 deletions src/virtual_members/laravel/database_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ fn discover_migration_files(
) -> std::io::Result<Vec<PathBuf>> {
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(
Expand Down Expand Up @@ -489,20 +489,36 @@ fn collect_configured_migration_files(

fn collect_default_migration_files(
workspace_root: &Path,
path: &Path,
files: &mut Vec<PathBuf>,
) -> 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(())
Expand Down Expand Up @@ -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, "<?php").unwrap();
std::fs::create_dir(root.join(".git")).unwrap();
std::fs::write(root.join(".gitignore"), "/ignored/\n").unwrap();
std::fs::create_dir_all(root.join("ignored/database/migrations")).unwrap();
std::fs::write(
root.join("ignored/database/migrations/2024_01_02_000000_ignored.php"),
"<?php",
)
.unwrap();
std::os::unix::fs::symlink(root, root.join("workspace-loop")).unwrap();

let files = discover_migration_files(root, &LaravelMigrationsConfig::default()).unwrap();

assert_eq!(files, vec![migration]);
}

#[test]
fn applies_migrations_by_basename_across_directories() {
let dir = tempfile::tempdir().unwrap();
Expand Down
Loading