Skip to content

Commit ce58861

Browse files
committed
Class origin classification no longer re-scans the whole classmap after
the fact
1 parent 8e7d58c commit ce58861

7 files changed

Lines changed: 166 additions & 49 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5353
- **Faster whole-workspace class pre-resolution.** Resolving every known class in dependency order after indexing now spreads across multiple workers instead of running on a single one, substantially cutting the pause between indexing and diagnostics on large Laravel projects. The editor's post-startup pre-resolution goes through the same path, so a large project becomes fully warm sooner.
5454
- **Project startup is significantly faster.** Building the class index now reads files normally instead of memory-mapping them, and the work is parallelized more effectively across your CPU cores. This meaningfully cuts both startup time and CPU use on large Laravel projects, speeding up whole-project analysis and getting the editor ready sooner, with no change in results.
5555
- **The `analyze` and `fix` CLI subcommands no longer build the cross-file reference index.** That index only serves Find References, Rename, and reference-count inlay hints, none of which the CLI subcommands query, so skipping it removes wasted work from whole-project `analyze` and `fix` runs. The editor's LSP session is unaffected.
56+
- **Class origin classification no longer re-scans the whole classmap after the fact.** Startup used to look up each discovered class's completion-ranking origin (project, explicit dependency, transitive dependency, core stub) by re-reading and re-parsing `installed.json` a second time and prefix-matching every class's file path against the package list on a single thread. The origin is now attached to a class the moment it is discovered during the already-parallel vendor scan, the same way it already worked for functions and constants, removing both the duplicate parse and the serial pass.
5657

5758
### Removed
5859

@@ -113,6 +114,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
113114
- **Generated return types and `@return` tags understand every kind of return expression, not just literals and variables.** Inferring a missing return type now resolves method/function calls, ternaries, matches, property access, and array literals split across multiple lines through the same type engine as hover, instead of degrading to `mixed` (or, for multi-line array literals, to a coarser `array<mixed>`) for anything beyond a simple literal, `new`, or a plain variable.
114115
- **Calls to functions declared in another `namespace` block of the same file resolve their return type.** In a file that declares more than one `namespace`, a call to a function from a later block used to leave the returned value untyped unless the function carried an `@return` docblock. The call and its return type now resolve regardless, so completion, hover, and diagnostics see the value's type.
115116
- **`@template` bindings resolve correctly when a call uses named arguments.** A generic function or method called with named arguments (for example `process(class: Product::class, flag: true, function ($p) { ... })`) previously bound its template parameter from the wrong argument whenever the named arguments were out of declaration order, leaving closure parameters untyped. Named arguments now route to the parameter they actually target.
117+
- **Vendor-provided functions and constants no longer rank as project-native in completion.** With the `self`, `full`, and `composer` indexing strategies, functions and constants discovered while scanning vendor packages lost their package origin when merged into the workspace scan, so they sorted ahead of other vendor symbols as if declared in the project itself instead of by their actual dependency tier.
116118

117119
## [0.9.0] - 2026-07-20
118120

