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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **The editor stays responsive during fast typing in large files.** Editors send a burst of requests on every keystroke (completion, a documentation lookup for each suggestion, diagnostics, code lens, semantic highlighting, and more). The server processed only a few at a time, so during continuous typing the burst backed up until the server stopped answering anything at all, including the completion the user was waiting on, and it only recovered after a restart. Now the burst is processed concurrently and every expensive request runs off the main loop: diagnostics (which re-analyze the whole file on each edit) compute in the background instead of on the request that asked for them, so a diagnostic pull returns immediately and never blocks the threads that deliver completion and hover, and repeated whole-file requests (semantic highlighting, code lens, the document outline, folding, document links) are collapsed so a fast typist's superseded requests no longer pile up and monopolize the CPU. Completion and other requests keep coming back while you type.
- **Typing in a large file no longer pegs the CPU and stalls completion.** Semantic highlighting recomputed every token's position by rescanning the file from the beginning, so a large file took many seconds at full CPU to highlight. Editors request highlighting on every keystroke, so this ran continuously while typing and starved completion, hover, and other requests until they appeared to hang. Highlighting a large file is now effectively instant, and the same speedup applies to the document outline and code folding, which used the same per-position rescan.
- **The first use of a global helper function no longer stalls.** Functions defined in Composer "files" autoload entries and guarded by `if (! function_exists(...))` (such as Laravel's `app()`, `session()`, and `route()`) were parsed on demand the first time one was used, which meant the first completion, hover, or go-to-definition involving such a helper blocked while the server parsed every autoload file in turn. These files are now parsed up front during indexing, so the first lookup is instant.
- **Framework global helpers loaded outside Composer autoload are now indexed.** Some frameworks ship their global function aliases in a `*_global.php` file that sits beside an autoloaded helper file but is pulled in by the framework's own bootstrap rather than Composer's `files` autoload, so it never appears in the autoload manifest. CakePHP is the canonical case: helpers like `__()`, `h()`, and `env()` live in such a sibling and were reported as unknown functions on every call. These sibling helper files are now indexed too, so the globals resolve. Contributed by @dereuromark in https://github.com/AJenbo/phpantom_lsp/pull/175.
- **Classes defined inside conditional blocks are now fully resolved.** A class declared inside an `if`/`else` version guard (the Doctrine `ServiceEntityRepository` pattern, where a base class is defined differently per ORM version) was previously discovered by name only, so its parent and `@extends` generics were dropped. Such classes now carry their full inheritance, so member completion, hover, go-to-definition, and generic type resolution work both on them and inside their own methods. When the same class name appears in more than one branch, the first declaration wins. Contributed by @MrSrsen in https://github.com/PHPantom-dev/phpantom_lsp/pull/154.
- **Editing a base class stays responsive in large projects.** Changing a class that many others extend used to invalidate the resolved-class cache by rescanning every cached class on each edit, which briefly stalled large projects with deep class hierarchies. Invalidation now touches only the classes that actually depend on the edited one.
- **Completion latency stays flat during sustained fast typing.** Concurrent requests resolving the same classes contended on a single lock guarding the resolved-class cache, so completion latency crept upward for as long as a typing burst continued. The cache now allows parallel reads, so the many lookups in flight at once no longer serialize behind one another, and when a request first loads a vendor class the work to record it in the shared index is prepared before the index is locked, so other requests no longer wait on it.
Expand Down
103 changes: 102 additions & 1 deletion src/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
//! Composer JSON parsing is delegated to the [`mago_composer`] crate,
//! which provides typed Rust structs for the full `composer.json` schema.

use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -342,6 +342,54 @@ pub fn parse_autoload_files(workspace_root: &Path, vendor_dir: &str) -> Vec<Path
files
}

