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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/indexing/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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));
}
Expand Down
99 changes: 92 additions & 7 deletions src/indexing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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, "<?php").expect("write class file");
let class_path = class_path.canonicalize().expect("canonical class path");

assert_eq!(
classify_class_origin(&class_path, &vendor_paths, &[]),
crate::ClassCompletionOrigin::VendorTransitive
);
}

#[test]
fn raw_vendor_files_match_a_canonical_package_root() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_vendor = dir.path().join("packages");
let package_root = canonical_vendor.join("acme/package");
std::fs::create_dir_all(package_root.join("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 roots = vec![(
package_root.canonicalize().expect("canonical package root"),
crate::ClassCompletionOrigin::VendorExplicit,
"acme/package".to_string(),
)];
// The spelling Composer's classmap produces: through the alias.
let class_path = aliased_vendor.join("acme/package/src/Service.php");

assert_eq!(
classify_class_origin(&class_path, &vendor_paths, &roots),
crate::ClassCompletionOrigin::VendorExplicit
);
}
crate::ClassCompletionOrigin::VendorTransitive
}
26 changes: 10 additions & 16 deletions src/indexing/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};

use super::classify_class_origin;
use super::{classify_class_origin, path_aliases};
use crate::Backend;
use crate::classmap_scanner::{self, WorkspaceScanResult};
use crate::composer;
Expand All @@ -28,13 +28,10 @@ type PharScannedBlock = (usize, Vec<PharClass>);
/// 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<String> {
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 {
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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));
Expand All @@ -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
Expand All @@ -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
Expand Down
17 changes: 7 additions & 10 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1609,19 +1609,16 @@ impl Backend {
path: &Path,
) -> (ClassCompletionOrigin, Option<String>) {
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
Expand Down
Loading