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).
- **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).
- **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).
- **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<Permission, $this, PermissionRole>`), 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).
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,6 @@ unlikely to move the needle for most users.
| 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 |
| L37 | [Request input key completion from validation rules](todo/laravel.md#l37-request-input-key-completion-from-validation-rules) | 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 |
| L29 | [Livewire and Volt component names](todo/laravel.md#l29-livewire-and-volt-component-names) | Low-Medium | Medium |
Expand Down
23 changes: 5 additions & 18 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -847,22 +847,6 @@ binding `bind(Gateway::class, StripeGateway::class)` does **not**
retype `app(Gateway::class)` to the concrete — the contract is the
interface.

#### L37. Request input key completion from validation rules

**Impact: Medium · Effort: Medium**

Inside a controller method or FormRequest, the keys of the validated
input are statically known from the rules array: the `rules()` method
of the `FormRequest` in the method's signature, or the `validate()` /
`Validator::make()` call earlier in the same method. Offer those field
names as string completion (with go-to-definition to the rule line)
in: `$request->input()`, `query()`, `post()`, `string()`, `integer()`,
`boolean()`, `date()`, `enum()`, `file()`, `has()`/`filled()`/
`missing()`, `validated('key')`, `safe()->only([...])`/`except([...])`,
and array access `$request['key']`. Dotted rule keys (`items.*.id`)
complete their root segment. This reuses the rules parsing from L38 —
implement the extraction once, feed both.

#### L38. Typed `validated()` array shapes from rules

**Impact: Medium-High · Effort: Medium-High**
Expand All @@ -889,8 +873,11 @@ member type. `safe()` returns a `ValidatedInput` whose `only()`/
the moment any rule key or rule string is non-literal — a partial
shape that claims completeness would produce false unknown-key
diagnostics. Array-shape machinery (shapes, optional keys, `list<>`)
already exists in the type engine; the work is the rules-to-shape
translation and wiring it as a conditional return.
already exists in the type engine, and
`virtual_members/laravel/validation_rules.rs` already recovers the
literal `(key, rule)` pairs and locates the rules array in scope for a
cursor; the work is the rules-to-shape translation and wiring it as a
conditional return.

#### L39. Unused view and translation key detection

Expand Down
44 changes: 44 additions & 0 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

namespace App;

use App\Http\Requests\StoreBakeryRequest;
use App\Models\Bakery;
use App\Models\BlogAuthor;
use App\Models\BlogPost;
Expand Down Expand Up @@ -413,6 +414,49 @@ public function authUser(Request $request): void
}


// ── Request input keys from validation rules ────────────────────────

public function requestInputKeys(StoreBakeryRequest $request): void
{
// StoreBakeryRequest::rules() names every input this request can
// carry, so the string arguments below complete to those keys and
// go-to-definition jumps to the rule that declares them.
$request->input('name'); // → 'name' => 'required|string|max:255'
$request->boolean('apricot'); // → 'apricot' => 'boolean'
$request->has('dough_temp'); // → 'dough_temp' => 'nullable|numeric'
$request->validated('name'); // → same keys, validated form
$request['name']; // → array access completes too

// A wildcard rule is only addressable through its root segment, so
// `notes.*.body` offers `notes`; a plain dotted key is offered whole.
$request->input('notes'); // → root of 'notes.*.body'
$request->input('owner.email'); // → 'owner.email' => 'required|email'

// safe() narrows the same rule set, whether it is chained straight
// through or parked in a variable first.
$request->safe()->only(['name', 'apricot']);

$safe = $request->safe();
$safe->except(['dough_temp']); // → still StoreBakeryRequest's keys
}


// ── Request input keys from an inline validate() call ───────────────

public function inlineValidateKeys(Request $request): void
{
// A plain Request has no rules() to read, so the keys come from the
// validate() call earlier in this same method.
$request->validate([
'headline' => 'required|string',
'published_at' => 'nullable|date',
]);

$request->input('headline'); // → from the validate() call above
$request->filled('published_at'); // → from the validate() call above
}


// ── @mixin of an Eloquent model exposes its virtual members ─────────

public function mixinModel(): void
Expand Down
39 changes: 39 additions & 0 deletions examples/laravel/app/Http/Requests/StoreBakeryRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

/**
* The keys of `rules()` are the complete set of inputs this request may
* carry, so PHPantom offers them as string completion inside
* `$request->input('…')` and friends — see `Demo::requestInputKeys()`.
*/
class StoreBakeryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'apricot' => 'boolean',
'dough_temp' => 'nullable|numeric',
'notes' => 'array',
'notes.*.body' => 'required|string',
'owner.email' => 'required|email',
];
}

public function withValidator(): void
{
// Inside the request itself, `$this` resolves the same rule keys.
$this->input('name');
}
}
33 changes: 33 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,39 @@ class_uses_recursive(\App\Models\BlogPost::class),
(new ReflectionMethod(\Carbon\CarbonImmutable::class, 'this'))->isProtected()
);

// ─── Validation rules are the request's input contract ──────────────────────

// Demo::requestInputKeys() claims these keys complete inside
// `$request->input('…')`. They come straight from rules(), so assert the
// rule set the demo comments describe is the one the class actually declares.
$bakeryRules = (new \App\Http\Requests\StoreBakeryRequest())->rules();
check(
'StoreBakeryRequest::rules() declares the demoed keys',
array_keys($bakeryRules) === [
'name',
'apricot',
'dough_temp',
'notes',
'notes.*.body',
'owner.email',
]
);
check(
'FormRequest extends Request, so its input accessors are inherited',
is_subclass_of(
\Illuminate\Foundation\Http\FormRequest::class,
\Illuminate\Http\Request::class
)
);
// `safe()` has no native return type — its docblock says
// `ValidatedInput|array` — so the demo's `safe()->only([…])` relies on
// ValidatedInput declaring the narrowing methods.
check(
'ValidatedInput narrows with only()/except()',
method_exists(\Illuminate\Support\ValidatedInput::class, 'only')
&& method_exists(\Illuminate\Support\ValidatedInput::class, 'except')
);

// ─── Summary ────────────────────────────────────────────────────────────────

echo "\n";
Expand Down
36 changes: 3 additions & 33 deletions src/completion/command_params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,41 +122,11 @@ impl Backend {
}
}

/// 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<DetectedContext> {
let cursor_offset = position_to_offset(content, position) as usize;
let (quote_pos, prefix) = find_open_quote(content, cursor_offset)?;
let (quote_pos, _) =
crate::completion::source::helpers::find_open_quote(content, cursor_offset)?;
let prefix = content[quote_pos + 1..cursor_offset].to_string();
let before_quote = content[..quote_pos].trim_end();

// ── Own argument / option: `->argument('|')` / `->option('|')` ─────────
Expand Down
40 changes: 9 additions & 31 deletions src/completion/eloquent_string.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ pub(crate) struct StringCallContext {
pub is_static: bool,
pub arg_index: usize,
pub string_content_start: usize,
/// Byte offset of the `(` that opens the call's argument list.
///
/// `subject` only captures a bare identifier or `$variable`; callers that
/// need the full receiver expression (e.g. `$request->safe()`) re-read it
/// from the source text preceding this offset.
pub call_open_paren: usize,
}

/// Detect a string-inside-call context at the cursor position.
Expand All @@ -130,37 +136,8 @@ pub(crate) fn detect_string_call_context(
position: Position,
) -> Option<StringCallContext> {
let cursor_offset = position_to_offset(content, position) as usize;
let bytes = content.as_bytes();

if cursor_offset == 0 || cursor_offset > bytes.len() {
return None;
}

let mut quote_pos = None;
let mut quote_char = '\'';
let mut i = cursor_offset;
while i > 0 {
i -= 1;
let ch = bytes[i];
if ch == b'\'' || ch == b'"' {
let mut backslashes = 0;
let mut j = i;
while j > 0 && bytes[j - 1] == b'\\' {
backslashes += 1;
j -= 1;
}
if backslashes % 2 == 0 {
quote_pos = Some(i);
quote_char = ch as char;
break;
}
}
if ch == b'\n' {
return None;
}
}

let quote_pos = quote_pos?;
let (quote_pos, quote_char) =
crate::completion::source::helpers::find_open_quote(content, cursor_offset)?;
let string_content_start = quote_pos + 1;
let partial = content[string_content_start..cursor_offset].to_string();

Expand Down Expand Up @@ -195,6 +172,7 @@ pub(crate) fn detect_string_call_context(
is_static,
arg_index,
string_content_start,
call_open_paren: paren_pos,
})
}

Expand Down
17 changes: 17 additions & 0 deletions src/completion/handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,23 @@ impl Backend {
return Ok(Some(response));
}

// ── Request input key completion ────────────────────────
// `$request->input('|')`, `->has('|')`, `$request['|']`, …
// offer the field names the validation rules in scope define.
// Runs after the Eloquent strategies because `has()` is also a
// relation method: a Builder subject resolves there first, and
// only a request-typed subject falls through to here.
if is_laravel
&& matches!(
string_ctx,
StringContext::InStringLiteral | StringContext::NotInString
)
&& let Some(response) =
self.try_request_input_key_completion(&uri, &content, position, &ctx)
{
return Ok(Some(response));
}

// ── Laravel route controller method completion ─────────
// Inside `Route::controller(X::class)->group(fn(){…})`,
// the 2nd argument string of Route::get/post/patch/… is a
Expand Down
Loading
Loading