From bb7227f4ca703f86c6b164251774a0fa7ee68a70 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Tue, 11 Aug 2026 12:18:20 +0200 Subject: [PATCH] feat(config): add the possibility to include specific files / directories for scanning The workspace scan is gitignore-aware and skips hidden entries, which keeps it off vendor/, node_modules/, and build output. That same filtering hid first-party PHP living in a dotted directory, and generated stub files, which are gitignored as build artefacts yet are the only declaration of the symbols a project calls. Paths listed under `[indexing] include` are indexed after the workspace scan, so they fill gaps rather than displacing what it found. The default is unchanged. --- config-schema.json | 8 + docs/ARCHITECTURE.md | 2 +- docs/CHANGELOG.md | 1 + docs/configuration.md | 32 +++- docs/todo/indexing.md | 5 +- src/classmap_scanner/discovery.rs | 46 +++++ src/classmap_scanner/discovery_tests.rs | 102 +++++++++++ src/classmap_scanner/mod.rs | 2 +- src/config.rs | 99 +++++++++++ src/indexing/init.rs | 8 + src/indexing/scan.rs | 36 ++++ tests/integration/indexing_include.rs | 220 ++++++++++++++++++++++++ tests/integration/main.rs | 1 + 13 files changed, 556 insertions(+), 6 deletions(-) create mode 100644 tests/integration/indexing_include.rs diff --git a/config-schema.json b/config-schema.json index 0efd65239..069c6fecc 100644 --- a/config-schema.json +++ b/config-schema.json @@ -83,6 +83,14 @@ "none" ], "default": "full" + }, + "include": { + "type": "array", + "description": "Extra files and directories to index, relative to the workspace root unless absolute. Listing a path here indexes it: directories are walked recursively, files are scanned directly, and entries that do not exist are ignored. Included paths are indexed after the workspace scan, so they fill gaps rather than shadowing symbols the scan already found.", + "items": { + "type": "string" + }, + "default": [] } } }, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5cc37501a..13a5ccb35 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -744,7 +744,7 @@ The indexing strategy is configurable via `[indexing] strategy` in `.phpantom.to The merged pipeline works in three steps: (1) load `autoload_classmap.php` into a `HashMap`, (2) collect the classmap's file paths into a `HashSet` skip set, (3) self-scan all PSR-4 and vendor directories, skipping files already in the skip set. The result is a merged index: classmap entries for everything Composer already knew about, plus self-scanned entries for everything it missed. When the classmap is complete (the common case), the self-scanner walks directories but skips every file, finishing almost instantly. When the classmap is empty or absent, it falls back to a full self-scan. When the classmap is partial (e.g. vendor classes only), vendor files are skipped and only user code is scanned. Every state of the classmap helps. -When self-scanning with a `composer.json` present, the scanner reads `autoload.psr-4`, `autoload-dev.psr-4`, `autoload.classmap`, and `autoload-dev.classmap` to determine which directories to walk. PSR-4 directories are filtered: only classes whose FQN matches the namespace prefix plus the relative file path are included. Vendor packages are discovered from `vendor/composer/installed.json` (both Composer 1 and 2 formats); the JSON packages array is borrowed rather than cloned to avoid allocating a copy of the entire vendor manifest. All directory walkers (full-scan, PSR-4 scanner, vendor package scanner, and go-to-implementation file collector) use the `ignore` crate for gitignore-aware traversal. Hidden directories are skipped automatically, and `.gitignore` rules are respected at every level. When no `composer.json` exists at all, the scanner falls back to walking all `.php` files under the workspace root. +When self-scanning with a `composer.json` present, the scanner reads `autoload.psr-4`, `autoload-dev.psr-4`, `autoload.classmap`, and `autoload-dev.classmap` to determine which directories to walk. PSR-4 directories are filtered: only classes whose FQN matches the namespace prefix plus the relative file path are included. Vendor packages are discovered from `vendor/composer/installed.json` (both Composer 1 and 2 formats); the JSON packages array is borrowed rather than cloned to avoid allocating a copy of the entire vendor manifest. All directory walkers (full-scan, PSR-4 scanner, vendor package scanner, and go-to-implementation file collector) use the `ignore` crate for gitignore-aware traversal. Hidden directories are skipped automatically, and `.gitignore` rules are respected at every level. Paths listed under `[indexing] include` in `.phpantom.toml` are scanned after that walk with both filters disabled, which is how first-party PHP in a dotted directory and generated stub files that are gitignored as build artefacts get indexed; because they run last, they fill gaps rather than displacing what the walk found. When no `composer.json` exists at all, the scanner falls back to walking all `.php` files under the workspace root. The scan results are converted to URI strings and inserted into `fqn_uri_index`. Everything downstream (resolution, diagnostics, go-to-definition) uses the unified index. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e330c52c1..c40bf244b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **`analyze` takes more than one path.** `phpantom_lsp analyze app/ lib/Helper.php tests/` scans the union of everything named, mixing directories and single files freely, so a pre-commit hook or a CI step can hand it exactly the paths that changed instead of running the whole project or invoking the binary once per path. Overlapping arguments are reported once, and a path that does not exist still stops the run with exit code 2. Naming no path scans the entire project, as before. +- **Allow scanning extra files hidden or gitignored.** The scan is gitignore-aware and skips hidden entries, which is what keeps it off `vendor/`, `node_modules/`, and build output. The same filtering hid two things a project genuinely depends on: first-party PHP living in a dotted directory, and generated stub files, which are gitignored precisely because they are build artefacts, yet are the only declaration of the symbols the project calls. A project relying on either got no completion for those symbols, no way to navigate to them, and an unknown-function error on every call. A new `[indexing] include` setting names such paths and indexes them regardless of either filter: directories are walked, files are read directly, and entries that no longer exist are skipped rather than failing the scan. They are indexed after the workspace scan, so they fill gaps rather than displacing what the scan already found. See [configuration.md](configuration.md#extra-include-paths). Contributed by @joelwurtz. ### Changed diff --git a/docs/configuration.md b/docs/configuration.md index 3eac6b620..69cc42acd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -81,9 +81,35 @@ 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. | +| `include` | array of string | `[]` | Extra files and directories to index, relative to the workspace root. See [Extra Include Paths](#extra-include-paths) below. | + +#### Extra Include Paths + +The workspace scan is gitignore-aware and skips hidden entries, which is +what keeps it off `vendor/`, `node_modules/` and build output. That same +filtering hides two things a project can legitimately depend on: +first-party PHP living in a dotted directory, and generated IDE stub +files, which are gitignored precisely because they are build artefacts — +yet are the only declaration of the symbols the project calls. + +Listing a path under `include` indexes it regardless of both filters. +Directories are walked recursively, files are scanned directly, and +entries that do not exist are ignored. Absolute paths are accepted; +relative ones resolve against the workspace root. + +A project whose helpers live in a dotted directory and whose signatures +come from a generated stub needs both halves: + +```toml +[indexing] +include = [".lib", ".lib.stub.php"] +``` + +Included paths are indexed after the workspace scan, so they fill gaps +rather than shadowing symbols the scan already found. ### `[semantic_tokens]` diff --git a/docs/todo/indexing.md b/docs/todo/indexing.md index 9657fc7a0..e47852fa8 100644 --- a/docs/todo/indexing.md +++ b/docs/todo/indexing.md @@ -45,7 +45,10 @@ access provides complete `FunctionInfo`/`DefineInfo`. All directory walkers (full-scan, PSR-4 scanner, vendor package scanner, and go-to-implementation file collector) use the `ignore` crate for gitignore-aware traversal instead of hardcoded directory name -filtering. Hidden directories are skipped automatically. +filtering. Hidden directories are skipped automatically. Paths listed +under `[indexing] include` are scanned on top of that walk, bypassing +both filters, for first-party PHP in a dotted directory and for +generated stub files that are gitignored as build artefacts. The monorepo path activates when there is no root `composer.json` but `discover_subproject_roots` finds subdirectories with their own diff --git a/src/classmap_scanner/discovery.rs b/src/classmap_scanner/discovery.rs index 37537c5ed..302fecaef 100644 --- a/src/classmap_scanner/discovery.rs +++ b/src/classmap_scanner/discovery.rs @@ -894,6 +894,52 @@ pub fn scan_workspace_fallback_full( scan_files_parallel_full(&php_files, progress) } +/// Scan the files and directories named by `[indexing] include`, +/// bypassing both the gitignore and the hidden-entry filters. +////// +/// Directories are walked recursively; files are scanned directly. A +/// path the normal walk already covered is simply scanned twice and +/// loses the first-wins merge, so callers need not deduplicate. +pub fn scan_include_paths( + paths: &[PathBuf], + progress: Option<&ScanProgress>, +) -> WorkspaceScanResult { + use ignore::WalkBuilder; + + let mut php_files: Vec<(PathBuf, crate::ClassCompletionOrigin)> = Vec::new(); + for path in paths { + if path.is_file() { + if is_php_file(path) { + php_files.push((path.clone(), crate::ClassCompletionOrigin::Project)); + } + continue; + } + + let walker = WalkBuilder::new(path) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .ignore(false) + .parents(false) + .hidden(false) + .build(); + for entry in walker.flatten() { + let entry_path = entry.path(); + if is_php_file(entry_path) + && (entry.file_type().is_some_and(|ft| ft.is_file()) || entry_path.is_file()) + { + php_files.push(( + entry_path.to_path_buf(), + crate::ClassCompletionOrigin::Project, + )); + } + } + } + + progress_add_total(progress, php_files.len()); + scan_files_parallel_full(&php_files, progress) +} + /// Scan Drupal-specific directories for PHP symbols, bypassing `.gitignore`. /// /// Drupal projects typically exclude their web root directories diff --git a/src/classmap_scanner/discovery_tests.rs b/src/classmap_scanner/discovery_tests.rs index c0c92561e..6eb7cbe54 100644 --- a/src/classmap_scanner/discovery_tests.rs +++ b/src/classmap_scanner/discovery_tests.rs @@ -453,6 +453,108 @@ fn scan_workspace_fallback_full_skips_hidden_dirs() { ); } +// ── scan_include_paths ────────────────────────────────────────── + +/// A hidden directory holding first-party PHP (Castor's `.castor/`) is +/// invisible to the workspace walk, which is the case `include` exists +/// for. +#[test] +fn scan_include_paths_walks_hidden_directory() { + let dir = tempfile::tempdir().unwrap(); + let hidden = dir.path().join(".castor"); + std::fs::create_dir_all(&hidden).unwrap(); + std::fs::write( + hidden.join("docker.php"), + ">() + ); +} + +/// A generated stub is hidden *and* gitignored; naming it directly has +/// to defeat both filters. +#[test] +fn scan_include_paths_reads_hidden_gitignored_file() { + let dir = tempfile::tempdir().unwrap(); + // `ignore` only honours `.gitignore` inside a git repository. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); + std::fs::write(dir.path().join(".gitignore"), "/.castor.stub.php\n").unwrap(); + let stub = dir.path().join(".castor.stub.php"); + std::fs::write( + &stub, + ", + + /// Extra files and directories to index, relative to the workspace + /// root (absolute paths are also accepted). + /// + /// Listing a path here indexes it regardless of both filters. + /// Directories are walked recursively; files are scanned directly. + /// + /// ```toml + /// [indexing] + /// include = [".lib", ".lib.stub.php"] + /// ``` + pub include: Vec, } impl IndexingConfig { pub fn strategy(&self) -> IndexingStrategy { self.strategy.unwrap_or_default() } + + /// Resolve [`include`](Self::include) against the workspace root, + /// dropping entries that do not exist. + /// + /// Relative entries are joined to `workspace_root`; absolute ones + /// are taken as-is. + pub fn include_paths(&self, workspace_root: &Path) -> Vec { + self.include + .iter() + .map(|entry| { + let path = Path::new(entry); + if path.is_absolute() { + path.to_path_buf() + } else { + workspace_root.join(path) + } + }) + .filter(|path| path.exists()) + .collect() + } } /// The indexing strategy that controls class discovery behaviour. @@ -1287,6 +1319,73 @@ paths = ["database/schema", "extra/schema.sql"] assert_eq!(config.indexing.strategy, Some(IndexingStrategy::None)); } + /// Asserted on the default value rather than through + /// [`load_config`], which merges the developer's own global + /// `.phpantom.toml` and would make the result machine-dependent. + #[test] + fn indexing_include_defaults_to_empty() { + let dir = tempfile::tempdir().unwrap(); + let config = IndexingConfig::default(); + assert!(config.include.is_empty()); + assert!(config.include_paths(dir.path()).is_empty()); + } + + #[test] + fn parses_indexing_include() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CONFIG_FILE_NAME); + std::fs::write( + &path, + "[indexing]\ninclude = [\".castor\", \".castor.stub.php\"]\n", + ) + .unwrap(); + let config = load_config(dir.path()).unwrap(); + assert_eq!(config.indexing.include, [".castor", ".castor.stub.php"]); + } + + #[test] + fn include_paths_resolve_relative_to_workspace_root() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".castor")).unwrap(); + + let config = IndexingConfig { + include: vec![".castor".to_string()], + ..Default::default() + }; + assert_eq!( + config.include_paths(dir.path()), + [dir.path().join(".castor")] + ); + } + + #[test] + fn include_paths_accept_absolute_entries() { + let dir = tempfile::tempdir().unwrap(); + let stub = dir.path().join(".castor.stub.php"); + std::fs::write(&stub, ", + ) { + let paths = self.config().indexing.include_paths(root); + if paths.is_empty() { + return; + } + + let scan = classmap_scanner::scan_include_paths(&paths, progress); + let class_count = scan.classmap.len(); + let symbol_count = class_count + scan.function_index.len() + scan.constant_index.len(); + self.populate_autoload_indices(&scan); + { + let mut idx = self.symbols.fqn_uri_index.write(); + let mut origins = self.symbols.fqn_origin_index.write(); + for (fqn, path) in scan.classmap { + origins.or_insert_with(fqn.as_str(), || crate::ClassCompletionOrigin::Project); + idx.or_insert_with(fqn, || crate::util::path_to_uri(&path)); + } + } + + tracing::info!( + "PHPantom: indexing.include — {} symbols from {} configured path(s)", + symbol_count, + paths.len() + ); + } } #[cfg(all(test, unix))] diff --git a/tests/integration/indexing_include.rs b/tests/integration/indexing_include.rs new file mode 100644 index 000000000..a4bdd7701 --- /dev/null +++ b/tests/integration/indexing_include.rs @@ -0,0 +1,220 @@ +use std::fs; + +use phpantom_lsp::Backend; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +/// A trimmed-down `.castor.stub.php`: braced namespace blocks holding +/// the helpers the task files call. Hidden *and* gitignored. +const CASTOR_STUB: &str = r#" (Backend, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let root = dir.path(); + + // `ignore` only honours `.gitignore` inside a git repository, so the + // gitignored half of the fixture needs a repo to be realistic. + fs::create_dir_all(root.join(".git")).unwrap(); + fs::write(root.join(".gitignore"), "/.castor.stub.php\n").unwrap(); + fs::write(root.join(".castor.stub.php"), CASTOR_STUB).unwrap(); + fs::create_dir_all(root.join(".castor")).unwrap(); + fs::write(root.join(".castor/docker.php"), DOCKER_TASKS).unwrap(); + fs::write(root.join("castor.php"), CASTOR_TASKS).unwrap(); + + let entries: Vec = include + .iter() + .map(|entry| format!("{entry:?}")) + .collect::>(); + fs::write( + root.join(".phpantom.toml"), + format!("[indexing]\ninclude = [{}]\n", entries.join(", ")), + ) + .unwrap(); + + let backend = Backend::new_test_with_workspace(root.to_path_buf(), Vec::new()); + backend.initialized(InitializedParams {}).await; + + let uri = Url::from_file_path(root.join("castor.php")).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri, + language_id: "php".into(), + version: 1, + text: CASTOR_TASKS.into(), + }, + }) + .await; + + (backend, dir) +} + +fn tasks_uri(dir: &tempfile::TempDir) -> Url { + Url::from_file_path(dir.path().join("castor.php")).unwrap() +} + +async fn goto(backend: &Backend, uri: &Url, line: u32, character: u32) -> Option { + let response = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: Position { line, character }, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap(); + + match response { + Some(GotoDefinitionResponse::Scalar(location)) => Some(location), + Some(GotoDefinitionResponse::Array(mut locations)) => { + (!locations.is_empty()).then(|| locations.remove(0)) + } + _ => None, + } +} + +/// Without the option nothing changes: both hidden paths stay invisible. +/// This is the bug report, and it pins the default behaviour so the +/// option cannot silently become unconditional. +#[tokio::test] +async fn without_include_hidden_paths_stay_unindexed() { + let (backend, dir) = castor_workspace(&[]).await; + let uri = tasks_uri(&dir); + + assert!( + goto(&backend, &uri, 9, 5).await.is_none(), + "`run` is only declared in the gitignored stub" + ); + assert!( + goto(&backend, &uri, 10, 5).await.is_none(), + "`up` is only declared in the hidden directory" + ); + + let mut diags = Vec::new(); + backend.collect_unknown_function_diagnostics(uri.as_str(), CASTOR_TASKS, &mut diags); + assert_eq!( + diags.len(), + 3, + "io/run/up should all be unknown, got: {diags:?}" + ); +} + +#[tokio::test] +async fn include_resolves_function_from_gitignored_stub() { + let (backend, dir) = castor_workspace(&[".castor", ".castor.stub.php"]).await; + let uri = tasks_uri(&dir); + + let location = goto(&backend, &uri, 9, 5) + .await + .expect("`run` should resolve to the included stub"); + assert_eq!( + location.uri, + Url::from_file_path(dir.path().join(".castor.stub.php")).unwrap() + ); + assert_eq!(location.range.start.line, 5, "Castor\\run is on line 5"); +} + +#[tokio::test] +async fn include_resolves_function_from_hidden_directory() { + let (backend, dir) = castor_workspace(&[".castor", ".castor.stub.php"]).await; + let uri = tasks_uri(&dir); + + let location = goto(&backend, &uri, 10, 5) + .await + .expect("`up` should resolve inside the included hidden directory"); + assert_eq!( + location.uri, + Url::from_file_path(dir.path().join(".castor/docker.php")).unwrap() + ); +} + +#[tokio::test] +async fn include_clears_unknown_function_diagnostics() { + let (backend, dir) = castor_workspace(&[".castor", ".castor.stub.php"]).await; + let uri = tasks_uri(&dir); + + let mut diags = Vec::new(); + backend.collect_unknown_function_diagnostics(uri.as_str(), CASTOR_TASKS, &mut diags); + assert!( + diags.is_empty(), + "included declarations must clear the unknown-function diagnostics, got: {diags:?}" + ); +} + +#[tokio::test] +async fn include_feeds_hover() { + let (backend, dir) = castor_workspace(&[".castor", ".castor.stub.php"]).await; + let uri = tasks_uri(&dir); + + // `io();` on line 8. + let hover = backend + .hover(HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: Position { + line: 8, + character: 5, + }, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap() + .expect("hover should resolve a function from the included stub"); + + let text = match hover.contents { + HoverContents::Markup(markup) => markup.value, + other => panic!("expected markup hover, got: {other:?}"), + }; + assert!( + text.contains("function io()"), + "hover should show the stub signature, got: {text}" + ); +} + +/// A missing entry must not break indexing of the entries beside it. +#[tokio::test] +async fn include_tolerates_missing_entries() { + let (backend, dir) = castor_workspace(&["does/not/exist", ".castor.stub.php"]).await; + let uri = tasks_uri(&dir); + + assert!( + goto(&backend, &uri, 9, 5).await.is_some(), + "the stub should still be indexed alongside a stale entry" + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index ada9c109a..38a1361d7 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -154,6 +154,7 @@ mod folding_ranges; mod formatting_blade; mod hover; mod implementation; +mod indexing_include; mod inlay_hints; mod laravel_app_facade_container; mod laravel_binding_precedence;