diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e330c52c1..fbb1a32d3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Vendor directories remain excluded through filesystem path aliases.** Project analysis no longer scans dependencies when the workspace or vendor path resolves through an alias such as macOS `/var` to `/private/var`. Contributed by @sidux. - **A value the type engine holds leniently stays lenient past an `if` and inside an array key.** Some builtins declare a failure branch nobody checks (`tempnam()`, `curl_init()`, `Redis::get()` and the rest of the list PHPStan marks the same way), and an array whose docblock names only a value type leaves its whole key domain open. Both are satisfied by a single branch fitting rather than by every branch, but two things that rebuild a type without meaning to change it were dropping that. Any branch merge lost it, so one unrelated `if` sitting between `$path = tempnam(sys_get_temp_dir(), 'x')` and `file_exists($path)` was enough to report the `false` half again, and moving the same call above the `if` silenced it. And the key of an array collected key by key lost it, so `$out[$key] = $value` inside a `foreach` over an `Arg[]` produced a key that was reported wherever the key it was copied from was accepted. Both now carry the leniency across, and both stop carrying it as soon as a type the code did spell out joins in: an honest `string|false` is still reported past an `if`, and a declared `array` still has its `string` key enforced. - **An array collected out of keys nobody described keeps the leniency a single key already had.** An array whose docblock names only a value type says nothing about its keys, so iterating one binds the whole key domain PHP allows, and because that domain is what the missing annotation left open rather than something the array stated, a single branch of it satisfies a declared type. That held for a key handed straight back but not for one collected first: `$out[] = $key` folded the key into the container as a plain `int|string`, so returning the result from a function declared `string[]` was reported as a mismatch under `declare(strict_types=1)` on code that is fine. The leniency now survives the write into a `list<…>` or an `array<…, …>`, for an append and a keyed write alike, and it stops as soon as an element the code did spell out joins it: a literal `int` written beside the keys is still reported, as is an element type the docblock stated. - **An assignment used as a value resolves to what it just wrote.** PHP assignments are expressions, so one can sit anywhere a value can, including as the receiver of the call that reads it straight back: `if (($cache[$key] ??= $this->compute($key))->isValid())`. Such a receiver had no type at all, and the report named an empty subject ("type of '' could not be resolved"), so every member reached through it went unverified. An assignment written this way now carries the type of the target it wrote, whether the target is a plain variable, a `??=` variable, or a `??=` array offset or property path, and whether it sits in an `if` or `while` condition, a `return`, a call argument, or another assignment's value. `??=` written in a condition also leaves its target non-null for the branch it guards, so `if ($cached ??= $this->load()) { $cached->render(); }` reads the loaded value rather than the `null` the fallback exists to replace. diff --git a/src/indexing/init.rs b/src/indexing/init.rs index 7d9aa365e..b69ae793c 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; @@ -63,6 +63,7 @@ impl Backend { // Cache the vendor dir path so cross-file scans can skip it // without re-reading composer.json on every request. let vendor_path = root.join(&vendor_dir); + let vendor_paths = path_aliases(&vendor_path); self.add_vendor_dir(&vendor_path); // Include PSR-4 mappings from path-repository packages (local @@ -218,7 +219,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 c04df07e6..e3aaa79af 100644 --- a/src/indexing/mod.rs +++ b/src/indexing/mod.rs @@ -19,14 +19,26 @@ pub(crate) mod preload; 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 +} + /// 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 { @@ -36,3 +48,29 @@ pub(crate) fn classify_class_origin( } crate::ClassCompletionOrigin::VendorTransitive } + +#[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, "