Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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).
- **Route parameter names complete from the route's URI.** The parameters array of `route('users.show', ['user' => $user])` is keyed by the `{parameters}` of the route's URI, so PHPantom now offers those keys as completion, in `to_route()`, `signedRoute()`, and `temporarySignedRoute()` as well. The URI comes from the registration the route name was declared on (`Route::get('/users/{user}/posts/{post}', …)->name('users.posts.show')`), together with every URI prefix that registration inherits, so nested and grouped routes offer the full parameter set. Optional markers and scoped bindings (`{post:slug}`) are reduced to the key Laravel actually accepts. Contributed by @shuvroroy (#301).
- **Request input keys complete from validation rules.** The keys of a Laravel validation rules array are the complete set of inputs a request may carry, so PHPantom now offers them as string completion wherever a request accessor names a field: `$request->input('key')`, the typed and presence accessors, the multi-key ones such as `only()` and `except()`, `safe()->only([...])`, and array access `$request['key']`. The rules come from the `rules()` method of the `FormRequest` type-hinted in the enclosing method (following `array_merge()` and the parent chain), or from a `validate()` / `Validator::make()` call earlier in the same method. Each suggestion shows its rule (`required|string|max:255`), and go-to-definition on the key jumps to the line that declares it. Wildcard rules like `items.*.id` complete their root segment, and a computed rule key simply contributes nothing rather than a wrong suggestion. Contributed by @shuvroroy (#292).
- **`validated()` is typed from the validation rules.** `$request->validated()` is declared to return `array` and no tool takes it further, but the rules array already says which keys the result holds and what each one is. PHPantom now translates it into an array shape, so `$data['title']` is a `string`, `$data['views']` is an `int`, `'nullable'` adds `null`, a field that is neither required nor nullable becomes an optional key, `'items.*.id'` produces `list<array{id: int}>`, and an `'image'` rule gives you a real `UploadedFile` to chain from. The keys complete on array access and the whole shape shows in hover. This covers `$request->validate([...])` return values, `$validator->validated()` where the rules are visible, `validated('key')` for a single member, and `safe()->only([...])` / `except([...])`, which narrow the same shape. When a rule key is computed the shape is abandoned for plain `array` rather than claiming a key set it cannot vouch for. Contributed by @shuvroroy (#294).
- **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<Model>` generic is required. Contributed by @shuvroroy (#260).
Expand Down
2 changes: 1 addition & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,13 @@ unlikely to move the needle for most users.
| L24 | [Translation depth: JSON lang files, locales, placeholders](todo/laravel.md#l24-translation-depth-json-lang-files-locales-placeholders) | Medium-High | Medium |
| L26 | [Gate ability and policy strings](todo/laravel.md#l26-gate-ability-and-policy-strings) | Medium-High | Medium-High |
| L16 | [Hover for Laravel string keys](todo/laravel.md) | Medium | Low-Medium |
| L23 | [Route parameter name completion](todo/laravel.md#l23-route-parameter-name-completion) | Medium | Low-Medium |
| L30 | [Eloquent attribute-array key completion](todo/laravel.md#l30-eloquent-attribute-array-key-completion) | Medium | Low-Medium |
| L32 | [Config-backed named-resource strings](todo/laravel.md#l32-config-backed-named-resource-strings) (log channels, cache stores, guards, connections, rate limiters) | Medium | Low-Medium |
| L17 | [Additional string contexts without booting](todo/laravel.md#l17-additional-string-contexts-without-booting) (middleware, assets, validation, Inertia) | Medium | Medium |
| L36 | [Container binding registrations from service providers](todo/laravel.md#l36-container-binding-registrations-from-service-providers) | Medium | Medium |
| L25 | [Storage disk name strings](todo/laravel.md#l25-storage-disk-name-strings) | Low-Medium | Low |
| L31 | [String-key rename, highlight, and semantic tokens](todo/laravel.md#l31-string-key-rename-highlight-and-semantic-tokens) | Low-Medium | Low-Medium |
| L43 | [Resource route URIs](todo/laravel.md#l43-resource-route-uris) | Low-Medium | Low-Medium |
| L42 | [Morph alias completion in array positions](todo/laravel.md#l42-morph-alias-completion-in-array-positions) | Low-Medium | Low-Medium |
| L41 | [Morph aliases in `*_type` column comparisons](todo/laravel.md#l41-morph-aliases-in-_type-column-comparisons) | Low-Medium | Medium |
| L29 | [Livewire and Volt component names](todo/laravel.md#l29-livewire-and-volt-component-names) | Low-Medium | Medium |
Expand Down
26 changes: 14 additions & 12 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,20 +687,22 @@ pattern set. Gaps by kind:
class. No new data needed. Provider-registered binding names stay out
of scope (see the table at the top).

#### L23. Route parameter name completion
#### L43. Resource route URIs

**Impact: Medium · Effort: Low-Medium**
**Impact: Low-Medium · Effort: Low-Medium**

`route('users.show', ['user' => 1])` — the second argument's array keys
are the route's URI parameters. The route-name scanner already locates
the `->name()` registration; extend it to record the URI literal from
the same registration chain (`Route::get('/users/{user}/posts/{post}',
…)`) and complete the `{param}` names (minus the `?` optional marker) as
array keys in the parameters argument of `route()`, `to_route()`,
`signedRoute()`, and `temporarySignedRoute()`. The Laravel LSP reads
parameters off booted route objects; the URI literal in the registration
gives us the same data statically. The recorded URI also feeds the L16
hover (`[GET] /users/{user}` next to the route name).
`Route::resource()` / `apiResource()` registrations name no URI, so the
routes the scanner generates for them carry no URI and their parameters
do not complete. Laravel derives the URI from the resource name
(`Route::resource('photos', …)` → `photos`, `photos/create`,
`photos/{photo}`, `photos/{photo}/edit`), where the parameter is
`Str::singular()` of the last segment — nested names singularize each
prior segment (`photos.comments` → `photos/{photo}/comments/{comment}`)
— and `->parameters(['photos' => 'grid'])` overrides the derived name.
Recording those URIs completes route parameters for resource routes and
gives the L16 hover a URI to show for them. A singularizer is needed;
`pluralize_english_word` in `virtual_members/laravel/mod.rs` is the
existing counterpart.

#### L24. Translation depth: JSON lang files, locales, placeholders

Expand Down
8 changes: 8 additions & 0 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,8 @@ public function laravelConfigEnv(): void
* 4. Ctrl+Click "auth.failed" to jump to lang/en/auth.php.
* 5. Ctrl+Click "theme.dashboard" to jump to a view under the custom
* path registered in config/view.php (resources/theme/views).
* 6. Type a quote inside route('bakeries.show', [ … ]) to complete the
* route's URI parameter names.
*/
public function laravelNavigation(): void
{
Expand All @@ -286,6 +288,12 @@ public function laravelNavigation(): void
// with the route file under app/Modules instead of the routes/ dir.
route('reviews.update');

// Route parameters — the keys of the second argument are the
// {parameters} of the route's URI (here bakeries/{bakery}, whose
// prefix comes from the enclosing Route::prefix('bakeries') group).
route('bakeries.show', ['bakery' => 1]);
route('bakeries.cancel', ['bakery' => 1]);

// Translation Keys
__('messages.welcome');
trans('auth.failed');
Expand Down
109 changes: 10 additions & 99 deletions src/completion/command_params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ fn detect_context(content: &str, position: Position) -> Option<DetectedContext>
// ── 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);
let (name, rest) = crate::completion::source::helpers::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 {
Expand Down Expand Up @@ -177,115 +177,26 @@ fn detect_context(content: &str, position: Position) -> Option<DetectedContext>
/// 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
/// Returns `None` when the enclosing call is not a recognised
/// command-running call.
fn command_name_for_array_key(content: &str, quote_pos: usize) -> Option<String> {
let bytes = content.as_bytes();
let call = crate::completion::source::helpers::enclosing_array_key_call(content, quote_pos)?;
let before_callee = call.before_callee;

// 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 recognised = if let Some(receiver) = before_callee.strip_suffix("::") {
let subject = crate::completion::source::helpers::trailing_class_name(receiver);
let subject = subject.rsplit('\\').next().unwrap_or(subject);
matches!(
(subject.to_ascii_lowercase().as_str(), method.as_str()),
(subject.to_ascii_lowercase().as_str(), call.callee.as_str()),
("artisan", "call" | "queue") | ("schedule", "command")
)
} else if is_instance {
matches!(method.as_str(), "call" | "callsilently")
} else if before_callee.ends_with("->") {
matches!(call.callee.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
recognised.then_some(call.name)
}

#[cfg(test)]
Expand Down
13 changes: 13 additions & 0 deletions src/completion/handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,19 @@ impl Backend {
return Ok(Some(response));
}

// ── Route parameter completion ──────────────────────────
// The keys of `route('users.show', ['|' => 1])` are the URI
// parameters of the named route.
if is_laravel
&& matches!(
string_ctx,
StringContext::InStringLiteral | StringContext::NotInString
)
&& let Some(response) = self.try_route_param_completion(&content, position)
{
return Ok(Some(response));
}

// ── Artisan command parameter completion ────────────────
// `$this->argument('|')` / `$this->option('|')` against the
// enclosing command's own signature, and array keys of
Expand Down
Loading
Loading