Skip to content

Commit 55dde0b

Browse files
authored
Request input key completion from validation rules (#292)
1 parent 3095978 commit 55dde0b

20 files changed

Lines changed: 2313 additions & 83 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525
- **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.
2626
- **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.
2727
- **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).
28+
- **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).
2829
- **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).
2930
- **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).
3031
- **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).

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,6 @@ unlikely to move the needle for most users.
171171
| 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 |
172172
| L17 | [Additional string contexts without booting](todo/laravel.md#l17-additional-string-contexts-without-booting) (middleware, assets, validation, Inertia) | Medium | Medium |
173173
| L36 | [Container binding registrations from service providers](todo/laravel.md#l36-container-binding-registrations-from-service-providers) | Medium | Medium |
174-
| L37 | [Request input key completion from validation rules](todo/laravel.md#l37-request-input-key-completion-from-validation-rules) | Medium | Medium |
175174
| L25 | [Storage disk name strings](todo/laravel.md#l25-storage-disk-name-strings) | Low-Medium | Low |
176175
| L31 | [String-key rename, highlight, and semantic tokens](todo/laravel.md#l31-string-key-rename-highlight-and-semantic-tokens) | Low-Medium | Low-Medium |
177176
| L29 | [Livewire and Volt component names](todo/laravel.md#l29-livewire-and-volt-component-names) | Low-Medium | Medium |

docs/todo/laravel.md

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -847,22 +847,6 @@ binding `bind(Gateway::class, StripeGateway::class)` does **not**
847847
retype `app(Gateway::class)` to the concrete — the contract is the
848848
interface.
849849

850-
#### L37. Request input key completion from validation rules
851-
852-
**Impact: Medium · Effort: Medium**
853-
854-
Inside a controller method or FormRequest, the keys of the validated
855-
input are statically known from the rules array: the `rules()` method
856-
of the `FormRequest` in the method's signature, or the `validate()` /
857-
`Validator::make()` call earlier in the same method. Offer those field
858-
names as string completion (with go-to-definition to the rule line)
859-
in: `$request->input()`, `query()`, `post()`, `string()`, `integer()`,
860-
`boolean()`, `date()`, `enum()`, `file()`, `has()`/`filled()`/
861-
`missing()`, `validated('key')`, `safe()->only([...])`/`except([...])`,
862-
and array access `$request['key']`. Dotted rule keys (`items.*.id`)
863-
complete their root segment. This reuses the rules parsing from L38 —
864-
implement the extraction once, feed both.
865-
866850
#### L38. Typed `validated()` array shapes from rules
867851

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

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

examples/laravel/app/Demo.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
namespace App;
1010

11+
use App\Http\Requests\StoreBakeryRequest;
1112
use App\Models\Bakery;
1213
use App\Models\BlogAuthor;
1314
use App\Models\BlogPost;
@@ -413,6 +414,49 @@ public function authUser(Request $request): void
413414
}
414415

415416

417+
// ── Request input keys from validation rules ────────────────────────
418+
419+
public function requestInputKeys(StoreBakeryRequest $request): void
420+
{
421+
// StoreBakeryRequest::rules() names every input this request can
422+
// carry, so the string arguments below complete to those keys and
423+
// go-to-definition jumps to the rule that declares them.
424+
$request->input('name'); // → 'name' => 'required|string|max:255'
425+
$request->boolean('apricot'); // → 'apricot' => 'boolean'
426+
$request->has('dough_temp'); // → 'dough_temp' => 'nullable|numeric'
427+
$request->validated('name'); // → same keys, validated form
428+
$request['name']; // → array access completes too
429+
430+
// A wildcard rule is only addressable through its root segment, so
431+
// `notes.*.body` offers `notes`; a plain dotted key is offered whole.
432+
$request->input('notes'); // → root of 'notes.*.body'
433+
$request->input('owner.email'); // → 'owner.email' => 'required|email'
434+
435+
// safe() narrows the same rule set, whether it is chained straight
436+
// through or parked in a variable first.
437+
$request->safe()->only(['name', 'apricot']);
438+
439+
$safe = $request->safe();
440+
$safe->except(['dough_temp']); // → still StoreBakeryRequest's keys
441+
}
442+
443+
444+
// ── Request input keys from an inline validate() call ───────────────
445+
446+
public function inlineValidateKeys(Request $request): void
447+
{
448+
// A plain Request has no rules() to read, so the keys come from the
449+
// validate() call earlier in this same method.
450+
$request->validate([
451+
'headline' => 'required|string',
452+
'published_at' => 'nullable|date',
453+
]);
454+
455+
$request->input('headline'); // → from the validate() call above
456+
$request->filled('published_at'); // → from the validate() call above
457+
}
458+
459+
416460
// ── @mixin of an Eloquent model exposes its virtual members ─────────
417461

418462
public function mixinModel(): void
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace App\Http\Requests;
4+
5+
use Illuminate\Foundation\Http\FormRequest;
6+
7+
/**
8+
* The keys of `rules()` are the complete set of inputs this request may
9+
* carry, so PHPantom offers them as string completion inside
10+
* `$request->input('…')` and friends — see `Demo::requestInputKeys()`.
11+
*/
12+
class StoreBakeryRequest extends FormRequest
13+
{
14+
public function authorize(): bool
15+
{
16+
return true;
17+
}
18+
19+
/**
20+
* @return array<string, mixed>
21+
*/
22+
public function rules(): array
23+
{
24+
return [
25+
'name' => 'required|string|max:255',
26+
'apricot' => 'boolean',
27+
'dough_temp' => 'nullable|numeric',
28+
'notes' => 'array',
29+
'notes.*.body' => 'required|string',
30+
'owner.email' => 'required|email',
31+
];
32+
}
33+
34+
public function withValidator(): void
35+
{
36+
// Inside the request itself, `$this` resolves the same rule keys.
37+
$this->input('name');
38+
}
39+
}

examples/laravel/assertions.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,39 @@ class_uses_recursive(\App\Models\BlogPost::class),
338338
(new ReflectionMethod(\Carbon\CarbonImmutable::class, 'this'))->isProtected()
339339
);
340340

341+
// ─── Validation rules are the request's input contract ──────────────────────
342+
343+
// Demo::requestInputKeys() claims these keys complete inside
344+
// `$request->input('…')`. They come straight from rules(), so assert the
345+
// rule set the demo comments describe is the one the class actually declares.
346+
$bakeryRules = (new \App\Http\Requests\StoreBakeryRequest())->rules();
347+
check(
348+
'StoreBakeryRequest::rules() declares the demoed keys',
349+
array_keys($bakeryRules) === [
350+
'name',
351+
'apricot',
352+
'dough_temp',
353+
'notes',
354+
'notes.*.body',
355+
'owner.email',
356+
]
357+
);
358+
check(
359+
'FormRequest extends Request, so its input accessors are inherited',
360+
is_subclass_of(
361+
\Illuminate\Foundation\Http\FormRequest::class,
362+
\Illuminate\Http\Request::class
363+
)
364+
);
365+
// `safe()` has no native return type — its docblock says
366+
// `ValidatedInput|array` — so the demo's `safe()->only([…])` relies on
367+
// ValidatedInput declaring the narrowing methods.
368+
check(
369+
'ValidatedInput narrows with only()/except()',
370+
method_exists(\Illuminate\Support\ValidatedInput::class, 'only')
371+
&& method_exists(\Illuminate\Support\ValidatedInput::class, 'except')
372+
);
373+
341374
// ─── Summary ────────────────────────────────────────────────────────────────
342375

343376
echo "\n";

src/completion/command_params.rs

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -122,41 +122,11 @@ impl Backend {
122122
}
123123
}
124124

125-
/// Find the opening quote before the cursor and the prefix typed so far.
126-
/// Returns `(quote_pos, prefix)` or `None` when the cursor is not inside an
127-
/// unterminated single-line string.
128-
fn find_open_quote(content: &str, cursor_offset: usize) -> Option<(usize, String)> {
129-
let bytes = content.as_bytes();
130-
if cursor_offset == 0 || cursor_offset > bytes.len() {
131-
return None;
132-
}
133-
let mut i = cursor_offset;
134-
while i > 0 {
135-
i -= 1;
136-
let ch = bytes[i];
137-
if ch == b'\'' || ch == b'"' {
138-
// Count preceding backslashes to skip escaped quotes.
139-
let mut bs = 0;
140-
let mut j = i;
141-
while j > 0 && bytes[j - 1] == b'\\' {
142-
bs += 1;
143-
j -= 1;
144-
}
145-
if bs % 2 == 0 {
146-
let prefix = content[i + 1..cursor_offset].to_string();
147-
return Some((i, prefix));
148-
}
149-
}
150-
if ch == b'\n' {
151-
return None;
152-
}
153-
}
154-
None
155-
}
156-
157125
fn detect_context(content: &str, position: Position) -> Option<DetectedContext> {
158126
let cursor_offset = position_to_offset(content, position) as usize;
159-
let (quote_pos, prefix) = find_open_quote(content, cursor_offset)?;
127+
let (quote_pos, _) =
128+
crate::completion::source::helpers::find_open_quote(content, cursor_offset)?;
129+
let prefix = content[quote_pos + 1..cursor_offset].to_string();
160130
let before_quote = content[..quote_pos].trim_end();
161131

162132
// ── Own argument / option: `->argument('|')` / `->option('|')` ─────────

src/completion/eloquent_string.rs

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ pub(crate) struct StringCallContext {
118118
pub is_static: bool,
119119
pub arg_index: usize,
120120
pub string_content_start: usize,
121+
/// Byte offset of the `(` that opens the call's argument list.
122+
///
123+
/// `subject` only captures a bare identifier or `$variable`; callers that
124+
/// need the full receiver expression (e.g. `$request->safe()`) re-read it
125+
/// from the source text preceding this offset.
126+
pub call_open_paren: usize,
121127
}
122128

123129
/// Detect a string-inside-call context at the cursor position.
@@ -130,37 +136,8 @@ pub(crate) fn detect_string_call_context(
130136
position: Position,
131137
) -> Option<StringCallContext> {
132138
let cursor_offset = position_to_offset(content, position) as usize;
133-
let bytes = content.as_bytes();
134-
135-
if cursor_offset == 0 || cursor_offset > bytes.len() {
136-
return None;
137-
}
138-
139-
let mut quote_pos = None;
140-
let mut quote_char = '\'';
141-
let mut i = cursor_offset;
142-
while i > 0 {
143-
i -= 1;
144-
let ch = bytes[i];
145-
if ch == b'\'' || ch == b'"' {
146-
let mut backslashes = 0;
147-
let mut j = i;
148-
while j > 0 && bytes[j - 1] == b'\\' {
149-
backslashes += 1;
150-
j -= 1;
151-
}
152-
if backslashes % 2 == 0 {
153-
quote_pos = Some(i);
154-
quote_char = ch as char;
155-
break;
156-
}
157-
}
158-
if ch == b'\n' {
159-
return None;
160-
}
161-
}
162-
163-
let quote_pos = quote_pos?;
139+
let (quote_pos, quote_char) =
140+
crate::completion::source::helpers::find_open_quote(content, cursor_offset)?;
164141
let string_content_start = quote_pos + 1;
165142
let partial = content[string_content_start..cursor_offset].to_string();
166143

@@ -195,6 +172,7 @@ pub(crate) fn detect_string_call_context(
195172
is_static,
196173
arg_index,
197174
string_content_start,
175+
call_open_paren: paren_pos,
198176
})
199177
}
200178

src/completion/handler/mod.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,23 @@ impl Backend {
326326
return Ok(Some(response));
327327
}
328328

329+
// ── Request input key completion ────────────────────────
330+
// `$request->input('|')`, `->has('|')`, `$request['|']`, …
331+
// offer the field names the validation rules in scope define.
332+
// Runs after the Eloquent strategies because `has()` is also a
333+
// relation method: a Builder subject resolves there first, and
334+
// only a request-typed subject falls through to here.
335+
if is_laravel
336+
&& matches!(
337+
string_ctx,
338+
StringContext::InStringLiteral | StringContext::NotInString
339+
)
340+
&& let Some(response) =
341+
self.try_request_input_key_completion(&uri, &content, position, &ctx)
342+
{
343+
return Ok(Some(response));
344+
}
345+
329346
// ── Laravel route controller method completion ─────────
330347
// Inside `Route::controller(X::class)->group(fn(){…})`,
331348
// the 2nd argument string of Route::get/post/patch/… is a

0 commit comments

Comments
 (0)