From 1fcf15487e84c3726502cbaea4ee75e312c29540 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Thu, 9 Jul 2026 20:11:30 -0500 Subject: [PATCH 1/2] feat: rank completion candidates by dependency provenance Class, function, and constant completions are now sorted by origin: 1. project code 2. core/stub symbols 3. explicit Composer dependencies (require / require-dev) 4. transitive vendor dependencies The provenance is inferred during indexing from composer.json's require/require-dev sections and vendor/composer/installed.json package roots. A shared ClassCompletionOrigin enum and sort helper avoid duplicating ranking logic across the three symbol kinds. New fields on Backend: fqn_origin_index, autoload_function_origin_index, autoload_constant_origin_index, vendor_package_origin_roots. The classmap_scanner now tracks per-package origins during vendor scanning and exposes them in WorkspaceScanResult. Closes #183 --- docs/CHANGELOG.md | 1 + src/classmap_scanner.rs | 175 +++++++++++-- src/completion/context/class_completion.rs | 58 ++++- .../context/class_completion_tests.rs | 64 ++++- src/completion/context/constant_completion.rs | 23 +- src/completion/context/function_completion.rs | 20 +- src/completion/context/mod.rs | 1 + src/completion/context/symbol_ranking.rs | 21 ++ src/composer.rs | 14 + src/lib.rs | 90 +++++++ src/server.rs | 63 ++++- .../completion_class_name_context.rs | 12 +- tests/integration/completion_class_names.rs | 240 +++++++++++++++++- 13 files changed, 705 insertions(+), 77 deletions(-) create mode 100644 src/completion/context/symbol_ranking.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3317386f..e03add10 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Completion candidates ranked by dependency provenance.** Class, function, and constant completions are now sorted by origin tier: project code first, then core/stub symbols, then explicit Composer dependencies (`require` / `require-dev`), then transitive vendor dependencies last. The provenance is inferred from `composer.json` and `installed.json` during indexing. Contributed by @calebdw. - **`update` command.** A new `phpantom_lsp update` subcommand downloads the latest release from GitHub and replaces the current binary. Supports `--check` (dry run, exit code 1 if update available) and `--no-confirm` (for CI). Handles `.tar.gz` (Unix) and `.zip` (Windows) archives across all 6 supported platforms. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/194. - **`array_map` infers the output element type from its callback.** The result of `array_map` now reflects what the callback actually returns instead of assuming the input element type is preserved. An explicit return type hint is honoured, including scalars like `string` or `int`, so `array_map(fn(Item $item): string => $item->id, $items)` produces `list` rather than `list`. When the callback has no return type hint, the type is inferred from its body expression, so `array_map(fn($item) => $item->id, $items)` over a `list` also produces `list`. Fixes #147. (contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/195) - **Static methods complete on instance access.** Member completion after `->` now offers a class's static methods alongside its instance methods, since PHP lets you call a static method through an instance (`$obj->make()`). Static properties remain excluded, as they are only reachable via `::`. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/174. diff --git a/src/classmap_scanner.rs b/src/classmap_scanner.rs index c4df6457..3113ba6f 100644 --- a/src/classmap_scanner.rs +++ b/src/classmap_scanner.rs @@ -90,10 +90,16 @@ pub struct ScanResult { pub struct WorkspaceScanResult { /// FQN → file path for classes, interfaces, traits, and enums. pub classmap: HashMap, + /// FQN → completion origin tier. + pub(crate) class_origins: HashMap, /// FQN → file path for standalone functions. pub function_index: HashMap, + /// FQN → completion origin tier for standalone functions. + pub(crate) function_origins: HashMap, /// Constant name → file path for `define()` and top-level `const`. pub constant_index: HashMap, + /// Constant name → completion origin tier. + pub(crate) constant_origins: HashMap, } // ─── Public API ───────────────────────────────────────────────────────────── @@ -168,15 +174,22 @@ pub fn scan_directories( dirs: &[PathBuf], vendor_dir_paths: &[PathBuf], ) -> HashMap { - let mut php_files: Vec = Vec::new(); + let mut php_files: Vec<(PathBuf, crate::ClassCompletionOrigin)> = Vec::new(); let skip_paths = HashSet::new(); for dir in dirs { if !dir.is_dir() { continue; } - collect_php_files(dir, vendor_dir_paths, &skip_paths, &mut php_files); + collect_php_files( + dir, + vendor_dir_paths, + &skip_paths, + &mut php_files, + crate::ClassCompletionOrigin::Project, + ); } - scan_files_parallel_classes(&php_files) + let paths: Vec = php_files.into_iter().map(|(p, _)| p).collect(); + scan_files_parallel_classes(&paths) } /// Build a classmap by scanning all `.php` files under the given @@ -216,7 +229,7 @@ pub fn scan_psr4_directories_with_skip( skip_paths: &HashSet, ) -> HashMap { // ── PSR-4 directories: collect (path, expected_fqn) pairs ─────── - let mut psr4_files: Vec<(PathBuf, String)> = Vec::new(); + let mut psr4_files: Vec<(PathBuf, String, crate::ClassCompletionOrigin)> = Vec::new(); for (prefix, base_path) in psr4 { if !base_path.is_dir() { continue; @@ -227,21 +240,31 @@ pub fn scan_psr4_directories_with_skip( vendor_dir_paths, skip_paths, &mut psr4_files, + crate::ClassCompletionOrigin::Project, ); } // ── Plain classmap directories ────────────────────────────────── - let mut plain_files: Vec = Vec::new(); + let mut plain_files: Vec<(PathBuf, crate::ClassCompletionOrigin)> = Vec::new(); for dir in classmap_dirs { if !dir.is_dir() { continue; } - collect_php_files(dir, vendor_dir_paths, skip_paths, &mut plain_files); + collect_php_files( + dir, + vendor_dir_paths, + skip_paths, + &mut plain_files, + crate::ClassCompletionOrigin::Project, + ); } // ── Scan all files in parallel ────────────────────────────────── - let mut classmap = scan_files_parallel_psr4(&psr4_files); - let plain_classmap = scan_files_parallel_classes(&plain_files); + let psr4_pairs: Vec<(PathBuf, String)> = + psr4_files.into_iter().map(|(p, s, _)| (p, s)).collect(); + let mut classmap = scan_files_parallel_psr4(&psr4_pairs); + let plain_paths: Vec = plain_files.into_iter().map(|(p, _)| p).collect(); + let plain_classmap = scan_files_parallel_classes(&plain_paths); for (fqcn, path) in plain_classmap { classmap.entry(fqcn).or_insert(path); } @@ -255,7 +278,58 @@ pub fn scan_psr4_directories_with_skip( /// package's autoload directories. Supports PSR-4 and classmap /// entries. pub fn scan_vendor_packages(workspace_root: &Path, vendor_dir: &str) -> WorkspaceScanResult { - scan_vendor_packages_with_skip(workspace_root, vendor_dir, &HashSet::new()) + scan_vendor_packages_with_skip(workspace_root, vendor_dir, &HashSet::new(), &HashSet::new()) +} + +pub(crate) fn vendor_package_roots( + workspace_root: &Path, + vendor_dir: &str, + explicit_deps: &HashSet, +) -> Vec<(PathBuf, crate::ClassCompletionOrigin)> { + let vendor_path = workspace_root.join(vendor_dir); + let installed_path = vendor_path.join("composer").join("installed.json"); + let Ok(content) = std::fs::read_to_string(&installed_path) else { + return Vec::new(); + }; + let Ok(json) = serde_json::from_str::(&content) else { + return Vec::new(); + }; + let packages = if let Some(arr) = json.as_array() { + arr.as_slice() + } else if let Some(pkgs) = json.get("packages").and_then(|p| p.as_array()) { + pkgs.as_slice() + } else { + return Vec::new(); + }; + let composer_dir = vendor_path.join("composer"); + let mut roots = Vec::new(); + for package in packages { + let origin = package + .get("name") + .and_then(|n| n.as_str()) + .map(|name| { + if explicit_deps.contains(name) { + crate::ClassCompletionOrigin::VendorExplicit + } else { + crate::ClassCompletionOrigin::VendorTransitive + } + }) + .unwrap_or(crate::ClassCompletionOrigin::VendorTransitive); + let pkg_path = + if let Some(install_path) = package.get("install-path").and_then(|p| p.as_str()) { + composer_dir.join(install_path) + } else if let Some(pkg_name) = package.get("name").and_then(|n| n.as_str()) { + vendor_path.join(pkg_name) + } else { + continue; + }; + let pkg_path = pkg_path.canonicalize().unwrap_or(pkg_path); + if pkg_path.is_dir() { + roots.push((pkg_path, origin)); + } + } + roots.sort_by_key(|(p, _)| std::cmp::Reverse(p.components().count())); + roots } /// Like [`scan_vendor_packages`] but accepts a set of absolute file @@ -265,6 +339,7 @@ pub fn scan_vendor_packages_with_skip( workspace_root: &Path, vendor_dir: &str, skip_paths: &HashSet, + explicit_deps: &HashSet, ) -> WorkspaceScanResult { let vendor_path = workspace_root.join(vendor_dir); let installed_path = vendor_path.join("composer").join("installed.json"); @@ -296,10 +371,21 @@ pub fn scan_vendor_packages_with_skip( // Phase 1: collect all file paths from all packages (sequential // walk, but no file I/O beyond stat calls). - let mut psr4_files: Vec<(PathBuf, String)> = Vec::new(); - let mut plain_files: Vec = Vec::new(); + let mut psr4_files: Vec<(PathBuf, String, crate::ClassCompletionOrigin)> = Vec::new(); + let mut plain_files: Vec<(PathBuf, crate::ClassCompletionOrigin)> = Vec::new(); for package in packages { + let origin = package + .get("name") + .and_then(|n| n.as_str()) + .map(|name| { + if explicit_deps.contains(name) { + crate::ClassCompletionOrigin::VendorExplicit + } else { + crate::ClassCompletionOrigin::VendorTransitive + } + }) + .unwrap_or(crate::ClassCompletionOrigin::VendorTransitive); // Locate the package on disk. Composer 2's installed.json // includes an `install-path` field that is relative to the // `vendor/composer/` directory. This is the authoritative @@ -349,6 +435,7 @@ pub fn scan_vendor_packages_with_skip( &vendor_dir_paths, skip_paths, &mut psr4_files, + origin, ); } } @@ -372,7 +459,7 @@ pub fn scan_vendor_packages_with_skip( { has_custom_autoloader = true; } - plain_files.push(file); + plain_files.push((file, origin)); } } } @@ -383,7 +470,13 @@ pub fn scan_vendor_packages_with_skip( // do a full scan of the package directory to discover all // classes it provides. if has_custom_autoloader { - collect_php_files(&pkg_path, &vendor_dir_paths, skip_paths, &mut plain_files); + collect_php_files( + &pkg_path, + &vendor_dir_paths, + skip_paths, + &mut plain_files, + origin, + ); } } @@ -393,12 +486,18 @@ pub fn scan_vendor_packages_with_skip( if let Some(dir_str) = entry.as_str() { let dir = pkg_path.join(dir_str); if dir.is_dir() { - collect_php_files(&dir, &vendor_dir_paths, skip_paths, &mut plain_files); + collect_php_files( + &dir, + &vendor_dir_paths, + skip_paths, + &mut plain_files, + origin, + ); } else if dir.is_file() && dir.extension().is_some_and(|ext| ext == "php") && !skip_paths.contains(&dir) { - plain_files.push(dir); + plain_files.push((dir, origin)); } } } @@ -406,10 +505,38 @@ pub fn scan_vendor_packages_with_skip( } // Phase 2: scan all collected files in parallel - let mut all_files: Vec = psr4_files.into_iter().map(|(path, _)| path).collect(); - all_files.extend(plain_files); - - scan_files_parallel_full(&all_files) + let mut all_files: Vec = psr4_files.iter().map(|(path, _, _)| path.clone()).collect(); + all_files.extend(plain_files.iter().map(|(path, _)| path.clone())); + + let mut result = scan_files_parallel_full(&all_files); + let mut class_origins = HashMap::new(); + let mut function_origins = HashMap::new(); + let mut constant_origins = HashMap::new(); + for (path, expected_fqn, origin) in psr4_files { + if let Ok(content) = std::fs::read(&path) { + for fqn in scan_content(&content) { + if fqn == expected_fqn { + class_origins.entry(fqn).or_insert(origin); + } + } + } + } + for (path, origin) in plain_files { + let symbols = scan_file_full(&path); + for fqn in symbols.classes { + class_origins.entry(fqn).or_insert(origin); + } + for fqn in symbols.functions { + function_origins.entry(fqn).or_insert(origin); + } + for name in symbols.constants { + constant_origins.entry(name).or_insert(origin); + } + } + result.class_origins = class_origins; + result.function_origins = function_origins; + result.constant_origins = constant_origins; + result } /// Scan all `.php` files under the workspace root using the PSR-4 @@ -1750,7 +1877,8 @@ fn collect_php_files( dir: &Path, vendor_dir_paths: &[PathBuf], skip_paths: &HashSet, - out: &mut Vec, + out: &mut Vec<(PathBuf, crate::ClassCompletionOrigin)>, + origin: crate::ClassCompletionOrigin, ) { use ignore::WalkBuilder; @@ -1779,7 +1907,7 @@ fn collect_php_files( if path.is_file() && path.extension().is_some_and(|ext| ext == "php") { let owned = path.to_path_buf(); if !skip_paths.contains(&owned) { - out.push(owned); + out.push((owned, origin)); } } } @@ -1795,7 +1923,8 @@ fn collect_psr4_php_files( namespace_prefix: &str, vendor_dir_paths: &[PathBuf], skip_paths: &HashSet, - out: &mut Vec<(PathBuf, String)>, + out: &mut Vec<(PathBuf, String, crate::ClassCompletionOrigin)>, + origin: crate::ClassCompletionOrigin, ) { use ignore::WalkBuilder; @@ -1841,7 +1970,7 @@ fn collect_psr4_php_files( // Convert path separators to namespace separators let expected_fqn = format!("{}{}", namespace_prefix, stem.replace('/', "\\")); - out.push((owned, expected_fqn)); + out.push((owned, expected_fqn, origin)); } } } diff --git a/src/completion/context/class_completion.rs b/src/completion/context/class_completion.rs index 54b3e4a6..0fa2bc8d 100644 --- a/src/completion/context/class_completion.rs +++ b/src/completion/context/class_completion.rs @@ -989,10 +989,12 @@ pub(in crate::completion) fn match_quality(short_name: &str, prefix: &str) -> ch /// Assemble a sort_text string for a class name completion item. /// -/// The format is `{match_quality}{source_tier}{affinity}{demote}{gap}_{short_name_lower}` +/// The format is `{match_quality}{origin_tier}{source_tier}{affinity}{demote}{gap}_{short_name_lower}` /// where: /// - `match_quality`: `'a'` exact, `'b'` starts-with, `'c'` contains /// - `source_tier`: `'0'` use-imported, `'1'` same-namespace, `'2'` everything else +/// - `origin_tier`: `'0'` project, `'1'` core/stub, `'2'` explicit vendor, +/// `'3'` transitive vendor /// - `affinity`: 4-digit inverted score (`9999 - score`, so higher scores sort first) /// - `demote`: `'0'` normal, `'1'` heuristically demoted /// - `gap`: 3-digit distance between short name length and prefix length @@ -1006,6 +1008,7 @@ pub(in crate::completion) fn class_sort_text( fqn: &str, prefix: &str, source_tier: char, + origin_tier: char, demoted: bool, affinity_table: &HashMap, ) -> String { @@ -1018,8 +1021,9 @@ pub(in crate::completion) fn class_sort_text( ); let demote = if demoted { '1' } else { '0' }; format!( - "{}{}{}{}{}_{}", + "{}{}{}{}{}{}_{}", quality, + origin_tier, source_tier, affinity, demote, @@ -1214,14 +1218,18 @@ impl ClassItemCtx<'_> { /// `\App\Models\U`), the label and filter_text remain the full /// FQN so namespace drilling works as expected. /// - /// The sort_text is computed internally from `source_tier` and - /// `demoted` using the affinity table and quality prefix stored - /// in `ClassItemCtx`. + /// The sort_text is computed internally from `sort_tiers` + /// and `demoted` using the affinity table and quality prefix + /// stored in `ClassItemCtx`. + /// + /// `sort_tiers` is `(origin_tier, source_tier)`. + #[allow(clippy::too_many_arguments)] pub(in crate::completion) fn build_item( &self, texts: ClassItemTexts, fqn: &str, source_tier: char, + origin_tier: char, demoted: bool, ctor_params: Option<&[ParameterInfo]>, is_deprecated: bool, @@ -1232,6 +1240,7 @@ impl ClassItemCtx<'_> { fqn, &self.quality_prefix, source_tier, + origin_tier, demoted, &self.affinity_table, ); @@ -1541,11 +1550,12 @@ impl Backend { // ── Helper: classify a FQN into source tier and demotion ───── // - // Returns `None` to exclude, or `Some((tier, demoted, deprecated))`. + // Returns `None` to exclude, or + // `Some((tier, origin_tier, demoted, deprecated))`. // '0' = use-imported (already in file's use-map) // '1' = same/sub namespace // '2' = everything else - let classify = |fqn: &str, sn: &str| -> Option<(char, bool, bool)> { + let classify = |fqn: &str, sn: &str| -> Option<(char, char, bool, bool)> { // Context-aware kind filtering (instantiability, Throwable, etc.) if context.is_class_only() { match self.check_context_match(fqn, context) { @@ -1573,6 +1583,14 @@ impl Backend { if in_same_or_sub_ns { '1' } else { '2' } }; + let origin_tier = self + .fqn_origin_index + .read() + .get(fqn) + .copied() + .unwrap_or(crate::ClassCompletionOrigin::Project) + .sort_tier(); + // Demotion: only for unloaded classes where context-match // was unknown. let demoted = context.is_class_only() @@ -1585,7 +1603,7 @@ impl Backend { .as_ref() .is_some_and(|c| c.deprecation_message.is_some()); - Some((tier, demoted, deprecated)) + Some((tier, origin_tier, demoted, deprecated)) }; // ── Pass 1: fqn_uri_index (project + vendor classes) ──────── @@ -1607,7 +1625,7 @@ impl Backend { if !seen_fqns.insert(fqn.clone()) { continue; } - let Some((tier, demoted, deprecated)) = classify(fqn, sn) else { + let Some((tier, origin_tier, demoted, deprecated)) = classify(fqn, sn) else { continue; }; // Skip namespace aliases. @@ -1644,7 +1662,15 @@ impl Backend { } else { None }; - items.push(ctx.build_item(texts, fqn, tier, demoted, ctor.as_deref(), deprecated)); + items.push(ctx.build_item( + texts, + fqn, + tier, + origin_tier, + demoted, + ctor.as_deref(), + deprecated, + )); } // ── Pass 2: stub_index (built-in PHP classes) ─────────────── @@ -1658,7 +1684,7 @@ impl Backend { if !seen_fqns.insert(fqn.to_string()) { continue; } - let Some((tier, demoted, deprecated)) = classify(fqn, sn) else { + let Some((tier, _origin_tier, demoted, deprecated)) = classify(fqn, sn) else { continue; }; let (mut base_name, filter, mut use_import) = class_edit_texts( @@ -1690,7 +1716,15 @@ impl Backend { } else { None }; - items.push(ctx.build_item(texts, fqn, tier, demoted, ctor.as_deref(), deprecated)); + items.push(ctx.build_item( + texts, + fqn, + tier, + crate::ClassCompletionOrigin::CoreStub.sort_tier(), + demoted, + ctor.as_deref(), + deprecated, + )); } // ── Namespace segment items (FQN mode only) ───────────────── diff --git a/src/completion/context/class_completion_tests.rs b/src/completion/context/class_completion_tests.rs index 15e0bd5e..dc252af1 100644 --- a/src/completion/context/class_completion_tests.rs +++ b/src/completion/context/class_completion_tests.rs @@ -816,16 +816,40 @@ fn test_match_quality_empty_prefix_returns_b() { fn test_class_sort_text_format() { let mut table = HashMap::new(); table.insert("App".to_string(), 4); - let result = class_sort_text("Order", "App\\Models\\Order", "Order", '2', false, &table); - // quality='a' (exact), tier='2', affinity=9999-4=9995 → "9995", demote='0', gap=5-5=0 → "000" - assert_eq!(result, "a299950000_order"); + let result = class_sort_text( + "Order", + "App\\Models\\Order", + "Order", + '2', + '0', + false, + &table, + ); + // quality='a' (exact), origin='0', tier='2', affinity=9999-4=9995 → "9995", demote='0', gap=5-5=0 → "000" + assert_eq!(result, "a0299950000_order"); } #[test] fn test_class_sort_text_demoted() { let table = HashMap::new(); - let normal = class_sort_text("Handler", "Vendor\\Handler", "Handler", '2', false, &table); - let demoted = class_sort_text("Handler", "Vendor\\Handler", "Handler", '2', true, &table); + let normal = class_sort_text( + "Handler", + "Vendor\\Handler", + "Handler", + '2', + '0', + false, + &table, + ); + let demoted = class_sort_text( + "Handler", + "Vendor\\Handler", + "Handler", + '2', + '0', + true, + &table, + ); assert!( normal < demoted, "Demoted should sort after normal: normal={normal}, demoted={demoted}" @@ -836,12 +860,13 @@ fn test_class_sort_text_demoted() { fn test_class_sort_text_quality_beats_tier() { let table = HashMap::new(); // Exact match in tier 2 should beat starts-with match in tier 0. - let exact_tier2 = class_sort_text("Order", "Vendor\\Order", "Order", '2', false, &table); + let exact_tier2 = class_sort_text("Order", "Vendor\\Order", "Order", '2', '0', false, &table); let prefix_tier0 = class_sort_text( "OrderLine", "Vendor\\OrderLine", "Order", '0', + '0', false, &table, ); @@ -856,8 +881,8 @@ fn test_class_sort_text_tier_beats_affinity() { let mut table = HashMap::new(); table.insert("Luxplus".to_string(), 50); // Same match quality, but tier 1 should beat tier 2 even with lower affinity. - let tier1_low = class_sort_text("Order", "App\\Order", "Order", '1', false, &table); - let tier2_high = class_sort_text("Order", "Luxplus\\Order", "Order", '2', false, &table); + let tier1_low = class_sort_text("Order", "App\\Order", "Order", '1', '0', false, &table); + let tier2_high = class_sort_text("Order", "Luxplus\\Order", "Order", '2', '0', false, &table); assert!( tier1_low < tier2_high, "Tier 1 should sort before tier 2 regardless of affinity: tier1={tier1_low}, tier2={tier2_high}" @@ -877,10 +902,19 @@ fn test_class_sort_text_affinity_within_same_tier() { "Luxplus\\Database\\Model\\Orders\\Order", "Order", '2', + '0', + false, + &table, + ); + let low = class_sort_text( + "Order", + "App\\Models\\Order", + "Order", + '2', + '0', false, &table, ); - let low = class_sort_text("Order", "App\\Models\\Order", "Order", '2', false, &table); assert!( high < low, "Higher affinity should sort first: high={high}, low={low}" @@ -891,12 +925,13 @@ fn test_class_sort_text_affinity_within_same_tier() { fn test_class_sort_text_demote_after_quality() { let table = HashMap::new(); // A demoted exact match should still beat a non-demoted prefix match. - let demoted_exact = class_sort_text("Order", "Vendor\\Order", "Order", '2', true, &table); + let demoted_exact = class_sort_text("Order", "Vendor\\Order", "Order", '2', '0', true, &table); let normal_prefix = class_sort_text( "OrderLine", "Vendor\\OrderLine", "Order", '2', + '0', false, &table, ); @@ -910,8 +945,8 @@ fn test_class_sort_text_demote_after_quality() { fn test_class_sort_text_alphabetical_tiebreak() { let table = HashMap::new(); // Same quality, tier, affinity, demotion — alphabetical by short name. - let alpha = class_sort_text("Alpha", "Vendor\\Alpha", "Al", '2', false, &table); - let beta = class_sort_text("Beta", "Vendor\\Beta", "B", '2', false, &table); + let alpha = class_sort_text("Alpha", "Vendor\\Alpha", "Al", '2', '0', false, &table); + let beta = class_sort_text("Beta", "Vendor\\Beta", "B", '2', '0', false, &table); // Both are starts-with ('b'), tier '2', zero affinity, not demoted. // Tiebreak: "alpha" < "beta". assert!( @@ -937,6 +972,7 @@ fn test_class_sort_text_gap_within_same_affinity() { "Luxplus\\Core\\Database\\Model\\Products\\Product", "Pro", '2', + '0', false, &table, ); @@ -945,6 +981,7 @@ fn test_class_sort_text_gap_within_same_affinity() { "Luxplus\\Core\\Database\\Model\\Products\\Filters\\ProductFilterTerm", "Pro", '2', + '0', false, &table, ); @@ -968,10 +1005,11 @@ fn test_class_sort_text_affinity_beats_gap() { "Luxplus\\Database\\Product", "Pro", '2', + '0', false, &table, ); - let low_affinity = class_sort_text("Proxy", "Mockery\\Proxy", "Pro", '2', false, &table); + let low_affinity = class_sort_text("Proxy", "Mockery\\Proxy", "Pro", '2', '0', false, &table); assert!( high_affinity < low_affinity, "Higher affinity should beat smaller gap: high_affinity={high_affinity}, low_affinity={low_affinity}" diff --git a/src/completion/context/constant_completion.rs b/src/completion/context/constant_completion.rs index d1810494..a9823e22 100644 --- a/src/completion/context/constant_completion.rs +++ b/src/completion/context/constant_completion.rs @@ -8,6 +8,7 @@ use tower_lsp::lsp_types::*; use crate::Backend; use crate::completion::builder::deprecation_tag; +use crate::completion::context::symbol_ranking::{flat_symbol_sort_text, origin_sort_tier}; use crate::completion::resolve::CompletionItemData; use crate::util::strip_fqn_prefix; @@ -119,7 +120,12 @@ impl Backend { items.push(build_constant_item( name.clone(), info.value.clone(), - format!("5_{}", name.to_lowercase()), + flat_symbol_sort_text( + name, + &prefix_lower, + origin_sort_tier(self.completion_origin_for_uri(&info.file_uri)), + '0', + ), false, uri, replace_range, @@ -148,10 +154,16 @@ impl Backend { // its value. Otherwise leave it as None — the resolve // handler will fill it in when the user selects the item. let value = dmap.get(name.as_str()).and_then(|info| info.value.clone()); + let origin = self + .autoload_constant_origin_index + .read() + .get(name) + .copied() + .unwrap_or(crate::ClassCompletionOrigin::Project); items.push(build_constant_item( name.clone(), value, - format!("5_{}", name.to_lowercase()), + flat_symbol_sort_text(name, &prefix_lower, origin_sort_tier(origin), '1'), false, uri, replace_range, @@ -173,7 +185,12 @@ impl Backend { items.push(build_constant_item( name.to_string(), None, - format!("6_{}", name.to_lowercase()), + flat_symbol_sort_text( + name, + &prefix_lower, + origin_sort_tier(crate::ClassCompletionOrigin::CoreStub), + '0', + ), false, uri, replace_range, diff --git a/src/completion/context/function_completion.rs b/src/completion/context/function_completion.rs index 6828454d..4e30a4e4 100644 --- a/src/completion/context/function_completion.rs +++ b/src/completion/context/function_completion.rs @@ -12,6 +12,7 @@ use crate::util::{short_name, strip_fqn_prefix}; use crate::completion::builder::{ analyze_use_block, build_callable_label, build_callable_snippet, deprecation_tag, }; +use crate::completion::context::symbol_ranking::{flat_symbol_sort_text, origin_sort_tier}; use crate::completion::resolve::CompletionItemData; use crate::completion::use_edit::build_use_function_edit; @@ -255,12 +256,13 @@ impl Backend { ) }; + let origin_tier = origin_sort_tier(self.completion_origin_for_uri(_uri)); items.push( FunctionItemBuilder::new( label, insert_text, filter_text, - format!("4_{}", info.name.to_lowercase()), + flat_symbol_sort_text(&info.name, &prefix_lower, origin_tier, '0'), fqn.clone(), uri.to_string(), ) @@ -317,12 +319,19 @@ impl Backend { (format!("{sn}()$0"), sn.to_string()) }; + let origin = self + .autoload_function_origin_index + .read() + .get(fqn) + .copied() + .unwrap_or(crate::ClassCompletionOrigin::Project); + let origin_tier = origin_sort_tier(origin); items.push( FunctionItemBuilder::new( sn.to_string(), insert_text, filter_text, - format!("4_{}", sn.to_lowercase()), + flat_symbol_sort_text(sn, &prefix_lower, origin_tier, '1'), fqn.to_owned(), uri.to_string(), ) @@ -373,7 +382,12 @@ impl Backend { sn.to_string(), format!("{sn}()$0"), sn.to_string(), - format!("5_{}", sn.to_lowercase()), + flat_symbol_sort_text( + sn, + &prefix_lower, + origin_sort_tier(crate::ClassCompletionOrigin::CoreStub), + '0', + ), name.to_string(), uri.to_string(), ) diff --git a/src/completion/context/mod.rs b/src/completion/context/mod.rs index 1d911650..d30a4c18 100644 --- a/src/completion/context/mod.rs +++ b/src/completion/context/mod.rs @@ -15,4 +15,5 @@ pub(crate) mod constant_completion; pub(crate) mod function_completion; pub(crate) mod keyword_completion; pub(crate) mod namespace_completion; +pub(crate) mod symbol_ranking; pub(crate) mod type_hint_completion; diff --git a/src/completion/context/symbol_ranking.rs b/src/completion/context/symbol_ranking.rs new file mode 100644 index 00000000..fdd6ce36 --- /dev/null +++ b/src/completion/context/symbol_ranking.rs @@ -0,0 +1,21 @@ +use crate::ClassCompletionOrigin; + +pub(crate) fn origin_sort_tier(origin: ClassCompletionOrigin) -> char { + origin.sort_tier() +} + +pub(crate) fn flat_symbol_sort_text( + short_name: &str, + prefix: &str, + origin_tier: char, + source_tier: char, +) -> String { + let quality = super::class_completion::match_quality(short_name, prefix); + format!( + "{}{}{}_{}", + quality, + origin_tier, + source_tier, + short_name.to_lowercase() + ) +} diff --git a/src/composer.rs b/src/composer.rs index e9b12462..2f1ae282 100644 --- a/src/composer.rs +++ b/src/composer.rs @@ -888,6 +888,20 @@ pub(crate) fn has_require_dev(package: &ComposerPackage, dep: &str) -> bool { package.require_dev.contains_key(dep) } +pub(crate) fn explicit_dependency_names(package: &ComposerPackage) -> HashSet { + package + .require + .keys() + .chain(package.require_dev.keys()) + .filter(|name| { + !name.eq_ignore_ascii_case("php") + && !name.starts_with("ext-") + && !name.starts_with("lib-") + }) + .cloned() + .collect() +} + /// Detect whether the project is a Drupal project and resolve the web root. /// /// Returns `Some(web_root)` if one of the canonical Drupal core packages is diff --git a/src/lib.rs b/src/lib.rs index 26bcef2e..24207caf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,6 +80,7 @@ use std::collections::HashMap; use std::collections::HashSet; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; @@ -316,6 +317,8 @@ pub struct Backend { /// `update_ast` on first access instead of eagerly parsing every /// file at startup. pub(crate) autoload_function_index: Arc>>, + /// Completion provenance for autoloaded function symbols. + pub(crate) autoload_function_origin_index: Arc>>, /// Autoload constant index: constant name → file path on disk. /// /// Populated alongside `autoload_function_index` by the @@ -324,6 +327,8 @@ pub struct Backend { /// the file that defines them for lazy resolution via /// `update_ast` on first access. pub(crate) autoload_constant_index: Arc>>, + /// Completion provenance for autoloaded constant symbols. + pub(crate) autoload_constant_origin_index: Arc>>, /// Paths of all files discovered through Composer's /// `autoload_files.php` (and their `require_once` chains). /// @@ -356,6 +361,12 @@ pub struct Backend { /// - Entries from Composer's `autoload_classmap.php` (merged during /// server initialization). pub(crate) fqn_uri_index: Arc>>, + /// Completion provenance for fully-qualified class names. + /// + /// Used only for ranking class-name completion candidates. Tracks + /// whether a class comes from project code, core/stubs, an explicit + /// Composer dependency, or a transitive vendor dependency. + pub(crate) fqn_origin_index: Arc>>, /// Secondary index mapping fully-qualified class names directly to /// their parsed `ClassInfo`. /// @@ -494,6 +505,11 @@ pub struct Backend { /// vendor directory. For single-project workspaces, contains /// exactly one entry. pub(crate) vendor_dir_paths: Mutex>, + /// Canonical vendor package roots paired with completion provenance. + /// + /// Used to classify function/constant/class symbols by whether they + /// originate from explicit or transitive Composer dependencies. + pub(crate) vendor_package_origin_roots: Arc>>, /// Monotonically increasing version counter for diagnostic debouncing. /// /// Bumped on every `did_change`. A background diagnostic task @@ -686,6 +702,26 @@ pub struct Backend { pub(crate) workspace_indexed: Arc, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ClassCompletionOrigin { + #[default] + Project, + CoreStub, + VendorExplicit, + VendorTransitive, +} + +impl ClassCompletionOrigin { + pub(crate) fn sort_tier(self) -> char { + match self { + Self::Project => '0', + Self::CoreStub => '1', + Self::VendorExplicit => '2', + Self::VendorTransitive => '3', + } + } +} + /// Request-coalescing state for expensive whole-file requests (semantic /// tokens, code lens, document symbols, folding, links). /// @@ -775,6 +811,7 @@ impl Backend { workspace_root: Arc::new(RwLock::new(None)), vendor_uri_prefixes: Mutex::new(Vec::new()), vendor_dir_paths: Mutex::new(Vec::new()), + vendor_package_origin_roots: Arc::new(RwLock::new(Vec::new())), psr4_mappings: Arc::new(RwLock::new(Vec::new())), file_imports: Arc::new(RwLock::new(HashMap::new())), resolved_names: Arc::new(RwLock::new(HashMap::new())), @@ -783,9 +820,12 @@ impl Backend { global_defines: Arc::new(RwLock::new(HashMap::new())), uri_globals_index: Arc::new(RwLock::new(HashMap::new())), autoload_function_index: Arc::new(RwLock::new(CiMap::new())), + autoload_function_origin_index: Arc::new(RwLock::new(CiMap::new())), autoload_constant_index: Arc::new(RwLock::new(HashMap::new())), + autoload_constant_origin_index: Arc::new(RwLock::new(HashMap::new())), autoload_file_paths: Arc::new(RwLock::new(Vec::new())), fqn_uri_index: Arc::new(RwLock::new(CiMap::new())), + fqn_origin_index: Arc::new(RwLock::new(CiMap::new())), fqn_class_index: Arc::new(RwLock::new(CiMap::new())), class_not_found_cache: Arc::new(RwLock::new(CiSet::new())), phar_archives: Arc::new(RwLock::new(HashMap::new())), @@ -858,6 +898,7 @@ impl Backend { workspace_root: Arc::new(RwLock::new(None)), vendor_uri_prefixes: Mutex::new(Vec::new()), vendor_dir_paths: Mutex::new(Vec::new()), + vendor_package_origin_roots: Arc::new(RwLock::new(Vec::new())), psr4_mappings: Arc::new(RwLock::new(Vec::new())), file_imports: Arc::new(RwLock::new(HashMap::new())), resolved_names: Arc::new(RwLock::new(HashMap::new())), @@ -866,9 +907,12 @@ impl Backend { global_defines: Arc::new(RwLock::new(HashMap::new())), uri_globals_index: Arc::new(RwLock::new(HashMap::new())), autoload_function_index: Arc::new(RwLock::new(CiMap::new())), + autoload_function_origin_index: Arc::new(RwLock::new(CiMap::new())), autoload_constant_index: Arc::new(RwLock::new(HashMap::new())), + autoload_constant_origin_index: Arc::new(RwLock::new(HashMap::new())), autoload_file_paths: Arc::new(RwLock::new(Vec::new())), fqn_uri_index: Arc::new(RwLock::new(CiMap::new())), + fqn_origin_index: Arc::new(RwLock::new(CiMap::new())), fqn_class_index: Arc::new(RwLock::new(CiMap::new())), class_not_found_cache: Arc::new(RwLock::new(CiSet::new())), phar_archives: Arc::new(RwLock::new(HashMap::new())), @@ -1063,6 +1107,12 @@ impl Backend { self.stub_constant_index.read() } + pub fn stub_function_index_mut( + &self, + ) -> parking_lot::RwLockWriteGuard<'_, CiMap<&'static str>> { + self.stub_function_index.write() + } + /// Write-access the stub constant index (used by integration tests /// to inject test stub entries). pub fn stub_constant_index_mut( @@ -1077,12 +1127,22 @@ impl Backend { &self.autoload_function_index } + pub fn autoload_function_origin_index(&self) -> &Arc>> { + &self.autoload_function_origin_index + } + /// Borrow the autoload constant index (used by integration tests to /// populate discovered constant entries for non-Composer projects). pub fn autoload_constant_index(&self) -> &Arc>> { &self.autoload_constant_index } + pub fn autoload_constant_origin_index( + &self, + ) -> &Arc>> { + &self.autoload_constant_origin_index + } + /// Borrow the autoload file paths list (used by integration tests /// to simulate Composer autoload file discovery). pub fn autoload_file_paths(&self) -> &Arc>> { @@ -1095,6 +1155,32 @@ impl Backend { &self.open_files } + pub(crate) fn completion_origin_for_path(&self, path: &Path) -> ClassCompletionOrigin { + let vendor_paths = self.vendor_dir_paths.lock(); + if !vendor_paths.iter().any(|vp| path.starts_with(vp)) { + return ClassCompletionOrigin::Project; + } + let roots = self.vendor_package_origin_roots.read(); + for (root, origin) in roots.iter() { + if path.starts_with(root) { + return *origin; + } + } + ClassCompletionOrigin::VendorTransitive + } + + pub(crate) fn completion_origin_for_uri(&self, uri: &str) -> ClassCompletionOrigin { + if uri.starts_with("phpantom-stub://") || uri.starts_with("phpantom-stub-fn://") { + return ClassCompletionOrigin::VendorExplicit; + } + if let Ok(url) = tower_lsp::lsp_types::Url::parse(uri) + && let Ok(path) = url.to_file_path() + { + return self.completion_origin_for_path(&path); + } + ClassCompletionOrigin::Project + } + /// Borrow the PHPStan diagnostics cache (used by integration tests /// to inject PHPStan diagnostics without running PHPStan). pub fn phpstan_last_diags( @@ -1367,9 +1453,12 @@ impl Backend { global_defines: Arc::clone(&self.global_defines), uri_globals_index: Arc::clone(&self.uri_globals_index), autoload_function_index: Arc::clone(&self.autoload_function_index), + autoload_function_origin_index: Arc::clone(&self.autoload_function_origin_index), autoload_constant_index: Arc::clone(&self.autoload_constant_index), + autoload_constant_origin_index: Arc::clone(&self.autoload_constant_origin_index), autoload_file_paths: Arc::clone(&self.autoload_file_paths), fqn_uri_index: Arc::clone(&self.fqn_uri_index), + fqn_origin_index: Arc::clone(&self.fqn_origin_index), fqn_class_index: Arc::clone(&self.fqn_class_index), phar_archives: Arc::clone(&self.phar_archives), parsed_uris: Arc::clone(&self.parsed_uris), @@ -1385,6 +1474,7 @@ impl Backend { php_version: Mutex::new(self.php_version()), vendor_uri_prefixes: Mutex::new(self.vendor_uri_prefixes.lock().clone()), vendor_dir_paths: Mutex::new(self.vendor_dir_paths.lock().clone()), + vendor_package_origin_roots: Arc::clone(&self.vendor_package_origin_roots), diag_version: Arc::clone(&self.diag_version), diag_notify: Arc::clone(&self.diag_notify), diag_pending_uris: Arc::clone(&self.diag_pending_uris), diff --git a/src/server.rs b/src/server.rs index acba2273..49a33b29 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1875,6 +1875,11 @@ impl Backend { .await; } + let explicit_deps = composer_json + .as_ref() + .map(crate::composer::explicit_dependency_names) + .unwrap_or_default(); + let (classmap, source_label) = match strategy { IndexingStrategy::None => { let cm = composer::parse_autoload_classmap(root, &vendor_dir); @@ -1897,7 +1902,12 @@ impl Backend { // Merge vendor packages (excluded from the workspace // walk above, scanned separately here). - let vendor_scan = classmap_scanner::scan_vendor_packages(root, &vendor_dir); + let vendor_scan = classmap_scanner::scan_vendor_packages_with_skip( + root, + &vendor_dir, + &HashSet::new(), + &explicit_deps, + ); for (fqcn, path) in vendor_scan.classmap { scan.classmap.entry(fqcn).or_insert(path); } @@ -1930,10 +1940,18 @@ impl Backend { } }; - let symbol_count = classmap.len(); + let vendor_package_roots = + classmap_scanner::vendor_package_roots(root, &vendor_dir, &explicit_deps); + + let class_entries: Vec<(String, PathBuf)> = classmap.into_iter().collect(); + let symbol_count = class_entries.len(); { let mut idx = self.fqn_uri_index.write(); - for (fqn, path) in classmap { + let mut origins = self.fqn_origin_index.write(); + origins.clear(); + for (fqn, path) in class_entries { + let origin = classify_class_origin(&path, &vendor_path, &vendor_package_roots); + origins.insert(fqn.clone(), origin); idx.or_insert_with(fqn, || crate::util::path_to_uri(&path)); } } @@ -2729,8 +2747,13 @@ impl Backend { ); // Scan vendor packages from installed.json. - let vendor_scan = - classmap_scanner::scan_vendor_packages_with_skip(project_root, vendor_dir, skip_paths); + let explicit_deps = crate::composer::explicit_dependency_names(package); + let vendor_scan = classmap_scanner::scan_vendor_packages_with_skip( + project_root, + vendor_dir, + skip_paths, + &explicit_deps, + ); let mut result = WorkspaceScanResult { classmap, @@ -2762,19 +2785,49 @@ impl Backend { pub(crate) fn populate_autoload_indices(&self, scan: &WorkspaceScanResult) { if !scan.function_index.is_empty() { let mut idx = self.autoload_function_index.write(); + let mut origins = self.autoload_function_origin_index.write(); for (fqn, path) in &scan.function_index { idx.or_insert_with(fqn.as_str(), || path.clone()); + let origin = scan + .function_origins + .get(fqn) + .copied() + .unwrap_or(crate::ClassCompletionOrigin::Project); + origins.insert(fqn.clone(), origin); } } if !scan.constant_index.is_empty() { let mut idx = self.autoload_constant_index.write(); + let mut origins = self.autoload_constant_origin_index.write(); for (name, path) in &scan.constant_index { idx.entry(name.clone()).or_insert_with(|| path.clone()); + let origin = scan + .constant_origins + .get(name) + .copied() + .unwrap_or(crate::ClassCompletionOrigin::Project); + origins.insert(name.clone(), origin); } } } } +fn classify_class_origin( + path: &Path, + vendor_path: &Path, + vendor_package_roots: &[(PathBuf, crate::ClassCompletionOrigin)], +) -> crate::ClassCompletionOrigin { + if !path.starts_with(vendor_path) { + return crate::ClassCompletionOrigin::Project; + } + for (root, origin) in vendor_package_roots { + if path.starts_with(root) { + return *origin; + } + } + crate::ClassCompletionOrigin::VendorTransitive +} + #[cfg(test)] mod coalesce_tests { use crate::Backend; diff --git a/tests/integration/completion_class_name_context.rs b/tests/integration/completion_class_name_context.rs index 2ed1643c..040e4317 100644 --- a/tests/integration/completion_class_name_context.rs +++ b/tests/integration/completion_class_name_context.rs @@ -1936,13 +1936,13 @@ async fn test_instanceof_no_heuristic_demotion() { assert!(repo_item.is_some(), "UserRepository should be present"); assert!(iface_item.is_some(), "UserInterface should be present"); - // sort_text format: {quality}{tier}{affinity:4}{demote}{gap:3}_{name} - // Demote flag is at position 6. Neither should be demoted in + // sort_text format: {quality}{origin_tier}{source_tier}{affinity:4}{demote}{gap:3}_{name} + // Demote flag is at position 7. Neither should be demoted in // instanceof context. let demote_flag = |item: &CompletionItem| -> char { item.sort_text .as_deref() - .and_then(|s| s.chars().nth(6)) + .and_then(|s| s.chars().nth(7)) .unwrap_or('?') }; @@ -2010,13 +2010,13 @@ async fn test_extends_interface_does_not_demote_interface_names() { assert!(logger_item.is_some(), "LoggerInterface should be present"); assert!(loggable_item.is_some(), "Loggable should be present"); - // sort_text format: {quality}{tier}{affinity:4}{demote}{gap:3}_{name} - // Demote flag is at position 6. Neither should be demoted in + // sort_text format: {quality}{origin_tier}{source_tier}{affinity:4}{demote}{gap:3}_{name} + // Demote flag is at position 7. Neither should be demoted in // extends-interface context. let demote_flag = |item: &CompletionItem| -> char { item.sort_text .as_deref() - .and_then(|s| s.chars().nth(6)) + .and_then(|s| s.chars().nth(7)) .unwrap_or('?') }; diff --git a/tests/integration/completion_class_names.rs b/tests/integration/completion_class_names.rs index ccd28240..1c08bbf1 100644 --- a/tests/integration/completion_class_names.rs +++ b/tests/integration/completion_class_names.rs @@ -1,8 +1,10 @@ use crate::common::{ - create_psr4_workspace, create_test_backend_with_function_stubs, create_test_backend_with_stubs, + create_psr4_workspace, create_test_backend_with_full_stubs, + create_test_backend_with_function_stubs, create_test_backend_with_stubs, }; use phpantom_lsp::Backend; use phpantom_lsp::composer::parse_autoload_classmap; +use phpantom_lsp::types::DefineInfo; use std::collections::HashMap; use std::fs; use tower_lsp::LanguageServer; @@ -75,6 +77,220 @@ fn fqns<'a>(items: &'a [&'a CompletionItem]) -> Vec<&'a str> { items.iter().filter_map(|i| i.detail.as_deref()).collect() } +fn function_items(items: &[CompletionItem]) -> Vec<&CompletionItem> { + items + .iter() + .filter(|i| i.kind == Some(CompletionItemKind::FUNCTION)) + .collect() +} + +fn constant_items(items: &[CompletionItem]) -> Vec<&CompletionItem> { + items + .iter() + .filter(|i| i.kind == Some(CompletionItemKind::CONSTANT)) + .collect() +} + +#[tokio::test] +async fn test_class_name_completion_prioritizes_project_core_explicit_then_transitive() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + fs::write( + dir.path().join("composer.json"), + r#"{ + "name": "demo/project", + "require": { + "acme/explicit": "*" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + } + }"#, + ) + .expect("failed to write composer.json"); + + let project_file = dir.path().join("src/RuntimeException.php"); + fs::create_dir_all(project_file.parent().unwrap()).expect("mkdir src"); + fs::write( + &project_file, + " = funcs.iter().filter_map(|i| i.sort_text.clone()).collect(); + assert!( + sorts.iter().any(|s| s.starts_with("b00_")) + && sorts.iter().any(|s| s.starts_with("b10_")) + && sorts.iter().any(|s| s.starts_with("b21_")) + && sorts.iter().any(|s| s.starts_with("b31_")), + "expected project/core/explicit/transitive sort tiers in function completions, got: {sorts:?}" + ); +} + +#[tokio::test] +async fn test_constant_completion_prioritizes_project_core_explicit_then_transitive() { + let backend = create_test_backend_with_full_stubs(); + + backend.global_defines().write().insert( + "APP_RUNTIME_FLAG".to_string(), + DefineInfo { + file_uri: "file:///workspace/src/constants.php".to_string(), + name_offset: 0, + value: Some("'project'".to_string()), + }, + ); + backend.stub_constant_index_mut().insert( + "APP_RUNTIME_FLAG_CORE", + " = consts.iter().map(|i| i.label.as_str()).collect(); + assert!( + names.iter().position(|n| *n == "APP_RUNTIME_FLAG").unwrap() + < names + .iter() + .position(|n| *n == "APP_RUNTIME_FLAG_TRANSITIVE") + .unwrap(), + "expected project constant before transitive constant, names={names:?}" + ); +} + // ─── extract_partial_class_name tests ─────────────────────────────────────── #[test] @@ -463,11 +679,11 @@ async fn test_class_name_completion_use_import_has_higher_sort_priority() { let widget_item = find_by_fqn(&classes, "Acme\\Widget").unwrap(); let sort = widget_item.sort_text.as_deref().unwrap_or(""); - // New format: {quality}{tier}{affinity:4}{demote}_{name} - // Tier '0' = use-imported, at position 1. + // New format: {quality}{origin}{tier}{affinity:4}{demote}_{name} + // Tier '0' = use-imported, at position 2. assert!( - sort.len() > 1 && &sort[1..2] == "0", - "Use-imported classes should have source tier '0' at position 1, got: {:?}", + sort.len() > 2 && &sort[2..3] == "0", + "Use-imported classes should have source tier '0' at position 2, got: {:?}", sort ); } @@ -552,12 +768,12 @@ async fn test_class_name_completion_same_namespace() { class_fqns ); - // Same-namespace should have source tier '1' at position 1. + // Same-namespace should have source tier '1' at position 2. let service_item = find_by_fqn(&classes, "App\\UserService").unwrap(); let sort = service_item.sort_text.as_deref().unwrap_or(""); assert!( - sort.len() > 1 && &sort[1..2] == "1", - "Same-namespace classes should have source tier '1' at position 1, got: {:?}", + sort.len() > 2 && &sort[2..3] == "1", + "Same-namespace classes should have source tier '1' at position 2, got: {:?}", sort ); } @@ -1694,16 +1910,16 @@ async fn test_new_context_demotes_likely_non_instantiable_classmap() { let items = complete_at(&backend, &uri, text, 1, 11).await; let classes = class_items(&items); - // New sort_text format: {quality}{tier}{affinity:4}{demote}{gap:3}_{name} - // Demote flag is at position 6 ('0' = normal, '1' = demoted). + // New sort_text format: {quality}{origin}{tier}{affinity:4}{demote}{gap:3}_{name} + // Demote flag is at position 7 ('0' = normal, '1' = demoted). // Within the same match quality group, demoted items sort after // normal items. - // Helper: extract the demote flag (position 6) from a sort_text. + // Helper: extract the demote flag (position 7) from a sort_text. let demote_flag = |item: &CompletionItem| -> char { item.sort_text .as_deref() - .and_then(|s| s.chars().nth(6)) + .and_then(|s| s.chars().nth(7)) .unwrap_or('?') }; From 819cd4bdc2f05d02165b777ab85aa87e41005c92 Mon Sep 17 00:00:00 2001 From: Anders Jenbo Date: Fri, 10 Jul 2026 11:34:34 +0200 Subject: [PATCH 2/2] Refresh import marking when composer updates --- src/lib.rs | 2 +- src/server.rs | 36 ++++++++- tests/integration/completion_class_names.rs | 90 +++++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 24207caf..52051ae3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1171,7 +1171,7 @@ impl Backend { pub(crate) fn completion_origin_for_uri(&self, uri: &str) -> ClassCompletionOrigin { if uri.starts_with("phpantom-stub://") || uri.starts_with("phpantom-stub-fn://") { - return ClassCompletionOrigin::VendorExplicit; + return ClassCompletionOrigin::CoreStub; } if let Ok(url) = tower_lsp::lsp_types::Url::parse(uri) && let Ok(path) = url.to_file_path() diff --git a/src/server.rs b/src/server.rs index 49a33b29..f30105e7 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1955,6 +1955,9 @@ impl Backend { idx.or_insert_with(fqn, || crate::util::path_to_uri(&path)); } } + // Cache the package roots so path-based origin lookups + // (functions, constants) can classify lazily parsed symbols. + *self.vendor_package_origin_roots.write() = vendor_package_roots; // ── Drupal: scan web-root directories (gitignore bypassed) ── // Drupal's .gitignore excludes web/core, web/modules/contrib, @@ -2371,8 +2374,17 @@ impl Backend { let vendor_dir = composer::get_vendor_dir(&pkg); let vendor_path = root.join(&vendor_dir); - // Rebuild vendor classmap. - let vendor_scan = classmap_scanner::scan_vendor_packages(root, &vendor_dir); + // Rebuild vendor classmap, tracking dependency provenance so + // completion ranking stays accurate after a composer change. + let explicit_deps = composer::explicit_dependency_names(&pkg); + let vendor_scan = classmap_scanner::scan_vendor_packages_with_skip( + root, + &vendor_dir, + &HashSet::new(), + &explicit_deps, + ); + let vendor_package_roots = + classmap_scanner::vendor_package_roots(root, &vendor_dir, &explicit_deps); { let vendor_uri_prefix = if let Ok(canonical) = vendor_path.canonicalize() { format!("{}/", crate::util::path_to_uri(&canonical)) @@ -2382,30 +2394,50 @@ impl Backend { // Remove old vendor entries and insert new ones. let mut idx = self.fqn_uri_index.write(); + let mut origins = self.fqn_origin_index.write(); idx.retain(|_, v| !v.starts_with(&vendor_uri_prefix)); for (fqn, path) in vendor_scan.classmap { + let origin = classify_class_origin(&path, &vendor_path, &vendor_package_roots); + origins.insert(fqn.clone(), origin); idx.insert(fqn, crate::util::path_to_uri(&path)); } } { let mut fi = self.autoload_function_index.write(); + let mut origins = self.autoload_function_origin_index.write(); // 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)); for (fqn, path) in vendor_scan.function_index { + let origin = vendor_scan + .function_origins + .get(&fqn) + .copied() + .unwrap_or(crate::ClassCompletionOrigin::Project); + origins.insert(fqn.clone(), origin); fi.insert(fqn, path); } } { let mut ci = self.autoload_constant_index.write(); + let mut origins = self.autoload_constant_origin_index.write(); // Same for constants from the old vendor tree. ci.retain(|_, v| !v.starts_with(&vendor_path)); for (name, path) in vendor_scan.constant_index { + let origin = vendor_scan + .constant_origins + .get(&name) + .copied() + .unwrap_or(crate::ClassCompletionOrigin::Project); + origins.insert(name.clone(), origin); ci.insert(name, path); } } + // Refresh the cached package roots for path-based lookups. + *self.vendor_package_origin_roots.write() = vendor_package_roots; + // Rescan autoload files (they may have changed). self.scan_autoload_files(root, &vendor_dir); } diff --git a/tests/integration/completion_class_names.rs b/tests/integration/completion_class_names.rs index 1c08bbf1..61157e90 100644 --- a/tests/integration/completion_class_names.rs +++ b/tests/integration/completion_class_names.rs @@ -192,6 +192,96 @@ async fn test_class_name_completion_prioritizes_project_core_explicit_then_trans ); } +/// After a `composer` change the origin ranking must be rebuilt: a package +/// that was transitive but is now an explicit dependency should be promoted +/// ahead of the packages that remain transitive. +#[tokio::test] +async fn test_composer_change_refreshes_completion_provenance() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let composer_path = dir.path().join("composer.json"); + // Initially neither vendor package is required, so both are transitive. + fs::write( + &composer_path, + r#"{ "name": "demo/project", "require": {} }"#, + ) + .expect("write composer.json"); + + // Equal-length class names so the only ranking difference is the origin + // tier: "ExAlpha" (stays transitive) sorts before "ExOmega" by name, + // and promoting ExOmega's package must flip that order. + for (pkg, ns, class) in [ + ("stays/trans", "Stays\\Trans", "ExAlpha"), + ("promoted/dep", "Promoted\\Dep", "ExOmega"), + ] { + let file = dir.path().join(format!("vendor/{pkg}/src/{class}.php")); + fs::create_dir_all(file.parent().unwrap()).expect("mkdir pkg"); + fs::write( + &file, + format!("