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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Completion candidates ranked by dependency provenance.** Class, function, and constant completions are now sorted by origin tier: project code first, then core/stub symbols, then explicit Composer dependencies (`require` / `require-dev`), then transitive vendor dependencies last. The provenance is inferred from `composer.json` and `installed.json` during indexing. Contributed by @calebdw.
- **`update` command.** A new `phpantom_lsp update` subcommand downloads the latest release from GitHub and replaces the current binary. Supports `--check` (dry run, exit code 1 if update available) and `--no-confirm` (for CI). Handles `.tar.gz` (Unix) and `.zip` (Windows) archives across all 6 supported platforms. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/194.
- **`array_map` infers the output element type from its callback.** The result of `array_map` now reflects what the callback actually returns instead of assuming the input element type is preserved. An explicit return type hint is honoured, including scalars like `string` or `int`, so `array_map(fn(Item $item): string => $item->id, $items)` produces `list<string>` rather than `list<Item>`. When the callback has no return type hint, the type is inferred from its body expression, so `array_map(fn($item) => $item->id, $items)` over a `list<Item>` also produces `list<string>`. Fixes #147. (contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/195)
- **Static methods complete on instance access.** Member completion after `->` now offers a class's static methods alongside its instance methods, since PHP lets you call a static method through an instance (`$obj->make()`). Static properties remain excluded, as they are only reachable via `::`. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/174.
Expand Down
175 changes: 152 additions & 23 deletions src/classmap_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,16 @@ pub struct ScanResult {
pub struct WorkspaceScanResult {
/// FQN → file path for classes, interfaces, traits, and enums.
pub classmap: HashMap<String, PathBuf>,
/// FQN → completion origin tier.
pub(crate) class_origins: HashMap<String, crate::ClassCompletionOrigin>,
/// FQN → file path for standalone functions.
pub function_index: HashMap<String, PathBuf>,
/// FQN → completion origin tier for standalone functions.
pub(crate) function_origins: HashMap<String, crate::ClassCompletionOrigin>,
/// Constant name → file path for `define()` and top-level `const`.
pub constant_index: HashMap<String, PathBuf>,
/// Constant name → completion origin tier.
pub(crate) constant_origins: HashMap<String, crate::ClassCompletionOrigin>,
}

// ─── Public API ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -168,15 +174,22 @@ pub fn scan_directories(
dirs: &[PathBuf],
vendor_dir_paths: &[PathBuf],
) -> HashMap<String, PathBuf> {
let mut php_files: Vec<PathBuf> = Vec::new();
let mut php_files: Vec<(PathBuf, crate::ClassCompletionOrigin)> = Vec::new();
let skip_paths = HashSet::new();
for dir in dirs {
if !dir.is_dir() {
continue;
}
collect_php_files(dir, vendor_dir_paths, &skip_paths, &mut php_files);
collect_php_files(
dir,
vendor_dir_paths,
&skip_paths,
&mut php_files,
crate::ClassCompletionOrigin::Project,
);
}
scan_files_parallel_classes(&php_files)
let paths: Vec<PathBuf> = php_files.into_iter().map(|(p, _)| p).collect();
scan_files_parallel_classes(&paths)
}

