From d829f36fc4563e5098db43daf0e6f877a868b172 Mon Sep 17 00:00:00 2001 From: Alex Sytnyk Date: Sat, 1 Aug 2026 18:58:01 +0300 Subject: [PATCH 1/3] fix: classify vendor files correctly under a symlinked workspace root Scanned file paths are canonicalized while the vendor directory was stored raw-only, so on a workspace whose real path goes through a symlink (macOS /var -> /private/var, link-farm checkouts) vendor files never prefix-matched the vendor directory. Hover lost the package provenance badge, completion ranked vendor classes as project symbols, and vendor service providers were scanned as if they were user code. add_vendor_dir now records the canonical path alongside the raw one, mirroring what the vendor URI prefixes already do. The analyse CLI had the mirror-image bug: it canonicalized each walked entry but compared against the raw vendor path, so analyse descended into the whole vendor tree. The skip list is now canonicalized too. --- docs/CHANGELOG.md | 1 + src/analyse/run.rs | 11 +++++++++-- src/indexing/scan.rs | 16 ++++++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 54210550..82044bd1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -72,6 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Vendor files under a symlinked workspace root classify correctly.** On a workspace whose real path goes through a symlink (macOS `/var` → `/private/var`, link-farm checkouts), vendor classes were classified as project code: hover lost the package provenance badge, completion ranked them as project symbols, and vendor service providers were scanned as if they were user code. The vendor directory's canonical path is now matched alongside the raw one. The `analyse` CLI had the mirror-image bug — it compared canonicalized file paths against the raw vendor path, so `analyse` walked and reported on the whole vendor tree; it now skips vendor regardless of symlinks. Contributed by @syntlyx. - **`parent::SOME_CONSTANT` resolves to a type.** A class constant reached through the `parent` keyword produced no type at all, so hover on it was blank and anything derived from it lost the value, while the same constant reached through `self`, `static`, or the class name resolved normally. Constants inherited further up the chain resolve through `parent::` too. - **A template parameter bound only by the argument it type-checks no longer flags a false positive.** PHPUnit's `assertSame(url('/login'), $x)` (and any other call where a `@template` is bound solely by the parameter being checked, such as `assertSame`'s `$expected`) could report a type mismatch: the substituted parameter type is derived from resolving that exact argument, so comparing the argument to it again is circular and, when the two resolution passes disagree on an ambiguous expression, produced a spurious diagnostic. Such a parameter is no longer checked against its own argument. - **`self`, `static`, and `parent` in a parameter type resolve to a real class.** A method declared `canChangeTo(self $next)` used to be checked against the literal keyword, so passing an instance of the declaring class was reported as "expects self, got State". The keywords now resolve wherever the call is made from, including through a property (`$this->state->canChangeTo(State::B)`), where the enclosing class is not the one declaring the method. `self` on an inherited method binds to the class that declares it, so a parent instance is still accepted when the method is called on a subclass, and a `parent` parameter is now checked instead of skipped. Mismatches name the class the keyword resolves to rather than the keyword. diff --git a/src/analyse/run.rs b/src/analyse/run.rs index 2cf09f29..bd6b9107 100644 --- a/src/analyse/run.rs +++ b/src/analyse/run.rs @@ -700,8 +700,15 @@ pub(crate) fn discover_user_files( continue; } - let skip_vendor = if filter_overlaps_psr4 { - vendor_dirs.clone() + let skip_vendor: Vec = if filter_overlaps_psr4 { + // The walker compares canonicalized entry paths below, so + // canonicalize the vendor dirs too — otherwise a symlinked + // workspace root (macOS `/var` → `/private/var`, monorepo + // link farms) never matches and vendor is walked anyway. + vendor_dirs + .iter() + .map(|v| v.canonicalize().unwrap_or_else(|_| v.clone())) + .collect() } else { // User explicitly targeted this path — don't skip vendor // subdirectories within it. diff --git a/src/indexing/scan.rs b/src/indexing/scan.rs index ab2aaeee..2673b049 100644 --- a/src/indexing/scan.rs +++ b/src/indexing/scan.rs @@ -42,10 +42,22 @@ impl Backend { /// Register a vendor directory path and its URI prefix for /// vendor-file detection. pub(crate) fn add_vendor_dir(&self, vendor_path: &std::path::Path) { - // Store the absolute path for filesystem-level skip logic. + // Store the absolute path for filesystem-level skip logic. Keep + // the canonical form alongside the raw one (mirroring the URI + // prefixes below): scanned file paths are canonicalized, so on a + // symlinked root (macOS `/var` → `/private/var`) a raw-only entry + // never prefix-matches and vendor files classify as project code. { let mut paths = self.workspace.vendor_dir_paths.lock(); - paths.push(vendor_path.to_path_buf()); + if !paths.iter().any(|p| p == vendor_path) { + paths.push(vendor_path.to_path_buf()); + } + if let Ok(canonical) = vendor_path.canonicalize() + && canonical != vendor_path + && !paths.contains(&canonical) + { + paths.push(canonical); + } } // Store URI prefixes for URI-level skip logic (diagnostics, find // references, rename). Keep both raw and canonical forms so macOS From 7d21510a56e5e22a4fb421056614f08bcd0495ce Mon Sep 17 00:00:00 2001 From: Alex Sytnyk Date: Sat, 1 Aug 2026 18:58:21 +0300 Subject: [PATCH 2/3] feat: exclude globs and extra PHP extensions for indexing Add `[indexing] exclude` and `[indexing] extensions` to `.phpantom.toml`. Exclude patterns use gitignore semantics relative to the workspace root and are honored by every workspace walker (the fallback full scan, PSR-4 and vendor scans, the Drupal web-root scan, the preload and go-to-implementation walkers) and by the file watcher, so generated code and test fixtures stay out of the index. Extra extensions let non-.php PHP source (e.g. Drupal's .module, .inc, .theme) be discovered by background scans outside the Drupal-specific directories, with matching file watchers so those files refresh the index on change. Patterns compile once into classmap_scanner::IndexFilters, cached on the Backend and invalidated on config reload, so no glob compilation happens per file. Gitignore matching (GitignoreBuilder) was chosen over the ignore crate's Override, whose whitelist-first semantics would invert an exclude list containing `!` re-includes. This ships the server side of backlog item X9; the item now tracks the remaining client-side work of forwarding the editor's files.exclude / files.associations settings to the server. Refs #48 --- config-schema.json | 16 ++ docs/CHANGELOG.md | 2 + docs/configuration.md | 14 +- docs/todo.md | 2 +- docs/todo/indexing.md | 39 +--- src/analyse/run.rs | 2 +- src/classmap_scanner/discovery.rs | 77 +++++-- src/classmap_scanner/discovery_tests.rs | 101 +++++++- src/classmap_scanner/filters.rs | 218 ++++++++++++++++++ src/classmap_scanner/mod.rs | 2 + src/config.rs | 35 +++ src/definition/implementation.rs | 3 +- src/fix.rs | 2 +- src/indexing/init.rs | 28 ++- src/indexing/preload.rs | 7 +- src/indexing/scan.rs | 5 + src/indexing/watch.rs | 13 +- src/lib.rs | 28 ++- src/references/mod.rs | 17 +- src/rename/namespace.rs | 7 +- src/server.rs | 72 +++--- src/util.rs | 19 +- src/workspace_env.rs | 6 + .../completion_non_composer_discovery.rs | 8 +- tests/unit/monorepo.rs | 10 +- 25 files changed, 604 insertions(+), 129 deletions(-) create mode 100644 src/classmap_scanner/filters.rs diff --git a/config-schema.json b/config-schema.json index 38f21b6c..d88a7449 100644 --- a/config-schema.json +++ b/config-schema.json @@ -83,6 +83,22 @@ "none" ], "default": "full" + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Paths the workspace scanners skip, in gitignore syntax relative to the workspace root: a bare name matches at any depth, a pattern containing / anchors to the root, a trailing / restricts to directories, and a leading ! re-includes. Applies to background discovery only; files opened in the editor are always served.", + "default": [] + }, + "extensions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Extra file extensions (without the dot) treated as PHP source during workspace discovery, e.g. [\"module\", \"inc\", \"theme\"] for Drupal. .php is always included.", + "default": [] } } }, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 82044bd1..b1acaf7d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Indexing excludes and extra PHP extensions.** `.phpantom.toml` now supports `[indexing] exclude` — gitignore-style patterns relative to the workspace root that background discovery skips (generated code, test fixtures, upload directories) — and `[indexing] extensions`, extra file extensions treated as PHP source (e.g. `["module", "inc", "theme"]` for Drupal), with matching file watchers so edits to those files refresh the index. Excludes apply to every workspace scanner, including the Drupal web-root scan; files opened in the editor are always served regardless. Contributed by @syntlyx. + - **Config return type inference.** `config('database.default')`, `Config::get('app.name')`, and `$repository->get('mail.from')` now infer their return type from the project's `config/*.php` files. Scalar values resolve to their base type (`string`, `int`, `bool`), `env()` defaults resolve through their fallback argument, and nested arrays resolve to array shapes with typed keys. Framework default configs from `vendor/laravel/framework/config/` fill in any keys the project's own config file leaves unset, so a partially published `config/app.php` still resolves the framework defaults it does not override. Parsed config trees are cached and invalidated when config files change. Contributed by @calebdw. - **Semantic token modes.** `.phpantom.toml` now supports `[semantic_tokens] mode = "contextual" | "full" | "off"`. The default `contextual` mode emits only context-sensitive highlighting that complements editor syntax grammars, while `full` keeps the previous broad semantic-token stream and `off` disables semantic tokens. Contributed by @calebdw. - **`@phpstan-ignore` identifiers are highlighted and completed.** PHPStan ignore comments now highlight the `@phpstan-ignore` tag and each listed error identifier in both docblocks and ordinary `//` comments. Identifier completion works inside the comma-separated ignore list, using PHPStan diagnostic codes already seen in the current file while staying out of per-code parenthesized reasons. Contributed by @calebdw. diff --git a/docs/configuration.md b/docs/configuration.md index c3306261..eed7ff9e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,9 +58,17 @@ message = "^Call to deprecated function some_legacy_helper\\(\\)" ### `[indexing]` -| Key | Type | Default | Description | -| ---------- | ------ | -------- | ----------- | -| `strategy` | string | `"full"` | Class discovery strategy: `"full"`, `"composer"`, `"self"`, or `"none"`. See [Indexing Strategy](#indexing-strategy) below. | +| Key | Type | Default | Description | +| ------------ | -------- | -------- | ----------- | +| `strategy` | string | `"full"` | Class discovery strategy: `"full"`, `"composer"`, `"self"`, or `"none"`. See [Indexing Strategy](#indexing-strategy) below. | +| `exclude` | string[] | `[]` | Paths the workspace scanners skip, in gitignore syntax relative to the workspace root: a bare name matches at any depth, a pattern containing `/` anchors to the root, a trailing `/` restricts to directories, and a leading `!` re-includes. Applies to background discovery only — files opened in the editor are always served. | +| `extensions` | string[] | `[]` | Extra file extensions (without the dot) treated as PHP source during workspace discovery, e.g. `["module", "inc", "theme"]` for Drupal. `.php` is always included. | + +```toml +[indexing] +exclude = ["generated", "web/sites/default/files"] +extensions = ["module", "install", "theme"] +``` ### `[semantic_tokens]` diff --git a/docs/todo.md b/docs/todo.md index bc53e5fc..993dccbc 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -218,7 +218,7 @@ unlikely to move the needle for most users. | X3 | Completion item detail on demand (`completionItem/resolve`) | Medium | Medium | | X7 | [Recency tracking](todo/indexing.md#x7-recency-tracking) | Medium | Medium | | X2 | Parallel file processing — remaining work | Low-Medium | Medium | -| X9 | [Honor editor file excludes and PHP associations during indexing](todo/indexing.md#x9-honor-editor-file-excludes-and-php-associations-during-indexing) | Low-Medium | Medium | +| X9 | [Forward editor file excludes and PHP associations to the server](todo/indexing.md#x9-forward-editor-file-excludes-and-php-associations-to-the-server) | Low-Medium | Medium | | X6 | Disk cache (evaluate later) | Medium | High | | | **[Inline Completion](todo/inline-completion.md)** | | | | N1 | Template engine (type-aware snippets) | Medium | High | diff --git a/docs/todo/indexing.md b/docs/todo/indexing.md index 17bac744..e40e9054 100644 --- a/docs/todo/indexing.md +++ b/docs/todo/indexing.md @@ -343,32 +343,18 @@ ready to implement. --- -## X9. Honor editor file excludes and PHP associations during indexing +## X9. Forward editor file excludes and PHP associations to the server **Impact: Low-Medium · Effort: Medium** -This task spans both the server and the IDE plugins. The server side -teaches the directory walkers to honor a generic list of exclude globs -and extra PHP extensions. The client side (each editor extension) must -gather the editor's effective `files.exclude` / `files.associations` -and forward them to the server, since only the extension has access to -those editor settings. - -The workspace scanners discover files by the `.php` extension and do -not consult any exclude list. Two pieces of information the editor -already has are ignored: - -- **`files.exclude` (and a PHPantom-specific exclude glob).** Large - generated/vendored directories that the user has hidden from the - editor are still walked and parsed by the indexer. Skipping them - would cut startup work and avoid indexing irrelevant symbols. -- **`files.associations`.** Files mapped to PHP under a non-`.php` - extension (e.g. `.module`, `.inc`, `.theme` in Drupal) are not - discovered by the byte-level scanners, so their classes/functions - are missing from the index. Note that *open* associated files - already work, because VS Code reports them with the `php` language - id and the client's document selector matches on language id, not - extension. Only background discovery is affected. +The server side of this task has shipped: the directory walkers honor +`[indexing] exclude` (gitignore-style patterns) and `[indexing] +extensions` (extra PHP extensions) from `.phpantom.toml`. What remains +is the client side: each editor extension must gather the editor's +effective `files.exclude` / `files.associations` and forward them to +the server, since only the extension has access to those editor +settings. Today a user has to mirror those editor settings into +`.phpantom.toml` by hand. ### Approach @@ -376,10 +362,9 @@ The client passes the effective exclude globs and the set of PHP-associated extensions to the server (via `initializationOptions`, or by responding to `workspace/configuration` the way Intelephense's middleware merges VS Code's native `files.exclude` / -`files.associations` into the server config). The directory walkers in -`classmap_scanner.rs` and `util.rs` consult the exclude globs before -descending, and treat the extra associated extensions as PHP when -collecting candidate files. +`files.associations` into the server config). The server merges them +into the same compiled filters the `.phpantom.toml` keys feed +(`classmap_scanner::IndexFilters`). ### Editor-agnostic note diff --git a/src/analyse/run.rs b/src/analyse/run.rs index bd6b9107..50221138 100644 --- a/src/analyse/run.rs +++ b/src/analyse/run.rs @@ -63,7 +63,7 @@ pub async fn run(options: AnalyseOptions) -> i32 { // calls are no-ops. let backend = Backend::new_headless(); *backend.workspace_root().write() = Some(root.to_path_buf()); - *backend.workspace.config.lock() = cfg.clone(); + backend.set_config(cfg.clone()); let composer_package = composer::read_composer_package(root); diff --git a/src/classmap_scanner/discovery.rs b/src/classmap_scanner/discovery.rs index 9f3ff147..2caf66ac 100644 --- a/src/classmap_scanner/discovery.rs +++ b/src/classmap_scanner/discovery.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use memchr::memmem; +use super::filters::IndexFilters; use super::{ScanResult, WorkspaceScanResult, read_for_scan, scan_content}; use crate::progress::ScanProgress; @@ -70,7 +71,11 @@ pub fn scan_directories( vendor_dir_paths: &[PathBuf], ) -> HashMap { let skip_paths = HashSet::new(); - let opts = WalkOptions::new(vendor_dir_paths.to_vec(), &skip_paths); + let opts = WalkOptions::new( + vendor_dir_paths.to_vec(), + &skip_paths, + IndexFilters::empty(), + ); let paths: Vec = walk_roots(dirs, &opts).into_iter().flatten().collect(); scan_files_parallel_classes(&paths, None) } @@ -97,7 +102,14 @@ pub fn scan_psr4_directories( classmap_dirs: &[PathBuf], vendor_dir_paths: &[PathBuf], ) -> HashMap { - scan_psr4_directories_with_skip(psr4, classmap_dirs, vendor_dir_paths, &HashSet::new(), None) + scan_psr4_directories_with_skip( + psr4, + classmap_dirs, + vendor_dir_paths, + &HashSet::new(), + &IndexFilters::empty(), + None, + ) } /// Like [`scan_psr4_directories`] but accepts a set of absolute file @@ -110,10 +122,15 @@ pub fn scan_psr4_directories_with_skip( classmap_dirs: &[PathBuf], vendor_dir_paths: &[PathBuf], skip_paths: &HashSet, + filters: &std::sync::Arc, progress: Option<&ScanProgress>, ) -> HashMap { // ── Walk the PSR-4 and classmap roots in one parallel pass ────── - let opts = WalkOptions::new(vendor_dir_paths.to_vec(), skip_paths); + let opts = WalkOptions::new( + vendor_dir_paths.to_vec(), + skip_paths, + std::sync::Arc::clone(filters), + ); let mut roots: Vec = psr4.iter().map(|(_, dir)| dir.clone()).collect(); roots.extend(classmap_dirs.iter().cloned()); let mut walked = walk_roots(&roots, &opts); @@ -154,6 +171,7 @@ pub fn scan_vendor_packages(workspace_root: &Path, vendor_dir: &str) -> Workspac vendor_dir, &HashSet::new(), &HashSet::new(), + &IndexFilters::empty(), None, ) } @@ -389,6 +407,7 @@ pub fn scan_vendor_packages_with_skip( vendor_dir: &str, skip_paths: &HashSet, explicit_deps: &HashSet, + filters: &std::sync::Arc, progress: Option<&ScanProgress>, ) -> WorkspaceScanResult { let vendor_path = workspace_root.join(vendor_dir); @@ -481,7 +500,11 @@ pub fn scan_vendor_packages_with_skip( // the cores are shared across all packages instead of one thread per // package. The roots are laid out PSR-4 first and classmap/`files` // second, matching the order phase 3 concatenates them in. - let opts = WalkOptions::new(vec![vendor_path.clone()], skip_paths); + let opts = WalkOptions::new( + vec![vendor_path.clone()], + skip_paths, + std::sync::Arc::clone(filters), + ); let mut roots: Vec = Vec::new(); for (_, sources) in &collected { roots.extend(sources.psr4.iter().cloned()); @@ -880,11 +903,16 @@ fn scan_files_parallel_full( pub fn scan_workspace_fallback_full( workspace_root: &Path, skip_dirs: &HashSet, + filters: &std::sync::Arc, progress: Option<&ScanProgress>, ) -> WorkspaceScanResult { // Phase 1: collect file paths let skip_paths = HashSet::new(); - let opts = WalkOptions::new(skip_dirs.iter().cloned().collect(), &skip_paths); + let opts = WalkOptions::new( + skip_dirs.iter().cloned().collect(), + &skip_paths, + std::sync::Arc::clone(filters), + ); let php_files: Vec<(PathBuf, crate::ClassCompletionOrigin)> = walk_roots(&[workspace_root.to_path_buf()], &opts) .into_iter() @@ -911,9 +939,11 @@ pub fn scan_workspace_fallback_full( /// `.inc`, and `.engine`. All are included by this scanner. /// /// Test directories (`tests/` and `Tests/`) are excluded by name to avoid -/// indexing duplicate class definitions from unit-test fixtures. +/// indexing duplicate class definitions from unit-test fixtures, and +/// `[indexing] exclude` patterns are honored like everywhere else. pub fn scan_drupal_directories( web_root: &Path, + filters: &std::sync::Arc, progress: Option<&ScanProgress>, ) -> WorkspaceScanResult { use ignore::WalkBuilder; @@ -936,6 +966,7 @@ pub fn scan_drupal_directories( continue; } + let filter_excludes = std::sync::Arc::clone(filters); let walker = WalkBuilder::new(&dir) // Gitignore is intentionally disabled — Drupal's .gitignore // excludes web/core and web/modules/contrib which are the @@ -946,21 +977,22 @@ pub fn scan_drupal_directories( .hidden(true) // still skip .git, .idea, etc. .parents(true) .ignore(false) - .filter_entry(|entry| { - if entry.file_type().is_some_and(|ft| ft.is_dir()) { + .filter_entry(move |entry| { + let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); + if is_dir { let name = entry.file_name().to_str().unwrap_or(""); // Exclude test directories (both conventional casings) if name == "tests" || name == "Tests" { return false; } } - true + !filter_excludes.is_excluded_entry(entry.path(), is_dir) }) .build(); for entry in walker.flatten() { let path = entry.path(); - if path.is_file() && is_drupal_php_file(path) { + if path.is_file() && (is_drupal_php_file(path) || filters.is_php_file(path)) { php_files.push((path.to_path_buf(), crate::ClassCompletionOrigin::Project)); } } @@ -999,11 +1031,6 @@ fn value_to_strings(value: &serde_json::Value) -> Vec { } } -/// Return `true` for a file the PHP scanners should read. -fn is_php_file(path: &Path) -> bool { - path.extension().is_some_and(|ext| ext == "php") -} - /// What a [`walk_roots`] call leaves out. struct WalkOptions<'a> { /// Directories that must never be entered: vendor trees scanned @@ -1014,13 +1041,20 @@ struct WalkOptions<'a> { /// Absolute file paths to leave out of the result, typically the ones /// Composer's generated classmap already covers. skip_paths: &'a HashSet, + /// Compiled `[indexing]` exclude globs and extra PHP extensions. + filters: std::sync::Arc, } impl<'a> WalkOptions<'a> { - fn new(skip_dirs: Vec, skip_paths: &'a HashSet) -> Self { + fn new( + skip_dirs: Vec, + skip_paths: &'a HashSet, + filters: std::sync::Arc, + ) -> Self { Self { skip_dirs: std::sync::Arc::new(skip_dirs), skip_paths, + filters, } } } @@ -1079,6 +1113,7 @@ fn walk_roots(roots: &[PathBuf], opts: &WalkOptions) -> Vec> { }; let skip_dirs = std::sync::Arc::clone(&opts.skip_dirs); + let filter_excludes = std::sync::Arc::clone(&opts.filters); builder .git_ignore(true) .git_global(true) @@ -1088,12 +1123,16 @@ fn walk_roots(roots: &[PathBuf], opts: &WalkOptions) -> Vec> { .ignore(true) .threads(thread_count()) .filter_entry(move |entry| { - !(entry.file_type().is_some_and(|ft| ft.is_dir()) - && skip_dirs.iter().any(|dir| dir == entry.path())) + let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); + if is_dir && skip_dirs.iter().any(|dir| dir == entry.path()) { + return false; + } + !filter_excludes.is_excluded_entry(entry.path(), is_dir) }); let (tx, rx) = std::sync::mpsc::channel::<(usize, PathBuf)>(); let skip_paths = opts.skip_paths; + let filters = &opts.filters; let roots_by_path = &roots_by_path; builder.build_parallel().run(|| { let tx = tx.clone(); @@ -1104,7 +1143,7 @@ fn walk_roots(roots: &[PathBuf], opts: &WalkOptions) -> Vec> { let path = entry.path(); let file_type = entry.file_type(); if file_type.is_some_and(|ft| ft.is_dir()) - || !is_php_file(path) + || !filters.is_php_file(path) || skip_paths.contains(path) // `ignore` reports a symlink's own type, so confirm the // target is a regular file before indexing it. The tests diff --git a/src/classmap_scanner/discovery_tests.rs b/src/classmap_scanner/discovery_tests.rs index c0c92561..8711499b 100644 --- a/src/classmap_scanner/discovery_tests.rs +++ b/src/classmap_scanner/discovery_tests.rs @@ -383,7 +383,7 @@ fn scan_workspace_fallback_full_finds_all_symbol_types() { std::fs::write(dir.path().join("Model.php"), " std::sync::Arc { + std::sync::Arc::new(IndexFilters::compile( + Some(root), + &exclude.iter().map(|s| s.to_string()).collect::>(), + &extensions.iter().map(|s| s.to_string()).collect::>(), + )) +} + +#[test] +fn workspace_scan_honors_exclude_globs() { + let dir = tempfile::tempdir().unwrap(); + let generated = dir.path().join("generated"); + let nested = dir.path().join("src").join("fixtures"); + std::fs::create_dir_all(&generated).unwrap(); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(dir.path().join("Keep.php"), ", + /// Lowercase extra extensions (without the dot) treated as PHP. + extensions: Vec, +} + +impl IndexFilters { + /// Compile the raw `[indexing]` filter strings. + /// + /// Invalid glob patterns are skipped with a warning rather than + /// failing the whole config load, mirroring how + /// `[[diagnostics.ignore]]` rules are compiled. `root` anchors + /// patterns containing `/`; without a workspace root the exclude + /// list is ignored (extensions still apply). + pub fn compile(root: Option<&Path>, exclude: &[String], extensions: &[String]) -> Self { + let excludes = root.filter(|_| !exclude.is_empty()).and_then(|root| { + let mut builder = GitignoreBuilder::new(root); + for pattern in exclude { + if let Err(e) = builder.add_line(None, pattern) { + eprintln!( + "warning: skipping invalid [indexing] exclude pattern `{pattern}`: {e}" + ); + } + } + match builder.build() { + Ok(gi) if gi.num_ignores() + gi.num_whitelists() > 0 => Some(gi), + Ok(_) => None, + Err(e) => { + eprintln!("warning: failed to compile [indexing] exclude patterns: {e}"); + None + } + } + }); + + let extensions: Vec = extensions + .iter() + .map(|ext| ext.trim_start_matches('.').to_ascii_lowercase()) + .filter(|ext| !ext.is_empty() && ext != "php") + .collect(); + + Self { + excludes, + extensions, + } + } + + /// A shared no-op filter for callers outside the indexing pipeline + /// (thin public wrappers, tests). + pub fn empty() -> Arc { + static EMPTY: OnceLock> = OnceLock::new(); + Arc::clone(EMPTY.get_or_init(|| { + Arc::new(IndexFilters { + excludes: None, + extensions: Vec::new(), + }) + })) + } + + /// Whether a walked entry is excluded by `[indexing] exclude`. + /// + /// Matches the entry's own path only. Directory walkers prune + /// excluded directories, so files below them are never asked; + /// for arbitrary paths (file-watch events) use + /// [`is_excluded_path`](Self::is_excluded_path) instead. + pub fn is_excluded_entry(&self, path: &Path, is_dir: bool) -> bool { + self.excludes + .as_ref() + .is_some_and(|gi| gi.matched(path, is_dir).is_ignore()) + } + + /// Whether a path is excluded, considering its ancestors. + /// + /// A file inside an excluded directory is itself excluded, the way + /// git never descends into an ignored directory. + pub fn is_excluded_path(&self, path: &Path, is_dir: bool) -> bool { + self.excludes + .as_ref() + .is_some_and(|gi| gi.matched_path_or_any_parents(path, is_dir).is_ignore()) + } + + /// Whether a file's extension marks it as PHP source: `.php` plus + /// any configured `[indexing] extensions`. + pub fn is_php_file(&self, path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| self.is_php_extension(ext)) + } + + /// Whether an extension string (without the dot) is treated as PHP. + pub fn is_php_extension(&self, ext: &str) -> bool { + ext.eq_ignore_ascii_case("php") + || self + .extensions + .iter() + .any(|extra| ext.eq_ignore_ascii_case(extra)) + } + + /// The configured extra extensions (lowercase, without the dot). + pub fn extra_extensions(&self) -> &[String] { + &self.extensions + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn filters(exclude: &[&str], extensions: &[&str]) -> IndexFilters { + IndexFilters::compile( + Some(Path::new("/ws")), + &exclude.iter().map(|s| s.to_string()).collect::>(), + &extensions.iter().map(|s| s.to_string()).collect::>(), + ) + } + + #[test] + fn bare_name_matches_at_any_depth() { + let f = filters(&["fixtures"], &[]); + assert!(f.is_excluded_entry(&PathBuf::from("/ws/a/b/fixtures"), true)); + assert!(f.is_excluded_entry(&PathBuf::from("/ws/fixtures"), true)); + assert!(!f.is_excluded_entry(&PathBuf::from("/ws/src"), true)); + } + + #[test] + fn slash_pattern_anchors_to_root() { + let f = filters(&["web/sites/default/files"], &[]); + assert!(f.is_excluded_entry(&PathBuf::from("/ws/web/sites/default/files"), true)); + assert!(!f.is_excluded_entry(&PathBuf::from("/ws/other/web/sites/default/files"), true)); + } + + #[test] + fn trailing_slash_restricts_to_directories() { + let f = filters(&["tests/"], &[]); + assert!(f.is_excluded_entry(&PathBuf::from("/ws/module/tests"), true)); + assert!(!f.is_excluded_entry(&PathBuf::from("/ws/module/tests"), false)); + } + + #[test] + fn negation_re_includes() { + // Gitignore idiom: `dir/*` + `!dir/keep.php`. (A bare `dir` + // pattern would prune the directory before the re-include is + // ever consulted, exactly like git.) + let f = filters(&["generated/*", "!generated/keep.php"], &[]); + assert!(f.is_excluded_entry(&PathBuf::from("/ws/generated/foo.php"), false)); + assert!(!f.is_excluded_entry(&PathBuf::from("/ws/generated/keep.php"), false)); + // The directory itself stays walkable so the re-include works. + assert!(!f.is_excluded_entry(&PathBuf::from("/ws/generated"), true)); + } + + #[test] + fn path_check_covers_ancestors() { + let f = filters(&["generated"], &[]); + // The entry check only matches the path itself… + assert!(!f.is_excluded_entry(&PathBuf::from("/ws/generated/deep/file.php"), false)); + // …the path check also matches through excluded ancestors. + assert!(f.is_excluded_path(&PathBuf::from("/ws/generated/deep/file.php"), false)); + } + + #[test] + fn invalid_pattern_is_skipped_not_fatal() { + let f = filters(&["[unclosed", "vendor-extra"], &[]); + assert!(f.is_excluded_entry(&PathBuf::from("/ws/vendor-extra"), true)); + } + + #[test] + fn no_root_disables_excludes_but_keeps_extensions() { + let strings = vec!["tests".to_string()]; + let exts = vec!["module".to_string()]; + let f = IndexFilters::compile(None, &strings, &exts); + assert!(!f.is_excluded_entry(&PathBuf::from("/ws/tests"), true)); + assert!(f.is_php_file(&PathBuf::from("/ws/foo.module"))); + } + + #[test] + fn extensions_are_normalized() { + let f = filters(&[], &[".Module", "php", "", "inc"]); + assert_eq!(f.extra_extensions(), &["module", "inc"]); + assert!(f.is_php_file(&PathBuf::from("/ws/foo.MODULE"))); + assert!(f.is_php_file(&PathBuf::from("/ws/foo.php"))); + assert!(!f.is_php_file(&PathBuf::from("/ws/foo.txt"))); + } + + #[test] + fn empty_filter_is_noop() { + let f = IndexFilters::empty(); + assert!(!f.is_excluded_path(&PathBuf::from("/ws/anything"), true)); + assert!(f.is_php_file(&PathBuf::from("/ws/foo.php"))); + assert!(!f.is_php_file(&PathBuf::from("/ws/foo.module"))); + } +} diff --git a/src/classmap_scanner/mod.rs b/src/classmap_scanner/mod.rs index fff4f57f..1eb1832e 100644 --- a/src/classmap_scanner/mod.rs +++ b/src/classmap_scanner/mod.rs @@ -76,6 +76,7 @@ use std::path::{Path, PathBuf}; use memmap2::Mmap; mod discovery; +mod filters; mod lexer; pub(crate) use discovery::vendor_package_roots; @@ -84,6 +85,7 @@ pub use discovery::{ scan_psr4_directories_with_skip, scan_vendor_packages, scan_vendor_packages_with_skip, scan_workspace_fallback, scan_workspace_fallback_full, }; +pub use filters::IndexFilters; pub use lexer::{find_classes, find_symbols}; // ─── File reading ──────────────────────────────────────────────────────────── diff --git a/src/config.rs b/src/config.rs index 465e7f75..cce18116 100644 --- a/src/config.rs +++ b/src/config.rs @@ -487,12 +487,34 @@ pub struct IndexingConfig { /// if present, still resolves on demand, but never falls back to /// self-scan. pub strategy: Option, + /// Paths the workspace scanners must skip, in gitignore syntax + /// relative to the workspace root: a bare name matches at any + /// depth, a pattern containing `/` anchors to the root, a trailing + /// `/` restricts to directories, and a leading `!` re-includes. + /// + /// Excludes apply to background discovery only; files the editor + /// opens are always served. + pub exclude: Option>, + /// Extra file extensions (without the dot) treated as PHP source + /// during workspace discovery, e.g. `["module", "inc", "theme"]` + /// for Drupal. `.php` is always included. Drupal projects get the + /// Drupal extensions inside the detected web root automatically; + /// this setting extends discovery elsewhere. + pub extensions: Option>, } impl IndexingConfig { pub fn strategy(&self) -> IndexingStrategy { self.strategy.unwrap_or_default() } + + pub fn exclude(&self) -> &[String] { + self.exclude.as_deref().unwrap_or_default() + } + + pub fn extensions(&self) -> &[String] { + self.extensions.as_deref().unwrap_or_default() + } } /// The indexing strategy that controls class discovery behaviour. @@ -1031,6 +1053,8 @@ message = "^Call to deprecated function some_legacy_helper\\(\\)" [indexing] strategy = "self" +exclude = ["generated", "web/sites/default/files"] +extensions = ["module", "inc"] [semantic_tokens] mode = "full" @@ -1072,6 +1096,17 @@ analyze-timeout = 45000 Some("deprecated_usage") ); assert_eq!(config.indexing.strategy, Some(IndexingStrategy::SelfScan)); + assert_eq!( + config.indexing.exclude(), + &[ + "generated".to_string(), + "web/sites/default/files".to_string() + ] + ); + assert_eq!( + config.indexing.extensions(), + &["module".to_string(), "inc".to_string()] + ); assert_eq!(config.semantic_tokens.mode, Some(SemanticTokensMode::Full)); assert_eq!(config.formatting.php_cs_fixer.as_deref(), Some("")); assert_eq!( diff --git a/src/definition/implementation.rs b/src/definition/implementation.rs index aa09a5c0..cb53c93f 100644 --- a/src/definition/implementation.rs +++ b/src/definition/implementation.rs @@ -920,8 +920,9 @@ impl Backend { let loaded_uris_p5: HashSet = self.parsed_uris.read().iter().cloned().collect(); + let filters = self.index_filters(); for dir in &psr4_dirs { - let php_files = collect_php_files(dir, &vendor_dir_paths); + let php_files = collect_php_files(dir, &vendor_dir_paths, &filters); if let Some(p) = progress { p.add_total(php_files.len() as u64); } diff --git a/src/fix.rs b/src/fix.rs index 0fe94265..39b28a9e 100644 --- a/src/fix.rs +++ b/src/fix.rs @@ -238,7 +238,7 @@ pub async fn run(options: FixOptions) -> i32 { // ── 2. Index project ──────────────────────────────────────────── let backend = Backend::new_headless(); *backend.workspace_root().write() = Some(root.to_path_buf()); - *backend.workspace.config.lock() = cfg.clone(); + backend.set_config(cfg.clone()); let composer_package = composer::read_composer_package(root); diff --git a/src/indexing/init.rs b/src/indexing/init.rs index 85dda926..479240a3 100644 --- a/src/indexing/init.rs +++ b/src/indexing/init.rs @@ -104,8 +104,10 @@ impl Backend { if let Some(p) = progress { p.begin_phase(0.0, 0.3, "Scanning workspace files"); } - let mut scan = - classmap_scanner::scan_workspace_fallback_full(root, &skip_dirs, progress); + let filters = self.index_filters(); + let mut scan = classmap_scanner::scan_workspace_fallback_full( + root, &skip_dirs, &filters, progress, + ); // Merge vendor packages (excluded from the workspace // walk above, scanned separately here). @@ -117,6 +119,7 @@ impl Backend { &vendor_dir, &HashSet::new(), &explicit_deps, + &filters, progress, ); let package_roots = std::mem::take(&mut vendor_scan.package_roots); @@ -229,8 +232,11 @@ impl Backend { if let Some(p) = progress { p.set_scope(70, 74, "Scanning Drupal directories"); } - let drupal_result = - classmap_scanner::scan_drupal_directories(&drupal_web_root, progress); + let drupal_result = classmap_scanner::scan_drupal_directories( + &drupal_web_root, + &self.index_filters(), + progress, + ); let drupal_count = drupal_result.classmap.len() + drupal_result.function_index.len() + drupal_result.constant_index.len(); @@ -446,7 +452,12 @@ impl Backend { p.set_scope(80, 85, "Scanning loose PHP files"); } - let scan = classmap_scanner::scan_workspace_fallback_full(root, &skip_dirs, progress); + let scan = classmap_scanner::scan_workspace_fallback_full( + root, + &skip_dirs, + &self.index_filters(), + progress, + ); self.populate_autoload_indices(&scan); { let mut idx = self.symbols.fqn_uri_index.write(); @@ -496,7 +507,12 @@ impl Backend { self.resolved_class_cache.write().set_laravel(false); let skip_dirs = HashSet::new(); - let scan = classmap_scanner::scan_workspace_fallback_full(root, &skip_dirs, progress); + let scan = classmap_scanner::scan_workspace_fallback_full( + root, + &skip_dirs, + &self.index_filters(), + progress, + ); self.populate_autoload_indices(&scan); let symbol_count = scan.classmap.len(); diff --git a/src/indexing/preload.rs b/src/indexing/preload.rs index 51821633..c5575a23 100644 --- a/src/indexing/preload.rs +++ b/src/indexing/preload.rs @@ -233,8 +233,11 @@ impl Backend { self.report_workspace_index_progress(progress, 3, "Scanning workspace files"); let walk_start = std::time::Instant::now(); - let php_files = - crate::references::collect_php_files_gitignore(&root, &vendor_dir_paths); + let php_files = crate::references::collect_php_files_gitignore( + &root, + &vendor_dir_paths, + &self.index_filters(), + ); tracing::info!( "ensure_workspace_indexed: Phase 2 disk walk found {} PHP files in {:?}", php_files.len(), diff --git a/src/indexing/scan.rs b/src/indexing/scan.rs index 2673b049..f130ab70 100644 --- a/src/indexing/scan.rs +++ b/src/indexing/scan.rs @@ -104,6 +104,7 @@ impl Backend { &vendor_dir, &HashSet::new(), &explicit_deps, + &self.index_filters(), None, ); // Package roots came out of the same `installed.json` parse @@ -513,6 +514,7 @@ impl Backend { return classmap_scanner::scan_workspace_fallback_full( project_root, &skip_dirs, + &self.index_filters(), progress, ); } @@ -540,12 +542,14 @@ impl Backend { if let Some(p) = progress { p.begin_phase(0.0, 0.2, "Scanning project files"); } + let filters = self.index_filters(); let vendor_dir_paths = vec![project_root.join(vendor_dir)]; let classmap = classmap_scanner::scan_psr4_directories_with_skip( &psr4_dirs, &classmap_dirs, &vendor_dir_paths, skip_paths, + &filters, progress, ); @@ -559,6 +563,7 @@ impl Backend { vendor_dir, skip_paths, &explicit_deps, + &filters, progress, ); diff --git a/src/indexing/watch.rs b/src/indexing/watch.rs index d20c12f9..da34e8be 100644 --- a/src/indexing/watch.rs +++ b/src/indexing/watch.rs @@ -43,6 +43,7 @@ impl Backend { let open = self.open_files.read(); let parsed = self.parsed_uris.read(); let laravel_config = self.config().laravel; + let filters = self.index_filters(); for change in ¶ms.changes { let path_str = change.uri.path(); if path_str.ends_with("/composer.json") || path_str.ends_with("/composer.lock") { @@ -69,7 +70,11 @@ impl Backend { } continue; } - if !path_str.ends_with(".php") { + let is_php = std::path::Path::new(path_str) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| filters.is_php_extension(ext)); + if !is_php { continue; } @@ -82,6 +87,12 @@ impl Backend { continue; }; + // Excluded paths are invisible to indexing; skip their + // events the way the workspace scanners skip the files. + if filters.is_excluded_path(&file_path, false) { + continue; + } + if change.typ == FileChangeType::CHANGED { // `parsed_uris` records the editor URI for open files and // the canonical `file://` URI for lazily loaded ones; diff --git a/src/lib.rs b/src/lib.rs index ef234558..584390f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1670,10 +1670,34 @@ impl Backend { /// Replace the current configuration. /// - /// Used by integration tests to enable opt-in diagnostics like - /// `unresolved-member-access` without needing a `.phpantom.toml` file. + /// Used when (re)loading `.phpantom.toml` and by integration tests + /// to enable opt-in diagnostics like `unresolved-member-access` + /// without needing a `.phpantom.toml` file. Resets the compiled + /// `[indexing]` filters so the next scan sees the new settings. pub fn set_config(&self, config: config::Config) { *self.workspace.config.lock() = config; + *self.workspace.index_filters.write() = None; + } + + /// Return the compiled `[indexing]` exclude globs and extra PHP + /// extensions, building them from the current config on first use. + /// + /// The compiled filters are cached until [`set_config`](Self::set_config) + /// replaces the configuration, so glob compilation never runs on a + /// per-file path. + pub(crate) fn index_filters(&self) -> Arc { + if let Some(filters) = self.workspace.index_filters.read().as_ref() { + return Arc::clone(filters); + } + let root = self.workspace.workspace_root.read().clone(); + let indexing = self.config().indexing; + let compiled = Arc::new(classmap_scanner::IndexFilters::compile( + root.as_deref(), + indexing.exclude(), + indexing.extensions(), + )); + *self.workspace.index_filters.write() = Some(Arc::clone(&compiled)); + compiled } /// Set the PHP version (used by integration tests and during diff --git a/src/references/mod.rs b/src/references/mod.rs index 68433992..59bebac8 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -327,11 +327,13 @@ pub(super) fn member_candidate_keys( pub(crate) fn collect_php_files_gitignore( root: &Path, vendor_dir_paths: &[PathBuf], + filters: &std::sync::Arc, ) -> Vec { use ignore::WalkBuilder; let mut result = Vec::new(); let vendor_paths_owned: Vec = vendor_dir_paths.to_vec(); + let filter_excludes = std::sync::Arc::clone(filters); let walker = WalkBuilder::new(root) // Respect .gitignore, .git/info/exclude, global gitignore @@ -344,21 +346,20 @@ pub(crate) fn collect_php_files_gitignore( .parents(true) // Also respect .ignore files (ripgrep convention) .ignore(true) - // Always skip vendor directories, even if not gitignored + // Always skip vendor directories (even if not gitignored) and + // `[indexing] exclude` matches .filter_entry(move |entry| { - if entry.file_type().is_some_and(|ft| ft.is_dir()) { - let path = entry.path(); - if vendor_paths_owned.iter().any(|vp| vp == path) { - return false; - } + let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); + if is_dir && vendor_paths_owned.iter().any(|vp| vp == entry.path()) { + return false; } - true + !filter_excludes.is_excluded_entry(entry.path(), is_dir) }) .build(); for entry in walker.flatten() { let path = entry.path(); - if path.is_file() && path.extension().is_some_and(|ext| ext == "php") { + if path.is_file() && filters.is_php_file(path) { result.push(path.to_path_buf()); } } diff --git a/src/rename/namespace.rs b/src/rename/namespace.rs index b1c4492a..7b860460 100644 --- a/src/rename/namespace.rs +++ b/src/rename/namespace.rs @@ -90,8 +90,11 @@ impl Backend { } if let Some(root) = workspace_root { - for path in crate::references::collect_php_files_gitignore(&root, &vendor_dir_paths) - { + for path in crate::references::collect_php_files_gitignore( + &root, + &vendor_dir_paths, + &self.index_filters(), + ) { if let Ok(uri) = Url::from_file_path(&path) { uris.insert(uri.to_string()); } diff --git a/src/server.rs b/src/server.rs index c4834be1..40e0ad9e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -289,7 +289,7 @@ impl LanguageServer for Backend { // from the very first file load. match crate::config::load_config(&root) { Ok(cfg) => { - *self.workspace.config.lock() = cfg; + self.set_config(cfg); } Err(e) => { self.log( @@ -466,39 +466,49 @@ impl LanguageServer for Backend { // Register file watchers for staleness detection. The client // will notify us when PHP files or composer files change on disk // (even outside the editor), so we can refresh our indices. + // `[indexing] extensions` entries get their own watchers so files + // like Drupal's `.module` refresh the index the way `.php` does. + let index_filters = self.index_filters(); + let mut watchers: Vec = index_filters + .extra_extensions() + .iter() + .map(|ext| FileSystemWatcher { + glob_pattern: GlobPattern::String(format!("**/*.{ext}")), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }) + .collect(); + watchers.extend([ + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.php".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/composer.json".to_string()), + kind: Some(WatchKind::Change), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/composer.lock".to_string()), + kind: Some(WatchKind::Change), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.sql".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/config/database.php".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/.phpantom.toml".to_string()), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }, + ]); registrations.push(Registration { id: "workspace/didChangeWatchedFiles".to_string(), method: "workspace/didChangeWatchedFiles".to_string(), register_options: Some( - serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { - watchers: vec![ - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/*.php".to_string()), - kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/composer.json".to_string()), - kind: Some(WatchKind::Change), - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/composer.lock".to_string()), - kind: Some(WatchKind::Change), - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/*.sql".to_string()), - kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/config/database.php".to_string()), - kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/.phpantom.toml".to_string()), - kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), - }, - ], - }) - .unwrap(), + serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }) + .unwrap(), ), }); @@ -2678,7 +2688,7 @@ impl Backend { pub(crate) fn reload_laravel_schema_index(&self, root: &std::path::Path) { if let Ok(cfg) = crate::config::load_config(root) { - *self.workspace.config.lock() = cfg; + self.set_config(cfg); } let laravel_config = self.config().laravel; diff --git a/src/util.rs b/src/util.rs index 583506c2..bf232d9d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -261,11 +261,16 @@ pub(crate) fn path_to_uri(path: &Path) -> String { /// /// Silently skips directories and files that cannot be read (e.g. /// permission errors, broken symlinks). -pub(crate) fn collect_php_files(dir: &Path, vendor_dir_paths: &[PathBuf]) -> Vec { +pub(crate) fn collect_php_files( + dir: &Path, + vendor_dir_paths: &[PathBuf], + filters: &std::sync::Arc, +) -> Vec { use ignore::WalkBuilder; let mut result = Vec::new(); let vendor_paths: Vec = vendor_dir_paths.to_vec(); + let filter_excludes = std::sync::Arc::clone(filters); let walker = WalkBuilder::new(dir) .git_ignore(true) @@ -275,19 +280,17 @@ pub(crate) fn collect_php_files(dir: &Path, vendor_dir_paths: &[PathBuf]) -> Vec .parents(true) .ignore(true) .filter_entry(move |entry| { - if entry.file_type().is_some_and(|ft| ft.is_dir()) { - let path = entry.path(); - if vendor_paths.iter().any(|vp| vp == path) { - return false; - } + let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); + if is_dir && vendor_paths.iter().any(|vp| vp == entry.path()) { + return false; } - true + !filter_excludes.is_excluded_entry(entry.path(), is_dir) }) .build(); for entry in walker.flatten() { let path = entry.path(); - if path.is_file() && path.extension().is_some_and(|ext| ext == "php") { + if path.is_file() && filters.is_php_file(path) { result.push(path.to_path_buf()); } } diff --git a/src/workspace_env.rs b/src/workspace_env.rs index 2efd55c5..f8dd8719 100644 --- a/src/workspace_env.rs +++ b/src/workspace_env.rs @@ -36,6 +36,10 @@ pub(crate) struct WorkspaceEnv { pub(crate) php_version: Mutex, /// Per-project configuration loaded from `.phpantom.toml`. pub(crate) config: Mutex, + /// Compiled `[indexing]` exclude globs and extra PHP extensions, + /// built lazily from `config` and reset when the config changes. + /// Shared across clones so a config reload invalidates everywhere. + pub(crate) index_filters: Arc>>>, } impl WorkspaceEnv { @@ -48,6 +52,7 @@ impl WorkspaceEnv { vendor_package_origin_roots: Arc::new(RwLock::new(Vec::new())), php_version: Mutex::new(PhpVersion::default()), config: Mutex::new(config::Config::default()), + index_filters: Arc::new(RwLock::new(None)), } } } @@ -62,6 +67,7 @@ impl Clone for WorkspaceEnv { vendor_package_origin_roots: Arc::clone(&self.vendor_package_origin_roots), php_version: Mutex::new(*self.php_version.lock()), config: Mutex::new(self.config.lock().clone()), + index_filters: Arc::clone(&self.index_filters), } } } diff --git a/tests/integration/completion_non_composer_discovery.rs b/tests/integration/completion_non_composer_discovery.rs index 51bea986..f3f7a5e5 100644 --- a/tests/integration/completion_non_composer_discovery.rs +++ b/tests/integration/completion_non_composer_discovery.rs @@ -349,7 +349,7 @@ async fn namespaced_function_completion_from_autoload_index() { #[test] fn scan_workspace_fallback_full_discovers_all_symbol_types() { - use phpantom_lsp::classmap_scanner::scan_workspace_fallback_full; + use phpantom_lsp::classmap_scanner::{IndexFilters, scan_workspace_fallback_full}; let dir = tempfile::tempdir().unwrap(); @@ -375,7 +375,7 @@ fn scan_workspace_fallback_full_discovers_all_symbol_types() { .unwrap(); let skip = std::collections::HashSet::new(); - let result = scan_workspace_fallback_full(dir.path(), &skip, None); + let result = scan_workspace_fallback_full(dir.path(), &skip, &IndexFilters::empty(), None); // Classes assert!( @@ -411,7 +411,7 @@ fn scan_workspace_fallback_full_discovers_all_symbol_types() { #[test] fn scan_workspace_fallback_full_excludes_class_methods_and_constants() { - use phpantom_lsp::classmap_scanner::scan_workspace_fallback_full; + use phpantom_lsp::classmap_scanner::{IndexFilters, scan_workspace_fallback_full}; let dir = tempfile::tempdir().unwrap(); std::fs::write( @@ -421,7 +421,7 @@ fn scan_workspace_fallback_full_excludes_class_methods_and_constants() { .unwrap(); let skip = std::collections::HashSet::new(); - let result = scan_workspace_fallback_full(dir.path(), &skip, None); + let result = scan_workspace_fallback_full(dir.path(), &skip, &IndexFilters::empty(), None); assert!( result.classmap.contains_key("Service"), diff --git a/tests/unit/monorepo.rs b/tests/unit/monorepo.rs index 7616612f..e156c8cd 100644 --- a/tests/unit/monorepo.rs +++ b/tests/unit/monorepo.rs @@ -8,7 +8,7 @@ use std::collections::HashSet; use std::path::PathBuf; -use phpantom_lsp::classmap_scanner::scan_workspace_fallback_full; +use phpantom_lsp::classmap_scanner::{IndexFilters, scan_workspace_fallback_full}; use phpantom_lsp::composer::{ discover_subproject_roots, parse_autoload_classmap, parse_autoload_files, parse_composer_json, }; @@ -383,7 +383,7 @@ fn loose_files_discovered_outside_subprojects() { let mut skip_dirs = HashSet::new(); skip_dirs.insert(sub.clone()); - let result = scan_workspace_fallback_full(dir.path(), &skip_dirs, None); + let result = scan_workspace_fallback_full(dir.path(), &skip_dirs, &IndexFilters::empty(), None); // Should find loose files assert!( @@ -432,7 +432,7 @@ fn no_double_scanning_of_subproject_files() { let mut skip_dirs = HashSet::new(); skip_dirs.insert(sub.clone()); - let result = scan_workspace_fallback_full(dir.path(), &skip_dirs, None); + let result = scan_workspace_fallback_full(dir.path(), &skip_dirs, &IndexFilters::empty(), None); // The subproject files should NOT be in the scan result // (they would be handled by the Composer pipeline instead) @@ -487,7 +487,7 @@ fn full_scan_with_empty_skip_set_finds_everything() { std::fs::write(sub.join("util.php"), " Date: Sat, 1 Aug 2026 19:07:41 +0300 Subject: [PATCH 3/3] fix: index Drupal test directories so test base classes resolve The Drupal web-root scanner skipped every directory named tests/ or Tests/ to avoid duplicate fixture classes. But module tests extend base classes that live under core/tests/ (Drupal\Tests\UnitTestCase, Drupal\KernelTests\KernelTestBase, ...) and reference test modules under */tests/modules/, and the gitignore-aware workspace scan never reaches core/ either, so every module test file reported "Class 'Drupal\Tests\UnitTestCase' not found". Index test directories by default: the classmap's first-wins merge already handles duplicate fixture FQNs, and projects that want a smaller index can trim it with the new [indexing] exclude setting instead of a hardcoded name filter. Refs #48 --- docs/CHANGELOG.md | 1 + src/classmap_scanner/discovery.rs | 18 +++++------ src/classmap_scanner/discovery_tests.rs | 41 +++++++++++++++++-------- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b1acaf7d..afad9a15 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -74,6 +74,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Drupal test base classes resolve.** Module tests extend `Drupal\Tests\UnitTestCase`, `Drupal\KernelTests\KernelTestBase`, and friends, which live under `web/core/tests/` — a directory the Drupal scanner used to skip by name, so every module test reported `Class 'Drupal\Tests\UnitTestCase' not found`. Test directories are now indexed by default; projects that want a smaller index can trim them with the new `[indexing] exclude` setting. Contributed by @syntlyx. - **Vendor files under a symlinked workspace root classify correctly.** On a workspace whose real path goes through a symlink (macOS `/var` → `/private/var`, link-farm checkouts), vendor classes were classified as project code: hover lost the package provenance badge, completion ranked them as project symbols, and vendor service providers were scanned as if they were user code. The vendor directory's canonical path is now matched alongside the raw one. The `analyse` CLI had the mirror-image bug — it compared canonicalized file paths against the raw vendor path, so `analyse` walked and reported on the whole vendor tree; it now skips vendor regardless of symlinks. Contributed by @syntlyx. - **`parent::SOME_CONSTANT` resolves to a type.** A class constant reached through the `parent` keyword produced no type at all, so hover on it was blank and anything derived from it lost the value, while the same constant reached through `self`, `static`, or the class name resolved normally. Constants inherited further up the chain resolve through `parent::` too. - **A template parameter bound only by the argument it type-checks no longer flags a false positive.** PHPUnit's `assertSame(url('/login'), $x)` (and any other call where a `@template` is bound solely by the parameter being checked, such as `assertSame`'s `$expected`) could report a type mismatch: the substituted parameter type is derived from resolving that exact argument, so comparing the argument to it again is circular and, when the two resolution passes disagree on an ambiguous expression, produced a spurious diagnostic. Such a parameter is no longer checked against its own argument. diff --git a/src/classmap_scanner/discovery.rs b/src/classmap_scanner/discovery.rs index 2caf66ac..e6344165 100644 --- a/src/classmap_scanner/discovery.rs +++ b/src/classmap_scanner/discovery.rs @@ -938,9 +938,14 @@ pub fn scan_workspace_fallback_full( /// for valid PHP source: `.module`, `.install`, `.theme`, `.profile`, /// `.inc`, and `.engine`. All are included by this scanner. /// -/// Test directories (`tests/` and `Tests/`) are excluded by name to avoid -/// indexing duplicate class definitions from unit-test fixtures, and -/// `[indexing] exclude` patterns are honored like everywhere else. +/// Test directories are indexed too: module tests extend base classes +/// that live in `core/tests/` (`Drupal\Tests\UnitTestCase`, +/// `Drupal\KernelTests\KernelTestBase`, …) and reference test modules +/// under `*/tests/modules/`, so skipping them by name leaves those +/// classes unresolvable. Projects that want a smaller index can trim it +/// with `[indexing] exclude`, which is honored here like everywhere +/// else; duplicate fixture classes are handled by the classmap's +/// first-wins merge. pub fn scan_drupal_directories( web_root: &Path, filters: &std::sync::Arc, @@ -979,13 +984,6 @@ pub fn scan_drupal_directories( .ignore(false) .filter_entry(move |entry| { let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); - if is_dir { - let name = entry.file_name().to_str().unwrap_or(""); - // Exclude test directories (both conventional casings) - if name == "tests" || name == "Tests" { - return false; - } - } !filter_excludes.is_excluded_entry(entry.path(), is_dir) }) .build(); diff --git a/src/classmap_scanner/discovery_tests.rs b/src/classmap_scanner/discovery_tests.rs index 8711499b..49fd4a7d 100644 --- a/src/classmap_scanner/discovery_tests.rs +++ b/src/classmap_scanner/discovery_tests.rs @@ -560,37 +560,52 @@ fn scan_drupal_directories_finds_php_and_module_files() { } #[test] -fn scan_drupal_directories_skips_test_dirs() { +fn scan_drupal_directories_indexes_test_dirs_by_default() { let dir = tempfile::tempdir().unwrap(); let web_root = dir.path(); - let test_dir = web_root.join("modules/contrib/token/tests/src"); - std::fs::create_dir_all(&test_dir).unwrap(); + // Module tests extend base classes living under core/tests/, so + // test directories must be indexed for those tests to resolve. + let base_dir = web_root.join("core/tests/Drupal/Tests"); + std::fs::create_dir_all(&base_dir).unwrap(); std::fs::write( - test_dir.join("TokenTest.php"), - "