Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
},
Expand Down
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`

Expand Down
67 changes: 63 additions & 4 deletions src/analyse/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -644,6 +649,7 @@ pub(crate) fn discover_user_files(
backend: &Backend,
workspace_root: &Path,
path_filter: Option<&Path>,
follow_links: bool,
) -> Vec<PathBuf> {
use ignore::WalkBuilder;

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<String> = files
.iter()
.map(|p| p.strip_prefix(root).unwrap().to_string_lossy().into_owned())
Expand Down Expand Up @@ -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
Expand All @@ -966,7 +973,59 @@ mod tests {
std::fs::write(root.join("other.php"), "<?php\n").unwrap();

let backend = Backend::new_headless();
let files = discover_user_files(&backend, root, Some(Path::new("includes/target.php")));
let files = discover_user_files(&backend, root, Some(Path::new("includes/target.php")), false);
assert_eq!(files, vec![root.join("includes/target.php")]);
}

#[test]
fn discover_user_files_follows_interior_symlink_when_enabled() {
// CLI analyse's user-file walker keeps the same follow-links
// contract as the 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"), "<?php\n").unwrap();

let link = root.join("link");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&real, &link).unwrap();

let backend = Backend::new_headless();
let files = discover_user_files(&backend, &root, None, true);
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 discover_user_files_does_not_follow_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"), "<?php\n").unwrap();

let link = root.join("link");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&real, &link).unwrap();

let backend = Backend::new_headless();
let files = discover_user_files(&backend, &root, None, false);
assert!(
!files.iter().any(|p| p.ends_with("Hidden.php")),
"interior symlink must not be followed by default: {files:?}"
);
}
}
47 changes: 37 additions & 10 deletions src/classmap_scanner/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ fn thread_count() -> usize {
pub fn scan_directories(
dirs: &[PathBuf],
vendor_dir_paths: &[PathBuf],
follow_links: bool,
) -> HashMap<String, PathBuf> {
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<PathBuf> = walk_roots(dirs, &opts).into_iter().flatten().collect();
scan_files_parallel_classes(&paths, None)
}
Expand All @@ -96,8 +97,16 @@ pub fn scan_psr4_directories(
psr4: &[(String, PathBuf)],
classmap_dirs: &[PathBuf],
vendor_dir_paths: &[PathBuf],
follow_links: bool,
) -> HashMap<String, PathBuf> {
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
Expand All @@ -111,9 +120,10 @@ pub fn scan_psr4_directories_with_skip(
vendor_dir_paths: &[PathBuf],
skip_paths: &HashSet<PathBuf>,
progress: Option<&ScanProgress>,
follow_links: bool,
) -> HashMap<String, PathBuf> {
// ── 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<PathBuf> = psr4.iter().map(|(_, dir)| dir.clone()).collect();
roots.extend(classmap_dirs.iter().cloned());
let mut walked = walk_roots(&roots, &opts);
Expand Down Expand Up @@ -155,6 +165,7 @@ pub fn scan_vendor_packages(workspace_root: &Path, vendor_dir: &str) -> Workspac
&HashSet::new(),
&HashSet::new(),
None,
false,
)
}

Expand Down Expand Up @@ -387,6 +398,7 @@ pub fn scan_vendor_packages_with_skip(
skip_paths: &HashSet<PathBuf>,
explicit_deps: &HashSet<String>,
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");
Expand Down Expand Up @@ -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<PathBuf> = Vec::new();
for (_, sources) in &collected {
roots.extend(sources.psr4.iter().cloned());
Expand Down Expand Up @@ -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<String, PathBuf> {
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.
Expand Down Expand Up @@ -878,10 +891,11 @@ pub fn scan_workspace_fallback_full(
workspace_root: &Path,
skip_dirs: &HashSet<PathBuf>,
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()
Expand Down Expand Up @@ -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<PathBuf>,
/// 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<PathBuf>, skip_paths: &'a HashSet<PathBuf>) -> Self {
fn new(
skip_dirs: Vec<PathBuf>,
skip_paths: &'a HashSet<PathBuf>,
follow_links: bool,
) -> Self {
Self {
skip_dirs: std::sync::Arc::new(skip_dirs),
skip_paths,
follow_links,
}
}
}
Expand Down Expand Up @@ -1083,6 +1107,7 @@ fn walk_roots(roots: &[PathBuf], opts: &WalkOptions) -> Vec<Vec<PathBuf>> {
.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())
Expand All @@ -1103,9 +1128,11 @@ fn walk_roots(roots: &[PathBuf], opts: &WalkOptions) -> Vec<Vec<PathBuf>> {
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;
Expand Down
Loading