diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2bc09fee5..27cea5134 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Laravel string key completion (route, config, view, trans).** Typing inside the first string argument of `route()`, `to_route()`, `config()`, `Config::get()`, `view()`, `View::make()`, `__()`, `trans()`, `Lang::get()`, and related helpers now offers autocompletion from the project's actual route names, config keys, view templates, and translation keys. Route names are collected from `routes/*.php` (including group prefixes and `Route::group([], __DIR__ . '/sub.php')` file includes), `Route::resource()` and `Route::apiResource()` generate conventional named routes (`index`, `create`, `store`, `show`, `edit`, `update`, `destroy`) respecting `->only()` and `->except()` modifiers, config keys from `config/*.php` array declarations, view names from `resources/views/` file paths, and translation keys from `lang/` files. Go-to-definition on route names also follows file includes and resolves resource routes. Laravel container attributes (`#[Config('key')]`, `#[Database('conn')]`, `#[Cache('store')]`, `#[Log('channel')]`, `#[Storage('disk')]`, `#[Auth('guard')]`) offer completion from the relevant config sub-keys (e.g. `#[Database('')]` shows database connection names from `config/database.php`). Facade methods like `Auth::guard()`, `DB::connection()`, `Cache::store()`, `Log::channel()`, `Storage::disk()`, and the `auth()` helper also complete from their respective config sub-keys. Contributed by @calebdw. - **Hover for Laravel string keys.** Hovering over a route name, config key, view name, or translation key string now shows the key kind, the key value, and where it's defined (e.g. `routes/web/ems.php`, `config/app.php`). Contributed by @calebdw. - **Diagnostics for invalid Laravel string keys.** A typo in `route('dashbaord')`, `config('app.naem')`, `view('layouts.ap')`, or `__('auth.failedd')` now produces a warning: `Unknown route: 'dashbaord'`. Only plain string literals are flagged; dynamic keys with variables are ignored. Contributed by @calebdw. +- **Artisan command names and signature strings.** Command names declared by a command class's `$signature`, `$name`, or `#[AsCommand]` attribute are now recovered from project and vendor command classes, so referencing one as a string completes, navigates to the declaring class, hovers with its arguments and options, and flags unknown names. This covers `Artisan::call()`, `Artisan::queue()`, `Schedule::command()`, and `$this->call()` / `$this->callSilently()` inside a command. The `$signature` grammar (`{user}`, `{user?}`, `{--queue=}`, arrays, defaults, shortcuts, and ` : ` descriptions) is parsed so that, inside a command, `$this->argument('user')` and `$this->option('queue')` complete and hover against that command's own signature and unknown parameter names are flagged, and the parameter array of `Artisan::call('cmd', [...])` completes the target command's argument and `--option` keys. Contributed by @shuvroroy (#274). - **Laravel model factory relationship methods.** Eloquent factories now offer the dynamic `has{Relationship}()` and `for{Relationship}()` methods that Laravel resolves through `Factory::__call()`, one per relationship on the associated model, plus `trashed()` when the model uses `SoftDeletes`. They complete, hover, and resolve, and because each returns the factory the fluent chain continues, so `Post::factory()->hasComments(3)->create()` still resolves to the model. The model is derived from the factory naming convention, so no `@extends Factory` generic is required. Contributed by @shuvroroy (#260). - **Eloquent `$pivot` attribute on many-to-many related models.** Models that are the target of a `belongsToMany`/`morphToMany` relationship now expose a `$pivot` property, so accessing the intermediate row (e.g. `$user->roles->first()->pivot`) completes, hovers, and resolves. The pivot type is taken from the relationship's `TPivotModel` generic (`BelongsToMany`), falling back to a `->using(CustomPivot::class)` call in the relationship body and then to the base `\Illuminate\Database\Eloquent\Relations\Pivot`; a declared `pivot` property still takes precedence. Only models actually reached through such a relationship gain the attribute, and the `->withPivot('col', …)` columns and custom pivot class are also shown when hovering the relationship. Contributed by @shuvroroy (#266). - **Laravel `Macroable::mixin()` registrations are recognized.** A `Str::mixin(new StrMixin())` or `Collection::mixin(CollectionMixin::class)` call in a service provider now contributes one macro per public/protected method of the mixin class, taking the signature of the closure that method returns, so those methods autocomplete, hover, resolve, and type-check on the target class just like a `Target::macro(...)` registration. Mixins registered through a facade also attach to the facade's concrete container-bound class, and go-to-definition on a mixed-in method jumps to the mixin method's own declaration. Editing the mixin class (for example adding a helper method) refreshes the recognized macros. Contributed by @shuvroroy (#256). diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index b25c9c2aa..640381267 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -819,26 +819,6 @@ Each family gets the full string-kind treatment for free once wired as a `LaravelStringKey`: completion, go-to-definition (jump to the config entry), hover (L16), diagnostics (L14), and references. -#### L33. Artisan command and signature strings - -**Impact: Low-Medium · Effort: Medium** - -Two related string surfaces, both statically recoverable: - -- **Command names.** `Artisan::call('app:sync')`, `Artisan::queue()`, - `$this->call()`/`callSilently()` in commands, and `Schedule::command()` - name a command declared by a `Command` subclass's `$signature` (or - `$name`) property, or an `#[AsCommand]` attribute. Scan project and - vendor command classes for literal signatures; complete, navigate to - the class, and flag unknown names. -- **Own arguments and options.** Inside a command class, - `$this->argument('user')` / `$this->option('queue')` name segments of - that same class's `$signature`. Parse the signature grammar - (`{user}`, `{user?}`, `{--queue=}`, arrays, defaults, descriptions) - once and complete/validate against it; hover shows the segment's - description. The parsed parameter list also enables array-key - completion for `Artisan::call('app:sync', [...])` second arguments. - #### L36. Container binding registrations from service providers **Impact: Medium · Effort: Medium** diff --git a/examples/laravel/README.md b/examples/laravel/README.md index 36512e593..987289fd8 100644 --- a/examples/laravel/README.md +++ b/examples/laravel/README.md @@ -12,6 +12,7 @@ features against a real Laravel installation. - **Route navigation.** Go-to-definition for `route('home')` (resolves to `->name('home')` in route files). - **Controller action navigation.** Go-to-definition, hover, rename, references, and completion for route action strings in `[Controller::class, 'method']` callables and `Route::controller(...)->group(...)` routes. - **Translation navigation.** Go-to-definition for `__('messages.welcome')`, `trans('auth.failed')`, and `trans_choice(...)` (resolves to `lang/` PHP files). +- **Artisan command names & signatures.** Completion, go-to-definition, hover, and unknown-name diagnostics for command names in `Artisan::call(...)`, `Artisan::queue(...)`, `Schedule::command(...)`, and `$this->call(...)` (resolves to the command class declared by `$signature` / `$name` / `#[AsCommand]`). Inside a command, `$this->argument(...)` / `$this->option(...)` complete, hover, and validate against that command's own signature, and the parameter array of `Artisan::call('cmd', [...])` completes its argument and `--option` keys. See `Demo::artisanCommands()` and `app/Console/Commands/`. - **Blade template intelligence.** Variable completion and hover in `{{ }}` expressions (shown as `e()` calls), go-to-definition on `@include`/`@extends` view references, `@forelse`/`@empty` directives, implicit `$loop` variable in `@foreach`/`@forelse`, implicit `$message` in `@error`, implicit `$value` in `@session`, `@verbatim` block handling, and standalone `@var` docblocks for type narrowing. ## Getting started diff --git a/examples/laravel/app/Console/Commands/GenerateReportCommand.php b/examples/laravel/app/Console/Commands/GenerateReportCommand.php new file mode 100644 index 000000000..63a4c88de --- /dev/null +++ b/examples/laravel/app/Console/Commands/GenerateReportCommand.php @@ -0,0 +1,31 @@ +option('format'); + + $this->info("Generating report as {$format}"); + + return self::SUCCESS; + } +} diff --git a/examples/laravel/app/Console/Commands/SyncBakeryCommand.php b/examples/laravel/app/Console/Commands/SyncBakeryCommand.php new file mode 100644 index 000000000..bb4b5a7c5 --- /dev/null +++ b/examples/laravel/app/Console/Commands/SyncBakeryCommand.php @@ -0,0 +1,52 @@ +argument(...)` / `$this->option(...)` below complete and hover + * against this same signature, and unknown names are flagged. + */ +class SyncBakeryCommand extends Command +{ + /** + * The signature encodes one argument and two options, each with an + * inline `:` description that PHPantom surfaces on hover. + */ + protected $signature = 'bakery:sync + {bakery : The bakery ID to synchronise} + {--fresh : Only synchronise freshly baked loaves} + {--since= : Only loaves baked since this date}'; + + protected $description = 'Synchronise a bakery and its loaves'; + + public function handle(): int + { + // Own-parameter completion + hover: trigger completion inside the + // string and only `bakery` is offered; hover shows its description. + $bakery = $this->argument('bakery'); + + // Options complete to `fresh` and `since`; hover shows "takes a + // value" for `--since`. + $onlyFresh = $this->option('fresh'); + $since = $this->option('since'); + + // Referencing a name that is NOT in the signature above is flagged + // as `invalid_command_parameter` (uncomment to see the diagnostic): + // $this->argument('kitchen'); + + // `$this->call('...')` inside a command runs another Artisan command, + // so the command name completes / navigates / validates too. + $this->call('reports:generate', ['--format' => 'json']); + + $this->info("Synced bakery {$bakery} (fresh={$onlyFresh}, since={$since})"); + + return self::SUCCESS; + } +} diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 5ec77a1ee..925216238 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -15,11 +15,13 @@ use Illuminate\Http\Request; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Lang; use Illuminate\Support\Facades\Redis; +use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\View; @@ -291,6 +293,44 @@ public function laravelNavigation(): void } + // ── Artisan Command Names & Signatures ───────────────────────────────── + + /** + * Command names declared by a command class's `$signature` / `$name` / + * `#[AsCommand]` are recoverable statically, so referencing them as a + * string completes, navigates, and validates. + * + * Try: + * 1. Trigger completion inside `Artisan::call('|')` — offers `bakery:sync` + * and `reports:generate` from app/Console/Commands. + * 2. Ctrl+Click "bakery:sync" to jump to SyncBakeryCommand. + * 3. Hover "bakery:sync" to see its arguments and options. + * 4. "does:not-exist" below is flagged as an unknown command. + * 5. Trigger completion inside the parameter array of the last call — + * offers `bakery` and `--fresh` / `--since` from the target signature. + */ + public function artisanCommands(): void + { + // Command-name string completion / navigation / hover. + Artisan::call('bakery:sync'); + Artisan::queue('reports:generate'); + + // Scheduled commands name the same declarations. + Schedule::command('bakery:sync')->daily(); + + // Unknown command name → `invalid_laravel_command` diagnostic. + Artisan::call('does:not-exist'); + + // Second-argument array-key completion resolves against the target + // command's parsed signature: arguments by name, options as `--name`. + Artisan::call('bakery:sync', [ + 'bakery' => 1, + '--fresh' => true, + '--since' => '2024-01-01', + ]); + } + + // ── PHPDoc Virtual Member References & Rename ─────────────────────────── // Try: right-click "displayName" or "bio" below and use // • Find All References — includes the @property/@method declaration diff --git a/src/completion/command_params.rs b/src/completion/command_params.rs new file mode 100644 index 000000000..5adb72e2a --- /dev/null +++ b/src/completion/command_params.rs @@ -0,0 +1,322 @@ +//! Completion for Artisan command parameters. +//! +//! Two related surfaces: +//! +//! - **Own arguments/options.** Inside a console command class, +//! `$this->argument('|')` and `$this->option('|')` name segments of the +//! *same* class's `$signature`. The enclosing signature is parsed and its +//! argument / option names offered. +//! +//! - **`Artisan::call` parameter arrays.** The second argument of +//! `Artisan::call('app:sync', ['|' => ...])` (and `Artisan::queue`, +//! `Schedule::command`, `$this->call`) is a map of the target command's +//! arguments and `--options`, so the referenced command's parsed signature +//! drives array-key completion. + +use tower_lsp::lsp_types::*; + +use crate::Backend; +use crate::text_position::position_to_offset; + +/// What kind of command-parameter completion the cursor sits in. +enum ParamContext { + /// `$this->argument('|')` — complete this command's argument names. + OwnArgument, + /// `$this->option('|')` — complete this command's option names. + OwnOption, + /// A key string inside the parameter array of `Artisan::call('cmd', [ '|' ])`. + CallArrayKey { command_name: String }, +} + +struct DetectedContext { + context: ParamContext, + prefix: String, + /// Byte offset just after the opening quote of the string being typed. + content_start_offset: usize, +} + +impl Backend { + /// Try completing an Artisan command parameter name. + /// + /// Returns `None` when the cursor is not inside a recognised + /// command-parameter position. + pub(crate) fn try_command_param_completion( + &self, + content: &str, + position: Position, + ) -> Option { + let detected = detect_context(content, position)?; + let cursor_offset = position_to_offset(content, position) as usize; + + let labels: Vec = match &detected.context { + ParamContext::OwnArgument => { + let sig = crate::virtual_members::laravel::command_signature_at_offset( + content, + cursor_offset, + )?; + sig.arguments.into_iter().map(|p| p.name).collect() + } + ParamContext::OwnOption => { + let sig = crate::virtual_members::laravel::command_signature_at_offset( + content, + cursor_offset, + )?; + sig.options.into_iter().map(|p| p.name).collect() + } + ParamContext::CallArrayKey { command_name } => { + let index = self.laravel_commands.read(); + let entry = index.get(command_name)?; + let mut labels: Vec = entry + .signature + .arguments + .iter() + .map(|p| p.name.clone()) + .collect(); + labels.extend( + entry + .signature + .options + .iter() + .map(|o| format!("--{}", o.name)), + ); + labels + } + }; + + if labels.is_empty() { + return None; + } + + let start_pos = crate::text_position::offset_to_position(content, detected.content_start_offset); + let edit_range = Range { + start: start_pos, + end: position, + }; + let prefix_lower = detected.prefix.to_lowercase(); + + let items: Vec = labels + .into_iter() + .filter(|name| { + prefix_lower.is_empty() || name.to_lowercase().starts_with(&prefix_lower) + }) + .enumerate() + .map(|(i, name)| CompletionItem { + label: name.clone(), + kind: Some(CompletionItemKind::FIELD), + sort_text: Some(format!("{:05}", i)), + filter_text: Some(name.clone()), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: edit_range, + new_text: name, + })), + ..Default::default() + }) + .collect(); + + if items.is_empty() { + None + } else { + Some(CompletionResponse::Array(items)) + } + } +} + +/// Find the opening quote before the cursor and the prefix typed so far. +/// Returns `(quote_pos, prefix)` or `None` when the cursor is not inside an +/// unterminated single-line string. +fn find_open_quote(content: &str, cursor_offset: usize) -> Option<(usize, String)> { + let bytes = content.as_bytes(); + if cursor_offset == 0 || cursor_offset > bytes.len() { + return None; + } + let mut i = cursor_offset; + while i > 0 { + i -= 1; + let ch = bytes[i]; + if ch == b'\'' || ch == b'"' { + // Count preceding backslashes to skip escaped quotes. + let mut bs = 0; + let mut j = i; + while j > 0 && bytes[j - 1] == b'\\' { + bs += 1; + j -= 1; + } + if bs % 2 == 0 { + let prefix = content[i + 1..cursor_offset].to_string(); + return Some((i, prefix)); + } + } + if ch == b'\n' { + return None; + } + } + None +} + +fn detect_context(content: &str, position: Position) -> Option { + let cursor_offset = position_to_offset(content, position) as usize; + let (quote_pos, prefix) = find_open_quote(content, cursor_offset)?; + let before_quote = content[..quote_pos].trim_end(); + + // ── Own argument / option: `->argument('|')` / `->option('|')` ───────── + if let Some(before_paren) = before_quote.strip_suffix('(') { + let before_paren = before_paren.trim_end(); + let (name, rest) = split_trailing_ident(before_paren); + if !name.is_empty() { + let is_method = rest.trim_end().ends_with("->") || rest.trim_end().ends_with("?->"); + if is_method { + match name.to_ascii_lowercase().as_str() { + "argument" | "hasargument" | "getargument" => { + return Some(DetectedContext { + context: ParamContext::OwnArgument, + prefix, + content_start_offset: quote_pos + 1, + }); + } + "option" | "hasoption" | "getoption" => { + return Some(DetectedContext { + context: ParamContext::OwnOption, + prefix, + content_start_offset: quote_pos + 1, + }); + } + _ => {} + } + } + } + } + + // ── Array key inside a command call's parameter array ────────────────── + // e.g. `Artisan::call('app:sync', [ '|' => ... ])`. The character before + // the quote is `[` (first key) or `,` (subsequent key). + let last = before_quote.chars().last()?; + if (last == '[' || last == ',') + && let Some(command_name) = command_name_for_array_key(content, quote_pos) + { + return Some(DetectedContext { + context: ParamContext::CallArrayKey { command_name }, + prefix, + content_start_offset: quote_pos + 1, + }); + } + + None +} + +/// Given the position of an array-key opening quote, resolve the command name +/// of the enclosing `Artisan::call('name', [...])`-style call. +/// +/// Scans backwards for the `[` that opens the parameter array, then for the +/// preceding `(` that opens the call's argument list, extracts the first +/// string argument (the command name), and confirms the call is a recognised +/// command-running call. +fn command_name_for_array_key(content: &str, quote_pos: usize) -> Option { + let bytes = content.as_bytes(); + + // Walk back to the `[` that opens the array, balancing nested brackets. + let mut i = quote_pos; + let mut depth = 0i32; + let bracket_open = loop { + if i == 0 { + return None; + } + i -= 1; + match bytes[i] { + b']' => depth += 1, + b'[' => { + if depth == 0 { + break i; + } + depth -= 1; + } + b'\n' if depth == 0 => { + // Allow the array to span lines; only bail on stray brackets. + } + _ => {} + } + }; + + // Before the `[` we expect `... ('command', ` — find the `,` then the + // preceding string literal (the command name) and the `(` and call name. + let before_bracket = content[..bracket_open].trim_end(); + let before_bracket = before_bracket.strip_suffix(',')?.trim_end(); + + // The command name is a trailing string literal. + let (command_name, before_name) = trailing_string_literal(before_bracket)?; + let before_name = before_name.trim_end(); + let before_paren = before_name.strip_suffix('(')?.trim_end(); + + let (method, before_method) = split_trailing_ident(before_paren); + let method = method.to_ascii_lowercase(); + let before_method = before_method.trim_end(); + + let is_static = before_method.ends_with("::"); + let is_instance = before_method.ends_with("->") || before_method.ends_with("?->"); + + let recognised = if is_static { + let subject = trailing_class_name(&before_method[..before_method.len() - 2]); + let subject = subject.rsplit('\\').next().unwrap_or(subject); + matches!( + (subject.to_ascii_lowercase().as_str(), method.as_str()), + ("artisan", "call" | "queue") | ("schedule", "command") + ) + } else if is_instance { + matches!(method.as_str(), "call" | "callsilently") + } else { + false + }; + + recognised.then_some(command_name) +} + +/// Split off a trailing PHP identifier (`[A-Za-z0-9_]+`) from `s`, returning +/// `(identifier, remainder_before_it)`. +fn split_trailing_ident(s: &str) -> (&str, &str) { + let bytes = s.as_bytes(); + let mut start = bytes.len(); + while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') { + start -= 1; + } + (&s[start..], &s[..start]) +} + +/// Split off a trailing class-name token (identifier plus `\` separators). +fn trailing_class_name(s: &str) -> &str { + let s = s.trim_end(); + let bytes = s.as_bytes(); + let mut start = bytes.len(); + while start > 0 + && (bytes[start - 1].is_ascii_alphanumeric() + || bytes[start - 1] == b'_' + || bytes[start - 1] == b'\\') + { + start -= 1; + } + &s[start..] +} + +/// If `s` ends with a single- or double-quoted string literal, return its +/// inner value and the text before the opening quote. +fn trailing_string_literal(s: &str) -> Option<(String, &str)> { + let s = s.trim_end(); + let bytes = s.as_bytes(); + let close = *bytes.last()?; + if close != b'\'' && close != b'"' { + return None; + } + // Find the matching opening quote (no escape handling needed for command + // names, which never contain quotes). + let mut i = bytes.len() - 1; + while i > 0 { + i -= 1; + if bytes[i] == close { + let value = s[i + 1..bytes.len() - 1].to_string(); + return Some((value, &s[..i])); + } + } + None +} + +#[cfg(test)] +#[path = "command_params_tests.rs"] +mod tests; diff --git a/src/completion/command_params_tests.rs b/src/completion/command_params_tests.rs new file mode 100644 index 000000000..0937ed88e --- /dev/null +++ b/src/completion/command_params_tests.rs @@ -0,0 +1,125 @@ +use super::*; + +fn ctx_at(content: &str, needle: &str) -> Option { + // Place the cursor right after `needle` (which should end inside a quote). + let idx = content.find(needle).expect("needle not found") + needle.len(); + let position = crate::text_position::offset_to_position(content, idx); + detect_context(content, position) +} + +#[test] +fn detects_own_argument() { + let content = "argument('us');\n"; + let d = ctx_at(content, "argument('us").expect("should detect"); + assert!(matches!(d.context, ParamContext::OwnArgument)); + assert_eq!(d.prefix, "us"); +} + +#[test] +fn detects_own_option() { + let content = "option('qu');\n"; + let d = ctx_at(content, "option('qu").expect("should detect"); + assert!(matches!(d.context, ParamContext::OwnOption)); + assert_eq!(d.prefix, "qu"); +} + +#[test] +fn ignores_unrelated_method() { + let content = "foo('bar');\n"; + assert!(ctx_at(content, "foo('bar").is_none()); +} + +#[test] +fn detects_call_array_key_first() { + let content = " assert_eq!(command_name, "app:sync"), + _ => panic!("expected CallArrayKey"), + } + assert_eq!(d.prefix, "us"); +} + +#[test] +fn detects_call_array_key_subsequent() { + let content = " 1, '--qu']);\n"; + let d = ctx_at(content, "'--qu").expect("should detect subsequent key"); + match d.context { + ParamContext::CallArrayKey { command_name } => assert_eq!(command_name, "app:sync"), + _ => panic!("expected CallArrayKey"), + } + assert_eq!(d.prefix, "--qu"); +} + +#[test] +fn detects_this_call_array_key() { + let content = "call('app:sync', ['us']);\n"; + let d = ctx_at(content, "['us").expect("should detect"); + match d.context { + ParamContext::CallArrayKey { command_name } => assert_eq!(command_name, "app:sync"), + _ => panic!("expected CallArrayKey"), + } +} + +#[test] +fn plain_array_not_command_call() { + let content = "argument(''); + } +} +"; + let idx = content.find("argument('").unwrap() + "argument('".len(); + let position = crate::text_position::offset_to_position(content, idx); + let response = backend.try_command_param_completion(content, position); + let labels = collect_labels(response); + assert!(labels.contains(&"user".to_string()), "got {labels:?}"); + assert!(labels.contains(&"team".to_string()), "got {labels:?}"); + // Options should not appear in argument() completion. + assert!(!labels.contains(&"queue".to_string()), "got {labels:?}"); +} + +#[test] +fn own_option_completion_end_to_end() { + let backend = crate::Backend::new_test(); + let content = "option(''); + } +} +"; + let idx = content.find("option('").unwrap() + "option('".len(); + let position = crate::text_position::offset_to_position(content, idx); + let response = backend.try_command_param_completion(content, position); + let labels = collect_labels(response); + assert!(labels.contains(&"queue".to_string()), "got {labels:?}"); + assert!(labels.contains(&"conn".to_string()), "got {labels:?}"); + assert!(!labels.contains(&"user".to_string()), "got {labels:?}"); +} + +fn collect_labels(response: Option) -> Vec { + match response { + Some(CompletionResponse::Array(items)) => items.into_iter().map(|i| i.label).collect(), + Some(CompletionResponse::List(list)) => list.items.into_iter().map(|i| i.label).collect(), + None => Vec::new(), + } +} diff --git a/src/completion/handler/mod.rs b/src/completion/handler/mod.rs index ab2875598..9dc857c5b 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -286,6 +286,20 @@ impl Backend { return Ok(Some(response)); } + // ── Artisan command parameter completion ──────────────── + // `$this->argument('|')` / `$this->option('|')` against the + // enclosing command's own signature, and array keys of + // `Artisan::call('cmd', ['|' => ...])`. + if is_laravel + && matches!( + string_ctx, + StringContext::InStringLiteral | StringContext::NotInString + ) + && let Some(response) = self.try_command_param_completion(&content, position) + { + return Ok(Some(response)); + } + // ── Eloquent relation/column string completion ────────── // Like array shape completion, this triggers inside string // literals where the cursor is in a method argument position diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs index 69c1868b3..c66fbddb0 100644 --- a/src/completion/laravel_string_keys.rs +++ b/src/completion/laravel_string_keys.rs @@ -207,11 +207,27 @@ fn detect_laravel_string_key_context( ("cache", "store") => (Some(LaravelStringKind::Config), Some("cache.stores.")), ("log", "channel") => (Some(LaravelStringKind::Config), Some("logging.channels.")), ("storage", "disk") => (Some(LaravelStringKind::Config), Some("filesystems.disks.")), + // Artisan command names. + ("artisan", "call" | "queue") => (Some(LaravelStringKind::Command), None), + ("schedule", "command") => (Some(LaravelStringKind::Command), None), _ => (None, None), } } else if is_instance_method { + // Whether the receiver is `$this` (used to scope command-running + // methods, whose names are too generic to match on any object). + let receiver_is_this = { + let recv = trimmed_before + .trim_end_matches("?->") + .trim_end_matches("->") + .trim_end(); + recv.ends_with("$this") + }; let k = match func_name.to_ascii_lowercase().as_str() { "route" => Some(LaravelStringKind::Route), + // `$this->call('cmd')` / `$this->callSilently('cmd')` inside a + // console command run another Artisan command. Restricted to a + // `$this` receiver because `->call()` is a common method name. + "call" | "callsilently" if receiver_is_this => Some(LaravelStringKind::Command), _ => None, }; (k, None) @@ -560,6 +576,7 @@ impl Backend { LaravelStringKind::Config => self.cached_config_keys(), LaravelStringKind::View => self.cached_view_names(), LaravelStringKind::Trans => self.cached_trans_keys(), + LaravelStringKind::Command => self.laravel_commands.read().all_names(), }; // For config-backed attributes like #[Database('mysql')], filter @@ -610,6 +627,7 @@ impl Backend { LaravelStringKind::Config => CompletionItemKind::PROPERTY, LaravelStringKind::View => CompletionItemKind::FILE, LaravelStringKind::Trans => CompletionItemKind::TEXT, + LaravelStringKind::Command => CompletionItemKind::VALUE, }; CompletionItem { label: name.clone(), @@ -664,6 +682,42 @@ mod tests { assert_eq!(ctx.prefix, "ho"); } + #[test] + fn detects_artisan_call_command() { + let content = "call('app:');\n"; + let line = 1; + let line_text = content.lines().nth(1).unwrap(); + let col = line_text.find("app:").unwrap() as u32 + 4; + let ctx = detect_laravel_string_key_context(content, Position::new(line, col)); + let ctx = ctx.expect("should detect $this->call() context"); + assert!(matches!(ctx.kind, LaravelStringKind::Command)); + } + + #[test] + fn rejects_non_this_call() { + // `->call()` on an arbitrary object is not a command reference. + let content = "call('doSomething');\n"; + let line = 1; + let line_text = content.lines().nth(1).unwrap(); + let col = line_text.find("doSomething").unwrap() as u32 + 2; + let ctx = detect_laravel_string_key_context(content, Position::new(line, col)); + assert!( + ctx.is_none(), + "->call() on a non-$this receiver should not match" + ); + } + #[test] fn detects_config_call() { let content = " None, + SymbolKind::CommandOwnParam { .. } + | SymbolKind::Keyword + | SymbolKind::CastType + | SymbolKind::Comment => None, } } diff --git a/src/definition/type_definition.rs b/src/definition/type_definition.rs index 052c6354b..08b9ec1d2 100644 --- a/src/definition/type_definition.rs +++ b/src/definition/type_definition.rs @@ -151,6 +151,7 @@ impl Backend { | SymbolKind::NamespaceDeclaration { .. } | SymbolKind::LaravelStringKey { .. } | SymbolKind::LaravelMacroString { .. } + | SymbolKind::CommandOwnParam { .. } | SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => { diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 350acba4e..e640838ba 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -326,6 +326,69 @@ impl Backend { let is_laravel = self.resolved_class_cache.read().is_laravel(); if is_laravel { self.collect_invalid_laravel_string_key_diagnostics(uri_str, content, out); + self.collect_invalid_command_param_diagnostics(uri_str, content, out); + } + } + + /// Emit a warning for each `$this->argument('x')` / `$this->option('x')` + /// whose name is not a parameter of the enclosing command's `$signature`. + fn collect_invalid_command_param_diagnostics( + &self, + uri: &str, + content: &str, + out: &mut Vec, + ) { + use crate::symbol_map::SymbolKind; + + // (name, is_option, start, end) for each own-param span. + let spans: Vec<(String, bool, u32, u32)> = { + let maps = self.symbol_maps.read(); + let Some(symbol_map) = maps.get(uri) else { + return; + }; + symbol_map + .spans + .iter() + .filter_map(|span| { + if let SymbolKind::CommandOwnParam { name, is_option } = &span.kind { + Some((name.clone(), *is_option, span.start, span.end)) + } else { + None + } + }) + .collect() + }; + if spans.is_empty() { + return; + } + + for (name, is_option, start, end) in &spans { + // Resolve the enclosing command signature at the span offset. If + // the class declares no `$signature` (e.g. a `$name`-only or + // dynamically-built command), skip — there is nothing to validate. + let Some(signature) = crate::virtual_members::laravel::command_signature_at_offset( + content, + *start as usize, + ) else { + continue; + }; + let known = if *is_option { + signature.option(name).is_some() + } else { + signature.argument(name).is_some() + }; + if !known + && let Some(range) = + offset_range_to_lsp_range(content, *start as usize, *end as usize) + { + let label = if *is_option { "option" } else { "argument" }; + out.push(helpers::make_diagnostic( + range, + DiagnosticSeverity::WARNING, + "invalid_command_parameter", + format!("Unknown command {}: '{}'", label, name), + )); + } } } @@ -352,6 +415,7 @@ impl Backend { let mut has_config = false; let mut has_view = false; let mut has_trans = false; + let mut has_command = false; let key_spans: Vec<(LaravelStringKind, String, u32, u32)> = { let maps = self.symbol_maps.read(); let Some(symbol_map) = maps.get(uri) else { @@ -367,6 +431,7 @@ impl Backend { LaravelStringKind::Config => has_config = true, LaravelStringKind::View => has_view = true, LaravelStringKind::Trans => has_trans = true, + LaravelStringKind::Command => has_command = true, } Some((kind.clone(), key.clone(), span.start, span.end)) } else { @@ -377,7 +442,7 @@ impl Backend { // `maps` read lock is dropped here. }; - if !has_route && !has_config && !has_view && !has_trans { + if !has_route && !has_config && !has_view && !has_trans && !has_command { return; } @@ -404,6 +469,15 @@ impl Backend { } else { HashSet::new() }; + let command_names: HashSet = if has_command { + self.laravel_commands + .read() + .all_names() + .into_iter() + .collect() + } else { + HashSet::new() + }; for (kind, key, start, end) in &key_spans { let (valid, label, code) = match kind { @@ -436,6 +510,21 @@ impl Backend { .any(|k| k.starts_with(&format!("{}.", key))); (valid, "translation key", "invalid_laravel_trans") } + LaravelStringKind::Command => { + // When no commands were indexed at all, skip command + // diagnostics entirely. The scan is heuristic (it relies + // on the `*Command` naming convention), so an empty index + // likely means discovery failed rather than that every + // referenced command is invalid. + if command_names.is_empty() { + continue; + } + ( + command_names.contains(key), + "command", + "invalid_laravel_command", + ) + } }; if !valid && let Some(range) = diff --git a/src/highlight/mod.rs b/src/highlight/mod.rs index 78b8faa02..3ef558f95 100644 --- a/src/highlight/mod.rs +++ b/src/highlight/mod.rs @@ -87,6 +87,7 @@ impl Backend { SymbolKind::NamespaceDeclaration { .. } | SymbolKind::LaravelStringKey { .. } | SymbolKind::LaravelMacroString { .. } + | SymbolKind::CommandOwnParam { .. } | SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => Vec::new(), diff --git a/src/hover/mod.rs b/src/hover/mod.rs index 63dc17dbd..57d84264e 100644 --- a/src/hover/mod.rs +++ b/src/hover/mod.rs @@ -476,6 +476,10 @@ impl Backend { SymbolKind::LaravelStringKey { kind, key } => self.hover_laravel_string_key(kind, key), + SymbolKind::CommandOwnParam { name, is_option } => { + hover_command_own_param(content, cursor_offset as usize, name, *is_option) + } + SymbolKind::LaravelMacroString { .. } | SymbolKind::Keyword | SymbolKind::CastType @@ -566,6 +570,37 @@ impl Backend { }; ("Trans", detail) } + LaravelStringKind::Command => { + let index = self.laravel_commands.read(); + let detail = if let Some(entry) = index.get(key) { + let mut parts = Vec::new(); + if let Some(fqn) = &entry.fqn { + parts.push(format!("Defined by `{}`", fqn)); + } + let sig = &entry.signature; + if !sig.arguments.is_empty() { + let args: Vec = + sig.arguments.iter().map(|a| a.name.clone()).collect(); + parts.push(format!("Arguments: `{}`", args.join("`, `"))); + } + if !sig.options.is_empty() { + let opts: Vec = sig + .options + .iter() + .map(|o| format!("--{}", o.name)) + .collect(); + parts.push(format!("Options: `{}`", opts.join("`, `"))); + } + if parts.is_empty() { + "Artisan command".to_string() + } else { + parts.join("\n\n") + } + } else { + "Artisan command".to_string() + }; + ("Command", detail) + } }; Some(make_hover(format!("**{}** `{}`\n\n{}", label, key, detail))) @@ -673,6 +708,56 @@ impl Backend { } } +/// Hover for `$this->argument('user')` / `$this->option('queue')`: resolve +/// the parameter against the enclosing command's parsed `$signature` and show +/// its description and shape. +fn hover_command_own_param( + content: &str, + offset: usize, + name: &str, + is_option: bool, +) -> Option { + let signature = crate::virtual_members::laravel::command_signature_at_offset(content, offset)?; + let param = if is_option { + signature.option(name) + } else { + signature.argument(name) + }?; + + let kind = if is_option { "Option" } else { "Argument" }; + let display = if is_option { + format!("--{}", param.name) + } else { + param.name.clone() + }; + + let mut lines = vec![format!("**{} `{}`**", kind, display)]; + if let Some(desc) = ¶m.description { + lines.push(desc.clone()); + } + let mut traits: Vec = Vec::new(); + if param.is_array { + traits.push("array".to_string()); + } + if !is_option && param.optional { + traits.push("optional".to_string()); + } + if is_option && param.takes_value { + traits.push("takes a value".to_string()); + } + if let Some(shortcut) = ¶m.shortcut { + traits.push(format!("shortcut -{}", shortcut)); + } + if let Some(default) = ¶m.default { + traits.push(format!("default `{}`", default)); + } + if !traits.is_empty() { + lines.push(format!("_{}_", traits.join(", "))); + } + + Some(make_hover(lines.join("\n\n"))) +} + /// Extract a model name from a `model-property` type, including /// when nested inside array/list generic arguments. pub(crate) fn extract_model_name_from_model_property_type(ty: &PhpType) -> Option { diff --git a/src/lib.rs b/src/lib.rs index dd0dcbb91..e5be7180f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -536,6 +536,16 @@ pub struct Backend { /// (a pivot-bearing file changed, or the index was never built). Starts /// `true` so the first class load builds it. pub(crate) laravel_pivots_dirty: Arc, + /// Index of Artisan console commands (project + vendor) keyed by command + /// name. Built during `initialized` for Laravel projects and refreshed + /// when a command file changes. Powers command-name completion, + /// go-to-definition, hover, and unknown-name diagnostics for + /// `Artisan::call('app:sync')` and friends. Empty for non-Laravel + /// projects. See [`virtual_members::laravel::commands`]. + pub(crate) laravel_commands: Arc>, + /// Fast gate for [`laravel_commands`](Self::laravel_commands): `true` only + /// when the index holds at least one command. + pub(crate) laravel_has_commands: Arc, /// Laravel macro seed files (service providers plus the app's provider /// registration files), mapped to the class references each contributed /// at the last macro-index build. An edit that changes a seed's @@ -813,6 +823,10 @@ impl Backend { )), laravel_has_pivots: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_pivots_dirty: Arc::new(std::sync::atomic::AtomicBool::new(true)), + laravel_commands: Arc::new(RwLock::new( + virtual_members::laravel::LaravelCommandIndex::default(), + )), + laravel_has_commands: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_macro_seeds: Arc::new(RwLock::new(HashMap::new())), laravel_macro_mixin_uris: Arc::new(RwLock::new(std::collections::HashSet::new())), laravel_date_class: Arc::new(RwLock::new(None)), @@ -892,6 +906,10 @@ impl Backend { )), laravel_has_pivots: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_pivots_dirty: Arc::new(std::sync::atomic::AtomicBool::new(true)), + laravel_commands: Arc::new(RwLock::new( + virtual_members::laravel::LaravelCommandIndex::default(), + )), + laravel_has_commands: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_macro_seeds: Arc::new(RwLock::new(HashMap::new())), laravel_macro_mixin_uris: Arc::new(RwLock::new(std::collections::HashSet::new())), laravel_date_class: Arc::new(RwLock::new(None)), @@ -1499,6 +1517,8 @@ impl Backend { laravel_pivots: Arc::clone(&self.laravel_pivots), laravel_has_pivots: Arc::clone(&self.laravel_has_pivots), laravel_pivots_dirty: Arc::clone(&self.laravel_pivots_dirty), + laravel_commands: Arc::clone(&self.laravel_commands), + laravel_has_commands: Arc::clone(&self.laravel_has_commands), laravel_macro_seeds: Arc::clone(&self.laravel_macro_seeds), laravel_macro_mixin_uris: Arc::clone(&self.laravel_macro_mixin_uris), laravel_date_class: Arc::clone(&self.laravel_date_class), diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 4ec7af745..dc36bdd3f 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -136,6 +136,10 @@ impl Backend { // declare (or previously declared) a many-to-many relationship. self.refresh_laravel_pivots(uri, content); + // Keep the Artisan command index coherent with edits to command + // files. Cheap no-op for files that are not (and were not) commands. + self.refresh_laravel_command_index(uri); + match result { Some(changed) => changed, None => { diff --git a/src/references/dispatch.rs b/src/references/dispatch.rs index 36921dbbb..657479d6c 100644 --- a/src/references/dispatch.rs +++ b/src/references/dispatch.rs @@ -357,7 +357,10 @@ impl Backend { self.find_laravel_macro_references(uri, span_start, name, include_declaration) } - SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => Vec::new(), + SymbolKind::CommandOwnParam { .. } + | SymbolKind::Keyword + | SymbolKind::CastType + | SymbolKind::Comment => Vec::new(), } } } diff --git a/src/rename/prepare.rs b/src/rename/prepare.rs index 78183fb44..bc4e92ae1 100644 --- a/src/rename/prepare.rs +++ b/src/rename/prepare.rs @@ -237,6 +237,7 @@ impl Backend { SymbolKind::LaravelMacroString { name } => Some((name.clone(), range)), SymbolKind::SelfStaticParent { .. } => None, SymbolKind::LaravelStringKey { .. } + | SymbolKind::CommandOwnParam { .. } | SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => None, diff --git a/src/semantic_tokens.rs b/src/semantic_tokens.rs index 0b014d15d..d48be5f43 100644 --- a/src/semantic_tokens.rs +++ b/src/semantic_tokens.rs @@ -393,7 +393,9 @@ impl Backend { SymbolKind::Comment => (TT_COMMENT, 0), - SymbolKind::LaravelStringKey { .. } | SymbolKind::LaravelMacroString { .. } => { + SymbolKind::LaravelStringKey { .. } + | SymbolKind::LaravelMacroString { .. } + | SymbolKind::CommandOwnParam { .. } => { continue; } }; diff --git a/src/server.rs b/src/server.rs index 444420b91..b6bd3af4e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -540,6 +540,7 @@ impl LanguageServer for Backend { self.build_laravel_date_class(); self.build_laravel_macro_index(); self.build_provider_resources(); + self.build_laravel_command_index(); } // Mark initialization as complete so that diagnostic workers @@ -2037,6 +2038,97 @@ impl Backend { ); } + /// Scan project and vendor Artisan command classes and build the + /// [`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 + /// [`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 = + std::collections::HashSet::new(); + { + let idx = self.symbols.fqn_uri_index.read(); + for (fqn, uri) in idx.iter() { + let short = fqn.rsplit('\\').next().unwrap_or(fqn); + if short.ends_with("Command") || uri.contains("/Console/Commands/") { + candidate_uris.insert(uri.to_string()); + } + } + } + + let mut index = crate::virtual_members::laravel::LaravelCommandIndex::default(); + for uri in &candidate_uris { + let Some(content) = self.get_file_content(uri) else { + continue; + }; + let bytes = content.as_bytes(); + let looks_like_command = 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 { + continue; + } + let entries = crate::virtual_members::laravel::scan_command_file(&content, uri); + index.set_file(uri.clone(), entries); + } + index.rebuild(); + + let has_commands = !index.is_empty(); + let count = index.all_names().len(); + *self.laravel_commands.write() = index; + self.laravel_has_commands + .store(has_commands, std::sync::atomic::Ordering::Relaxed); + + tracing::info!( + "PHPantom: scanned {} Laravel command candidates, indexed {} commands", + candidate_uris.len(), + count, + ); + } + + /// Refresh the command index after a single file edit. + /// + /// Cheap: re-scans only the edited file when it is a command candidate + /// (or was contributing before), replacing just that file's entries. + pub(crate) fn refresh_laravel_command_index(&self, uri: &str) { + if !self.resolved_class_cache.read().is_laravel() { + return; + } + let was_contributor = self.laravel_commands.read().has_uri(uri); + let looks_like_command_file = uri.ends_with("Command.php") || uri.contains("/Console/"); + if !was_contributor && !looks_like_command_file { + return; + } + + let entries = self + .get_file_content(uri) + .filter(|content| { + let bytes = content.as_bytes(); + memchr::memmem::find(bytes, b"signature").is_some() + || memchr::memmem::find(bytes, b"AsCommand").is_some() + || memchr::memmem::find(bytes, b"$name").is_some() + }) + .map(|content| crate::virtual_members::laravel::scan_command_file(&content, uri)) + .unwrap_or_default(); + + if !was_contributor && entries.is_empty() { + return; + } + + let mut index = self.laravel_commands.write(); + index.set_file(uri.to_string(), entries); + index.rebuild(); + let has_commands = !index.is_empty(); + drop(index); + self.laravel_has_commands + .store(has_commands, std::sync::atomic::Ordering::Relaxed); + } + /// Find the date class selected by project service providers. Laravel's /// helpers use this factory, so `now()` and `today()` return this class /// rather than their broad `CarbonInterface` declaration. diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index 4e3192659..9f116e452 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -61,10 +61,57 @@ pub(super) fn extract_from_class<'a>(class: &'a Class<'a>, ctx: &mut ExtractionC } } + // Whether this class is (syntactically) an Artisan console command, so + // `$this->call(...)` / `$this->callSilently(...)` inside its members can + // be recognised as command-name references without false-positives in + // ordinary classes (where `->call()` is a very common method name). + // Detection is deliberately conservative: the direct `extends` clause + // must name a `Command`-suffixed class, or the class must carry an + // `#[AsCommand]` attribute. + let prev_in_console_command = ctx.in_console_command; + ctx.in_console_command = class_is_console_command(class); + // Members. for member in class.members.iter() { extract_from_class_member(member, ctx); } + + ctx.in_console_command = prev_in_console_command; +} + +/// Whether `class` is (syntactically) an Artisan console command: it +/// `extends` a `Command`-suffixed class or carries an `#[AsCommand]` +/// attribute. +fn class_is_console_command(class: &Class<'_>) -> bool { + let has_as_command = class + .attribute_lists + .iter() + .flat_map(|list| list.attributes.iter()) + .any(|attr| { + let name = attr.name.value(); + let short = match name.iter().rposition(|&b| b == b'\\') { + Some(idx) => &name[idx + 1..], + None => name, + }; + short == b"AsCommand" + }); + if has_as_command { + return true; + } + class + .extends + .as_ref() + .map(|ext| { + ext.types.iter().any(|ty| { + let v = ty.value(); + let short = match v.iter().rposition(|&b| b == b'\\') { + Some(idx) => &v[idx + 1..], + None => v, + }; + short.ends_with(b"Command") + }) + }) + .unwrap_or(false) } pub(super) fn extract_from_interface<'a>(iface: &'a Interface<'a>, ctx: &mut ExtractionCtx<'a>) { diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index ac0b2700c..4a04fc34f 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -158,6 +158,42 @@ pub(super) fn extract_call_expr<'a>( &mut ctx.spans, ); } + // `$this->call('app:sync')` / `$this->callSilently('app:sync')` + // inside a console command runs another Artisan command. + // Gated on `in_console_command` because `->call()` is an + // extremely common method name elsewhere (e.g. + // `$this->call('GET', '/uri')` in HTTP tests). + if ctx.in_console_command + && matches!(method_call.object, Expression::Variable(Variable::Direct(v)) if v.name == b"$this") + { + match member_name.to_ascii_lowercase().as_str() { + "call" | "callsilently" => { + try_emit_laravel_string_span( + crate::symbol_map::LaravelStringKind::Command, + &method_call.argument_list, + ctx.content, + &mut ctx.spans, + ); + } + "argument" | "hasargument" | "getargument" => { + try_emit_command_own_param_span( + false, + &method_call.argument_list, + ctx.content, + &mut ctx.spans, + ); + } + "option" | "hasoption" | "getoption" => { + try_emit_command_own_param_span( + true, + &method_call.argument_list, + ctx.content, + &mut ctx.spans, + ); + } + _ => {} + } + } // Emit call site for method call: `$subject->method(...)` emit_call_site( format!("{}->{}", subject_text, member_name), @@ -303,6 +339,16 @@ pub(super) fn extract_call_expr<'a>( &mut ctx.spans, ); } + // Artisan command names: `Artisan::call('app:sync')`, + // `Artisan::queue('app:sync')`, `Schedule::command('app:sync')`. + if is_artisan_command_static_call(clean_subject, &member_name) { + try_emit_laravel_string_span( + crate::symbol_map::LaravelStringKind::Command, + &static_call.argument_list, + ctx.content, + &mut ctx.spans, + ); + } } extract_from_arguments(&static_call.argument_list.arguments, ctx, scope_start); } diff --git a/src/symbol_map/extraction/laravel.rs b/src/symbol_map/extraction/laravel.rs index ec4ad0f5b..395115f6c 100644 --- a/src/symbol_map/extraction/laravel.rs +++ b/src/symbol_map/extraction/laravel.rs @@ -94,6 +94,41 @@ pub(super) fn try_emit_laravel_string_span( }); } +/// If the first argument of `argument_list` is a plain, non-empty string +/// literal, push a [`SymbolKind::CommandOwnParam`] span covering the string +/// content. Used for `$this->argument('user')` / `$this->option('queue')` +/// inside a console command. +pub(super) fn try_emit_command_own_param_span( + is_option: bool, + argument_list: &ArgumentList<'_>, + content: &str, + spans: &mut Vec, +) { + let Some(first_arg) = argument_list.arguments.iter().next() else { + return; + }; + let Expression::Literal(literal::Literal::String(s)) = first_arg.value() else { + return; + }; + let inner_start = s.span.start.offset + 1; + let inner_end = s.span.end.offset - 1; + if inner_start >= inner_end || inner_end as usize > content.len() { + return; + } + let name = &content[inner_start as usize..inner_end as usize]; + if name.is_empty() { + return; + } + spans.push(SymbolSpan { + start: inner_start, + end: inner_end, + kind: SymbolKind::CommandOwnParam { + name: name.to_string(), + is_option, + }, + }); +} + /// If the first argument of `argument_list` is a non-empty, non-interpolated /// string literal, push a [`SymbolKind::LaravelStringKey`] span covering the /// string content (inside the quotes) onto `spans`. @@ -358,6 +393,20 @@ pub(super) fn is_config_repository_method(name: &str) -> bool { ) } +/// Returns `true` for a static call whose first argument is an Artisan +/// command name: `Artisan::call(...)`, `Artisan::queue(...)`, or +/// `Schedule::command(...)`. Recognises both the short facade name and its +/// `Illuminate\Support\Facades\` FQN. +pub(super) fn is_artisan_command_static_call(clean_subject: &str, member_name: &str) -> bool { + let is_artisan = clean_subject.eq_ignore_ascii_case("Artisan") + || clean_subject.eq_ignore_ascii_case("Illuminate\\Support\\Facades\\Artisan"); + let is_schedule = clean_subject.eq_ignore_ascii_case("Schedule") + || clean_subject.eq_ignore_ascii_case("Illuminate\\Support\\Facades\\Schedule"); + let member = member_name.to_ascii_lowercase(); + (is_artisan && matches!(member.as_str(), "call" | "queue")) + || (is_schedule && member == "command") +} + /// Returns `true` if `object` is a `config()` (or `\config()`) helper call and /// `member_name` is a config-key-accepting method, e.g. `config()->get('app.name')`. pub(super) fn is_laravel_config_repository_call( diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index d8ca9adf2..0c90b0c24 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -81,6 +81,11 @@ struct ExtractionCtx<'a> { /// Whether the file imports from `Illuminate\Container\Attributes\` /// (checked once lazily, cached for all attribute inspections). has_laravel_container_attrs: Option, + /// Whether the members currently being extracted belong to a class that + /// syntactically extends an Artisan console command (or is `#[AsCommand]` + /// decorated). Gates recognition of `$this->call('cmd')` / + /// `$this->callSilently('cmd')` as command-name references. + in_console_command: bool, } mod class_like; @@ -142,6 +147,7 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), has_laravel_container_attrs: None, + in_console_command: false, }; for stmt in program.statements.iter() { diff --git a/src/symbol_map/mod.rs b/src/symbol_map/mod.rs index 1c1e25d0f..6e9dfcdae 100644 --- a/src/symbol_map/mod.rs +++ b/src/symbol_map/mod.rs @@ -253,6 +253,19 @@ pub(crate) enum SymbolKind { /// Macro method name, e.g. `"sumPrices"`. name: String, }, + + /// The string-literal parameter name inside a console command's own + /// `$this->argument('user')` / `$this->option('queue')` call. + /// + /// The span covers the string content inside the quotes. Hover and + /// diagnostics resolve it against the enclosing command class's parsed + /// `$signature` (looked up on demand via the span offset). + CommandOwnParam { + /// The parameter name, e.g. `"user"` or `"queue"`. + name: String, + /// `true` for `option()`, `false` for `argument()`. + is_option: bool, + }, } /// Identifies the category of a [`SymbolKind::LaravelStringKey`] span. @@ -271,6 +284,10 @@ pub(crate) enum LaravelStringKind { Route, /// A `__('key')`, `trans('key')`, or `Lang::get('key')` call. Trans, + /// An Artisan command name, e.g. `Artisan::call('app:sync')`, + /// `Schedule::command('app:sync')`, or `$this->call('app:sync')` + /// inside a console command. + Command, } // ─── Template parameter definition site structures ────────────────────────── diff --git a/src/virtual_members/laravel/commands.rs b/src/virtual_members/laravel/commands.rs new file mode 100644 index 000000000..ff7fcc32d --- /dev/null +++ b/src/virtual_members/laravel/commands.rs @@ -0,0 +1,651 @@ +//! Artisan console command index and signature parsing. +//! +//! 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: +//! +//! - `protected $signature = 'app:sync {user} {--queue}';` +//! - `protected $name = 'app:sync';` +//! - `#[AsCommand(name: 'app:sync')]` +//! +//! 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 +//! [`LaravelCommandIndex`] keyed by command name. The index powers: +//! +//! - completion / go-to-definition / hover / unknown-name diagnostics for +//! command-name string literals (`Artisan::call('app:sync')`, +//! `Schedule::command('app:sync')`, `$this->call('app:sync')`), and +//! - array-key completion for the parameter array of +//! `Artisan::call('app:sync', [...])`. +//! +//! The parsed signature of the *enclosing* command class also drives +//! completion / validation of `$this->argument('user')` and +//! `$this->option('queue')` against that same command's own parameters. + +use std::collections::HashMap; + +use mago_allocator::LocalArena; +use mago_database::file::FileId; +use mago_syntax::cst::*; + +use super::helpers::extract_string_literal; + +/// A single parsed argument or option from a command `$signature`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CommandParam { + /// The parameter name without any decoration: `user`, `queue`. + pub name: String, + /// The `:`-delimited description, if any. + pub description: Option, + /// Optional default value (the text after `=`). + pub default: Option, + /// Single-character shortcut for options (`--queue|-q` → `q`). + pub shortcut: Option, + /// Whether the parameter accepts multiple values (`*`). + pub is_array: bool, + /// Arguments: whether the argument is optional (`?`). + /// Options: always effectively optional, so this stays `false`. + pub optional: bool, + /// Options only: whether the option takes a value (`--queue=`). + /// Value-less options are boolean flags. + pub takes_value: bool, +} + +/// A parsed command signature: the command name plus its arguments and +/// options. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct CommandSignature { + /// The command name (first whitespace-delimited token of the signature). + pub name: String, + pub arguments: Vec, + pub options: Vec, +} + +impl CommandSignature { + /// Find an argument by name (case-sensitive, Laravel names are literal). + pub(crate) fn argument(&self, name: &str) -> Option<&CommandParam> { + self.arguments.iter().find(|p| p.name == name) + } + + /// Find an option by name. + pub(crate) fn option(&self, name: &str) -> Option<&CommandParam> { + self.options.iter().find(|p| p.name == name) + } +} + +/// One command discovered in a source file. +#[derive(Debug, Clone)] +pub(crate) struct CommandEntry { + /// The command name, e.g. `app:sync` or `migrate`. + pub name: String, + /// Best-effort fully-qualified class name (`App\Console\Commands\Sync`). + pub fqn: Option, + /// URI of the file declaring the command. + pub uri: String, + /// Byte offset of the command-name string literal (inside the quotes), + /// used for go-to-definition. + pub name_offset: u32, + /// The parsed `$signature`. Arguments and options are empty when the + /// command declares only a `$name`/`#[AsCommand]` with no signature. + pub signature: CommandSignature, +} + +/// Index of Artisan commands keyed by command name. +/// +/// Mirrors [`super::LaravelMacroIndex`]'s per-URI storage so an edit to a +/// single command file can replace just that file's contribution +/// ([`Self::set_file`]) before a cheap [`Self::rebuild`] refreshes the +/// merged by-name lookup. +#[derive(Default)] +pub(crate) struct LaravelCommandIndex { + by_uri: HashMap>, + by_name: HashMap, +} + +impl LaravelCommandIndex { + /// Replace the commands contributed by `uri`. An empty vector removes + /// the file's contribution. Call [`Self::rebuild`] afterwards. + pub(crate) fn set_file(&mut self, uri: String, entries: Vec) { + if entries.is_empty() { + self.by_uri.remove(&uri); + } else { + self.by_uri.insert(uri, entries); + } + } + + /// Rebuild the merged name → entry lookup from per-file contributions. + /// + /// When two files declare the same command name, the first one + /// encountered wins; the ordering is deterministic only up to the + /// hash map iteration order, which is acceptable for a diagnostic / + /// navigation aid. + pub(crate) fn rebuild(&mut self) { + let mut by_name = HashMap::new(); + for entries in self.by_uri.values() { + for entry in entries { + by_name + .entry(entry.name.clone()) + .or_insert_with(|| entry.clone()); + } + } + self.by_name = by_name; + } + + /// Whether `uri` currently contributes any commands. + pub(crate) fn has_uri(&self, uri: &str) -> bool { + self.by_uri.contains_key(uri) + } + + /// Whether the index contains no commands at all. + pub(crate) fn is_empty(&self) -> bool { + self.by_name.is_empty() + } + + /// Look up a command by name. + pub(crate) fn get(&self, name: &str) -> Option<&CommandEntry> { + self.by_name.get(name) + } + + /// All known command names, sorted and deduplicated. + pub(crate) fn all_names(&self) -> Vec { + let mut names: Vec = self.by_name.keys().cloned().collect(); + names.sort(); + names.dedup(); + names + } +} + +// ─── Signature grammar parser ───────────────────────────────────────────────── + +/// Parse a Laravel command signature expression into its name, arguments +/// and options. +/// +/// Mirrors `Illuminate\Console\Parser`: +/// - the name is the first whitespace-delimited token; +/// - each `{...}` token is an option when it starts with `--`, otherwise an +/// argument; +/// - a ` : ` splits a token from its description; +/// - decorations: `?` (optional), `*` (array), `=default` (default value), +/// `=*` (array with defaults), and `shortcut|name` for options. +pub(crate) fn parse_signature(expression: &str) -> CommandSignature { + let name = expression + .split_whitespace() + .next() + .unwrap_or("") + .to_string(); + + let mut arguments = Vec::new(); + let mut options = Vec::new(); + + for token in signature_tokens(expression) { + let (body, description) = extract_description(&token); + if let Some(rest) = body.strip_prefix("--") { + // Strip any extra leading dashes (`-{2,}`). + let rest = rest.trim_start_matches('-'); + options.push(parse_option(rest, description)); + } else { + arguments.push(parse_argument(&body, description)); + } + } + + CommandSignature { + name, + arguments, + options, + } +} + +/// Extract the raw `{...}` token bodies from a signature expression. +/// +/// Laravel uses the non-greedy regex `\{\s*(.*?)\s*\}`, so the first `}` +/// closes a token; the inner text is trimmed of surrounding whitespace. +fn signature_tokens(expression: &str) -> Vec { + let mut tokens = Vec::new(); + let bytes = expression.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'{' { + if let Some(close_rel) = expression[i + 1..].find('}') { + let inner = &expression[i + 1..i + 1 + close_rel]; + tokens.push(inner.trim().to_string()); + i = i + 1 + close_rel + 1; + continue; + } else { + break; + } + } + i += 1; + } + tokens +} + +/// Split a token into its body and optional description on the first ` : ` +/// (whitespace-colon-whitespace) separator, matching `\s+:\s+`. +fn extract_description(token: &str) -> (String, Option) { + let trimmed = token.trim(); + let bytes = trimmed.as_bytes(); + for (idx, &b) in bytes.iter().enumerate() { + if b == b':' + && idx > 0 + && bytes[idx - 1].is_ascii_whitespace() + && idx + 1 < bytes.len() + && bytes[idx + 1].is_ascii_whitespace() + { + let body = trimmed[..idx].trim().to_string(); + let desc = trimmed[idx + 1..].trim().to_string(); + let desc = if desc.is_empty() { None } else { Some(desc) }; + return (body, desc); + } + } + (trimmed.to_string(), None) +} + +fn parse_argument(token: &str, description: Option) -> CommandParam { + // Match order follows Illuminate\Console\Parser::parseArgument. + if token.ends_with("?*") { + return CommandParam { + name: token.trim_matches(|c| c == '?' || c == '*').to_string(), + description, + default: None, + shortcut: None, + is_array: true, + optional: true, + takes_value: false, + }; + } + if token.ends_with('*') { + return CommandParam { + name: token.trim_matches('*').to_string(), + description, + default: None, + shortcut: None, + is_array: true, + optional: false, + takes_value: false, + }; + } + if token.ends_with('?') { + return CommandParam { + name: token.trim_matches('?').to_string(), + description, + default: None, + shortcut: None, + is_array: false, + optional: true, + takes_value: false, + }; + } + if let Some((name, default)) = split_default_array(token) { + return CommandParam { + name, + description, + default: Some(default), + shortcut: None, + is_array: true, + optional: true, + takes_value: false, + }; + } + if let Some((name, default)) = token.split_once('=') { + return CommandParam { + name: name.to_string(), + description, + default: Some(default.to_string()), + shortcut: None, + is_array: false, + optional: true, + takes_value: false, + }; + } + CommandParam { + name: token.to_string(), + description, + default: None, + shortcut: None, + is_array: false, + optional: false, + takes_value: false, + } +} + +fn parse_option(token: &str, description: Option) -> CommandParam { + // Split a leading `shortcut|name` (regex `\s*\|\s*`, limit 2). + let (shortcut, token) = match token.split_once('|') { + Some((short, rest)) => (Some(short.trim().to_string()), rest.trim().to_string()), + None => (None, token.to_string()), + }; + + // Match order follows Illuminate\Console\Parser::parseOption. + if token.ends_with("=*") { + return CommandParam { + name: token.trim_end_matches("=*").to_string(), + description, + default: None, + shortcut, + is_array: true, + optional: true, + takes_value: true, + }; + } + if token.ends_with('=') { + return CommandParam { + name: token.trim_end_matches('=').to_string(), + description, + default: None, + shortcut, + is_array: false, + optional: true, + takes_value: true, + }; + } + if let Some((name, default)) = split_default_array(&token) { + return CommandParam { + name, + description, + default: Some(default), + shortcut, + is_array: true, + optional: true, + takes_value: true, + }; + } + if let Some((name, default)) = token.split_once('=') { + return CommandParam { + name: name.to_string(), + description, + default: Some(default.to_string()), + shortcut, + is_array: false, + optional: true, + takes_value: true, + }; + } + // Value-less option — a boolean flag. + CommandParam { + name: token, + description, + default: None, + shortcut, + is_array: false, + optional: true, + takes_value: false, + } +} + +/// Split `name=*value` into `(name, value)`. Returns `None` when the token +/// does not contain the `=*` array-default marker. +fn split_default_array(token: &str) -> Option<(String, String)> { + let idx = token.find("=*")?; + let default = &token[idx + 2..]; + if default.is_empty() { + return None; + } + Some((token[..idx].to_string(), default.to_string())) +} + +// ─── Source scanner ──────────────────────────────────────────────────────────── + +/// Scan a PHP source file for Artisan command declarations. +/// +/// A class is treated as a command when it `extends` a class whose short +/// name ends in `Command`, or carries an `#[AsCommand]` attribute. For each +/// such class the command name is recovered from (in priority order) the +/// `#[AsCommand]` attribute, the `$signature` property, or the `$name` +/// property. +pub(crate) fn scan_command_file(content: &str, uri: &str) -> Vec { + let arena = LocalArena::new(); + let file_id = FileId::new(b"input.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); + + let mut entries = Vec::new(); + for stmt in program.statements.iter() { + scan_stmt_for_commands(stmt, None, content, uri, &mut entries); + } + entries +} + +fn scan_stmt_for_commands( + stmt: &Statement<'_>, + namespace: Option<&str>, + content: &str, + uri: &str, + out: &mut Vec, +) { + match stmt { + Statement::Namespace(ns) => { + let ns_name = ns.name.map(|n| bytes_to_string(n.value())); + for inner in ns.statements().iter() { + scan_stmt_for_commands(inner, ns_name.as_deref(), content, uri, out); + } + } + Statement::Class(class) => { + if let Some(entry) = command_from_class(class, namespace, content, uri) { + out.push(entry); + } + } + _ => {} + } +} + +fn command_from_class( + class: &Class<'_>, + namespace: Option<&str>, + content: &str, + uri: &str, +) -> Option { + let has_as_command = class + .attribute_lists + .iter() + .flat_map(|list| list.attributes.iter()) + .any(|attr| last_segment(attr.name.value()) == b"AsCommand"); + + let extends_command = class + .extends + .as_ref() + .map(|ext| { + ext.types + .iter() + .any(|ty| last_segment(ty.value()).ends_with(b"Command")) + }) + .unwrap_or(false); + + if !has_as_command && !extends_command { + return None; + } + + let fqn = match namespace { + Some(ns) => Some(format!("{}\\{}", ns, bytes_to_string(class.name.value))), + None => Some(bytes_to_string(class.name.value)), + }; + + // 1. #[AsCommand(name: '...')] / #[AsCommand('...')]. + if let Some((name, offset)) = as_command_name(class, content) { + let signature = signature_property_value(class, content) + .map(|(sig, _)| parse_signature(sig)) + .unwrap_or_else(|| CommandSignature { + name: name.clone(), + ..Default::default() + }); + return Some(CommandEntry { + name, + fqn, + uri: uri.to_string(), + name_offset: offset, + signature, + }); + } + + // 2. $signature = '...'. + if let Some((sig, offset)) = signature_property_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. $name = '...'. + if let Some((name, offset)) = string_property_value(class, "name", content) { + if name.is_empty() { + return None; + } + return Some(CommandEntry { + name: name.clone(), + fqn, + uri: uri.to_string(), + name_offset: offset, + signature: CommandSignature { + name, + ..Default::default() + }, + }); + } + + None +} + +/// The first string argument of an `#[AsCommand]` attribute, with its inner +/// byte offset. +fn as_command_name(class: &Class<'_>, content: &str) -> Option<(String, u32)> { + for list in class.attribute_lists.iter() { + for attr in list.attributes.iter() { + if last_segment(attr.name.value()) != b"AsCommand" { + 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.to_string(), 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)> { + string_property_value_ref(class, "signature", content) +} + +/// The named string property's value (owned) plus its inner byte offset. +fn string_property_value(class: &Class<'_>, prop: &str, content: &str) -> Option<(String, u32)> { + string_property_value_ref(class, prop, content).map(|(v, o)| (v.to_string(), o)) +} + +/// The named string property's value (borrowed) plus its inner byte offset. +fn string_property_value_ref<'c>( + class: &Class<'_>, + prop: &str, + content: &'c str, +) -> Option<(&'c str, u32)> { + for member in class.members.iter() { + let ClassLikeMember::Property(Property::Plain(plain)) = member else { + continue; + }; + for item in plain.items.iter() { + let PropertyItem::Concrete(concrete) = item else { + continue; + }; + let var_name = concrete.variable.name; + if trim_dollar(var_name) != prop.as_bytes() { + continue; + } + if let Some((value, start, _)) = extract_string_literal(concrete.value, content) { + return Some((value, start as u32)); + } + } + } + None +} + +// ─── Enclosing-signature lookup ──────────────────────────────────────────────── + +/// Parse the command `$signature` of the class enclosing `offset`, if any. +/// +/// Used for completing / validating `$this->argument('user')` and +/// `$this->option('queue')` against the *current* command's own parameters. +/// Returns `None` when `offset` is not inside a class, or the enclosing class +/// declares no `$signature`. +pub(crate) fn command_signature_at_offset( + content: &str, + offset: usize, +) -> Option { + let arena = LocalArena::new(); + let file_id = FileId::new(b"input.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); + let mut found: Option = None; + for stmt in program.statements.iter() { + find_signature_at_offset(stmt, offset as u32, content, &mut found); + if found.is_some() { + break; + } + } + found +} + +fn find_signature_at_offset( + stmt: &Statement<'_>, + offset: u32, + content: &str, + out: &mut Option, +) { + match stmt { + Statement::Namespace(ns) => { + for inner in ns.statements().iter() { + find_signature_at_offset(inner, offset, content, out); + if out.is_some() { + return; + } + } + } + Statement::Class(class) => { + let start = class.left_brace.start.offset; + let end = class.right_brace.end.offset; + if offset >= start + && offset <= end + && let Some((sig, _)) = signature_property_value(class, content) + { + *out = Some(parse_signature(sig)); + } + } + _ => {} + } +} + +// ─── Byte helpers ──────────────────────────────────────────────────────────── + +fn last_segment(name: &[u8]) -> &[u8] { + match name.iter().rposition(|&b| b == b'\\') { + Some(idx) => &name[idx + 1..], + None => name, + } +} + +fn trim_dollar(name: &[u8]) -> &[u8] { + name.strip_prefix(b"$").unwrap_or(name) +} + +fn bytes_to_string(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes) + .trim_start_matches('\\') + .to_string() +} + +#[cfg(test)] +#[path = "commands_tests.rs"] +mod tests; diff --git a/src/virtual_members/laravel/commands_tests.rs b/src/virtual_members/laravel/commands_tests.rs new file mode 100644 index 000000000..90663c9a5 --- /dev/null +++ b/src/virtual_members/laravel/commands_tests.rs @@ -0,0 +1,211 @@ +use super::*; + +fn arg_names(sig: &CommandSignature) -> Vec<&str> { + sig.arguments.iter().map(|p| p.name.as_str()).collect() +} + +fn opt_names(sig: &CommandSignature) -> Vec<&str> { + sig.options.iter().map(|p| p.name.as_str()).collect() +} + +#[test] +fn parses_command_name() { + let sig = parse_signature("app:sync {user}"); + assert_eq!(sig.name, "app:sync"); +} + +#[test] +fn parses_name_only_signature() { + let sig = parse_signature("mail:send"); + assert_eq!(sig.name, "mail:send"); + assert!(sig.arguments.is_empty()); + assert!(sig.options.is_empty()); +} + +#[test] +fn parses_required_and_optional_arguments() { + let sig = parse_signature("app:sync {user} {team?}"); + assert_eq!(arg_names(&sig), vec!["user", "team"]); + assert!(!sig.argument("user").unwrap().optional); + assert!(sig.argument("team").unwrap().optional); +} + +#[test] +fn parses_array_arguments() { + let sig = parse_signature("app:sync {user*} {team?*}"); + assert!(sig.argument("user").unwrap().is_array); + assert!(!sig.argument("user").unwrap().optional); + assert!(sig.argument("team").unwrap().is_array); + assert!(sig.argument("team").unwrap().optional); +} + +#[test] +fn parses_argument_default() { + let sig = parse_signature("app:sync {user=guest}"); + let user = sig.argument("user").unwrap(); + assert_eq!(user.default.as_deref(), Some("guest")); + assert!(user.optional); +} + +#[test] +fn parses_options() { + let sig = parse_signature("app:sync {--queue} {--connection=}"); + assert_eq!(opt_names(&sig), vec!["queue", "connection"]); + // Flag: no value. + assert!(!sig.option("queue").unwrap().takes_value); + // Value option. + assert!(sig.option("connection").unwrap().takes_value); +} + +#[test] +fn parses_option_default_and_shortcut() { + let sig = parse_signature("app:sync {--Q|queue=default}"); + let queue = sig.option("queue").unwrap(); + assert_eq!(queue.shortcut.as_deref(), Some("Q")); + assert_eq!(queue.default.as_deref(), Some("default")); + assert!(queue.takes_value); +} + +#[test] +fn parses_array_option() { + let sig = parse_signature("app:sync {--id=*}"); + let id = sig.option("id").unwrap(); + assert!(id.is_array); + assert!(id.takes_value); +} + +#[test] +fn parses_descriptions() { + let sig = parse_signature("app:sync {user : The user ID} {--queue : Queue the job}"); + assert_eq!( + sig.argument("user").unwrap().description.as_deref(), + Some("The user ID") + ); + assert_eq!( + sig.option("queue").unwrap().description.as_deref(), + Some("Queue the job") + ); +} + +#[test] +fn parses_multiline_signature() { + let sig = parse_signature( + "app:sync + {user : The user} + {--queue : Whether to queue}", + ); + assert_eq!(sig.name, "app:sync"); + assert_eq!(arg_names(&sig), vec!["user"]); + assert_eq!(opt_names(&sig), vec!["queue"]); +} + +#[test] +fn scans_signature_command_class() { + let content = r#" view_names::resolve_view_definitions(backend, key), LaravelStringKind::Route => route_names::resolve_route_definitions(backend, key), LaravelStringKind::Trans => trans_keys::resolve_trans_definitions(backend, key), + LaravelStringKind::Command => resolve_command_definition(backend, key) + .into_iter() + .collect(), } } +/// Resolve an Artisan command name to the declaration site inside its +/// command class (the `$signature` / `$name` / `#[AsCommand]` literal). +fn resolve_command_definition(backend: &crate::Backend, name: &str) -> Option { + use tower_lsp::lsp_types::Url; + let index = backend.laravel_commands.read(); + let entry = index.get(name)?; + let uri = Url::parse(&entry.uri).ok()?; + let content = backend.get_file_content(&entry.uri)?; + let position = crate::text_position::offset_to_position(&content, entry.name_offset as usize); + Some(crate::definition::point_location(uri, position)) +} + /// Unified find-references entry point for all Laravel string-key spans. /// /// Dispatches on [`crate::symbol_map::LaravelStringKind`] — see @@ -49,9 +64,10 @@ pub(crate) fn find_laravel_string_key_references( LaravelStringKind::Config => { find_all_config_references(backend, key, snapshot, include_declaration) } - LaravelStringKind::View | LaravelStringKind::Route | LaravelStringKind::Trans => { - find_string_key_usages(kind, key, backend, snapshot) - } + LaravelStringKind::View + | LaravelStringKind::Route + | LaravelStringKind::Trans + | LaravelStringKind::Command => find_string_key_usages(kind, key, backend, snapshot), }; if include_declaration && kind != &LaravelStringKind::Config { diff --git a/tests/integration/laravel_commands.rs b/tests/integration/laravel_commands.rs new file mode 100644 index 000000000..018935cab --- /dev/null +++ b/tests/integration/laravel_commands.rs @@ -0,0 +1,326 @@ +//! Tests for Artisan command-name and signature support. +//! +//! A command declared by a `$signature` (or `$name` / `#[AsCommand]`) is +//! surfaced when referenced as a string literal: it completes inside +//! `Artisan::call('|')`, resolves to its declaring class, and unknown names +//! are flagged. Own arguments/options complete against the enclosing +//! command's signature. + +use crate::common::create_psr4_workspace; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +const COMPOSER_JSON: &str = r#"{ + "require": { "laravel/framework": "^11.0" }, + "autoload": { "psr-4": { "App\\": "src/" } } +}"#; + +const SYNC_COMMAND: &str = "\ + Position { + let idx = content.find(needle).expect("needle not found") + needle.len(); + let mut line = 0u32; + let mut character = 0u32; + for (i, ch) in content.char_indices() { + if i == idx { + break; + } + if ch == '\n' { + line += 1; + character = 0; + } else { + character += 1; + } + } + Position { line, character } +} + +fn completion_labels(response: Option) -> Vec { + match response { + Some(CompletionResponse::Array(items)) => items.into_iter().map(|i| i.label).collect(), + Some(CompletionResponse::List(list)) => list.items.into_iter().map(|i| i.label).collect(), + None => Vec::new(), + } +} + +async fn complete_at( + backend: &phpantom_lsp::Backend, + uri: &str, + position: Position, +) -> Vec { + let result = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: Url::parse(uri).unwrap(), + }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap(); + completion_labels(result) +} + +#[tokio::test] +async fn command_name_completes_in_artisan_call() { + let consumer = "\ + loc.uri, + GotoDefinitionResponse::Array(locs) => locs.into_iter().next().unwrap().uri, + GotoDefinitionResponse::Link(links) => links.into_iter().next().unwrap().target_uri, + }; + assert!( + target + .as_str() + .ends_with("/Console/Commands/SyncCommand.php"), + "should jump to SyncCommand.php, got {target}" + ); +} + +#[tokio::test] +async fn unknown_command_name_is_flagged() { + let consumer = "\ + = diags + .iter() + .filter(|d| { + matches!(&d.code, Some(NumberOrString::String(c)) if c == "invalid_laravel_command") + }) + .collect(); + assert_eq!( + command_diags.len(), + 1, + "exactly one unknown command should be flagged, got {command_diags:?}" + ); + assert!( + command_diags[0].message.contains("does:not-exist"), + "message should name the bad command, got {:?}", + command_diags[0].message + ); +} + +#[tokio::test] +async fn own_option_completes_against_signature() { + let (backend, dir) = create_psr4_workspace( + COMPOSER_JSON, + &[("src/Console/Commands/SyncCommand.php", SYNC_COMMAND)], + ); + backend.initialized(InitializedParams {}).await; + + // Edit the command to reference its own option inside handle(). + let edited = "\ +option(''); + } +} +"; + let uri = Url::from_file_path(dir.path().join("src/Console/Commands/SyncCommand.php")) + .unwrap() + .to_string(); + open(&backend, &uri, edited).await; + + let position = position_after(edited, "$this->option('"); + let labels = complete_at(&backend, &uri, position).await; + assert!(labels.contains(&"queue".to_string()), "got {labels:?}"); + assert!(labels.contains(&"conn".to_string()), "got {labels:?}"); + assert!( + !labels.contains(&"user".to_string()), + "arguments should not appear for option(), got {labels:?}" + ); +} + +#[tokio::test] +async fn unknown_own_argument_is_flagged() { + let (backend, dir) = create_psr4_workspace( + COMPOSER_JSON, + &[("src/Console/Commands/SyncCommand.php", SYNC_COMMAND)], + ); + backend.initialized(InitializedParams {}).await; + + let edited = "\ +argument('user'); + $this->argument('nope'); + } +} +"; + let uri = Url::from_file_path(dir.path().join("src/Console/Commands/SyncCommand.php")) + .unwrap() + .to_string(); + open(&backend, &uri, edited).await; + + let mut diags = Vec::new(); + backend.collect_slow_diagnostics(&uri, edited, &mut diags); + + let param_diags: Vec<&Diagnostic> = diags + .iter() + .filter(|d| { + matches!(&d.code, Some(NumberOrString::String(c)) if c == "invalid_command_parameter") + }) + .collect(); + assert_eq!( + param_diags.len(), + 1, + "only the unknown argument should be flagged, got {param_diags:?}" + ); + assert!( + param_diags[0].message.contains("nope"), + "got {:?}", + param_diags[0].message + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 0bc9968e0..028b7e03b 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -120,6 +120,7 @@ mod folding_ranges; mod hover; mod implementation; mod inlay_hints; +mod laravel_commands; mod laravel_contract_concrete; mod laravel_custom_builder; mod laravel_date_factory;