/// Build a classmap by scanning all `.php` files under the given
Expand Down Expand Up @@ -216,7 +229,7 @@ pub fn scan_psr4_directories_with_skip(
skip_paths: &HashSet<PathBuf>,
) -> HashMap<String, PathBuf> {
// ── PSR-4 directories: collect (path, expected_fqn) pairs ───────
let mut psr4_files: Vec<(PathBuf, String)> = Vec::new();
let mut psr4_files: Vec<(PathBuf, String, crate::ClassCompletionOrigin)> = Vec::new();
for (prefix, base_path) in psr4 {
if !base_path.is_dir() {
continue;
Expand All @@ -227,21 +240,31 @@ pub fn scan_psr4_directories_with_skip(
vendor_dir_paths,
skip_paths,
&mut psr4_files,
crate::ClassCompletionOrigin::Project,
);
}

// ── Plain classmap directories ──────────────────────────────────
let mut plain_files: Vec<PathBuf> = Vec::new();
let mut plain_files: Vec<(PathBuf, crate::ClassCompletionOrigin)> = Vec::new();
for dir in classmap_dirs {
if !dir.is_dir() {
continue;
}
collect_php_files(dir, vendor_dir_paths, skip_paths, &mut plain_files);
collect_php_files(
dir,
vendor_dir_paths,
skip_paths,
&mut plain_files,
crate::ClassCompletionOrigin::Project,
);
}

// ── Scan all files in parallel ──────────────────────────────────
let mut classmap = scan_files_parallel_psr4(&psr4_files);
let plain_classmap = scan_files_parallel_classes(&plain_files);
let psr4_pairs: Vec<(PathBuf, String)> =
psr4_files.into_iter().map(|(p, s, _)| (p, s)).collect();
let mut classmap = scan_files_parallel_psr4(&psr4_pairs);
let plain_paths: Vec<PathBuf> = plain_files.into_iter().map(|(p, _)| p).collect();
let plain_classmap = scan_files_parallel_classes(&plain_paths);
for (fqcn, path) in plain_classmap {
classmap.entry(fqcn).or_insert(path);
}
Expand All @@ -255,7 +278,58 @@ pub fn scan_psr4_directories_with_skip(
/// package's autoload directories. Supports PSR-4 and classmap
/// entries.
pub fn scan_vendor_packages(workspace_root: &Path, vendor_dir: &str) -> WorkspaceScanResult {
scan_vendor_packages_with_skip(workspace_root, vendor_dir, &HashSet::new())
scan_vendor_packages_with_skip(workspace_root, vendor_dir, &HashSet::new(), &HashSet::new())
}

pub(crate) fn vendor_package_roots(
workspace_root: &Path,
vendor_dir: &str,
explicit_deps: &HashSet<String>,
) -> Vec<(PathBuf, crate::ClassCompletionOrigin)> {
let vendor_path = workspace_root.join(vendor_dir);
let installed_path = vendor_path.join("composer").join("installed.json");
let Ok(content) = std::fs::read_to_string(&installed_path) else {
return Vec::new();
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
return Vec::new();
};
let packages = if let Some(arr) = json.as_array() {
arr.as_slice()
} else if let Some(pkgs) = json.get("packages").and_then(|p| p.as_array()) {
pkgs.as_slice()
} else {
return Vec::new();
};
let composer_dir = vendor_path.join("composer");
let mut roots = Vec::new();
for package in packages {
let origin = package
.get("name")
.and_then(|n| n.as_str())
.map(|name| {
if explicit_deps.contains(name) {
crate::ClassCompletionOrigin::VendorExplicit
} else {
crate::ClassCompletionOrigin::VendorTransitive
}
})
.unwrap_or(crate::ClassCompletionOrigin::VendorTransitive);
let pkg_path =
if let Some(install_path) = package.get("install-path").and_then(|p| p.as_str()) {
composer_dir.join(install_path)
} else if let Some(pkg_name) = package.get("name").and_then(|n| n.as_str()) {
vendor_path.join(pkg_name)
} else {
continue;
};
let pkg_path = pkg_path.canonicalize().unwrap_or(pkg_path);
if pkg_path.is_dir() {
roots.push((pkg_path, origin));
}
}
roots.sort_by_key(|(p, _)| std::cmp::Reverse(p.components().count()));
roots
}

/// Like [`scan_vendor_packages`] but accepts a set of absolute file
Expand All @@ -265,6 +339,7 @@ pub fn scan_vendor_packages_with_skip(
workspace_root: &Path,
vendor_dir: &str,
skip_paths: &HashSet<PathBuf>,
explicit_deps: &HashSet<String>,
) -> WorkspaceScanResult {
let vendor_path = workspace_root.join(vendor_dir);
let installed_path = vendor_path.join("composer").join("installed.json");
Expand Down Expand Up @@ -296,10 +371,21 @@ pub fn scan_vendor_packages_with_skip(

// Phase 1: collect all file paths from all packages (sequential
// walk, but no file I/O beyond stat calls).
let mut psr4_files: Vec<(PathBuf, String)> = Vec::new();
let mut plain_files: Vec<PathBuf> = Vec::new();
let mut psr4_files: Vec<(PathBuf, String, crate::ClassCompletionOrigin)> = Vec::new();
let mut plain_files: Vec<(PathBuf, crate::ClassCompletionOrigin)> = Vec::new();

for package in packages {
let origin = package
.get("name")
.and_then(|n| n.as_str())
.map(|name| {
if explicit_deps.contains(name) {
crate::ClassCompletionOrigin::VendorExplicit
} else {
crate::ClassCompletionOrigin::VendorTransitive
}
})
.unwrap_or(crate::ClassCompletionOrigin::VendorTransitive);
// Locate the package on disk. Composer 2's installed.json
// includes an `install-path` field that is relative to the
// `vendor/composer/` directory. This is the authoritative
Expand Down Expand Up @@ -349,6 +435,7 @@ pub fn scan_vendor_packages_with_skip(
&vendor_dir_paths,
skip_paths,
&mut psr4_files,
origin,
);
}
}
Expand All @@ -372,7 +459,7 @@ pub fn scan_vendor_packages_with_skip(
{
has_custom_autoloader = true;
}
plain_files.push(file);
plain_files.push((file, origin));
}
}
}
Expand All @@ -383,7 +470,13 @@ pub fn scan_vendor_packages_with_skip(
// do a full scan of the package directory to discover all
// classes it provides.
if has_custom_autoloader {
collect_php_files(&pkg_path, &vendor_dir_paths, skip_paths, &mut plain_files);
collect_php_files(
&pkg_path,
&vendor_dir_paths,
skip_paths,
&mut plain_files,
origin,
);
}
}

Expand All @@ -393,23 +486,57 @@ pub fn scan_vendor_packages_with_skip(
if let Some(dir_str) = entry.as_str() {
let dir = pkg_path.join(dir_str);
if dir.is_dir() {
collect_php_files(&dir, &vendor_dir_paths, skip_paths, &mut plain_files);
collect_php_files(
&dir,
&vendor_dir_paths,
skip_paths,
&mut plain_files,
origin,
);
} else if dir.is_file()
&& dir.extension().is_some_and(|ext| ext == "php")
&& !skip_paths.contains(&dir)
{
plain_files.push(dir);
plain_files.push((dir, origin));
}
}
}
}
}

