Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,8 @@ The merged pipeline works in three steps: (1) load `autoload_classmap.php` into

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.

**Directory symlinks.** The walkers descend into a symlinked directory, so a project that keeps its framework or a shared library outside the repository and links it into the tree gets the linked code indexed with the rest. Every path keeps the symlink spelling rather than the target's, which is what makes a file reached through the link the same file the editor opened. `LinkClaims` gives each walk one visit per target directory: the walk's own roots and its skipped trees are claimed up front, and each link claims its target the first time it is descended, so two links to one tree, a link pointing back at something the walk already covers, and a chain of directories holding several links apiece all cost one pass rather than one per route. `orchestra/testbench-core` ships `laravel/vendor -> <project>/vendor`, which is why the skipped trees are claimed and not merely pruned by path. Each link the index reached through also gets a watcher based at it (see `build_watched_file_registration`), since a workspace-relative watcher pattern never covers a path outside the workspace folders.

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.

**Redundant I/O elimination:** `init_single_project` parses `composer.json` once and passes the pre-parsed `serde_json::Value` to `build_self_scan_composer`. Previously each function re-read and re-parsed the file independently.
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Directories reached through a symlink are indexed with the rest of the project.** A project that keeps its framework or a shared library outside the repository and links it into the tree (`kdhelp -> ../kdhelp`, say) now resolves the symbols there like any other project code. Indexed paths keep the symlink spelling, so a file reached through the link is the same file the editor opened, and a tree is indexed once however many links lead to it. Changes another tool writes inside a linked directory are picked up as well on editors that can watch a path outside the project; where they cannot, such a change needs a window reload, while a file open in the editor always re-parses as it is edited. Contributed by @liudashuang. Closes #383.
- **Formatting from the command line.** `phpantom_lsp format` formats every PHP file and Blade template in a project with the same formatter the editor runs on save, and `phpantom_lsp format --check` reports the files that are not formatted and exits non-zero without writing anything, so a CI job can require that a pull request ran the formatter. A run honours whatever the project already formats with, a Laravel Pint, php-cs-fixer, or PHP_CodeSniffer it depends on, and the built-in formatter otherwise, exactly as the editor resolves it, and opens with a line naming what it resolved so a CI log records which formatter enforced the result. Templates whose indentation is output rather than layout are left alone and never fail a check, and formatting turned off in `.phpantom.toml` is reported as such rather than passing as a project where every file happens to be formatted. Paths can be named to restrict the run, `--format github` annotates the pull request diff, and `--format json` is shaped like the object `analyze` and `fix` emit.
- **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits at runtime. Contributed by @shuvroroy.
- **Qualified names can be converted to imports in one action.** Invoke the refactoring on an absolute or relative qualified class, function, or constant to add the matching `use`, `use function`, or `use const` declaration and shorten every equivalent usage in the file. When the natural short name is already imported from elsewhere, the new import receives a namespace-derived alias instead. A companion action on the same cursor position does the whole namespace at once, importing every qualified class, function, and constant it contains and aliasing the ones whose short names collide. Contributed by @calebdw.
Expand Down
78 changes: 72 additions & 6 deletions src/analyse/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,21 +250,30 @@ fn collect_php_files(

let skip_vendor = skip_vendor.to_vec();
let filter_excludes = std::sync::Arc::clone(filters);
// Same one-visit-per-target rule the shared workspace walker applies,
// so `analyze` cannot report the same file once per spelling a chain
// of links gives it.
let claims = crate::classmap_scanner::LinkClaims::new([dir.to_path_buf()], None);
let walker = WalkBuilder::new(dir)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.hidden(true)
.parents(true)
.ignore(true)
.follow_links(true)
.filter_entry(move |entry| {
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
if is_dir
&& !skip_vendor.is_empty()
&& let Ok(canonical) = entry.path().canonicalize()
&& skip_vendor.iter().any(|v| canonical.starts_with(v))
{
return false;
if is_dir {
if !skip_vendor.is_empty()
&& let Ok(canonical) = entry.path().canonicalize()
&& skip_vendor.iter().any(|v| canonical.starts_with(v))
{
return false;
}
if entry.depth() > 0 && entry.path_is_symlink() && !claims.claim(entry.path()) {
return false;
}
}
!filter_excludes.is_excluded_entry(entry.path(), is_dir)
})
Expand Down Expand Up @@ -488,4 +497,61 @@ mod tests {

assert_eq!(files, vec![root.join("src/A.php"), root.join("src/B.php")]);
}

#[test]
fn discover_user_files_follows_interior_symlink_when_enabled() {
// CLI analyse's user-file walker keeps the same symlink
// 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, &[]);
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_walks_a_link_target_once() {
// Two links to one tree must not make `analyze` report the same
// file, and so the same diagnostics, twice.
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("Dup.php"), "<?php\n").unwrap();

for name in ["a", "b"] {
let link = root.join(name);
#[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, &[]);
assert_eq!(
files.len(),
1,
"the linked tree must be reported once, not once per link: {files:?}"
);
}
}
25 changes: 23 additions & 2 deletions src/backend/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ impl Backend {
self.supports_inlay_hint_refresh
.store(client_supports_inlay_hint_refresh, Ordering::Release);

// A tree indexed through a symlink is not inside any workspace
// folder, so being told about a change in it takes a watcher that
// names the link. Clients that predate LSP 3.17 get none, and a
// `git pull` into a linked framework needs a reload there.
let client_supports_relative_pattern_watchers = params
.capabilities
.workspace
.as_ref()
.and_then(|ws| ws.did_change_watched_files.as_ref())
.and_then(|w| w.relative_pattern_support)
.unwrap_or(false);
self.supports_relative_pattern_watchers
.store(client_supports_relative_pattern_watchers, Ordering::Release);

let client_supports_type_hierarchy_dynamic_registration = params
.capabilities
.text_document
Expand Down Expand Up @@ -492,10 +506,10 @@ impl Backend {
// Built by the same helper `reload_config` uses to keep this
// registration current when the extension list changes mid-session
// (see `indexing::watch::reregister_watched_files_if_changed`).
let (watched_files_registration, extra_extensions, is_laravel) =
let (watched_files_registration, watched_file_inputs) =
self.build_watched_file_registration();
registrations.push(watched_files_registration);
*self.registered_watcher_state.write() = Some((extra_extensions, is_laravel));
*self.registered_watcher_state.write() = Some(watched_file_inputs);

if let Some(client) = &self.client {
let _ = client.register_capability(registrations).await;
Expand Down Expand Up @@ -648,6 +662,13 @@ impl Backend {
.full_index_in_progress
.store(false, Ordering::Release);

// The walk above covers the whole workspace root, so it is
// where a symlink nested below the roots the Composer pipeline
// walked first turns up. Each one needs its own watcher, and
// the registration built during `initialized` could only carry
// the links known by then.
progress_backend.reregister_watched_files_if_changed();

if let Some(tok) = progress_token {
progress_backend
.progress_end(&tok, Some(format!("Parsed {} files", indexed_files)))
Expand Down
37 changes: 32 additions & 5 deletions src/classmap_scanner/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf};

use memchr::memmem;

use super::filters::IndexFilters;
use super::filters::{FollowedLinks, IndexFilters};
use super::{ScanResult, WorkspaceScanResult, read_for_scan, scan_content};
use crate::progress::ScanProgress;

Expand Down Expand Up @@ -69,12 +69,14 @@ fn thread_count() -> usize {
pub fn scan_directories(
dirs: &[PathBuf],
vendor_dir_paths: &[PathBuf],
followed: Option<&FollowedLinks>,
) -> HashMap<String, PathBuf> {
let skip_paths = HashSet::new();
let opts = WalkOptions::new(
vendor_dir_paths.to_vec(),
&skip_paths,
IndexFilters::empty(),
followed,
);
let paths: Vec<PathBuf> = walk_roots(dirs, &opts).into_iter().flatten().collect();
scan_files_parallel_classes(&paths, None)
Expand All @@ -101,6 +103,7 @@ pub fn scan_psr4_directories(
psr4: &[(String, PathBuf)],
classmap_dirs: &[PathBuf],
vendor_dir_paths: &[PathBuf],
followed: Option<&FollowedLinks>,
) -> HashMap<String, PathBuf> {
scan_psr4_directories_with_skip(
psr4,
Expand All @@ -109,6 +112,7 @@ pub fn scan_psr4_directories(
&HashSet::new(),
&IndexFilters::empty(),
None,
followed,
)
}

Expand All @@ -124,12 +128,14 @@ pub fn scan_psr4_directories_with_skip(
skip_paths: &HashSet<PathBuf>,
filters: &std::sync::Arc<IndexFilters>,
progress: Option<&ScanProgress>,
followed: Option<&FollowedLinks>,
) -> 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,
std::sync::Arc::clone(filters),
followed,
);
let mut roots: Vec<PathBuf> = psr4.iter().map(|(_, dir)| dir.clone()).collect();
roots.extend(classmap_dirs.iter().cloned());
Expand Down Expand Up @@ -173,6 +179,7 @@ pub fn scan_vendor_packages(workspace_root: &Path, vendor_dir: &str) -> Workspac
&HashSet::new(),
&IndexFilters::empty(),
None,
None,
)
}

Expand Down Expand Up @@ -396,6 +403,7 @@ pub fn scan_vendor_packages_with_skip(
explicit_deps: &HashSet<String>,
filters: &std::sync::Arc<IndexFilters>,
progress: Option<&ScanProgress>,
followed: Option<&FollowedLinks>,
) -> WorkspaceScanResult {
let vendor_path = workspace_root.join(vendor_dir);

Expand Down Expand Up @@ -476,6 +484,7 @@ pub fn scan_vendor_packages_with_skip(
vec![vendor_path.clone()],
skip_paths,
std::sync::Arc::clone(filters),
followed,
);
let mut roots: Vec<PathBuf> = Vec::new();
for (_, sources) in &collected {
Expand Down Expand Up @@ -539,8 +548,9 @@ pub fn scan_vendor_packages_with_skip(
pub fn scan_workspace_fallback(
workspace_root: &Path,
vendor_dir_paths: &[PathBuf],
followed: Option<&FollowedLinks>,
) -> HashMap<String, PathBuf> {
scan_directories(&[workspace_root.to_path_buf()], vendor_dir_paths)
scan_directories(&[workspace_root.to_path_buf()], vendor_dir_paths, followed)
}

/// Scan `files` in parallel, calling `emit` on each to append the
Expand Down Expand Up @@ -820,13 +830,15 @@ pub fn scan_workspace_fallback_full(
skip_dirs: &HashSet<PathBuf>,
filters: &std::sync::Arc<IndexFilters>,
progress: Option<&ScanProgress>,
followed: Option<&FollowedLinks>,
) -> WorkspaceScanResult {
// Phase 1: collect file paths
let skip_paths = HashSet::new();
let opts = WalkOptions::new(
skip_dirs.iter().cloned().collect(),
&skip_paths,
std::sync::Arc::clone(filters),
followed,
);
let php_files: Vec<(PathBuf, crate::ClassCompletionOrigin)> =
walk_roots(&[workspace_root.to_path_buf()], &opts)
Expand Down Expand Up @@ -948,18 +960,25 @@ struct WalkOptions<'a> {
skip_paths: &'a HashSet<PathBuf>,
/// Compiled `[indexing]` exclude globs and extra PHP extensions.
filters: std::sync::Arc<IndexFilters>,
/// Where to report the directory symlinks this walk descends
/// through, so the client can be asked to watch the trees behind
/// them. `None` for a walk whose caller has no watchers to register
/// (the `analyze`, `fix`, and `format` pipelines).
followed: Option<&'a FollowedLinks>,
}

impl<'a> WalkOptions<'a> {
fn new(
skip_dirs: Vec<PathBuf>,
skip_paths: &'a HashSet<PathBuf>,
filters: std::sync::Arc<IndexFilters>,
followed: Option<&'a FollowedLinks>,
) -> Self {
Self {
skip_dirs: std::sync::Arc::new(skip_dirs),
skip_paths,
filters,
followed,
}
}
}
Expand Down Expand Up @@ -1012,11 +1031,17 @@ fn walk_roots(roots: &[PathBuf], opts: &WalkOptions) -> Vec<Vec<PathBuf>> {
return out;
};

// Every root is claimed up front, so a link inside one root pointing
// into another is skipped in favour of that root's own walk.
let mut builder = super::workspace_walk_builder(
first_root,
std::sync::Arc::clone(&opts.skip_dirs),
std::sync::Arc::clone(&opts.filters),
false,
super::LinkClaims::new(
distinct_roots.iter().map(|r| r.to_path_buf()),
opts.followed,
),
);
for dir in other_roots {
builder.add(dir);
Expand All @@ -1038,9 +1063,11 @@ fn walk_roots(roots: &[PathBuf], opts: &WalkOptions) -> Vec<Vec<PathBuf>> {
if file_type.is_some_and(|ft| ft.is_dir())
|| !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
// above keep this stat off the common path.
// `ignore` reports the target's type for a symlink it
// followed, and the symlink's own for one it did not
// (a claimed target, a broken link), so 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