From 8c6e4d5591921713c7f906efcc681045c63c6698 Mon Sep 17 00:00:00 2001 From: sidux Date: Wed, 29 Jul 2026 14:04:25 +0200 Subject: [PATCH] fix(indexing): Canonicalize vendor scan paths --- docs/CHANGELOG.md | 1 + src/indexing/init.rs | 5 ++- src/indexing/mod.rs | 99 ++++++++++++++++++++++++++++++++++++++++---- src/indexing/scan.rs | 26 +++++------- src/lib.rs | 17 ++++---- 5 files changed, 113 insertions(+), 35 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 23064f7d2..58daafce0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -55,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Dependencies reached through a symlinked path are still treated as dependencies.** When the workspace or its `vendor` directory sits behind a symlink or another path alias (macOS's `/var` for `/private/var`, for instance), the same file could be seen under two spellings, and only one of them was recognised as vendor code. Completion could rank a dependency's class as your own code, or a package the project requires directly as an indirect one. After a `composer update`, a function or constant the update removed could also keep resolving. Both spellings are now recognised. Contributed by @sidux. - **Find References on `self` and `static` names the right class in a file with several `namespace` blocks.** The keyword was resolved against the file's first namespace, so `self` written inside a later block named a class that does not exist there and its references came back empty. It is now resolved from the enclosing class itself, the way every other keyword lookup already was. - **Closing a file no longer holds up the editor while the file is re-read.** The re-parse that keeps a closed project file in the index ran on the request loop, so closing a large file, or a service provider whose edit rebuilds the Laravel indexes, delayed every request until it finished. It now runs on a background task, as a keystroke's re-parse does. - **A workspace symbol search no longer blocks edits while it reads files.** The search kept the symbol indexes locked while it read unopened files from disk to position each match, so a keystroke's parse waited for the whole read. Matching now finishes under the locks and the files are read afterwards, and only for the results that will be answered. A second pass over the class index that could never add a result is gone. diff --git a/src/indexing/init.rs b/src/indexing/init.rs index 395e70eae..f677a4fb3 100644 --- a/src/indexing/init.rs +++ b/src/indexing/init.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use tower_lsp::lsp_types::*; -use super::classify_class_origin; +use super::{classify_class_origin, path_aliases}; use crate::Backend; use crate::classmap_scanner; use crate::composer; @@ -136,6 +136,7 @@ impl Backend { .set_runtime_permission_package(runtime_permissions); let (vendor_dir, vendor_path) = self.init_autoload_paths(root, composer_json.as_ref()); + let vendor_paths = path_aliases(&vendor_path); // ── Build the classmap ────────────────────────────────────── let strategy = self.config().indexing.strategy(); @@ -287,7 +288,7 @@ impl Backend { let origin = class_origins .get(&fqn) .copied() - .unwrap_or_else(|| classify_class_origin(&path, &vendor_path, &package_roots)); + .unwrap_or_else(|| classify_class_origin(&path, &vendor_paths, &package_roots)); origins.insert(fqn.clone(), origin); idx.or_insert_with(fqn, || crate::util::path_to_uri(&path)); } diff --git a/src/indexing/mod.rs b/src/indexing/mod.rs index 7edf8d3c5..b90459057 100644 --- a/src/indexing/mod.rs +++ b/src/indexing/mod.rs @@ -22,20 +22,105 @@ mod reconcile; mod scan; mod watch; +/// Return the path as supplied plus its canonical spelling when the +/// filesystem exposes the same location through an alias. +pub(crate) fn path_aliases(path: &Path) -> Vec { + let mut paths = vec![path.to_path_buf()]; + if let Ok(canonical) = path.canonicalize() + && canonical != path + { + paths.push(canonical); + } + paths +} + +/// Find the vendor package root a file under the vendor directory belongs +/// to, or `None` when the file is outside every spelling in `vendor_paths` +/// or under no known package root. +/// +/// Package roots are stored canonicalized while a file path can arrive in +/// either spelling (Composer's classmap keeps the raw one), so a root is +/// also matched by its position relative to the vendor directory. +pub(crate) fn vendor_package_root_for_path<'a>( + path: &Path, + vendor_paths: &[PathBuf], + vendor_package_roots: &'a [(PathBuf, crate::ClassCompletionOrigin, String)], +) -> Option<&'a (PathBuf, crate::ClassCompletionOrigin, String)> { + let relative = vendor_paths + .iter() + .find_map(|vendor| path.strip_prefix(vendor).ok())?; + vendor_package_roots.iter().find(|(root, _, _)| { + path.starts_with(root) + || vendor_paths.iter().any(|vendor| { + root.strip_prefix(vendor).is_ok_and(|root_relative| { + !root_relative.as_os_str().is_empty() && relative.starts_with(root_relative) + }) + }) + }) +} + /// Classify where a class file originates (project source, a direct vendor /// dependency, or a transitive vendor dependency) for completion ranking. pub(crate) fn classify_class_origin( path: &Path, - vendor_path: &Path, + vendor_paths: &[PathBuf], vendor_package_roots: &[(PathBuf, crate::ClassCompletionOrigin, String)], ) -> crate::ClassCompletionOrigin { - if !path.starts_with(vendor_path) { + if !vendor_paths.iter().any(|vendor| path.starts_with(vendor)) { return crate::ClassCompletionOrigin::Project; } - for (root, origin, _pkg_name) in vendor_package_roots { - if path.starts_with(root) { - return *origin; - } + vendor_package_root_for_path(path, vendor_paths, vendor_package_roots).map_or( + crate::ClassCompletionOrigin::VendorTransitive, + |(_, origin, _)| *origin, + ) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + #[test] + fn canonical_vendor_files_stay_vendor_through_an_aliased_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let canonical_vendor = dir.path().join("packages"); + let package_src = canonical_vendor.join("acme/package/src"); + std::fs::create_dir_all(&package_src).expect("create package directory"); + let aliased_vendor = dir.path().join("vendor"); + symlink(&canonical_vendor, &aliased_vendor).expect("create vendor alias"); + + let vendor_paths = path_aliases(&aliased_vendor); + let class_path = package_src.join("Service.php"); + std::fs::write(&class_path, "); /// Build the vendor URI prefixes (raw and canonicalized) for a vendor /// directory path, used to detect and skip vendor files. fn vendor_uri_prefixes_for_path(vendor_path: &std::path::Path) -> Vec { - let mut prefixes = vec![format!("{}/", crate::util::path_to_uri(vendor_path))]; - if let Ok(canonical) = vendor_path.canonicalize() { - prefixes.push(format!("{}/", crate::util::path_to_uri(&canonical))); - } - prefixes.sort(); - prefixes.dedup(); - prefixes + path_aliases(vendor_path) + .iter() + .map(|path| format!("{}/", crate::util::path_to_uri(path))) + .collect() } impl Backend { @@ -48,14 +45,10 @@ impl Backend { // of filesystem calls. { let mut paths = self.workspace.vendor_dir_paths.lock(); - let mut insert = |path: PathBuf| { + for path in path_aliases(vendor_path) { if !paths.contains(&path) { paths.push(path); } - }; - insert(vendor_path.to_path_buf()); - if let Ok(canonical) = vendor_path.canonicalize() { - insert(canonical); } } // Store URI prefixes for URI-level skip logic (diagnostics, find @@ -97,6 +90,7 @@ impl Backend { // clone before `composer install`). Register it again now so the // shared vendor filters cache both raw and canonical spellings. self.add_vendor_dir(&vendor_path); + let vendor_paths = path_aliases(&vendor_path); // Rebuild vendor classmap, tracking dependency provenance so // completion ranking stays accurate after a composer change. @@ -132,7 +126,7 @@ impl Backend { .get(&fqn) .copied() .unwrap_or_else(|| { - classify_class_origin(&path, &vendor_path, &vendor_package_roots) + classify_class_origin(&path, &vendor_paths, &vendor_package_roots) }); origins.insert(fqn.clone(), origin); idx.insert(fqn, crate::util::path_to_uri(&path)); @@ -144,7 +138,7 @@ impl Backend { // Purge functions that pointed into the old vendor tree // before re-inserting, so symbols removed by a // `composer update` no longer resolve. - fi.retain(|_, v| !v.starts_with(&vendor_path)); + fi.retain(|_, v| !vendor_paths.iter().any(|vendor| v.starts_with(vendor))); for (fqn, path) in vendor_scan.function_index { let origin = vendor_scan .function_origins @@ -159,7 +153,7 @@ impl Backend { let mut ci = self.symbols.autoload_constant_index.write(); let mut origins = self.symbols.autoload_constant_origin_index.write(); // Same for constants from the old vendor tree. - ci.retain(|_, v| !v.starts_with(&vendor_path)); + ci.retain(|_, v| !vendor_paths.iter().any(|vendor| v.starts_with(vendor))); for (name, path) in vendor_scan.constant_index { let origin = vendor_scan .constant_origins diff --git a/src/lib.rs b/src/lib.rs index c2cbafae8..511e6eb29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1609,19 +1609,16 @@ impl Backend { path: &Path, ) -> (ClassCompletionOrigin, Option) { let vendor_paths = self.workspace.vendor_dir_paths.lock(); - let is_under_vendor = vendor_paths.iter().any(|vp| path.starts_with(vp)); - drop(vendor_paths); - let roots = self.workspace.vendor_package_origin_roots.read(); - if is_under_vendor { - for (root, origin, pkg_name) in roots.iter() { - if path.starts_with(root) { - return (*origin, Some(pkg_name.clone())); - } - } - return (ClassCompletionOrigin::VendorTransitive, None); + if vendor_paths.iter().any(|vp| path.starts_with(vp)) { + return match crate::indexing::vendor_package_root_for_path(path, &vendor_paths, &roots) + { + Some((_, origin, pkg_name)) => (*origin, Some(pkg_name.clone())), + None => (ClassCompletionOrigin::VendorTransitive, None), + }; } + drop(vendor_paths); // Path is outside vendor/. This is usually project code, but // it can also be a symlinked path-repository package whose