// Phase 2: scan all collected files in parallel
let mut all_files: Vec<PathBuf> = psr4_files.into_iter().map(|(path, _)| path).collect();
all_files.extend(plain_files);

scan_files_parallel_full(&all_files)
let mut all_files: Vec<PathBuf> = psr4_files.iter().map(|(path, _, _)| path.clone()).collect();
all_files.extend(plain_files.iter().map(|(path, _)| path.clone()));

let mut result = scan_files_parallel_full(&all_files);
let mut class_origins = HashMap::new();
let mut function_origins = HashMap::new();
let mut constant_origins = HashMap::new();
for (path, expected_fqn, origin) in psr4_files {
if let Ok(content) = std::fs::read(&path) {
for fqn in scan_content(&content) {
if fqn == expected_fqn {
class_origins.entry(fqn).or_insert(origin);
}
}
}
}
for (path, origin) in plain_files {
let symbols = scan_file_full(&path);
for fqn in symbols.classes {
class_origins.entry(fqn).or_insert(origin);
}
for fqn in symbols.functions {
function_origins.entry(fqn).or_insert(origin);
}
for name in symbols.constants {
constant_origins.entry(name).or_insert(origin);
}
}
result.class_origins = class_origins;
result.function_origins = function_origins;
result.constant_origins = constant_origins;
result
}

/// Scan all `.php` files under the workspace root using the PSR-4
Expand Down Expand Up @@ -1750,7 +1877,8 @@ fn collect_php_files(
dir: &Path,
vendor_dir_paths: &[PathBuf],
skip_paths: &HashSet<PathBuf>,
out: &mut Vec<PathBuf>,
out: &mut Vec<(PathBuf, crate::ClassCompletionOrigin)>,
origin: crate::ClassCompletionOrigin,
) {
use ignore::WalkBuilder;

Expand Down Expand Up @@ -1779,7 +1907,7 @@ fn collect_php_files(
if path.is_file() && path.extension().is_some_and(|ext| ext == "php") {
let owned = path.to_path_buf();
if !skip_paths.contains(&owned) {
out.push(owned);
out.push((owned, origin));
}
}
}
Expand All @@ -1795,7 +1923,8 @@ fn collect_psr4_php_files(
namespace_prefix: &str,
vendor_dir_paths: &[PathBuf],
skip_paths: &HashSet<PathBuf>,
out: &mut Vec<(PathBuf, String)>,
out: &mut Vec<(PathBuf, String, crate::ClassCompletionOrigin)>,
origin: crate::ClassCompletionOrigin,
) {
use ignore::WalkBuilder;

Expand Down Expand Up @@ -1841,7 +1970,7 @@ fn collect_psr4_php_files(
// Convert path separators to namespace separators
let expected_fqn = format!("{}{}", namespace_prefix, stem.replace('/', "\\"));

out.push((owned, expected_fqn));
out.push((owned, expected_fqn, origin));
}
}
}
Expand Down
Loading
Loading