/// Discover global-helper sibling files that frameworks load through their
/// own bootstrap rather than Composer's `files` autoload.
///
/// Some frameworks split their global function aliases into a dedicated
/// file that sits *beside* an autoloaded `functions.php` but is itself
/// pulled in by the application bootstrap, not by Composer. CakePHP is the
/// canonical case: `vendor/cakephp/cakephp/src/Core/functions.php` is in
/// `autoload_files.php`, but the global aliases (`__`, `h`, `env`, `pr`,
/// ...) live in the sibling `Core/functions_global.php`, which the app
/// loads via `require CAKE . 'functions.php'` in `config/bootstrap.php`.
/// Because that sibling never appears in `autoload_files.php`, its global
/// functions are otherwise invisible to the indexer and every `__()` call
/// resolves to "unknown function".
///
/// For each file already listed in `autoload_files.php`, this returns any
/// sibling in the same directory whose name ends in `_global.php`, so the
/// caller can scan it alongside the real autoload files. The lookup is
/// anchored to existing autoload entries (one `read_dir` per unique
/// directory) rather than a blind walk of `vendor/`, keeping it cheap.
pub fn discover_global_sibling_files(autoload_files: &[PathBuf]) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut scanned_dirs = HashSet::new();
let mut seen_files = HashSet::new();

for file in autoload_files {
let Some(dir) = file.parent() else {
continue;
};
if !scanned_dirs.insert(dir.to_path_buf()) {
continue;
}
let Ok(entries) = fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.ends_with("_global.php") && path.is_file() && seen_files.insert(path.clone()) {
out.push(path);
}
}
}

out
}

// ── PSR-4 path abstraction ─────────────────────────────────────────
//
// `mago-composer` emits two structurally identical but nominally
Expand Down Expand Up @@ -972,6 +1020,59 @@ mod tests {

// ── detect_drupal_web_root ──────────────────────────────────────

#[test]
fn discovers_global_sibling_beside_autoload_file() {
// Mirrors CakePHP: an autoloaded `functions.php` sits next to a
// `functions_global.php` sibling that Composer never lists.
let dir = tempfile::tempdir().unwrap();
let core = dir.path().join("Core");
std::fs::create_dir_all(&core).unwrap();
let autoloaded = core.join("functions.php");
let sibling = core.join("functions_global.php");
std::fs::write(
&autoloaded,
"<?php\nnamespace Cake\\Core;\nfunction toBool() {}",
)
.unwrap();
std::fs::write(
&sibling,
"<?php\nif (!function_exists('__')) { function __() {} }",
)
.unwrap();

let found = discover_global_sibling_files(&[autoloaded]);
assert_eq!(found, vec![sibling]);
}

#[test]
fn discovers_global_sibling_scans_each_directory_once() {
// Two autoload entries in the same directory must not yield the
// sibling twice.
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let a = src.join("functions.php");
let b = src.join("helpers.php");
let sibling = src.join("functions_global.php");
std::fs::write(&a, "<?php").unwrap();
std::fs::write(&b, "<?php").unwrap();
std::fs::write(&sibling, "<?php").unwrap();

let found = discover_global_sibling_files(&[a, b]);
assert_eq!(found, vec![sibling]);
}

#[test]
fn discovers_global_sibling_returns_empty_without_sibling() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let autoloaded = src.join("functions.php");
std::fs::write(&autoloaded, "<?php").unwrap();

assert!(discover_global_sibling_files(&[autoloaded]).is_empty());
}

#[test]
fn drupal_not_detected_without_drupal_packages() {
let dir = tempfile::tempdir().unwrap();
Expand Down
9 changes: 9 additions & 0 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2342,8 +2342,17 @@ impl Backend {
let autoload_files = composer::parse_autoload_files(project_root, vendor_dir);
let autoload_count = autoload_files.len();

// Some frameworks (e.g. CakePHP) ship global function aliases in a
// `*_global.php` sibling that is loaded via the application
// bootstrap rather than Composer's `files` autoload, so it never
// appears in `autoload_files.php`. Seed those siblings too, so
// globals like `__()`/`h()` are indexed instead of resolving to
// "unknown function".
let sibling_globals = composer::discover_global_sibling_files(&autoload_files);

// Work queue + visited set for following require_once chains.
let mut file_queue: Vec<PathBuf> = autoload_files;
file_queue.extend(sibling_globals);
let mut visited: HashSet<PathBuf> = HashSet::new();

while let Some(file_path) = file_queue.pop() {
Expand Down