diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e60e517b..7672dc64 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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). +- **`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`, 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` 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.md b/docs/todo.md index 49181955..245ba73a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -163,7 +163,6 @@ unlikely to move the needle for most users. | L15 | [Completion for Laravel string keys](todo/laravel.md) | High | Medium | | L5 | [`abort_if`/`abort_unless` type narrowing](todo/laravel.md#l5-abort_ifabort_unless-type-narrowing) | High | Medium | | L24 | [Translation depth: JSON lang files, locales, placeholders](todo/laravel.md#l24-translation-depth-json-lang-files-locales-placeholders) | Medium-High | Medium | -| L38 | [Typed `validated()` array shapes from rules](todo/laravel.md#l38-typed-validated-array-shapes-from-rules) | Medium-High | Medium-High | | 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 | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 59700e6f..d8fd40a5 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -847,38 +847,6 @@ binding `bind(Gateway::class, StripeGateway::class)` does **not** retype `app(Gateway::class)` to the concrete — the contract is the interface. -#### L38. Typed `validated()` array shapes from rules - -**Impact: Medium-High · Effort: Medium-High** - -No mainstream tool types `$request->validated()` beyond `array` — this -is a leapfrog opportunity, not catch-up. The rules array is a static -type contract; translate it into an array shape: - -- `'name' => 'required|string|max:255'` → `name: string` -- `'age' => 'nullable|integer'` → `age: int|null` -- `'active' => 'boolean'` → `active: bool` -- `'role' => [new Enum(Role::class)]` / enum-cast columns → `role: string` - (the raw validated value; the enum object only exists after `enum()`) -- `'items' => 'array'`, `'items.*.id' => 'integer'` → - `items: list` -- fields without `required` (and without `nullable`) are optional keys - (`name?:`), since absent input never reaches the validated array - -Apply the shape to `$request->validated()` (no-argument form), -`$request->validate([...])` return values, and `$validator->validated()` -where the rules are visible. `validated('key')` returns the shape's -member type. `safe()` returns a `ValidatedInput` whose `only()`/ -`except()` results narrow the same shape. Fall back to plain `array` -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, 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 **Impact: Low · Effort: Medium** diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 39c135f1..6a855834 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -457,6 +457,51 @@ public function inlineValidateKeys(Request $request): void } + // ── Typed validated() array shapes from rules ─────────────────────── + + public function validatedArrayShape(StoreBakeryRequest $request): void + { + // validated() is declared `array`, but the rules array says exactly + // which keys it holds and what each one is, so it resolves to: + // array{ + // name: string, + // apricot?: bool, + // dough_temp?: ?int|float, + // notes?: list, + // owner: array{email: string}, + // } + $data = $request->validated(); + + $data['name']; // → string + $data['apricot']; // → bool ('apricot' is optional) + $data['notes']; // → list + $data['owner']['email']; // → string + + // A key argument returns that member's type rather than the array. + $request->validated('name'); // → string + + // safe() narrows the same shape. + $subset = $request->safe()->only(['name', 'apricot']); + $subset['name']; // → string, and 'notes' is gone + } + + + public function validatedShapeFromInlineRules(Request $request): void + { + // The rules passed to validate() type its return value directly, so + // no FormRequest class is needed. + $data = $request->validate([ + 'headline' => 'required|string', + 'rank' => 'required|integer', + 'featured' => 'boolean', + ]); + + $data['headline']; // → string + $data['rank']; // → int + $data['featured']; // → bool (optional key) + } + + // ── @mixin of an Eloquent model exposes its virtual members ───────── public function mixinModel(): void diff --git a/examples/laravel/assertions.php b/examples/laravel/assertions.php index 7eefab50..9cf19fc8 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -371,6 +371,49 @@ class_uses_recursive(\App\Models\BlogPost::class), && method_exists(\Illuminate\Support\ValidatedInput::class, 'except') ); +// ─── Validated arrays only carry keys the rules named ─────────────────────── + +// Demo::validatedArrayShape() reads StoreBakeryRequest's rules as an array +// shape. Two claims it makes are worth pinning to the real validator: that +// the result carries only keys the rules named, and — the reason `apricot` +// and `dough_temp` are *optional* keys rather than nullable ones — that a +// field which is merely allowed is absent when it was not sent. +// +// Built directly rather than through the facade, which needs a booted +// container. +$translator = new \Illuminate\Translation\Translator( + new \Illuminate\Translation\ArrayLoader(), + 'en' +); +$validated = (new \Illuminate\Validation\Validator( + $translator, + [ + 'name' => 'Sourdough', + 'owner' => ['email' => 'baker@example.com'], + 'unlisted' => 'ignored', + ], + (new \App\Http\Requests\StoreBakeryRequest())->rules() +))->validated(); +check( + 'validated() drops input the rules do not name', + ! array_key_exists('unlisted', $validated) +); +check( + 'validated() keeps the fields that were supplied', + ($validated['name'] ?? null) === 'Sourdough' +); +check( + 'an unsent optional field is absent from validated()', + ! array_key_exists('apricot', $validated) +); +// `dough_temp` is `nullable|numeric`. `nullable` permits a null value; it +// does not make the key appear, which is why the shape marks it optional +// (`dough_temp?: ?int|float`) rather than merely nullable. +check( + 'an unsent nullable field is absent, not present-and-null', + ! array_key_exists('dough_temp', $validated) +); + // ─── Summary ──────────────────────────────────────────────────────────────── echo "\n"; diff --git a/src/analyse/run.rs b/src/analyse/run.rs index ef22dd3e..dd07f9bb 100644 --- a/src/analyse/run.rs +++ b/src/analyse/run.rs @@ -288,6 +288,8 @@ pub async fn run(options: AnalyseOptions) -> i32 { crate::type_engine::call_resolution::with_callable_target_cache(); let _body_infer_guard = backend.activate_body_return_inferrer(); let _auth_user_guard = backend.activate_auth_user_resolver(); + let _validation_rules_guard = + backend.activate_validation_rules_resolver(); // ── Forward-walked diagnostic scope cache ─── // Walk every function/method body once with the diff --git a/src/completion/handler/mod.rs b/src/completion/handler/mod.rs index f7227071..c3577e99 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -163,6 +163,7 @@ impl Backend { let _chain_guard = crate::type_engine::resolver::with_chain_resolution_cache(); let _body_infer_guard = self.activate_body_return_inferrer(); let _auth_user_guard = self.activate_auth_user_resolver(); + let _validation_rules_guard = self.activate_validation_rules_resolver(); let _cache_guard = crate::virtual_members::with_active_resolved_class_cache( &self.resolved_class_cache, ); diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 9fbce7b4..266edd1e 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -262,6 +262,7 @@ impl Backend { let _callable_guard = crate::type_engine::call_resolution::with_callable_target_cache(); let _body_infer_guard = self.activate_body_return_inferrer(); let _auth_user_guard = self.activate_auth_user_resolver(); + let _validation_rules_guard = self.activate_validation_rules_resolver(); // ── Phase 2: forward-walked diagnostic scope cache ────── // Walk every function/method body in the file once with the diff --git a/src/hover/mod.rs b/src/hover/mod.rs index 534e205b..98e2452e 100644 --- a/src/hover/mod.rs +++ b/src/hover/mod.rs @@ -88,6 +88,7 @@ impl Backend { pub fn handle_hover(&self, uri: &str, content: &str, position: Position) -> Option { let _body_infer_guard = self.activate_body_return_inferrer(); let _auth_user_guard = self.activate_auth_user_resolver(); + let _validation_rules_guard = self.activate_validation_rules_resolver(); let offset = crate::text_position::position_to_offset(content, position); // Try the exact cursor offset first. diff --git a/src/type_engine/call_resolution/mod.rs b/src/type_engine/call_resolution/mod.rs index e92e0947..5dd311a5 100644 --- a/src/type_engine/call_resolution/mod.rs +++ b/src/type_engine/call_resolution/mod.rs @@ -53,4 +53,6 @@ mod target_cache; mod template_subs; pub(crate) use return_types::MethodReturnCtx; -pub(crate) use target_cache::{try_infer_body_return_type, with_callable_target_cache}; +pub(crate) use target_cache::{ + VALIDATION_RULES_RESOLVER, try_infer_body_return_type, with_callable_target_cache, +}; diff --git a/src/type_engine/call_resolution/return_types.rs b/src/type_engine/call_resolution/return_types.rs index 7a576998..0ae6128a 100644 --- a/src/type_engine/call_resolution/return_types.rs +++ b/src/type_engine/call_resolution/return_types.rs @@ -143,6 +143,39 @@ fn resolve_auth_user_at_call( } } +/// The array shape a Laravel `validated()` / `validate()` / +/// `safe()->only()` call returns, given the rules in scope at the call site. +/// +/// Returns `None` for every other call, leaving the declared return type +/// alone. +fn resolve_validated_shape_at_call( + base: &SubjectExpr, + method_name: &str, + text_args: &str, + owners: &[ResolvedType], + ctx: &ResolutionCtx<'_>, +) -> Option { + use crate::virtual_members::laravel::validated_shape::{self, ShapeCall}; + + let call = validated_shape::shape_bearing_method(method_name)?; + let receiver = owners.iter().find_map(|rt| rt.class_info.as_ref())?; + // Tracing the `safe()` hop resolves another expression, so only do it for + // the narrowing calls that need it. + let safe_source = matches!(call, ShapeCall::Only | ShapeCall::Except) + .then(|| validated_shape::safe_source_class(base, ctx)) + .flatten(); + + validated_shape::resolve_shape_at_call( + receiver, + call, + &split_text_args(text_args), + safe_source.as_deref(), + ctx.content, + ctx.cursor_offset, + ctx.class_loader, + ) +} + /// Recover the guard name from a `user()` call site. /// /// The guard name may be an explicit argument to `user()` itself @@ -292,6 +325,23 @@ impl Backend { return classes; } + // Laravel validated input: `validated()`, `validate([…])` + // and `safe()->only([…])` return the array shape the + // validation rules in scope describe. An array shape has + // no class, so it travels as the type hint alone. + if let Some(shape) = resolve_validated_shape_at_call( + base, + method_name, + text_args, + &lhs_resolved, + ctx, + ) { + if let Some(ref mut hint_out) = return_type_hint_out { + **hint_out = Some(shape); + } + return Vec::new(); + } + // Capture the raw return type hint while we iterate // the owner classes below. We grab it from the first // owner that has a matching method — before the return diff --git a/src/type_engine/call_resolution/target_cache.rs b/src/type_engine/call_resolution/target_cache.rs index 628c7543..4264220c 100644 --- a/src/type_engine/call_resolution/target_cache.rs +++ b/src/type_engine/call_resolution/target_cache.rs @@ -27,6 +27,15 @@ type BodyReturnInferrerFn = Box Option>; /// down. type AuthUserResolverFn = Box) -> Option>; +/// Closure type for looking up the validation rules that describe a +/// request-like receiver. +/// +/// Takes `(receiver_fqn, file_content, call_offset)` and returns the rules +/// array in scope: a `FormRequest`'s own `rules()`, or the nearest preceding +/// `validate()` / `Validator::make()` call in the same function body. +type ValidationRulesResolverFn = + Box Option>; + /// Memoized body-return-inference results, keyed by `(FQN, method)`. type BodyInferMemo = HashMap<(Atom, Atom), Option>; @@ -88,6 +97,16 @@ thread_local! { pub(super) static AUTH_USER_RESOLVER: RefCell> = const { RefCell::new(None) }; + /// When `Some`, `validated()` / `validate()` calls on a request-like + /// receiver resolve to the array shape the validation rules in scope + /// describe, instead of plain `array`. + /// + /// Set up by [`Backend::activate_validation_rules_resolver`] at request + /// entry points, which is where the class index needed to read a + /// `FormRequest`'s `rules()` from another file is available. + pub(crate) static VALIDATION_RULES_RESOLVER: RefCell> = + const { RefCell::new(None) }; + } pub(crate) struct CallableTargetCacheGuard { @@ -277,6 +296,39 @@ pub(crate) fn with_auth_user_resolver(resolver: AuthUserResolverFn) -> AuthUserR AuthUserResolverGuard { owns: true } } +// ── Validation rules resolution ───────────────────────────────────────────── + +/// RAII guard that clears [`VALIDATION_RULES_RESOLVER`] on drop. +pub(crate) struct ValidationRulesResolverGuard { + owns: bool, +} + +impl Drop for ValidationRulesResolverGuard { + fn drop(&mut self) { + if self.owns { + VALIDATION_RULES_RESOLVER.with(|cell| { + *cell.borrow_mut() = None; + }); + } + } +} + +/// Activate validation-rule lookup for the current thread. +/// +/// Returns an RAII guard that clears the resolver on drop. +fn with_validation_rules_resolver( + resolver: ValidationRulesResolverFn, +) -> ValidationRulesResolverGuard { + let already_active = VALIDATION_RULES_RESOLVER.with(|cell| cell.borrow().is_some()); + if already_active { + return ValidationRulesResolverGuard { owns: false }; + } + VALIDATION_RULES_RESOLVER.with(|cell| { + *cell.borrow_mut() = Some(resolver); + }); + ValidationRulesResolverGuard { owns: true } +} + impl Backend { /// Build and activate the thread-local guard-aware auth user model /// resolver. @@ -296,6 +348,22 @@ impl Backend { with_auth_user_resolver(Box::new(resolver)) } + /// Build and activate the thread-local validation rules resolver, which + /// lets `$request->validated()` resolve to the array shape its rules + /// describe. + /// + /// Returns an RAII guard that deactivates the resolver on drop. Call it + /// alongside [`activate_auth_user_resolver`] at request entry points. + /// + /// [`activate_auth_user_resolver`]: Backend::activate_auth_user_resolver + pub(crate) fn activate_validation_rules_resolver(&self) -> ValidationRulesResolverGuard { + let backend = self.clone_for_diagnostic_worker(); + let resolver = move |fqn: &str, content: &str, offset: u32| { + crate::virtual_members::laravel::rules_for_receiver(&backend, fqn, content, offset) + }; + with_validation_rules_resolver(Box::new(resolver)) + } + /// Build and activate the thread-local body return type inferrer. /// /// Returns an RAII guard that deactivates the inferrer on drop. diff --git a/src/type_engine/variable/rhs_resolution/calls.rs b/src/type_engine/variable/rhs_resolution/calls.rs index c31f3f3d..f29273fc 100644 --- a/src/type_engine/variable/rhs_resolution/calls.rs +++ b/src/type_engine/variable/rhs_resolution/calls.rs @@ -12,6 +12,7 @@ use crate::Backend; use crate::atom::{atom, bytes_to_str}; use crate::php_type::{PhpType, TypeKind}; use crate::types::{ClassInfo, ResolvedType}; +use crate::virtual_members::laravel::validated_shape; use crate::type_engine::call_resolution::MethodReturnCtx; use crate::type_engine::conditional_resolution::resolve_conditional_with_args; @@ -1206,12 +1207,25 @@ pub(super) fn resolve_rhs_method_call_inner<'b>( let (owner_classes, receiver_resolved) = expand_union_generic_owners(owner_classes, receiver_resolved, ctx); + // Laravel validated input: the rules that guard this request describe the + // array it hands back, so `$data = $request->validated()` gets a shape + // rather than plain `array`. Classifying the call and tracing a `safe()` + // hop do not depend on the receiver, so both happen once rather than once + // per owner. + let shape_call = validated_shape::shape_bearing_method(&method_name) + .filter(|_| validated_shape::rules_resolver_active()); + for owner in &owner_classes { if let Some(result) = try_resolve_config_method_type(&owner.fqn(), &method_name, argument_list, ctx) { return result; } + if let Some(call) = shape_call + && let Some(shape) = try_resolve_validated_shape(owner, call, object, &arg_refs, ctx) + { + return vec![ResolvedType::from_type_string(shape)]; + } } let is_union = owner_classes.len() > 1; @@ -2075,6 +2089,66 @@ fn facade_accessor_concrete_owner( }) } +/// The array shape a Laravel `validated()` / `validate()` / +/// `safe()->only()` call assigns, given the validation rules in scope. +/// +/// `object` is the expression the method was called on, which is what tells +/// a `ValidatedInput` receiver apart from the request it came from. +fn try_resolve_validated_shape( + owner: &ClassInfo, + call: validated_shape::ShapeCall, + object: &Expression<'_>, + arg_refs: &[&str], + ctx: &VarResolutionCtx<'_>, +) -> Option { + // Tracing a `safe()` hop parses the file, and `only`/`except` are common + // names on Collection and Arr too, so it waits until the receiver is + // actually the wrapper `safe()` returns. + let safe_source = crate::virtual_members::laravel::is_validated_input(owner) + .then(|| safe_source_owner(object, ctx)) + .flatten(); + + validated_shape::resolve_shape_at_call( + owner, + call, + arg_refs, + safe_source.as_deref(), + ctx.content, + object.span().end.offset, + ctx.as_resolution_ctx().class_loader, + ) +} + +/// The request class behind a `ValidatedInput` receiver. +/// +/// Covers both the direct chain (`$request->safe()->only(…)`) and the +/// two-step form (`$safe = $request->safe(); $safe->only(…)`), which L37's +/// assignment tracing already resolves back to the request variable. +fn safe_source_owner( + object: &Expression<'_>, + ctx: &VarResolutionCtx<'_>, +) -> Option> { + let variable = match object { + // `$request->safe()->only(…)` — the receiver is the `safe()` call. + Expression::Call(_) => { + crate::virtual_members::laravel::safe_call_receiver_variable(object)? + } + // `$safe->only(…)` — trace the assignment that produced `$safe`. + Expression::Variable(Variable::Direct(dv)) => { + crate::virtual_members::laravel::safe_source_variable( + ctx.content, + object.span().end.offset as usize, + bytes_to_str(dv.name), + )? + } + _ => return None, + }; + let resolved = resolve_var_types(&variable, ctx, object.span().end.offset); + ResolvedType::into_arced_classes(resolved) + .into_iter() + .next() +} + fn try_resolve_config_method_type( owner_fqn: &str, method_name: &str, diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index be5660f7..8d3518ff 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -100,6 +100,7 @@ mod route_names; mod scopes; mod string_keys; mod trans_keys; +pub(crate) mod validated_shape; pub(crate) mod validation_rules; mod view_names; pub(crate) mod where_property; @@ -127,6 +128,10 @@ pub(crate) use provider_resources::{ProviderResources, extract_provider_resource pub(crate) use request_fields::{request_fields_at_position, resolve_request_field_definition}; pub(crate) use route_names::enumerate_all_route_names; pub(crate) use trans_keys::collect_trans_declarations; +pub(crate) use validated_shape::rules_for_receiver; +pub(crate) use validation_rules::{ + RulesArray, is_validated_input, safe_call_receiver_variable, safe_source_variable, +}; pub(crate) use builder_injection::{try_inject_builder_scopes, try_inject_mixin_builder_scopes}; pub(crate) use string_keys::{find_laravel_string_key_references, resolve_laravel_string_key}; diff --git a/src/virtual_members/laravel/request_fields.rs b/src/virtual_members/laravel/request_fields.rs index de90eb95..2f340427 100644 --- a/src/virtual_members/laravel/request_fields.rs +++ b/src/virtual_members/laravel/request_fields.rs @@ -24,8 +24,8 @@ use crate::text_position::position_to_offset; use crate::types::{ClassInfo, FileContext}; use super::validation_rules::{ - ResolvedRules, RuleField, RulesSource, form_request_rules, inline_validate_rules, - is_form_request, is_request_like, is_validated_input, rule_fields, safe_source_variable, + ResolvedRules, RuleField, RulesSource, is_request_like, is_validated_input, rule_fields, + rules_in_scope, safe_source_variable, }; /// Request accessors whose *first* argument names a single input field. @@ -249,19 +249,15 @@ pub(crate) fn request_fields_at_position( return None; } - let rules = if is_form_request(receiver_class, &class_loader) { - form_request_rules(backend, receiver_class, uri, content) - } else { - None - } - .or_else(|| { - inline_validate_rules(content, cursor_offset as usize).map(|rules| ResolvedRules { - source: RulesSource::CurrentFile, - rules, - }) - })?; - - let fields = rule_fields(&rules.rules); + let rules = rules_in_scope( + backend, + receiver_class, + uri, + content, + cursor_offset as usize, + )?; + + let fields = rule_fields(&rules.rules.rules); if fields.is_empty() { return None; } @@ -310,6 +306,7 @@ pub(crate) fn resolve_request_field_definition( // An exact rule key wins over the root segment it also contributes, so // `input('address.city')` lands on that key rather than on `address`. let key_start = rules + .rules .rules .iter() .find(|rule| rule.key == value) diff --git a/src/virtual_members/laravel/validated_shape.rs b/src/virtual_members/laravel/validated_shape.rs new file mode 100644 index 00000000..41071aad --- /dev/null +++ b/src/virtual_members/laravel/validated_shape.rs @@ -0,0 +1,490 @@ +//! Translate a Laravel validation rules array into an array shape. +//! +//! `$request->validated()` is declared `array` and nothing more, which is as +//! far as every mainstream tool takes it. But the rules array is a static +//! type contract — it names every key the validated result can hold and says +//! what each one is — so it translates directly into an `array{…}` shape: +//! +//! ```text +//! 'name' => 'required|string|max:255' → name: string +//! 'age' => 'nullable|integer' → age?: ?int +//! 'active' => 'boolean' → active?: bool +//! 'items' => 'array' +//! 'items.*.id' => 'integer' → items?: list +//! ``` +//! +//! Two deliberate imprecisions, both matching how the Laravel ecosystem +//! reasons about validated data rather than what PHP holds at runtime: +//! +//! - `validated()` returns the *raw* input, so `'age' => 'integer'` is really +//! the string `"42"` when it came from a form body. The shape says `int`, +//! because that is the value's meaning and every consumer treats it that +//! way. +//! - A rule object (`new Enum(Role::class)`, `Rule::unique(…)`) still yields +//! the raw scalar Laravel validated, not the object. +//! +//! Where a key's *type* cannot be read the entry degrades to `mixed`, which +//! accepts anything and so cannot cause a false diagnostic. Where the key +//! *set* itself is incomplete the whole shape is abandoned for plain `array`, +//! because a shape that omits real keys would report valid code as wrong. + +use std::sync::Arc; + +use crate::Backend; +use crate::php_type::{PhpType, ShapeEntry}; +use crate::type_engine::call_resolution::VALIDATION_RULES_RESOLVER; +use crate::type_engine::resolver::ResolutionCtx; +use crate::type_engine::subject_expr::SubjectExpr; +use crate::types::{AccessKind, ClassInfo}; + +use super::validation_rules::{ + RulesArray, ValidationRule, is_request_like, is_validated_input, rules_from_array_text, + rules_in_scope, +}; + +/// The type of an uploaded file in a validated array. +const UPLOADED_FILE_FQN: &str = "\\Illuminate\\Http\\UploadedFile"; + +/// The contract every validator implements, including `Validator::make()`'s. +const VALIDATOR_CONTRACT_FQN: &str = "Illuminate\\Contracts\\Validation\\Validator"; + +/// The concrete validator `Validator::make()` returns. +const VALIDATOR_FQN: &str = "Illuminate\\Validation\\Validator"; + +/// Translate a rules array into an `array{…}` shape. +/// +/// Returns `None` when the shape would be untrustworthy — an incomplete key +/// set, or no keys at all — and the caller should fall back to plain `array`. +fn rules_to_shape(rules: &RulesArray) -> Option { + if !rules.keys_complete || rules.is_empty() { + return None; + } + + let mut root = Node::default(); + for rule in &rules.rules { + root.insert(&rule.key, RuleSpec::parse(rule)); + } + root.shape() +} + +/// The type of one key of the shape `rules` describes. +/// +/// Dot notation reaches nested keys, so `validated('owner.email')` resolves +/// through the shape the same way the runtime lookup walks the array. +/// Returns `None` when the key is not in the shape. +fn rules_member_type(rules: &RulesArray, key: &str) -> Option { + let shape = rules_to_shape(rules)?; + key.split('.') + .try_fold(shape, |ty, segment| ty.shape_value_type(segment).cloned()) +} + +/// Narrow a shape to the listed keys (`safe()->only([…])`) or to everything +/// but them (`safe()->except([…])`). +/// +/// Returns `None` when no shape is available; an empty `keys` list narrows to +/// nothing and yields an empty shape, matching the runtime. +fn narrow_shape(shape: &PhpType, keys: &[String], keep: bool) -> Option { + let entries = shape.shape_entries()?; + let narrowed: Vec = entries + .iter() + .filter(|entry| { + let listed = entry + .key + .as_deref() + .is_some_and(|k| keys.iter().any(|wanted| wanted == k)); + listed == keep + }) + .cloned() + .collect(); + Some(PhpType::array_shape(narrowed)) +} + +// ─── Call sites ───────────────────────────────────────────────────────────── + +/// The rules that describe a request-like receiver. +/// +/// A `FormRequest` carries its own `rules()`; anything else (a plain +/// `Request`, a `Validator`, a `ValidatedInput`) is described by the nearest +/// `validate()` / `Validator::make()` call preceding `offset`. +/// +/// Called through the thread-local resolver so the type engine can reach the +/// class index without owning a `Backend` handle. +pub(crate) fn rules_for_receiver( + backend: &Backend, + receiver_fqn: &str, + content: &str, + offset: u32, +) -> Option { + let class = backend.find_or_load_class(receiver_fqn)?; + // The type engine has no document URI to offer, so the class lookup falls + // back to the FQN index. Only the entries are wanted here — which file + // they came from matters to go-to-definition, not to a type. + rules_in_scope(backend, &class, "", content, offset as usize).map(|resolved| resolved.rules) +} + +/// A call that can carry a validated-array shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ShapeCall { + /// `$request->validate([…])` — typed from its own argument. + Validate, + /// `$request->validated()` / `validated('key')`. + Validated, + /// `safe()->only([…])`. + Only, + /// `safe()->except([…])`. + Except, +} + +/// Classify a method name, or `None` when it can never bear a shape. +/// +/// Callers gate on this before doing any other work. It runs on every method +/// call the type engine resolves, so it compares in place rather than +/// lowercasing into a fresh `String`. +pub(crate) fn shape_bearing_method(method: &str) -> Option { + const CALLS: [(&str, ShapeCall); 4] = [ + ("validate", ShapeCall::Validate), + ("validated", ShapeCall::Validated), + ("only", ShapeCall::Only), + ("except", ShapeCall::Except), + ]; + CALLS + .iter() + .find(|(name, _)| method.eq_ignore_ascii_case(name)) + .map(|(_, call)| *call) +} + +/// The array shape a validation-aware call returns, or `None` to leave the +/// declared return type alone. +/// +/// Recognises, on a request-like receiver: +/// +/// - `validated()` → the whole shape; `validated('key')` → that key's type +/// - `validate([…])` → the shape of the rules passed at the call site +/// - `safe()->only([…])` / `safe()->except([…])` → the narrowed shape +/// +/// `safe_source` is the request a `ValidatedInput` receiver was narrowed +/// from; callers recover it from whichever expression form they hold. +pub(crate) fn resolve_shape_at_call( + receiver: &ClassInfo, + call: ShapeCall, + args: &[&str], + safe_source: Option<&ClassInfo>, + content: &str, + offset: u32, + class_loader: &dyn Fn(&str) -> Option>, +) -> Option { + match call { + // `$request->validate([...])` returns exactly what its own argument + // describes, so the rules come from the call site rather than scope — + // which is why this arm needs no active resolver. + ShapeCall::Validate if is_request_like(receiver, class_loader) => { + let rules = rules_from_array_text(args.first()?)?; + rules_to_shape(&rules) + } + ShapeCall::Validated + if is_request_like(receiver, class_loader) || is_validator(receiver, class_loader) => + { + let rules = lookup_rules(receiver, content, offset)?; + match args.first().and_then(|a| unquote(a)) { + Some(key) => rules_member_type(&rules, &key), + None => rules_to_shape(&rules), + } + } + // Narrowing applies to a `ValidatedInput` only. `$request->only()` + // reads raw input, which the rules do not describe. + ShapeCall::Only | ShapeCall::Except if is_validated_input(receiver) => { + let rules = lookup_rules(safe_source?, content, offset)?; + let shape = rules_to_shape(&rules)?; + let keys = key_list(args); + narrow_shape(&shape, &keys, call == ShapeCall::Only) + } + _ => None, + } +} + +/// Whether the rules resolver is active on this thread. +/// +/// Callers use it as a free early-out: outside the request entry points that +/// activate it there are no rules to find, so the whole feature is a no-op. +pub(crate) fn rules_resolver_active() -> bool { + VALIDATION_RULES_RESOLVER.with(|cell| cell.borrow().is_some()) +} + +/// Ask the active resolver for the rules describing `receiver` at the cursor. +fn lookup_rules(receiver: &ClassInfo, content: &str, offset: u32) -> Option { + VALIDATION_RULES_RESOLVER.with(|cell| { + let borrowed = cell.borrow(); + let resolver = borrowed.as_ref()?; + resolver(&receiver.fqn(), content, offset) + }) +} + +/// Whether `class` is a validator. +/// +/// Matches the concrete class by name and anything implementing the contract, +/// so a custom validator installed through `Validator::resolver()` is +/// recognised too. Both halves are needed for the same reason +/// [`is_request_like`] needs both: the name check does not require the +/// contract's own file to be indexed, and the subtype walk covers subclasses. +fn is_validator(class: &ClassInfo, class_loader: &dyn Fn(&str) -> Option>) -> bool { + let fqn = class.fqn(); + fqn == VALIDATOR_FQN + || fqn == VALIDATOR_CONTRACT_FQN + || crate::class_lookup::is_subtype_of(class, VALIDATOR_CONTRACT_FQN, class_loader) +} + +/// The request behind a `ValidatedInput`, given the expression `safe()` was +/// called on. +/// +/// For `$request->safe()->only([…])` the receiver of `only` is the `safe()` +/// call, whose own receiver is the request whose rules apply. +pub(crate) fn safe_source_class( + base: &SubjectExpr, + ctx: &ResolutionCtx<'_>, +) -> Option> { + match base { + // `$request->safe()->only(…)` — the request is one hop further left. + SubjectExpr::CallExpr { callee, .. } => { + let SubjectExpr::MethodCall { + base: request_expr, + method, + } = callee.as_ref() + else { + return None; + }; + if !method.eq_ignore_ascii_case("safe") { + return None; + } + crate::type_engine::resolver::resolve_target_classes_expr( + request_expr, + AccessKind::Arrow, + ctx, + ) + .into_iter() + .find_map(|rt| rt.class_info) + } + // `$safe = $request->safe(); $safe->only(…)` — trace the assignment + // back to the request variable, then resolve that. + SubjectExpr::Variable(name) => { + let request = super::validation_rules::safe_source_variable( + ctx.content, + ctx.cursor_offset as usize, + name, + )?; + crate::type_engine::resolver::resolve_target_classes(&request, AccessKind::Arrow, ctx) + .into_iter() + .find_map(|rt| rt.class_info) + } + _ => None, + } +} + +// ─── Argument text ────────────────────────────────────────────────────────── + +/// Read an argument as a plain string literal. +fn unquote(arg: &str) -> Option { + crate::text_scan::unquote_php_string(arg.trim()).map(str::to_string) +} + +/// Every field name an `only()` / `except()` call lists, written either as one +/// array literal (`only(['name', 'email'])`) or variadically +/// (`only('name', 'email')`). +fn key_list(args: &[&str]) -> Vec { + let single_array = args + .first() + .map(|arg| arg.trim()) + .and_then(|arg| arg.strip_prefix('[')) + .and_then(|arg| arg.strip_suffix(']')); + + match single_array { + Some(inner) => crate::type_engine::conditional_resolution::split_text_args(inner) + .into_iter() + .filter_map(unquote) + .collect(), + None => args.iter().filter_map(|arg| unquote(arg)).collect(), + } +} + +// ─── Rule specs ───────────────────────────────────────────────────────────── + +/// What one rules-array entry says about its field. +#[derive(Debug, Clone, Default)] +struct RuleSpec { + required: bool, + nullable: bool, + sometimes: bool, + /// The declared element type, or `None` when no rule names one. + base: Option, +} + +impl RuleSpec { + fn parse(rule: &ValidationRule) -> Self { + let mut spec = RuleSpec::default(); + for token in rule.rules.split('|') { + // `max:255` and `date_format:Y-m-d` carry a parameter that says + // nothing about the type. + let name = token.split(':').next().unwrap_or(token).trim(); + match name.to_ascii_lowercase().as_str() { + // `required_if`, `required_with` and friends are deliberately + // not matched here: they only sometimes demand the key, so the + // shape has to allow it to be missing. + "required" | "present" => spec.required = true, + "nullable" => spec.nullable = true, + "sometimes" => spec.sometimes = true, + other => { + if spec.base.is_none() + && let Some(ty) = rule_token_type(other) + { + spec.base = Some(ty); + } + } + } + } + spec + } + + /// Whether the key may be absent from the validated array. + /// + /// Only `required` (or `present`) guarantees the key is there, and + /// `sometimes` withdraws even that. `nullable` does *not* make a field + /// present: validating `['age' => 'nullable|integer']` against `[]` + /// yields `[]`, not `['age' => null]`, so a nullable field is an optional + /// key whose value may additionally be null. + fn optional(&self) -> bool { + self.sometimes || !self.required + } + + fn scalar_type(&self) -> PhpType { + // No rule named a type — a rule object, or a purely constraining rule + // like `confirmed`. It still validated *something*; we just cannot + // say what, and `mixed` never lies. + let base = self.base.clone().unwrap_or_else(PhpType::mixed); + if self.nullable { + PhpType::nullable(base) + } else { + base + } + } +} + +/// The value type a single validation rule implies, or `None` when the rule +/// constrains the value without naming its type (`max`, `unique`, `confirmed`). +fn rule_token_type(name: &str) -> Option { + let ty = match name { + // Rules that only accept strings. `date` and `enum` included: the + // validated array holds the raw input, so a date is still its string + // and a backed enum is still its scalar. + "string" | "email" | "url" | "active_url" | "uuid" | "ulid" | "ip" | "ipv4" | "ipv6" + | "json" | "alpha" | "alpha_dash" | "alpha_num" | "ascii" | "date" | "date_format" + | "timezone" | "mac_address" | "hex_color" | "current_password" | "enum" => { + PhpType::string() + } + "integer" | "int" => PhpType::int(), + "boolean" | "bool" | "accepted" | "declined" => PhpType::bool(), + "numeric" | "decimal" => PhpType::union(vec![PhpType::int(), PhpType::float()]), + "file" | "image" | "mimes" | "mimetypes" => { + PhpType::named(crate::atom::atom(UPLOADED_FILE_FQN)) + } + "array" | "list" => PhpType::array(), + _ => return None, + }; + Some(ty) +} + +// ─── Key tree ─────────────────────────────────────────────────────────────── + +/// One node of the tree that dotted rule keys describe. +/// +/// `'items' => 'array'` with `'items.*.id' => 'integer'` builds a node for +/// `items` that carries both its own spec and a wildcard child holding `id`. +#[derive(Debug, Default)] +struct Node { + /// The spec declared for this exact path, if the rules array names it. + spec: Option, + /// Children under literal segments, in declaration order. + children: Vec<(String, Node)>, + /// The child under a `*` segment, which makes this node a list. + wildcard: Option>, +} + +impl Node { + fn insert(&mut self, key: &str, spec: RuleSpec) { + match key.split_once('.') { + None => self.child(key).spec = Some(spec), + Some((head, rest)) => self.child(head).insert(rest, spec), + } + } + + fn child(&mut self, segment: &str) -> &mut Node { + if segment == "*" { + return self.wildcard.get_or_insert_with(Box::default); + } + if let Some(index) = self.children.iter().position(|(name, _)| name == segment) { + return &mut self.children[index].1; + } + self.children.push((segment.to_string(), Node::default())); + &mut self.children.last_mut().expect("just pushed a child").1 + } + + /// The shape of this node's children, or `None` when it has none. + fn shape(&self) -> Option { + if self.children.is_empty() { + return None; + } + let entries = self + .children + .iter() + .map(|(name, child)| ShapeEntry { + key: Some(name.clone()), + value_type: child.value_type(), + optional: child.optional(), + }) + .collect(); + Some(PhpType::array_shape(entries)) + } + + /// The type of the value this node holds. + fn value_type(&self) -> PhpType { + // A wildcard child means the rules describe the array's *elements*, + // which is exactly a `list<…>`. + if let Some(wildcard) = &self.wildcard { + return PhpType::list(wildcard.value_type()); + } + if let Some(shape) = self.shape() { + return shape; + } + match &self.spec { + Some(spec) => spec.scalar_type(), + None => PhpType::mixed(), + } + } + + /// Whether the key this node sits under may be absent. + /// + /// A node with no spec of its own — `owner` when only `owner.email` is + /// declared — is required exactly when something beneath it is, since a + /// required leaf cannot arrive without its parent. + fn optional(&self) -> bool { + match &self.spec { + Some(spec) => spec.optional(), + None => !self.has_required_descendant(), + } + } + + fn has_required_descendant(&self) -> bool { + if self.spec.as_ref().is_some_and(|spec| !spec.optional()) { + return true; + } + self.children + .iter() + .any(|(_, child)| child.has_required_descendant()) + || self + .wildcard + .as_ref() + .is_some_and(|child| child.has_required_descendant()) + } +} + +#[cfg(test)] +#[path = "validated_shape_tests.rs"] +mod tests; diff --git a/src/virtual_members/laravel/validated_shape_tests.rs b/src/virtual_members/laravel/validated_shape_tests.rs new file mode 100644 index 00000000..1b73f999 --- /dev/null +++ b/src/virtual_members/laravel/validated_shape_tests.rs @@ -0,0 +1,276 @@ +use super::*; +use crate::virtual_members::laravel::validation_rules::rules_from_array_text; + +/// Build the shape for a rules array written as PHP source, and render it. +fn shape_of(array_text: &str) -> String { + let rules = rules_from_array_text(array_text).expect("rules should parse"); + rules_to_shape(&rules) + .map(|ty| ty.to_string()) + .unwrap_or_else(|| "array".to_string()) +} + +#[test] +fn required_string_is_a_required_key() { + assert_eq!( + shape_of("['name' => 'required|string|max:255']"), + "array{name: string}" + ); +} + +#[test] +fn nullable_adds_null_and_leaves_the_key_optional() { + // Validating `['age' => 'nullable|integer']` against `[]` yields `[]`, + // not `['age' => null]`, so the key is optional as well as nullable. + assert_eq!( + shape_of("['age' => 'nullable|integer']"), + "array{age?: ?int}" + ); +} + +#[test] +fn present_guarantees_the_key_without_requiring_a_value() { + assert_eq!( + shape_of("['note' => 'present|string']"), + "array{note: string}" + ); +} + +#[test] +fn a_conditional_requirement_still_allows_the_key_to_be_missing() { + assert_eq!( + shape_of("['tax_id' => 'required_if:kind,business|string']"), + "array{tax_id?: string}" + ); +} + +#[test] +fn a_field_that_is_neither_required_nor_nullable_is_optional() { + assert_eq!(shape_of("['active' => 'boolean']"), "array{active?: bool}"); +} + +#[test] +fn sometimes_makes_even_a_required_field_optional() { + assert_eq!( + shape_of("['nickname' => 'sometimes|required|string']"), + "array{nickname?: string}" + ); +} + +#[test] +fn array_form_rules_are_read_like_pipe_form() { + assert_eq!( + shape_of("['age' => ['required', 'integer']]"), + "array{age: int}" + ); +} + +#[test] +fn rule_parameters_do_not_confuse_the_type() { + assert_eq!( + shape_of("['published_at' => 'required|date_format:Y-m-d']"), + "array{published_at: string}" + ); +} + +#[test] +fn a_rule_object_keeps_the_key_but_loses_the_type() { + // `new Enum(Role::class)` still validated something; we cannot say what, + // so the key survives as `mixed` rather than dropping out of the shape. + assert_eq!( + shape_of("['role' => [new Enum(Role::class)]]"), + "array{role?: mixed}" + ); +} + +#[test] +fn a_required_rule_object_is_still_a_required_key() { + assert_eq!( + shape_of("['role' => ['required', new Enum(Role::class)]]"), + "array{role: mixed}" + ); +} + +#[test] +fn unknown_constraint_rules_leave_the_type_open() { + assert_eq!( + shape_of("['token' => 'required|confirmed']"), + "array{token: mixed}" + ); +} + +#[test] +fn file_rules_resolve_to_uploaded_file() { + assert_eq!( + shape_of("['avatar' => 'required|image|max:2048']"), + "array{avatar: \\Illuminate\\Http\\UploadedFile}" + ); +} + +#[test] +fn numeric_admits_both_number_types() { + assert_eq!( + shape_of("['price' => 'required|numeric']"), + "array{price: int|float}" + ); +} + +// ─── Nesting ──────────────────────────────────────────────────────────────── + +#[test] +fn wildcard_children_build_a_list_of_shapes() { + assert_eq!( + shape_of( + "[ + 'items' => 'required|array', + 'items.*.id' => 'required|integer', + 'items.*.note' => 'nullable|string', + ]" + ), + "array{items: list}" + ); +} + +#[test] +fn a_bare_wildcard_lists_its_scalar() { + assert_eq!( + shape_of( + "[ + 'tags' => 'required|array', + 'tags.*' => 'string', + ]" + ), + "array{tags: list}" + ); +} + +#[test] +fn dotted_keys_build_a_nested_shape() { + assert_eq!( + shape_of( + "[ + 'owner' => 'required|array', + 'owner.email' => 'required|email', + ]" + ), + "array{owner: array{email: string}}" + ); +} + +#[test] +fn an_undeclared_parent_is_required_when_a_child_is() { + // No `'owner' => …` entry, but `owner.email` is required, so the parent + // key cannot be absent. + assert_eq!( + shape_of("['owner.email' => 'required|email']"), + "array{owner: array{email: string}}" + ); +} + +#[test] +fn an_undeclared_parent_is_optional_when_every_child_is() { + assert_eq!( + shape_of("['owner.email' => 'email']"), + "array{owner?: array{email?: string}}" + ); +} + +// ─── Bailing out ──────────────────────────────────────────────────────────── + +#[test] +fn a_computed_key_abandons_the_shape() { + // The key set is incomplete, so a shape would report real input as an + // unknown key. + let rules = rules_from_array_text("['name' => 'required', $dynamic => 'required']").unwrap(); + assert!(!rules.keys_complete); + assert!(rules_to_shape(&rules).is_none()); +} + +#[test] +fn a_spread_abandons_the_shape() { + let rules = rules_from_array_text("[...$base, 'name' => 'required']").unwrap(); + assert!(rules_to_shape(&rules).is_none()); +} + +#[test] +fn an_empty_rules_array_has_no_shape() { + assert!(rules_from_array_text("[]").is_none()); +} + +// ─── Member lookup and narrowing ──────────────────────────────────────────── + +fn rules(array_text: &str) -> RulesArray { + rules_from_array_text(array_text).expect("rules should parse") +} + +#[test] +fn member_type_reads_a_single_key() { + let rules = rules("['name' => 'required|string', 'age' => 'nullable|integer']"); + assert_eq!( + rules_member_type(&rules, "age").map(|t| t.to_string()), + Some("?int".to_string()) + ); +} + +#[test] +fn member_type_walks_dot_notation() { + let rules = rules("['owner.email' => 'required|email']"); + assert_eq!( + rules_member_type(&rules, "owner.email").map(|t| t.to_string()), + Some("string".to_string()) + ); +} + +#[test] +fn member_type_of_an_unknown_key_is_none() { + let rules = rules("['name' => 'required|string']"); + assert!(rules_member_type(&rules, "nope").is_none()); +} + +#[test] +fn only_keeps_the_listed_keys() { + let shape = rules_to_shape(&rules( + "['name' => 'required|string', 'age' => 'required|integer', 'city' => 'required|string']", + )) + .unwrap(); + let narrowed = narrow_shape(&shape, &["name".to_string(), "city".to_string()], true).unwrap(); + assert_eq!(narrowed.to_string(), "array{name: string, city: string}"); +} + +#[test] +fn except_drops_the_listed_keys() { + let shape = rules_to_shape(&rules( + "['name' => 'required|string', 'age' => 'required|integer']", + )) + .unwrap(); + let narrowed = narrow_shape(&shape, &["age".to_string()], false).unwrap(); + assert_eq!(narrowed.to_string(), "array{name: string}"); +} + +#[test] +fn nullable_numeric_renders_as_a_nullable_union() { + // Documented in `examples/laravel/app/Demo.php::validatedArrayShape`. + assert_eq!( + shape_of("['dough_temp' => 'nullable|numeric']"), + "array{dough_temp?: ?int|float}" + ); +} + +#[test] +fn the_bakery_demo_rules_produce_the_documented_shape() { + // Mirrors App\Http\Requests\StoreBakeryRequest::rules() so the comments + // in the Laravel demo cannot drift from what the engine infers. + assert_eq!( + shape_of( + "[ + 'name' => 'required|string|max:255', + 'apricot' => 'boolean', + 'dough_temp' => 'nullable|numeric', + 'notes' => 'array', + 'notes.*.body' => 'required|string', + 'owner.email' => 'required|email', + ]" + ), + "array{name: string, apricot?: bool, dough_temp?: ?int|float, \ +notes?: list, owner: array{email: string}}" + ); +} diff --git a/src/virtual_members/laravel/validation_rules.rs b/src/virtual_members/laravel/validation_rules.rs index 3a3d3358..31d15cb0 100644 --- a/src/virtual_members/laravel/validation_rules.rs +++ b/src/virtual_members/laravel/validation_rules.rs @@ -51,6 +51,35 @@ pub(crate) struct ValidationRule { pub key_start: usize, } +/// The entries of one validation rules array, plus whether its key set is +/// known to be complete. +#[derive(Debug, Clone)] +pub(crate) struct RulesArray { + /// The field entries, in declaration order. + pub rules: Vec, + /// `false` when a key was not a string literal (`$field => 'required'`), + /// which means entries are missing from [`Self::rules`]. + /// + /// Callers that turn the rules into an array shape must bail on this: a + /// shape that omits keys the request really accepts would report valid + /// input as unknown. + pub keys_complete: bool, +} + +impl RulesArray { + fn new() -> Self { + Self { + rules: Vec::new(), + keys_complete: true, + } + } + + /// Whether no entries were recovered at all. + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } +} + /// Which file a resolved rules array was parsed from. #[derive(Debug)] pub(crate) enum RulesSource { @@ -71,8 +100,8 @@ pub(crate) enum RulesSource { pub(crate) struct ResolvedRules { /// Where the rules were parsed from. pub source: RulesSource, - /// The field entries, in declaration order. - pub rules: Vec, + /// The recovered entries. + pub rules: RulesArray, } // ─── Array parsing ────────────────────────────────────────────────────────── @@ -82,7 +111,7 @@ pub(crate) struct ResolvedRules { /// Unlike config files, rules arrays are flat: nesting is expressed in the /// key itself (`items.*.id`), so only the outermost level is walked. /// `array_merge(parent::rules(), [...])` is followed into each argument. -fn collect_rules_from_expr(expr: &Expression<'_>, content: &str, out: &mut Vec) { +fn collect_rules_from_expr(expr: &Expression<'_>, content: &str, out: &mut RulesArray) { match expr { Expression::Array(arr) => collect_rules_from_elements(arr.elements.iter(), content, out), Expression::LegacyArray(arr) => { @@ -96,28 +125,39 @@ fn collect_rules_from_expr(expr: &Expression<'_>, content: &str, out: &mut Vec {} + // A spread, a variable, a match — whatever keys it contributes are + // not recoverable, so the set is no longer known to be complete. + _ => out.keys_complete = false, } } fn collect_rules_from_elements<'a>( elements: impl Iterator>, content: &str, - out: &mut Vec, + out: &mut RulesArray, ) { for element in elements { let ArrayElement::KeyValue(kv) = element else { + // A spread (`...$base`) or a positional entry adds keys we + // cannot name. + out.keys_complete = false; continue; }; let Some((key, key_start, _)) = extract_string_literal(kv.key, content) else { + out.keys_complete = false; continue; }; if key.is_empty() { + out.keys_complete = false; continue; } - out.push(ValidationRule { + out.rules.push(ValidationRule { key: key.to_string(), rules: render_rule_value(kv.value, content), key_start, @@ -264,9 +304,9 @@ fn beats_best(best: &Option<(u32, T)>, end: u32, cursor: u32) -> bool { /// method names are case-insensitive, so an unconventional `VALIDATE(` is /// missed here; that costs suggestions rather than producing wrong ones. fn mentions_validation(content: &str) -> bool { - let bytes = content.as_bytes(); - memchr::memmem::find(bytes, b"validat").is_some() - || memchr::memmem::find(bytes, b"Validat").is_some() + // Matching on the case-invariant tail covers `validate`, `Validator` and + // `validateWithBag` in one pass. + memchr::memmem::find(content.as_bytes(), b"alidat").is_some() } /// Rules from the last `validate()` / `Validator::make()` call that completes @@ -274,7 +314,7 @@ fn mentions_validation(content: &str) -> bool { /// /// Returns `None` when the cursor is not inside a function body, or when no /// such call precedes it. -pub(crate) fn inline_validate_rules(content: &str, offset: usize) -> Option> { +pub(crate) fn inline_validate_rules(content: &str, offset: usize) -> Option { if !mentions_validation(content) { return None; } @@ -286,7 +326,7 @@ pub(crate) fn inline_validate_rules(content: &str, offset: usize) -> Option)> = None; + let mut best: Option<(u32, RulesArray)> = None; walk_before_cursor(body, cursor, &mut |node| { let rules_arg = match node { Node::MethodCall(mc) => method_rules_argument(&mc.method, &mc.argument_list), @@ -301,7 +341,7 @@ pub(crate) fn inline_validate_rules(content: &str, offset: usize) -> Option Optionvalidate([…])`. +/// +/// The text is parsed standalone, so [`ValidationRule::key_start`] offsets +/// index `array_text` rather than any file; callers that need navigable +/// offsets must use [`inline_validate_rules`] instead. +pub(crate) fn rules_from_array_text(array_text: &str) -> Option { + let source = format!("validate([...])`, `->validate($request, [...])`, /// or `->validateWithBag('bag', [...])`. fn method_rules_argument<'ast, 'arena>( @@ -406,7 +463,7 @@ pub(crate) fn safe_source_variable(content: &str, offset: usize, variable: &str) if !beats_best(&best, end, cursor) { return; } - if let Some(source) = safe_call_receiver(assignment.rhs) { + if let Some(source) = safe_call_receiver_variable(assignment.rhs) { best = Some((end, source)); } }); @@ -415,7 +472,7 @@ pub(crate) fn safe_source_variable(content: &str, offset: usize, variable: &str) /// The receiver of a no-argument `->safe()` call, when it is a plain /// variable. -fn safe_call_receiver(expr: &Expression<'_>) -> Option { +pub(crate) fn safe_call_receiver_variable(expr: &Expression<'_>) -> Option { let (object, method) = match expr { Expression::Call(Call::Method(mc)) => (mc.object, &mc.method), Expression::Call(Call::NullSafeMethod(mc)) => (mc.object, &mc.method), @@ -513,23 +570,46 @@ pub(crate) fn form_request_rules( } } +/// The rules that describe `class` at `offset`. +/// +/// A `FormRequest` carries its own `rules()`; every other request-like +/// receiver — a plain `Request`, a `Validator`, a `ValidatedInput` — is +/// described by the nearest `validate()` / `Validator::make()` call preceding +/// the cursor in the same function body. +/// +/// This is the single definition of that precedence; both request-input +/// completion and `validated()` shape inference go through it. +pub(crate) fn rules_in_scope( + backend: &Backend, + class: &ClassInfo, + current_uri: &str, + content: &str, + offset: usize, +) -> Option { + let loader = |name: &str| backend.find_or_load_class(name); + if is_form_request(class, &loader) + && let Some(resolved) = form_request_rules(backend, class, current_uri, content) + { + return Some(resolved); + } + inline_validate_rules(content, offset).map(|rules| ResolvedRules { + source: RulesSource::CurrentFile, + rules, + }) +} + /// Parse the array returned by `class_name`'s `rules()` method. -pub(crate) fn rules_from_class_source(content: &str, class_name: &str) -> Vec { +pub(crate) fn rules_from_class_source(content: &str, class_name: &str) -> RulesArray { 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 out = Vec::new(); + let mut out = RulesArray::new(); collect_rules_method(Node::Program(program), class_name, content, &mut out); out } -fn collect_rules_method( - node: Node<'_, '_>, - class_name: &str, - content: &str, - out: &mut Vec, -) { +fn collect_rules_method(node: Node<'_, '_>, class_name: &str, content: &str, out: &mut RulesArray) { if let Node::Class(class) = node { if !bytes_to_str(class.name.value).eq_ignore_ascii_case(class_name) { return; @@ -547,7 +627,7 @@ fn collect_rules_method( node.visit_children(|child| collect_rules_method(child, class_name, content, out)); } -fn collect_returned_rules(node: Node<'_, '_>, content: &str, out: &mut Vec) { +fn collect_returned_rules(node: Node<'_, '_>, content: &str, out: &mut RulesArray) { if let Node::Return(ret) = node { if let Some(value) = ret.value { collect_rules_from_expr(value, content, out); diff --git a/src/virtual_members/laravel/validation_rules_tests.rs b/src/virtual_members/laravel/validation_rules_tests.rs index c376726b..a3fa3f71 100644 --- a/src/virtual_members/laravel/validation_rules_tests.rs +++ b/src/virtual_members/laravel/validation_rules_tests.rs @@ -1,7 +1,7 @@ use super::*; -fn keys(rules: &[ValidationRule]) -> Vec<&str> { - rules.iter().map(|r| r.key.as_str()).collect() +fn keys(rules: &RulesArray) -> Vec<&str> { + rules.rules.iter().map(|r| r.key.as_str()).collect() } #[test] @@ -19,8 +19,8 @@ class StoreUserRequest extends FormRequest { "; let rules = rules_from_class_source(content, "StoreUserRequest"); assert_eq!(keys(&rules), vec!["name", "age"]); - assert_eq!(rules[0].rules, "required|string|max:255"); - assert_eq!(rules[1].rules, "nullable|integer"); + assert_eq!(rules.rules[0].rules, "required|string|max:255"); + assert_eq!(rules.rules[1].rules, "nullable|integer"); } #[test] @@ -69,7 +69,7 @@ class StoreUserRequest extends FormRequest { } "; let rules = rules_from_class_source(content, "StoreUserRequest"); - assert_eq!(rules[0].rules, "required|new Enum(Role::class)"); + assert_eq!(rules.rules[0].rules, "required|new Enum(Role::class)"); } #[test] @@ -80,7 +80,10 @@ class StoreUserRequest extends FormRequest { } "; let rules = rules_from_class_source(content, "StoreUserRequest"); - assert_eq!(&content[rules[0].key_start..rules[0].key_start + 4], "name"); + assert_eq!( + &content[rules.rules[0].key_start..rules.rules[0].key_start + 4], + "name" + ); } #[test] diff --git a/tests/integration/laravel_validated_shape.rs b/tests/integration/laravel_validated_shape.rs new file mode 100644 index 00000000..bbb01176 --- /dev/null +++ b/tests/integration/laravel_validated_shape.rs @@ -0,0 +1,491 @@ +//! Integration tests for typing `validated()` from validation rules. +//! +//! The rules array names every key a validated request can carry and says +//! what each one is, so `$request->validated()` resolves to an `array{…}` +//! shape rather than plain `array`. These tests drive it through hover and +//! completion, the two surfaces a user actually sees it on. + +use crate::common::create_psr4_workspace; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +// ─── Shared stubs ─────────────────────────────────────────────────────────── + +const COMPOSER_JSON: &str = r#"{ + "autoload": { + "psr-4": { + "App\\": "src/", + "App\\Http\\Requests\\": "src/Http/Requests/", + "Illuminate\\Http\\": "vendor/illuminate/Http/", + "Illuminate\\Foundation\\Http\\": "vendor/illuminate/Foundation/Http/", + "Illuminate\\Support\\": "vendor/illuminate/Support/", + "Illuminate\\Support\\Facades\\": "vendor/illuminate/Support/Facades/", + "Illuminate\\Validation\\": "vendor/illuminate/Validation/", + "Illuminate\\Contracts\\Validation\\": "vendor/illuminate/Contracts/Validation/" + } + } +}"#; + +const REQUEST_PHP: &str = "\ + 'required|string|max:255', + 'views' => 'required|integer', + 'summary' => 'nullable|string', + 'draft' => 'boolean', + 'cover' => 'required|image', + 'tags' => 'required|array', + 'tags.*' => 'string', + 'author.email' => 'required|email', + ]; + } +} +"; + +/// A project's own validator, to prove the receiver test is a subtype walk +/// rather than a match on the framework's two FQNs. +const APP_VALIDATOR_PHP: &str = "\ + Vec<(&'static str, &'static str)> { + vec![ + ("vendor/illuminate/Http/Request.php", REQUEST_PHP), + ("vendor/illuminate/Http/UploadedFile.php", UPLOADED_FILE_PHP), + ( + "vendor/illuminate/Foundation/Http/FormRequest.php", + FORM_REQUEST_PHP, + ), + ( + "vendor/illuminate/Support/ValidatedInput.php", + VALIDATED_INPUT_PHP, + ), + ("vendor/illuminate/Validation/Validator.php", VALIDATOR_PHP), + ( + "vendor/illuminate/Contracts/Validation/Validator.php", + VALIDATOR_CONTRACT_PHP, + ), + ( + "vendor/illuminate/Support/Facades/Validator.php", + VALIDATOR_FACADE_PHP, + ), + ( + "src/Http/Requests/StorePostRequest.php", + STORE_POST_REQUEST_PHP, + ), + ("src/AppValidator.php", APP_VALIDATOR_PHP), + ] +} + +// ─── Harness ──────────────────────────────────────────────────────────────── + +/// Open `content` (cursor marked `§`) and return the hover text there. +async fn hover_text(content: &str) -> String { + let (backend, _dir, uri, position) = open_at_cursor(content).await; + + let hover = backend + .hover(HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap(); + + match hover.map(|h| h.contents) { + Some(HoverContents::Markup(markup)) => markup.value, + Some(HoverContents::Scalar(MarkedString::String(s))) => s, + Some(HoverContents::Scalar(MarkedString::LanguageString(ls))) => ls.value, + _ => String::new(), + } +} + +/// Open `content` (cursor marked `§`) and return the completion labels there. +async fn complete_labels(content: &str) -> Vec { + let (backend, _dir, uri, position) = open_at_cursor(content).await; + + let result = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap(); + + let items = match result { + Some(CompletionResponse::Array(items)) => items, + Some(CompletionResponse::List(list)) => list.items, + _ => Vec::new(), + }; + items.into_iter().map(|i| i.label).collect() +} + +async fn open_at_cursor( + content: &str, +) -> (phpantom_lsp::Backend, tempfile::TempDir, Url, Position) { + let offset = content.find('§').expect("test source needs a § cursor"); + let stripped = content.replace('§', ""); + let before = &content[..offset]; + let line = before.matches('\n').count() as u32; + let character = before.rsplit('\n').next().unwrap_or("").chars().count() as u32; + + let mut files = base_files(); + files.push(("src/PostController.php", stripped.as_str())); + let (backend, dir) = create_psr4_workspace(COMPOSER_JSON, &files); + + let uri = Url::from_file_path(dir.path().join("src/PostController.php")).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: stripped.clone(), + }, + }) + .await; + + (backend, dir, uri, Position { line, character }) +} + +/// Wrap a controller body in the namespace and imports every test needs. +fn controller(body: &str) -> String { + format!( + "validated(); + }", + ); + let hover = hover_text(&source).await; + assert!( + hover.contains("array{"), + "expected an array shape, got: {hover}" + ); + assert!( + hover.contains("title: string"), + "expected `title: string`, got: {hover}" + ); + assert!( + hover.contains("views: int"), + "expected `views: int`, got: {hover}" + ); +} + +#[tokio::test] +async fn optionality_follows_required_and_nullable() { + let source = controller( + " public function store(StorePostRequest $request) { + $data§ = $request->validated(); + }", + ); + let hover = hover_text(&source).await; + // `draft` is neither required nor nullable, so it may be absent. + assert!( + hover.contains("draft?: bool"), + "expected `draft?: bool`, got: {hover}" + ); + // `summary` is nullable but not required: it may be absent, and when + // present it may be null. + assert!( + hover.contains("summary?: ?string"), + "expected `summary?: ?string`, got: {hover}" + ); +} + +#[tokio::test] +async fn wildcard_rules_become_a_list() { + let source = controller( + " public function store(StorePostRequest $request) { + $data§ = $request->validated(); + }", + ); + let hover = hover_text(&source).await; + assert!( + hover.contains("tags: list"), + "expected `tags: list`, got: {hover}" + ); +} + +#[tokio::test] +async fn shape_keys_complete_on_array_access() { + let source = controller( + " public function store(StorePostRequest $request) { + $data = $request->validated(); + $data['§']; + }", + ); + let labels = complete_labels(&source).await; + assert!( + labels.contains(&"title".to_string()) && labels.contains(&"views".to_string()), + "expected the shape's keys to complete, got: {labels:?}" + ); +} + +#[tokio::test] +async fn a_file_rule_resolves_to_uploaded_file_members() { + let source = controller( + " public function store(StorePostRequest $request) { + $data = $request->validated(); + $data['cover']->§ + }", + ); + let labels = complete_labels(&source).await; + assert!( + labels.iter().any(|l| l.starts_with("store")), + "expected UploadedFile members on an `image` rule, got: {labels:?}" + ); +} + +// ─── Call-site rules ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn validate_returns_the_shape_of_its_own_argument() { + let source = controller( + " public function store(Request $request) { + $data§ = $request->validate([ + 'slug' => 'required|string', + 'rank' => 'required|integer', + ]); + }", + ); + let hover = hover_text(&source).await; + assert!( + hover.contains("slug: string") && hover.contains("rank: int"), + "expected the argument's shape, got: {hover}" + ); +} + +#[tokio::test] +async fn validator_validated_uses_the_rules_it_was_made_with() { + let source = controller( + " public function store(Request $request) { + $validator = Validator::make($request->all(), ['nickname' => 'required|string']); + $clean§ = $validator->validated(); + }", + ); + let hover = hover_text(&source).await; + assert!( + hover.contains("nickname: string"), + "expected the Validator::make rules, got: {hover}" + ); +} + +#[tokio::test] +async fn validated_with_a_key_returns_that_members_type() { + let source = controller( + " public function store(StorePostRequest $request) { + $views§ = $request->validated('views'); + }", + ); + let hover = hover_text(&source).await; + assert!( + hover.contains("int"), + "expected the `views` member type, got: {hover}" + ); +} + +#[tokio::test] +async fn safe_only_narrows_the_shape() { + let source = controller( + " public function store(StorePostRequest $request) { + $subset§ = $request->safe()->only(['title', 'views']); + }", + ); + let hover = hover_text(&source).await; + assert!( + hover.contains("title: string") && hover.contains("views: int"), + "expected the narrowed shape, got: {hover}" + ); + assert!( + !hover.contains("draft"), + "`only()` should drop unlisted keys, got: {hover}" + ); +} + +#[tokio::test] +async fn safe_except_drops_the_listed_keys() { + let source = controller( + " public function store(StorePostRequest $request) { + $subset§ = $request->safe()->except(['title']); + }", + ); + let hover = hover_text(&source).await; + assert!( + !hover.contains("title:"), + "`except()` should drop the listed key, got: {hover}" + ); + assert!( + hover.contains("views: int"), + "`except()` should keep the rest, got: {hover}" + ); +} + +#[tokio::test] +async fn safe_narrowing_survives_being_parked_in_a_variable() { + // The chained and two-step forms name the same request, so hover must + // give the same shape for both. + let source = controller( + " public function store(StorePostRequest $request) { + $safe = $request->safe(); + $subset§ = $safe->only([\'title\']); + }", + ); + let hover = hover_text(&source).await; + assert!( + hover.contains("title: string"), + "expected the narrowed shape through a `safe()` variable, got: {hover}" + ); + assert!( + !hover.contains("views"), + "`only()` should drop unlisted keys, got: {hover}" + ); +} + +#[tokio::test] +async fn a_custom_validator_subclass_is_recognised() { + // The receiver test is a subtype walk, so a project's own validator + // resolves its rules just like the framework's does. + let source = controller( + " public function store(Request $request) { + $validator = Validator::make($request->all(), [\'nickname\' => \'required|string\']); + $custom = new \\App\\AppValidator(); + $clean§ = $custom->validated(); + }", + ); + let hover = hover_text(&source).await; + assert!( + hover.contains("nickname: string"), + "expected a validator subclass to resolve the rules, got: {hover}" + ); +} + +// ─── Falling back ─────────────────────────────────────────────────────────── + +#[tokio::test] +async fn a_request_with_no_visible_rules_stays_plain_array() { + let source = controller( + " public function store(Request $request) { + $data§ = $request->validated(); + }", + ); + let hover = hover_text(&source).await; + assert!( + !hover.contains("array{"), + "no rules are visible, so no shape may be claimed: {hover}" + ); +} + +#[tokio::test] +async fn a_computed_rule_key_falls_back_to_plain_array() { + let source = controller( + " public function store(Request $request) { + $data§ = $request->validate([ + 'slug' => 'required|string', + $extra => 'required', + ]); + }", + ); + let hover = hover_text(&source).await; + assert!( + !hover.contains("array{"), + "an incomplete key set must not become a shape: {hover}" + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index b61b8358..3863b93c 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -129,6 +129,7 @@ mod laravel_macros; mod laravel_references; mod laravel_request_keys; mod laravel_route_controller; +mod laravel_validated_shape; mod linked_editing; mod lsp_concurrency; mod parser;