From 14e63242a128b5829a671b57f72f965da4c4a421 Mon Sep 17 00:00:00 2001 From: Krist Ponpairin Date: Wed, 5 Aug 2026 16:39:02 +0700 Subject: [PATCH 1/3] fix(laravel): discover Artisan commands registered via withCommands() The Artisan command index only treated classes whose short name ends in `Command` or files under `Console/Commands/` as candidates, so commands registered from arbitrary directories (e.g. `withCommands()` in bootstrap/app.php pointing at app/Actions/Sync) were never indexed. The LSP then reported `$this->call('sync:forma-projects')` as "Unknown command" even though `php artisan list` registers it. Three gaps closed: - Candidate discovery now also scans every non-vendor project class, letting scan_command_file's extends-Command / attribute checks decide. - The byte pre-filter (build + incremental refresh paths) now recognizes the Laravel 11+ `#[Signature]` attribute, which previously slipped past the lowercase `signature` token check. - command_from_class and command_signature_at_offset now read the `#[Signature('...')]` attribute as the command name / signature, so commands declared the modern way are indexed and their options validate. The analyze (CLI) path already built the index (added in 6dc9a7a1), so this makes phpantom_lsp analyze and the LSP validate command names against the same, complete command set. --- src/server.rs | 31 ++++++++-- src/virtual_members/laravel/commands.rs | 53 +++++++++++++++++- src/virtual_members/laravel/commands_tests.rs | 56 +++++++++++++++++++ 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/server.rs b/src/server.rs index 5a839b51e..618e94910 100644 --- a/src/server.rs +++ b/src/server.rs @@ -2050,11 +2050,15 @@ impl Backend { /// [`laravel_commands`](Backend::laravel_commands) index. /// /// Candidate files are those declaring a class whose short name ends in - /// `Command` (the near-universal Laravel/Symfony convention) or which - /// live under a `Console/Commands/` directory (so project commands with - /// unconventional names are still found). Each candidate is read once, - /// gated by a cheap byte pre-filter for a `signature`/`AsCommand`/`$name` - /// declaration before parsing, then scanned by + /// `Command` (the near-universal Laravel/Symfony convention), which live + /// under a `Console/Commands/` directory (so project commands with + /// unconventional names are still found), or any other non-vendor + /// project class (so commands registered via `withCommands()` in + /// `bootstrap/app.php` from arbitrary directories — e.g. + /// `app/Actions/Sync` — are indexed too). Each candidate is read once, + /// gated by a cheap byte pre-filter for a + /// `signature`/`Signature`/`AsCommand`/`$name` declaration before + /// parsing, then scanned by /// [`scan_command_file`](crate::virtual_members::laravel::scan_command_file). pub(crate) fn build_laravel_command_index(&self) { let mut candidate_uris: std::collections::HashSet = @@ -2065,6 +2069,14 @@ impl Backend { let short = fqn.rsplit('\\').next().unwrap_or(fqn); if short.ends_with("Command") || uri.contains("/Console/Commands/") { candidate_uris.insert(uri.to_string()); + } else if !uri.contains("/vendor/") { + // A project class registered as a command outside the + // conventional locations — e.g. `withCommands()` in + // bootstrap/app.php pointing at an arbitrary directory + // such as app/Actions/Sync. scan_command_file's + // extends-Command / attribute checks decide whether the + // file really declares a command. + candidate_uris.insert(uri.to_string()); } } } @@ -2076,6 +2088,7 @@ impl Backend { }; let bytes = content.as_bytes(); let looks_like_command = memchr::memmem::find(bytes, b"signature").is_some() + || memchr::memmem::find(bytes, b"Signature").is_some() || memchr::memmem::find(bytes, b"AsCommand").is_some() || memchr::memmem::find(bytes, b"$name").is_some(); if !looks_like_command { @@ -2200,7 +2213,12 @@ impl Backend { return; } let was_contributor = self.laravel_commands.read().has_uri(uri); - let looks_like_command_file = uri.ends_with("Command.php") || uri.contains("/Console/"); + // Same candidate rule as the full build: a contributor file, a + // conventionally-named command file, or any non-vendor project file + // (commands registered via `withCommands()` may live anywhere). + let looks_like_command_file = uri.ends_with("Command.php") + || uri.contains("/Console/") + || (!uri.contains("/vendor/") && uri.ends_with(".php")); if !was_contributor && !looks_like_command_file { return; } @@ -2210,6 +2228,7 @@ impl Backend { .filter(|content| { let bytes = content.as_bytes(); memchr::memmem::find(bytes, b"signature").is_some() + || memchr::memmem::find(bytes, b"Signature").is_some() || memchr::memmem::find(bytes, b"AsCommand").is_some() || memchr::memmem::find(bytes, b"$name").is_some() }) diff --git a/src/virtual_members/laravel/commands.rs b/src/virtual_members/laravel/commands.rs index ff7fcc32d..d19291172 100644 --- a/src/virtual_members/laravel/commands.rs +++ b/src/virtual_members/laravel/commands.rs @@ -2,8 +2,9 @@ //! //! Laravel encodes console commands as classes extending //! `Illuminate\Console\Command`. Each command declares a name through one -//! of three surfaces, all statically recoverable from source: +//! of four surfaces, all statically recoverable from source: //! +//! - `#[Signature('app:sync {user} {--queue}')]` (Laravel 11+ attribute form) //! - `protected $signature = 'app:sync {user} {--queue}';` //! - `protected $name = 'app:sync';` //! - `#[AsCommand(name: 'app:sync')]` @@ -476,7 +477,23 @@ fn command_from_class( }); } - // 2. $signature = '...'. + // 2. #[Signature('name {--opt}')] — the Laravel 11+ attribute form of + // `$signature`. + if let Some((sig, offset)) = signature_attribute_value(class, content) { + let signature = parse_signature(sig); + if signature.name.is_empty() { + return None; + } + return Some(CommandEntry { + name: signature.name.clone(), + fqn, + uri: uri.to_string(), + name_offset: offset, + signature, + }); + } + + // 3. $signature = '...'. if let Some((sig, offset)) = signature_property_value(class, content) { let signature = parse_signature(sig); if signature.name.is_empty() { @@ -491,7 +508,7 @@ fn command_from_class( }); } - // 3. $name = '...'. + // 4. $name = '...'. if let Some((name, offset)) = string_property_value(class, "name", content) { if name.is_empty() { return None; @@ -536,6 +553,35 @@ fn as_command_name(class: &Class<'_>, content: &str) -> Option<(String, u32)> { None } +/// The string value of the first `#[Signature('...')]` attribute — the +/// Laravel 11+ attribute form of `$signature` — plus the inner byte offset of +/// the literal. +fn signature_attribute_value<'c>( + class: &Class<'_>, + content: &'c str, +) -> Option<(&'c str, u32)> { + for list in class.attribute_lists.iter() { + for attr in list.attributes.iter() { + if last_segment(attr.name.value()) != b"Signature" { + continue; + } + let Some(arg_list) = attr.argument_list.as_ref() else { + continue; + }; + let Some(first) = arg_list.arguments.first() else { + continue; + }; + let Some(expr) = first.value() else { + continue; + }; + if let Some((value, start, _)) = extract_string_literal(expr, content) { + return Some((value, start as u32)); + } + } + } + None +} + /// The `$signature` property's string value and the inner byte offset of the /// literal. fn signature_property_value<'c>(class: &Class<'_>, content: &'c str) -> Option<(&'c str, u32)> { @@ -619,6 +665,7 @@ fn find_signature_at_offset( if offset >= start && offset <= end && let Some((sig, _)) = signature_property_value(class, content) + .or_else(|| signature_attribute_value(class, content)) { *out = Some(parse_signature(sig)); } diff --git a/src/virtual_members/laravel/commands_tests.rs b/src/virtual_members/laravel/commands_tests.rs index 90663c9a5..76537eede 100644 --- a/src/virtual_members/laravel/commands_tests.rs +++ b/src/virtual_members/laravel/commands_tests.rs @@ -178,6 +178,62 @@ class User assert!(entries.is_empty()); } +#[test] +fn scans_signature_attribute_command_class() { + let content = r#"option('force'); + return self::SUCCESS; + } +} +"#; + let offset = content.find("option").expect("option call present"); + let sig = command_signature_at_offset(content, offset).expect("enclosing signature"); + assert_eq!(sig.name, "sync:all"); + assert!(sig.option("force").is_some()); + assert!(sig.option("months").is_some()); +} + #[test] fn index_dedupes_and_looks_up() { let mut index = LaravelCommandIndex::default(); From 23cdd891e9096ecbddfc42d155ad8b66fbba3bae Mon Sep 17 00:00:00 2001 From: Krist Ponpairin Date: Wed, 5 Aug 2026 16:49:00 +0700 Subject: [PATCH 2/3] feat(laravel): index command aliases from #[Aliases] / aliases: attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands can answer to alternative names declared via #[Aliases([...])], the aliases: argument of #[Signature] / #[AsCommand], or Symfony's #[AsCommand(aliases: ...)]. phpantom ignored all of them, so calling a valid alias (->call('cache:clean') for a cache:clear command) was reported as "Unknown command" — the same false-positive class as the withCommands() discovery gap. - CommandEntry gains an aliases: Vec field; LaravelCommandIndex indexes each alias alongside the primary name (get()/all_names() resolve aliases), so the unknown-command diagnostic accepts them. - command_aliases() recovers aliases from #[Aliases([...])] (which wins, mirroring configureFromAttributes) and the aliases: named/positional argument of #[Signature] / #[AsCommand]. --- src/virtual_members/laravel/commands.rs | 116 ++++++++++++++++++ src/virtual_members/laravel/commands_tests.rs | 67 ++++++++++ 2 files changed, 183 insertions(+) diff --git a/src/virtual_members/laravel/commands.rs b/src/virtual_members/laravel/commands.rs index d19291172..d6fa51d07 100644 --- a/src/virtual_members/laravel/commands.rs +++ b/src/virtual_members/laravel/commands.rs @@ -9,6 +9,10 @@ //! - `protected $name = 'app:sync';` //! - `#[AsCommand(name: 'app:sync')]` //! +//! Command aliases (extra names a command answers to) are recovered from +//! `#[Aliases([...])]` and the `aliases:` argument of `#[Signature]` / +//! `#[AsCommand]`, and are indexed alongside the primary name. +//! //! This module scans project and vendor command classes for those literals //! (see [`scan_command_file`]), parses the `$signature` grammar into //! arguments and options ([`parse_signature`]), and stores everything in a @@ -80,6 +84,9 @@ impl CommandSignature { pub(crate) struct CommandEntry { /// The command name, e.g. `app:sync` or `migrate`. pub name: String, + /// Alternative names the command answers to (`#[Aliases]`, + /// `#[Signature(aliases:)]`, `#[AsCommand(aliases:)]`). + pub aliases: Vec, /// Best-effort fully-qualified class name (`App\Console\Commands\Sync`). pub fqn: Option, /// URI of the file declaring the command. @@ -128,6 +135,11 @@ impl LaravelCommandIndex { by_name .entry(entry.name.clone()) .or_insert_with(|| entry.clone()); + for alias in &entry.aliases { + by_name + .entry(alias.clone()) + .or_insert_with(|| entry.clone()); + } } } self.by_name = by_name; @@ -460,6 +472,11 @@ fn command_from_class( None => Some(bytes_to_string(class.name.value)), }; + // Alias names from #[Aliases([...])] (which wins, mirroring + // Illuminate\Console\Command::configureFromAttributes) or the + // `aliases:` argument of #[Signature] / #[AsCommand]. + let aliases = command_aliases(class); + // 1. #[AsCommand(name: '...')] / #[AsCommand('...')]. if let Some((name, offset)) = as_command_name(class, content) { let signature = signature_property_value(class, content) @@ -470,6 +487,7 @@ fn command_from_class( }); return Some(CommandEntry { name, + aliases: aliases.clone(), fqn, uri: uri.to_string(), name_offset: offset, @@ -486,6 +504,7 @@ fn command_from_class( } return Some(CommandEntry { name: signature.name.clone(), + aliases: aliases.clone(), fqn, uri: uri.to_string(), name_offset: offset, @@ -501,6 +520,7 @@ fn command_from_class( } return Some(CommandEntry { name: signature.name.clone(), + aliases: aliases.clone(), fqn, uri: uri.to_string(), name_offset: offset, @@ -515,6 +535,7 @@ fn command_from_class( } return Some(CommandEntry { name: name.clone(), + aliases: aliases.clone(), fqn, uri: uri.to_string(), name_offset: offset, @@ -582,6 +603,101 @@ fn signature_attribute_value<'c>( None } +/// Collect the command's alias names, mirroring +/// `Illuminate\Console\Command::configureFromAttributes`: a standalone +/// `#[Aliases([...])]` attribute wins; otherwise the `aliases:` argument of +/// `#[Signature]` / `#[AsCommand]` is used. +fn command_aliases(class: &Class<'_>) -> Vec { + let mut aliases = Vec::new(); + for list in class.attribute_lists.iter() { + for attr in list.attributes.iter() { + let segment = last_segment(attr.name.value()); + match segment { + // #[Aliases(['a', 'b'])] — the single positional argument. + b"Aliases" => { + return attr + .argument_list + .as_ref() + .and_then(|args| args.arguments.first()) + .and_then(|arg| arg.value()) + .and_then(|expr| string_array_literal(expr)) + .unwrap_or_default(); + } + // `aliases:` named argument (positional index for + // Signature(sig, aliases) / AsCommand(name, desc, aliases)). + b"Signature" | b"AsCommand" => { + let Some(arg_list) = attr.argument_list.as_ref() else { + continue; + }; + let positional_index = if segment == b"Signature" { 1 } else { 2 }; + let mut positional = 0usize; + let mut found: Option> = None; + for arg in arg_list.arguments.iter() { + match arg { + PartialArgument::Named(named) => { + if bytes_to_string(named.name.value) == "aliases" { + found = string_array_literal(named.value); + } + } + PartialArgument::Positional(_) => { + if positional == positional_index && found.is_none() { + found = arg + .value() + .and_then(|expr| string_array_literal(expr)); + } + positional += 1; + } + PartialArgument::NamedPlaceholder(_) + | PartialArgument::Placeholder(_) + | PartialArgument::VariadicPlaceholder(_) => {} + } + } + if let Some(list) = found + && !list.is_empty() + { + aliases = list; + } + } + _ => {} + } + } + } + aliases +} + +/// Collect the string literals of a `['a', 'b']` array expression (or a bare +/// string literal, which Laravel also accepts). +fn string_array_literal(expr: &Expression<'_>) -> Option> { + match expr { + Expression::Literal(Literal::String(s)) => s.value.map(|v| vec![bytes_to_string(v)]), + Expression::Array(arr) => Some(collect_array_strings(arr.elements.iter())), + Expression::LegacyArray(arr) => Some(collect_array_strings(arr.elements.iter())), + _ => None, + } +} + +fn collect_array_strings<'a, 'b>( + elements: impl IntoIterator>, +) -> Vec +where + 'b: 'a, +{ + elements + .into_iter() + .filter_map(|el| match el { + ArrayElement::Value(v) => Some(v.value), + _ => None, + }) + .filter_map(|inner| { + if let Expression::Literal(Literal::String(s)) = inner { + s.value.map(bytes_to_string) + } else { + None + } + }) + .collect() +} + /// The `$signature` property's string value and the inner byte offset of the /// literal. fn signature_property_value<'c>(class: &Class<'_>, content: &'c str) -> Option<(&'c str, u32)> { diff --git a/src/virtual_members/laravel/commands_tests.rs b/src/virtual_members/laravel/commands_tests.rs index 76537eede..170277743 100644 --- a/src/virtual_members/laravel/commands_tests.rs +++ b/src/virtual_members/laravel/commands_tests.rs @@ -234,6 +234,73 @@ class SyncAll extends Command assert!(sig.option("months").is_some()); } +#[test] +fn indexes_standalone_aliases_attribute() { + let content = r#" Date: Wed, 5 Aug 2026 16:53:47 +0700 Subject: [PATCH 3/3] style: apply cargo fmt --- src/virtual_members/laravel/commands.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/virtual_members/laravel/commands.rs b/src/virtual_members/laravel/commands.rs index d6fa51d07..b6f73fa3d 100644 --- a/src/virtual_members/laravel/commands.rs +++ b/src/virtual_members/laravel/commands.rs @@ -577,10 +577,7 @@ fn as_command_name(class: &Class<'_>, content: &str) -> Option<(String, u32)> { /// The string value of the first `#[Signature('...')]` attribute — the /// Laravel 11+ attribute form of `$signature` — plus the inner byte offset of /// the literal. -fn signature_attribute_value<'c>( - class: &Class<'_>, - content: &'c str, -) -> Option<(&'c str, u32)> { +fn signature_attribute_value<'c>(class: &Class<'_>, content: &'c str) -> Option<(&'c str, u32)> { for list in class.attribute_lists.iter() { for attr in list.attributes.iter() { if last_segment(attr.name.value()) != b"Signature" { @@ -641,9 +638,7 @@ fn command_aliases(class: &Class<'_>) -> Vec { } PartialArgument::Positional(_) => { if positional == positional_index && found.is_none() { - found = arg - .value() - .and_then(|expr| string_array_literal(expr)); + found = arg.value().and_then(|expr| string_array_literal(expr)); } positional += 1; }