From d65ce61354ed501ea38736d41ad7668477c12253 Mon Sep 17 00:00:00 2001 From: liudashuang <1009407069@qq.com> Date: Thu, 20 Aug 2026 22:15:56 +0800 Subject: [PATCH] feat(indexing): add [indexing] follow-links to walk symlinked directories Workspace walks now follow interior directory symlinks when `follow-links = true` is set in .phpantom.toml (off by default). A project that keeps its framework behind a symlink (e.g. `kdhelp -> ../kdhelp`) gets go-to-definition, hover, completion, Find References, and workspace diagnostics for the linked tree, with the symlink spelling preserved in every indexed path and returned URI. All four workspace walkers are switched together so the feature is never half-wired (classes indexed but references missed): - classmap_scanner::discovery::walk_roots (self-scan / PSR-4 / vendor / no-composer classmaps) - references::collect_php_files_gitignore (Find References, Rename, preload Phase 2, Laravel config trees) - analyse::discover_user_files (CLI analyze / fix) - util::collect_php_files (Go-to-implementation) Deliberately unchanged: the Drupal scanner, the Laravel migration walk (its comment refuses to follow links to avoid walking the whole project through a cycle), and composer::discover_subproject_roots. Both walker shapes are cycle-safe: the parallel path uses ignore's check_symlink_loop and skips Err entries, the serial walkers share the same loop detection and consume via flatten(), which drops the loop error instead of panicking. Each is covered by a test. Watcher blind spot documented: the client's watchers only cover the workspace root, so changes on disk inside a linked target (a git pull into the framework) do not trigger a re-index; reload the window after such changes. Closes #383 --- config-schema.json | 5 + docs/CHANGELOG.md | 2 + docs/configuration.md | 7 +- src/analyse/run.rs | 67 ++++++- src/classmap_scanner/discovery.rs | 47 ++++- src/classmap_scanner/discovery_tests.rs | 184 ++++++++++++++++++- src/config.rs | 49 +++++ src/definition/implementation.rs | 3 +- src/fix.rs | 7 +- src/indexing/init.rs | 48 ++++- src/indexing/preload.rs | 3 +- src/indexing/scan.rs | 4 + src/references/mod.rs | 4 + src/references/tests.rs | 78 ++++++++ src/rename/namespace.rs | 8 +- src/util.rs | 57 +++++- src/virtual_members/laravel/config_values.rs | 5 +- 17 files changed, 541 insertions(+), 37 deletions(-) diff --git a/config-schema.json b/config-schema.json index 0efd65239..da700b470 100644 --- a/config-schema.json +++ b/config-schema.json @@ -83,6 +83,11 @@ "none" ], "default": "full" + }, + "follow-links": { + "type": "boolean", + "description": "Follow directory symlinks found inside the workspace during workspace walks. Off by default: a symlinked directory is not descended into, so a link pointing at an external framework tree is skipped. When enabled the walk enters the link target and the symlink spelling is preserved in the index. The Composer pipeline, Drupal scanner, and Laravel migration walk are unaffected. Changes on disk inside a linked target directory do not trigger re-indexing; reload the window after such changes.", + "default": false } } }, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e0836d237..5a3fc01fe 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 +- **`follow-links` under `[indexing]`.** Directory symlinks found inside the workspace are now followed during the workspace walks when `follow-links = true` is set in `.phpantom.toml` — off by default, matching PHPantom's existing opt-in posture for behaviour that widens the scan. A project that keeps its framework or shared library behind a symlink (e.g. `kdhelp -> ../kdhelp`) gets go-to-definition, hover, completion, Find References, and workspace diagnostics for the linked tree, with the symlink spelling preserved in every indexed path and returned URI. The Composer pipeline, the Drupal scanner, and the Laravel migration walk are unaffected. Because the walker reports the target's files under the link's path, changes on disk inside a linked directory (a `git pull` into the framework, say) do not trigger a re-index — a file open in the editor re-parses on `didOpen`/`didChange`, but everything else needs a window reload or server restart. Closes #383. + ### Changed ### Fixed diff --git a/docs/configuration.md b/docs/configuration.md index 4764a4ac0..a4fe63b2a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -81,9 +81,10 @@ 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. | +| `follow-links` | bool | `false` | Follow directory symlinks found inside the workspace during the workspace walks. Off by default: a symlinked directory is yielded as the symlink itself and never descended into, so a link to an external framework tree is skipped entirely. When enabled, the walk enters the link target and the symlink spelling is preserved in the index and returned URIs. The Composer pipeline, the Drupal scanner, and the Laravel migration walk are unaffected. Changes on disk *inside* a linked target (a `git pull` into the framework, say) do not trigger a re-index — the client's watchers only cover the workspace root. Files open in the editor re-parse on `didOpen`/`didChange`; anything else needs a window reload or server restart. | ### `[semantic_tokens]` diff --git a/src/analyse/run.rs b/src/analyse/run.rs index c6c7e49aa..5da2e6dcc 100644 --- a/src/analyse/run.rs +++ b/src/analyse/run.rs @@ -84,7 +84,12 @@ pub async fn run(options: AnalyseOptions) -> i32 { .init_single_project(root, php_version, composer_package, None) .await; // ── 3. Locate user files (via PSR-4) and crop to path ─────────── - let files = discover_user_files(&backend, root, options.path_filter.as_deref()); + let files = discover_user_files( + &backend, + root, + options.path_filter.as_deref(), + cfg.indexing.follow_links(), + ); if files.is_empty() { eprintln!("No PHP files found."); @@ -644,6 +649,7 @@ pub(crate) fn discover_user_files( backend: &Backend, workspace_root: &Path, path_filter: Option<&Path>, + follow_links: bool, ) -> Vec { use ignore::WalkBuilder; @@ -758,6 +764,7 @@ pub(crate) fn discover_user_files( .hidden(true) .parents(true) .ignore(true) + .follow_links(follow_links) .filter_entry(move |entry| { if entry.file_type().is_some_and(|ft| ft.is_dir()) && !skip_vendor.is_empty() @@ -904,7 +911,7 @@ mod tests { let backend = Backend::new_headless(); backend.add_vendor_dir(&root.join("vendor")); - let files = discover_user_files(&backend, root, None); + let files = discover_user_files(&backend, root, None, false); let names: Vec = files .iter() .map(|p| p.strip_prefix(root).unwrap().to_string_lossy().into_owned()) @@ -945,7 +952,7 @@ mod tests { .lock() .push(linked_root.join("vendor")); - let files = discover_user_files(&backend, &real_root, None); + let files = discover_user_files(&backend, &real_root, None, false); assert!(files.contains(&real_root.join("app/Main.php")), "{files:?}"); assert!( !files @@ -966,7 +973,59 @@ mod tests { std::fs::write(root.join("other.php"), " usize { pub fn scan_directories( dirs: &[PathBuf], vendor_dir_paths: &[PathBuf], + follow_links: bool, ) -> 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, follow_links); let paths: Vec = walk_roots(dirs, &opts).into_iter().flatten().collect(); scan_files_parallel_classes(&paths, None) } @@ -96,8 +97,16 @@ pub fn scan_psr4_directories( psr4: &[(String, PathBuf)], classmap_dirs: &[PathBuf], vendor_dir_paths: &[PathBuf], + follow_links: bool, ) -> 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(), + None, + follow_links, + ) } /// Like [`scan_psr4_directories`] but accepts a set of absolute file @@ -111,9 +120,10 @@ pub fn scan_psr4_directories_with_skip( vendor_dir_paths: &[PathBuf], skip_paths: &HashSet, progress: Option<&ScanProgress>, + follow_links: bool, ) -> 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, follow_links); let mut roots: Vec = psr4.iter().map(|(_, dir)| dir.clone()).collect(); roots.extend(classmap_dirs.iter().cloned()); let mut walked = walk_roots(&roots, &opts); @@ -155,6 +165,7 @@ pub fn scan_vendor_packages(workspace_root: &Path, vendor_dir: &str) -> Workspac &HashSet::new(), &HashSet::new(), None, + false, ) } @@ -387,6 +398,7 @@ pub fn scan_vendor_packages_with_skip( skip_paths: &HashSet, explicit_deps: &HashSet, progress: Option<&ScanProgress>, + follow_links: bool, ) -> WorkspaceScanResult { let vendor_path = workspace_root.join(vendor_dir); let installed_path = vendor_path.join("composer").join("installed.json"); @@ -478,7 +490,7 @@ 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, follow_links); let mut roots: Vec = Vec::new(); for (_, sources) in &collected { roots.extend(sources.psr4.iter().cloned()); @@ -541,8 +553,9 @@ pub fn scan_vendor_packages_with_skip( pub fn scan_workspace_fallback( workspace_root: &Path, vendor_dir_paths: &[PathBuf], + follow_links: bool, ) -> HashMap { - scan_directories(&[workspace_root.to_path_buf()], vendor_dir_paths) + scan_directories(&[workspace_root.to_path_buf()], vendor_dir_paths, follow_links) } /// Scan a batch of files for class names in parallel and return a classmap. @@ -878,10 +891,11 @@ pub fn scan_workspace_fallback_full( workspace_root: &Path, skip_dirs: &HashSet, progress: Option<&ScanProgress>, + follow_links: bool, ) -> 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, follow_links); let php_files: Vec<(PathBuf, crate::ClassCompletionOrigin)> = walk_roots(&[workspace_root.to_path_buf()], &opts) .into_iter() @@ -1011,13 +1025,23 @@ 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, + /// Whether to follow directory symlinks found inside a walk root. + /// Passed straight to [`ignore::WalkBuilder::follow_links`]; when + /// `false` (the default) a symlinked directory is yielded as the + /// symlink itself and never descended into. + follow_links: bool, } impl<'a> WalkOptions<'a> { - fn new(skip_dirs: Vec, skip_paths: &'a HashSet) -> Self { + fn new( + skip_dirs: Vec, + skip_paths: &'a HashSet, + follow_links: bool, + ) -> Self { Self { skip_dirs: std::sync::Arc::new(skip_dirs), skip_paths, + follow_links, } } } @@ -1083,6 +1107,7 @@ fn walk_roots(roots: &[PathBuf], opts: &WalkOptions) -> Vec> { .hidden(true) .parents(true) .ignore(true) + .follow_links(opts.follow_links) .threads(thread_count()) .filter_entry(move |entry| { !(entry.file_type().is_some_and(|ft| ft.is_dir()) @@ -1103,9 +1128,11 @@ fn walk_roots(roots: &[PathBuf], opts: &WalkOptions) -> Vec> { if file_type.is_some_and(|ft| ft.is_dir()) || !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 - // above keep this stat off the common path. + // `ignore` reports a symlink's own type when not + // following; with follow-links the type is the target's. + // Either way, confirm the target is a regular file before + // indexing it. The tests above keep this stat off the + // common path. || !(file_type.is_some_and(|ft| ft.is_file()) || path.is_file()) { return WalkState::Continue; diff --git a/src/classmap_scanner/discovery_tests.rs b/src/classmap_scanner/discovery_tests.rs index c0c92561e..4ac91b5c9 100644 --- a/src/classmap_scanner/discovery_tests.rs +++ b/src/classmap_scanner/discovery_tests.rs @@ -19,7 +19,7 @@ fn scan_directories_finds_classes() { .unwrap(); let vendor_dir_paths = vec![dir.path().join("vendor")]; - let classmap = scan_directories(&[src], &vendor_dir_paths); + let classmap = scan_directories(&[src], &vendor_dir_paths, false); assert_eq!(classmap.len(), 2); assert!(classmap.contains_key("App\\Models\\User")); assert!(classmap.contains_key("App\\Models\\Order")); @@ -32,7 +32,7 @@ fn scan_directories_skips_hidden() { std::fs::create_dir_all(&hidden).unwrap(); std::fs::write(hidden.join("Secret.php"), " ../kdhelp` style link to an +// external framework tree is skipped entirely. With it on, the walk +// enters the link target and keeps the symlink spelling in every path it +// yields — the spelling contract the index and returned URIs depend on. + +#[test] +fn walk_roots_does_not_follow_interior_symlink_by_default() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + let real = dir.path().join("real"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("Hidden.php"), " = walk_roots(&[root], &opts).into_iter().flatten().collect(); + assert!( + !files.iter().any(|p| p.ends_with("Hidden.php")), + "interior symlink must not be followed by default: {files:?}" + ); +} + +#[test] +fn walk_roots_follows_interior_symlink_when_enabled() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + let real = dir.path().join("real"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("Hidden.php"), " = walk_roots(&[root], &opts).into_iter().flatten().collect(); + let linked = files + .iter() + .find(|p| p.ends_with("Hidden.php")) + .unwrap_or_else(|| panic!("linked file must be indexed: {files:?}")); + assert!( + linked.starts_with(&link), + "paths must keep the symlink spelling: {linked:?} vs {link:?}" + ); +} + +#[test] +fn walk_roots_follows_nested_symlinks() { + // The kdhelp/soa scenario: a symlink inside a symlinked target, + // pointing at a second external tree, is followed transitively and + // keeps the full symlink prefix in its yielded paths. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + let ext1 = dir.path().join("ext1"); + let ext2 = dir.path().join("ext2"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&ext1).unwrap(); + std::fs::create_dir_all(&ext2).unwrap(); + std::fs::write(ext2.join("Deep.php"), " = walk_roots(&[root.clone()], &opts).into_iter().flatten().collect(); + let linked = files + .iter() + .find(|p| p.ends_with("Deep.php")) + .unwrap_or_else(|| panic!("nested linked file must be indexed: {files:?}")); + // Both link spellings survive transitively: the yielded path is + // ws/kdhelp/soa/Deep.php, never the real ext1/… / ext2/… targets. + let expected_prefix = root.join("kdhelp").join("soa"); + assert!( + linked.starts_with(&expected_prefix), + "nested links must keep the full symlink prefix: {linked:?} vs {expected_prefix:?}" + ); +} + +#[test] +fn walk_roots_follows_symlink_cycle_safely() { + // A symlink pointing back at the workspace itself must terminate: + // `ignore`'s parallel walker detects the cycle via dev+inode handles + // and skips the re-entered directory. Without the cycle guard this + // test would walk forever. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("App.php"), " = walk_roots(&[root], &opts).into_iter().flatten().collect(); + assert!( + files.iter().any(|p| p.ends_with("App.php")), + "workspace files must still be found next to a cycle: {files:?}" + ); +} + +#[test] +fn walk_roots_skip_dirs_match_literal_walk_spelling() { + // `skip_dirs` is a literal path comparison against the walked entry + // path. With follow-links on, an interior symlink is walked under + // its *link* spelling, so a skip entry pointing at the link *target* + // does not prune the linked tree — a project's own excludes never + // accidentally hit a linked external tree, and a linked tree's own + // same-named directories are not pruned by the project's excludes + // either (the two are the same fact seen from each side). + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + let real_vendor = dir.path().join("real-vendor"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&real_vendor).unwrap(); + std::fs::write(real_vendor.join("Pkg.php"), " = walk_roots(&[root], &opts).into_iter().flatten().collect(); + assert!( + files.iter().any(|p| p.ends_with("Pkg.php")), + "skip_dirs matches literal walk paths; the target spelling must not prune the link spelling: {files:?}" + ); +} diff --git a/src/config.rs b/src/config.rs index c490b2570..6d929dd96 100644 --- a/src/config.rs +++ b/src/config.rs @@ -506,12 +506,41 @@ pub struct IndexingConfig { /// if present, still resolves on demand, but never falls back to /// self-scan. pub strategy: Option, + /// Follow directory symlinks found inside the workspace during the + /// workspace walks. Off by default: `ignore` yields a symlinked + /// directory as the symlink itself and never descends into it, which + /// is how a `kdhelp -> ../kdhelp` style link to an external framework + /// tree is skipped entirely. When enabled the walk descends into the + /// link target, and the symlink spelling is preserved in the index and + /// returned URIs. + /// + /// The Composer pipeline (`strategy = "composer"`), the Drupal + /// scanner, and the Laravel migration walk are not affected: the + /// composer pipeline keeps scanning the PSR-4 / vendor roots it is + /// given, the Drupal scanner stays gitignore-less by design, and the + /// migration walker deliberately refuses to follow links to avoid + /// walking the whole project through a cycle. + /// + /// When enabled, changes on disk *inside* a linked target directory + /// (e.g. a `git pull` into an external framework) do not trigger a + /// re-index — watchers registered by the client only cover the + /// workspace root, and a file open in the editor re-parses on + /// `didOpen`/`didChange` regardless. Reload the window (or restart + /// the server) after such changes. + #[serde(rename = "follow-links")] + pub follow_links: Option, } impl IndexingConfig { pub fn strategy(&self) -> IndexingStrategy { self.strategy.unwrap_or_default() } + + /// Whether the workspace walks should follow interior directory + /// symlinks. Defaults to `false`. + pub fn follow_links(&self) -> bool { + self.follow_links.unwrap_or(false) + } } /// The indexing strategy that controls class discovery behaviour. @@ -1303,6 +1332,26 @@ paths = ["database/schema", "extra/schema.sql"] assert_eq!(config.indexing.strategy(), IndexingStrategy::Full); } + #[test] + fn indexing_follow_links_parses_true() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CONFIG_FILE_NAME); + std::fs::write(&path, "[indexing]\nfollow-links = true\n").unwrap(); + let config = load_config(dir.path()).unwrap(); + assert_eq!(config.indexing.follow_links, Some(true)); + assert!(config.indexing.follow_links()); + } + + #[test] + fn indexing_follow_links_defaults_to_false() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CONFIG_FILE_NAME); + std::fs::write(&path, "[indexing]\n").unwrap(); + let config = load_config(dir.path()).unwrap(); + assert_eq!(config.indexing.follow_links, None); + assert!(!config.indexing.follow_links()); + } + #[test] fn indexing_strategy_display() { assert_eq!(IndexingStrategy::Composer.to_string(), "composer"); diff --git a/src/definition/implementation.rs b/src/definition/implementation.rs index ec60275b8..41a3924f3 100644 --- a/src/definition/implementation.rs +++ b/src/definition/implementation.rs @@ -955,9 +955,10 @@ impl Backend { }; let loaded_uris_p5: HashSet = self.parsed_uris.read().iter().cloned().collect(); + let follow_links = self.config().indexing.follow_links(); 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, follow_links); if let Some(p) = progress { p.add_total(php_files.len() as u64); } diff --git a/src/fix.rs b/src/fix.rs index c600d9c85..58d2afd6d 100644 --- a/src/fix.rs +++ b/src/fix.rs @@ -265,7 +265,12 @@ pub async fn run(options: FixOptions) -> i32 { .await; // ── 3. Discover files ─────────────────────────────────────────── - let files = crate::analyse::discover_user_files(&backend, root, options.path_filter.as_deref()); + let files = crate::analyse::discover_user_files( + &backend, + root, + options.path_filter.as_deref(), + cfg.indexing.follow_links(), + ); if files.is_empty() { eprintln!("No PHP files found."); diff --git a/src/indexing/init.rs b/src/indexing/init.rs index 8b58f362e..a13b8c52f 100644 --- a/src/indexing/init.rs +++ b/src/indexing/init.rs @@ -114,8 +114,12 @@ 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 mut scan = classmap_scanner::scan_workspace_fallback_full( + root, + &skip_dirs, + progress, + self.config().indexing.follow_links(), + ); // Merge vendor packages (excluded from the workspace // walk above, scanned separately here). @@ -128,6 +132,7 @@ impl Backend { &HashSet::new(), &explicit_deps, progress, + self.config().indexing.follow_links(), ); let package_roots = std::mem::take(&mut vendor_scan.package_roots); @@ -307,6 +312,17 @@ impl Backend { ), ) .await; + + // When symlink-following is enabled, tell the user it is active so + // a configuration mistake (or a deliberate `git pull` into a linked + // framework) is diagnosable from the log alone. + if self.config().indexing.follow_links() && has_interior_symlink(root) { + self.log( + MessageType::INFO, + "PHPantom: symlink-following enabled — indexing directories reached through workspace symlinks. Changes inside a linked target on disk do not trigger a re-index; reload the window after a `git pull` or similar.".to_string(), + ) + .await; + } } /// Initialize a monorepo workspace (no root `composer.json`, but @@ -463,7 +479,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, + progress, + self.config().indexing.follow_links(), + ); self.populate_autoload_indices(&scan); { let mut idx = self.symbols.fqn_uri_index.write(); @@ -513,7 +534,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, + progress, + self.config().indexing.follow_links(), + ); self.populate_autoload_indices(&scan); let symbol_count = scan.classmap.len(); @@ -541,3 +567,17 @@ impl Backend { .await; } } + +/// Return `true` when any entry directly under `root` is a symlink to a +/// directory. Used only to decide whether the "symlink-following +/// enabled" startup notice is worth printing; the walks themselves do +/// the real work. +fn has_interior_symlink(root: &std::path::Path) -> bool { + let Ok(entries) = std::fs::read_dir(root) else { + return false; + }; + entries.flatten().any(|e| { + e.file_type().is_ok_and(|ft| ft.is_symlink()) + && std::fs::metadata(e.path()).is_ok_and(|m| m.is_dir()) + }) +} diff --git a/src/indexing/preload.rs b/src/indexing/preload.rs index 5b8d422ff..7f9318e0c 100644 --- a/src/indexing/preload.rs +++ b/src/indexing/preload.rs @@ -232,8 +232,9 @@ impl Backend { self.report_workspace_index_progress(progress, 3, "Scanning workspace files"); let walk_start = std::time::Instant::now(); + let follow_links = self.config().indexing.follow_links(); let php_files = - crate::references::collect_php_files_gitignore(&root, &vendor_dir_paths); + crate::references::collect_php_files_gitignore(&root, &vendor_dir_paths, follow_links); 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 6bc33c824..1f8050b7c 100644 --- a/src/indexing/scan.rs +++ b/src/indexing/scan.rs @@ -107,6 +107,7 @@ impl Backend { &HashSet::new(), &explicit_deps, None, + self.config().indexing.follow_links(), ); // Package roots came out of the same `installed.json` parse // `scan_vendor_packages_with_skip` already did; no need to @@ -515,6 +516,7 @@ impl Backend { project_root, &skip_dirs, progress, + self.config().indexing.follow_links(), ); } } @@ -548,6 +550,7 @@ impl Backend { &vendor_dir_paths, skip_paths, progress, + self.config().indexing.follow_links(), ); // Scan vendor packages from installed.json. @@ -561,6 +564,7 @@ impl Backend { skip_paths, &explicit_deps, progress, + self.config().indexing.follow_links(), ); let mut result = WorkspaceScanResult { diff --git a/src/references/mod.rs b/src/references/mod.rs index 6c173db94..042d86ad8 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -314,6 +314,7 @@ pub(super) fn member_candidate_keys( pub(crate) fn collect_php_files_gitignore( root: &Path, vendor_dir_paths: &[PathBuf], + follow_links: bool, ) -> Vec { use ignore::WalkBuilder; @@ -331,6 +332,9 @@ pub(crate) fn collect_php_files_gitignore( .parents(true) // Also respect .ignore files (ripgrep convention) .ignore(true) + // Follow interior directory symlinks only when configured; a + // linked framework tree is otherwise skipped entirely. + .follow_links(follow_links) // Always skip vendor directories, even if not gitignored .filter_entry(move |entry| { if entry.file_type().is_some_and(|ft| ft.is_dir()) { diff --git a/src/references/tests.rs b/src/references/tests.rs index 5400087ac..cf91a3f86 100644 --- a/src/references/tests.rs +++ b/src/references/tests.rs @@ -2978,3 +2978,81 @@ async fn function_references_from_a_use_function_import_reach_its_call_sites() { "expected the shout() call site, got {locs:?}" ); } + +// ─── follow-links: collect_php_files_gitignore (issue #383) ──────── +// The Find References / rename / preload walker is a *serial* `ignore` +// walk (`.build()` + `flatten()`). The same follow-links contract as +// `walk_roots` applies, and a symlink cycle must terminate instead of +// panicking: `flatten()` silently drops `Err` entries, which is where +// the loop error lands. + +#[test] +fn collect_php_files_gitignore_follows_interior_symlink_when_enabled() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + let real = dir.path().join("real"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("Hidden.php"), " 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], + follow_links: bool, +) -> Vec { use ignore::WalkBuilder; let mut result = Vec::new(); @@ -311,6 +315,7 @@ pub(crate) fn collect_php_files(dir: &Path, vendor_dir_paths: &[PathBuf]) -> Vec .hidden(true) .parents(true) .ignore(true) + .follow_links(follow_links) .filter_entry(move |entry| { if entry.file_type().is_some_and(|ft| ft.is_dir()) { let path = entry.path(); @@ -611,4 +616,54 @@ mod tests { assert_eq!(unescape_php_string_literal("bare"), None); assert_eq!(unescape_php_string_literal("'unterminated"), None); } + + #[test] + fn collect_php_files_follows_interior_symlink_when_enabled() { + // Go-to-implementation's walker keeps the same follow-links + // contract as the other workspace walkers (issue #383). + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + let real = dir.path().join("real"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("Hidden.php"), " = Vec::new(); if let Some(root) = &workspace_root { let vendor_dir_paths = self.workspace.vendor_dir_paths.lock().clone(); - for path in crate::references::collect_php_files_gitignore(root, &vendor_dir_paths) { + let follow_links = self.config().indexing.follow_links(); + for path in + crate::references::collect_php_files_gitignore(root, &vendor_dir_paths, follow_links) + { let uri = crate::util::path_to_uri(&path); if laravel_config_prefix_from_uri(&uri).is_some() { config_uris.push(uri);