Skip to content

Commit 5cb6455

Browse files
committed
Index vendor packages with custom autoloaders and prefer PSR-4 classmap
files
1 parent 6ca3852 commit 5cb6455

3 files changed

Lines changed: 162 additions & 31 deletions

File tree

docs/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- **Eloquent `$dates` and `where{Property}` go-to-definition.** Ctrl+clicking a property backed by the `$dates` array now jumps to the string entry inside `$dates`, matching the behaviour already supported for `$casts`, `$attributes`, `$fillable`, `$guarded`, `$hidden`, `$visible`, and `$appends`. Ctrl+clicking a `where{Property}()` dynamic method (e.g. `whereFlour`, `whereKitchenId`) now jumps to the corresponding column entry in whichever Eloquent array defines that column.
2929
- **Property `self`/`static` type resolution.** Properties declared with `@var self|null` or `static` type annotations now resolve to the owning class name instead of displaying the raw `self`/`static` keyword in hover and type inference.
3030
- **Magic `__get` property access.** Accessing undefined properties on objects with a `__get` method now resolves to the method's declared return type, even when `__get` has no template parameters (e.g. `SimpleXMLElement::$child` resolves to `SimpleXMLElement`).
31+
- **By-reference out-parameters no longer flagged as unused.** Variables passed to functions with known by-reference out-parameters (e.g. `getmxrr($domain, $hosts)`, `preg_match($pattern, $subject, $matches)`) are now treated as used, eliminating false-positive unused-variable diagnostics.
32+
- **Composer `files` autoload packages now indexed.** Vendor packages that register a custom autoloader via `spl_autoload_register` in their `"autoload": {"files": [...]}` entries now have their entire package directory scanned for classes, fixing "Class not found" diagnostics for packages like `dms/phpunit-arraysubset-asserts`.
33+
- **Classmap collision resolution.** When two files in the same package declare the same fully-qualified class name (e.g. conditional loading with a normal and empty variant), the file whose name matches the class name (PSR-4 convention) is now preferred.
3134
- **Magic `__call` method return type.** Calling undefined methods on objects with a `__call` method now resolves to `__call`'s declared return type for hover and type inference.
3235
- **SoapClient arbitrary methods.** Calling any method on `SoapClient` (or subclasses) no longer produces false-positive "unknown member" diagnostics. SoapClient is a SOAP proxy where any method name is valid at runtime.
3336
- **Property narrowing in type error diagnostics.** Properties narrowed via `instanceof` checks (e.g. `if ($this->service instanceof MockInterface)`) now use the narrowed type when checking argument compatibility, eliminating false positives in test code that uses Mockery or similar patterns.

src/classmap_scanner.rs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,38 @@ pub fn scan_vendor_packages_with_skip(
355355
}
356356
}
357357

358+
// Files entries (individual PHP files that are always loaded)
359+
if let Some(files) = autoload.get("files").and_then(|f| f.as_array()) {
360+
let mut has_custom_autoloader = false;
361+
for entry in files {
362+
if let Some(file_str) = entry.as_str() {
363+
let file = pkg_path.join(file_str);
364+
if file.is_file()
365+
&& file.extension().is_some_and(|ext| ext == "php")
366+
&& !skip_paths.contains(&file)
367+
{
368+
// Check if this file registers a custom autoloader.
369+
if !has_custom_autoloader
370+
&& let Ok(content) = std::fs::read(&file)
371+
&& memmem::find(&content, b"spl_autoload_register").is_some()
372+
{
373+
has_custom_autoloader = true;
374+
}
375+
plain_files.push(file);
376+
}
377+
}
378+
}
379+
380+
// When a files entry registers a custom autoloader via
381+
// spl_autoload_register, it will load classes from the
382+
// package at runtime. Since we can't execute that logic,
383+
// do a full scan of the package directory to discover all
384+
// classes it provides.
385+
if has_custom_autoloader {
386+
collect_php_files(&pkg_path, &vendor_dir_paths, skip_paths, &mut plain_files);
387+
}
388+
}
389+
358390
// Classmap entries
359391
if let Some(cm) = autoload.get("classmap").and_then(|c| c.as_array()) {
360392
for entry in cm {
@@ -544,7 +576,19 @@ fn scan_files_parallel_full(files: &[PathBuf]) -> WorkspaceScanResult {
544576
if let Ok(content) = std::fs::read(path) {
545577
let scan = find_symbols(&content);
546578
for fqcn in scan.classes {
547-
result.classmap.entry(fqcn).or_insert_with(|| path.clone());
579+
let class_short_name = fqcn_short_name(&fqcn).to_owned();
580+
result
581+
.classmap
582+
.entry(fqcn)
583+
.and_modify(|existing| {
584+
let existing_stem =
585+
existing.file_stem().and_then(|s| s.to_str()).unwrap_or("");
586+
let new_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
587+
if existing_stem != class_short_name && new_stem == class_short_name {
588+
*existing = path.clone();
589+
}
590+
})
591+
.or_insert_with(|| path.clone());
548592
}
549593
for fqn in scan.functions {
550594
result
@@ -602,7 +646,25 @@ fn scan_files_parallel_full(files: &[PathBuf]) -> WorkspaceScanResult {
602646
for batch in results {
603647
for (scan, path) in batch {
604648
for fqcn in scan.classes {
605-
result.classmap.entry(fqcn).or_insert_with(|| path.clone());
649+
let class_short_name = fqcn_short_name(&fqcn).to_owned();
650+
result
651+
.classmap
652+
.entry(fqcn)
653+
.and_modify(|existing| {
654+
// When two files declare the same FQN, prefer the one
655+
// whose filename matches the class's short name (PSR-4
656+
// convention). This handles packages with conditional
657+
// loading (e.g. ArraySubsetAsserts.php vs
658+
// ArraySubsetAssertsEmpty.php both defining the same
659+
// trait name).
660+
let existing_stem =
661+
existing.file_stem().and_then(|s| s.to_str()).unwrap_or("");
662+
let new_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
663+
if existing_stem != class_short_name && new_stem == class_short_name {
664+
*existing = path.clone();
665+
}
666+
})
667+
.or_insert_with(|| path.clone());
606668
}
607669
for fqn in scan.functions {
608670
result
@@ -1665,6 +1727,14 @@ fn normalise_prefix(prefix: &str) -> String {
16651727
}
16661728
}
16671729

1730+
/// Extract the short (unqualified) class name from a fully-qualified name.
1731+
///
1732+
/// For example, `"DMS\\PHPUnitExtensions\\ArraySubset\\ArraySubsetAsserts"`
1733+
/// yields `"ArraySubsetAsserts"`.
1734+
fn fqcn_short_name(fqcn: &str) -> &str {
1735+
fqcn.rsplit('\\').next().unwrap_or(fqcn)
1736+
}
1737+
16681738
/// Extract string values from a JSON value that is either a single
16691739
/// string or an array of strings.
16701740
fn value_to_strings(value: &serde_json::Value) -> Vec<String> {

0 commit comments

Comments
 (0)