docs/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ unlikely to move the needle for most users.
197197
| | **[Performance](todo/performance.md)** | | |
198198
| P46 | [`mago-phpdoc-syntax` cannot parse `@method static (…) name()`](todo/performance.md#p46-mago-phpdoc-syntax-cannot-parse-method-static--name) | Low | Low |
199199
| P30 | [Evaluate migrating parse/resolve/docblock pipeline to `mago-hir`](todo/performance.md#p30-evaluate-migrating-parseresolvedocblock-pipeline-to-mago-hir) (parked — re-evaluated at mago 1.45.0, still no `mago-hir` consumers upstream) | Medium-High | High |
200-
| P48 | [Project init's remaining serial stretches](todo/performance.md#p48-project-inits-remaining-serial-stretches) | Low-Medium | Medium |
200+
| P49 | [Project init's remaining serial stretches](todo/performance.md#p49-project-inits-remaining-serial-stretches) | Low-Medium | Medium |
201201
| P47 | [The resolved-class cache lock caps concurrent class resolution](todo/performance.md#p47-the-resolved-class-cache-lock-caps-concurrent-class-resolution) | Medium | Medium-High |
202202
| P16 | [Pre-parsed stub format (eliminate raw PHP embedding)](todo/performance.md#p16-pre-parsed-stub-format-eliminate-raw-php-embedding) | High | Medium-High |
203203
| P25 | [`type_mismatch_argument` / `argument_count_mismatch` slow on large single files](todo/performance.md#p25-type_mismatch_argument-argument_count_mismatch-slow-on-large-single-files) | Medium | Medium |

docs/todo/performance.md

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,7 +1049,7 @@ real ceiling than the hashing was.
10491049

10501050
---
10511051

1052-
## P48. Project init's remaining serial stretches
1052+
## P49. Project init's remaining serial stretches
10531053

10541054
**Impact: Low-Medium · Effort: Medium**
10551055

@@ -1065,9 +1065,7 @@ large Laravel project (32-core machine, release build, warm page cache,
10651065
| vendor package collection (`collect_package_files`) | 0.060 s | 6.8 |
10661066
| vendor file scan (`scan_files_parallel_full`) | 0.033 s | 20.5 |
10671067
| `scan_autoload_files` | 0.038 s | 3.9 |
1068-
| `classify_class_origin` + `fqn_uri_index` fill | 0.019 s | 1.6 |
10691068
| workspace walk (`ignore` crate) | 0.018 s | 1.1 |
1070-
| `vendor_package_roots` | 0.006 s | 1.0 |
10711069

10721070
Each is small on its own; together they are most of what is left:
10731071

@@ -1077,18 +1075,6 @@ Each is small on its own; together they are most of what is left:
10771075
roots (or its subdirectories) into separate work items would even it
10781076
out, but the concatenation has to stay in `installed.json` order for
10791077
duplicate FQNs to resolve to the same file.
1080-
- **Class origins are derived twice.** The vendor scan knows which
1081-
package each file came from, and records it for functions and constants
1082-
(`WorkspaceScanResult::function_origins` / `constant_origins`), but not
1083-
for classes. Init then re-derives every vendor class's origin by
1084-
prefix-matching its path against ~400 package roots, single-threaded.
1085-
Carrying class origins out of the scan the way the other two already
1086-
are would delete the step. The Composer-classmap path (`strategy =
1087-
"composer"`) has no origin for its entries, so the prefix match has to
1088-
stay as the fallback there.
1089-
- **`vendor_package_roots` re-reads and re-parses `installed.json`**
1090-
moments after `scan_vendor_packages_with_skip` parsed the same file,
1091-
and canonicalizes every package path a second time.
10921078
- **The workspace walk is single-threaded.** `ignore::WalkBuilder` has
10931079
`build_parallel`, but the walk order feeds the duplicate-FQN
10941080
tie-break, so a parallel walk needs its results sorted to stay

src/classmap_scanner/discovery.rs

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ struct PackageFiles {
257257
/// Files from `autoload.files` and `autoload.classmap`, plus the full
258258
/// package tree when a `files` entry registers its own autoloader.
259259
plain: Vec<(PathBuf, crate::ClassCompletionOrigin)>,
260+
/// The package's own root (path, origin, package name), resolved
261+
/// once here so `vendor_package_roots` doesn't need to re-read and
262+
/// re-parse `installed.json` to get the same information.
263+
root: Option<(PathBuf, crate::ClassCompletionOrigin, String)>,
260264
}
261265

262266
/// Walk one `installed.json` entry and return the PHP files it exposes.
@@ -272,9 +276,8 @@ fn collect_package_files(
272276
explicit_deps: &HashSet<String>,
273277
) -> PackageFiles {
274278
let mut out = PackageFiles::default();
275-
let origin = package
276-
.get("name")
277-
.and_then(|n| n.as_str())
279+
let pkg_name = package.get("name").and_then(|n| n.as_str());
280+
let origin = pkg_name
278281
.map(|name| classify_package_origin(name, explicit_deps))
279282
.unwrap_or(crate::ClassCompletionOrigin::VendorTransitive);
280283
// Locate the package on disk. Composer 2's installed.json
@@ -287,7 +290,7 @@ fn collect_package_files(
287290
let pkg_path = if let Some(install_path) = package.get("install-path").and_then(|p| p.as_str())
288291
{
289292
composer_dir.join(install_path)
290-
} else if let Some(pkg_name) = package.get("name").and_then(|n| n.as_str()) {
293+
} else if let Some(pkg_name) = pkg_name {
291294
vendor_path.join(pkg_name)
292295
} else {
293296
return out;
@@ -308,6 +311,12 @@ fn collect_package_files(
308311
return out;
309312
}
310313

314+
out.root = Some((
315+
pkg_path.clone(),
316+
origin,
317+
pkg_name.unwrap_or("unknown/unknown").to_string(),
318+
));
319+
311320
// Extract autoload section
312321
let Some(autoload) = package.get("autoload") else {
313322
return out;
@@ -459,7 +468,10 @@ pub fn scan_vendor_packages_with_skip(
459468
skip_paths,
460469
explicit_deps,
461470
);
462-
if !files.psr4.is_empty() || !files.plain.is_empty() {
471+
if !files.psr4.is_empty()
472+
|| !files.plain.is_empty()
473+
|| files.root.is_some()
474+
{
463475
out.push((i, files));
464476
}
465477
}
@@ -482,10 +494,17 @@ pub fn scan_vendor_packages_with_skip(
482494

483495
let mut psr4_files: Vec<(PathBuf, String, crate::ClassCompletionOrigin)> = Vec::new();
484496
let mut plain_files: Vec<(PathBuf, crate::ClassCompletionOrigin)> = Vec::new();
497+
let mut package_roots: Vec<(PathBuf, crate::ClassCompletionOrigin, String)> = Vec::new();
485498
for (_, files) in collected {
486499
psr4_files.extend(files.psr4);
487500
plain_files.extend(files.plain);
501+
if let Some(root) = files.root {
502+
package_roots.push(root);
503+
}
488504
}
505+
// Match `vendor_package_roots`'s ordering: longest path first, so a
506+
// nested package's root wins a prefix match before its parent's.
507+
package_roots.sort_by_key(|(p, _, _)| std::cmp::Reverse(p.components().count()));
489508

490509
// Phase 2: scan all collected files in parallel. Each file's origin is
491510
// already known from the package it was collected under (phase 1), so
@@ -500,7 +519,9 @@ pub fn scan_vendor_packages_with_skip(
500519

501520
progress_add_total(progress, all_files.len());
502521

503-
scan_files_parallel_full(&all_files, progress)
522+
let mut result = scan_files_parallel_full(&all_files, progress);
523+
result.package_roots = package_roots;
524+
result
504525
}
505526

506527
/// Scan all `.php` files under the workspace root using the PSR-4
@@ -688,18 +709,26 @@ fn scan_files_parallel_full(
688709
let scan = super::find_symbols(&content);
689710
for fqcn in scan.classes {
690711
let class_short_name = fqcn_short_name(&fqcn).to_owned();
712+
let mut origin_wins = false;
691713
result
692714
.classmap
693-
.entry(fqcn)
715+
.entry(fqcn.clone())
694716
.and_modify(|existing| {
695717
let existing_stem =
696718
existing.file_stem().and_then(|s| s.to_str()).unwrap_or("");
697719
let new_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
698720
if existing_stem != class_short_name && new_stem == class_short_name {
699721
*existing = path.clone();
722+
origin_wins = true;
700723
}
701724
})
702-
.or_insert_with(|| path.clone());
725+
.or_insert_with(|| {
726+
origin_wins = true;
727+
path.clone()
728+
});
729+
if origin_wins {
730+
result.class_origins.insert(fqcn, *origin);
731+
}
703732
}
704733
for fqn in scan.functions {
705734
result
@@ -780,9 +809,10 @@ fn scan_files_parallel_full(
780809
for (scan, path, origin) in batch {
781810
for fqcn in scan.classes {
782811
let class_short_name = fqcn_short_name(&fqcn).to_owned();
812+
let mut origin_wins = false;
783813
result
784814
.classmap
785-
.entry(fqcn)
815+
.entry(fqcn.clone())
786816
.and_modify(|existing| {
787817
// When two files declare the same FQN, prefer the one
788818
// whose filename matches the class's short name (PSR-4
@@ -795,9 +825,16 @@ fn scan_files_parallel_full(
795825
let new_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
796826
if existing_stem != class_short_name && new_stem == class_short_name {
797827
*existing = path.clone();
828+
origin_wins = true;
798829
}
799830
})
800-
.or_insert_with(|| path.clone());
831+
.or_insert_with(|| {
832+
origin_wins = true;
833+
path.clone()
834+
});
835+
if origin_wins {
836+
result.class_origins.insert(fqcn, origin);
837+
}
801838
}
802839
for fqn in scan.functions {
803840
result

src/classmap_scanner/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ pub struct ScanResult {
162162
pub struct WorkspaceScanResult {
163163
/// FQN → file path for classes, interfaces, traits, and enums.
164164
pub classmap: HashMap<String, PathBuf>,
165+
/// FQN → completion origin tier for classes, interfaces, traits, and
166+
/// enums. Populated from the package a file was collected under, so
167+
/// no second pass over the classmap is needed to classify origins.
168+
pub(crate) class_origins: HashMap<String, crate::ClassCompletionOrigin>,
165169
/// FQN → file path for standalone functions.
166170
pub function_index: HashMap<String, PathBuf>,
167171
/// FQN → completion origin tier for standalone functions.
@@ -170,6 +174,11 @@ pub struct WorkspaceScanResult {
170174
pub constant_index: HashMap<String, PathBuf>,
171175
/// Constant name → completion origin tier.
172176
pub(crate) constant_origins: HashMap<String, crate::ClassCompletionOrigin>,
177+
/// Vendor package roots discovered while parsing `installed.json`
178+
/// during this scan (path, origin, package name), sorted
179+
/// longest-path-first for prefix-match origin lookups. Empty for
180+
/// scans that never touch a vendor tree.
181+
pub(crate) package_roots: Vec<(PathBuf, crate::ClassCompletionOrigin, String)>,
173182
}
174183

175184
// ─── Public API ─────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)