From 6d09459e0555ab3f8e21308b4e7237d4a035e5ca Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Wed, 29 Jul 2026 10:23:56 +0600 Subject: [PATCH 1/4] feat(laravel): type validated() arrays from validation rules --- examples/laravel/app/Demo.php | 45 ++ examples/laravel/assertions.php | 43 ++ src/analyse/run.rs | 2 + src/completion/handler/mod.rs | 1 + src/diagnostics/mod.rs | 1 + src/hover/mod.rs | 1 + src/type_engine/call_resolution/mod.rs | 4 +- .../call_resolution/return_types.rs | 50 ++ .../call_resolution/target_cache.rs | 68 +++ .../variable/rhs_resolution/calls.rs | 74 +++ src/virtual_members/laravel/mod.rs | 5 + src/virtual_members/laravel/request_fields.rs | 27 +- .../laravel/validated_shape.rs | 490 +++++++++++++++++ .../laravel/validated_shape_tests.rs | 276 ++++++++++ .../laravel/validation_rules.rs | 126 ++++- .../laravel/validation_rules_tests.rs | 15 +- tests/integration/laravel_validated_shape.rs | 491 ++++++++++++++++++ tests/integration/main.rs | 1 + 18 files changed, 1675 insertions(+), 45 deletions(-) create mode 100644 src/virtual_members/laravel/validated_shape.rs create mode 100644 src/virtual_members/laravel/validated_shape_tests.rs create mode 100644 tests/integration/laravel_validated_shape.rs diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 39c135f1d..6a855834a 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 7eefab50c..9cf19fc85 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 ef22dd3e6..dd07f9bb2 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 f7227071b..c3577e99a 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 9fbce7b46..266edd1e4 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 534e205b3..98e2452e4 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 e92e09472..5dd311a56 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 7a5769983..0ae6128a1 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 628c75438..4264220c1 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 c31f3f3d2..f29273fc1 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 be5660f72..8d3518ff3 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 de90eb958..2f340427e 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 000000000..41071aad2 --- /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 000000000..1b73f9994 --- /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 3a3d3358e..31d15cb06 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 c376726b0..a3fa3f710 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 000000000..bbb011761 --- /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 b61b83583..3863b93c0 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; From 879a82b2cb3a2d8a8cbb906ccd3fe56f102ea282 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Wed, 29 Jul 2026 10:25:02 +0600 Subject: [PATCH 2/4] chore: removes the completed L38 backlog entry per the project's todo convention --- docs/todo.md | 1 - docs/todo/laravel.md | 32 -------------------------------- 2 files changed, 33 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 49181955a..245ba73a4 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 59700e6fd..d8fd40a5a 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** From 8920a0889982182ae358becd8e7b91d5da274eed Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Wed, 29 Jul 2026 10:32:02 +0600 Subject: [PATCH 3/4] chore: update changelog for type validated() arrays from validation rules --- docs/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e60e517b3..7672dc643 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). From a1ff14fe1562fe6f63a09caccf8a812c994398f1 Mon Sep 17 00:00:00 2001 From: Anders Jenbo Date: Fri, 31 Jul 2026 22:52:06 +0200 Subject: [PATCH 4/4] Fix some edge cases --- docs/todo.md | 1 + docs/todo/laravel.md | 21 ++++++++++ .../call_resolution/return_types.rs | 9 +---- .../variable/rhs_resolution/calls.rs | 23 +++++------ src/virtual_members/laravel/mod.rs | 4 +- src/virtual_members/laravel/request_fields.rs | 4 +- .../laravel/validated_shape.rs | 39 ++++++++++++------- .../laravel/validated_shape_tests.rs | 15 +++++++ .../laravel/validation_rules.rs | 16 +++++--- .../laravel/validation_rules_tests.rs | 31 ++++++++++++--- tests/integration/laravel_validated_shape.rs | 38 ++++++++++++++++++ 11 files changed, 151 insertions(+), 50 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 245ba73a4..203494434 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -184,6 +184,7 @@ unlikely to move the needle for most users. | L27 | [Legacy `Controller@method` action strings](todo/laravel.md#l27-legacy-controllermethod-action-strings) | Low | Low | | L28 | [Path helper links and completion](todo/laravel.md#l28-path-helper-links-and-completion) | Low | Low | | L39 | [Unused view and translation key detection](todo/laravel.md#l39-unused-view-and-translation-key-detection) | Low | Medium | +| L40 | [Backing type for enum validation rules](todo/laravel.md#l40-backing-type-for-enum-validation-rules) | Low | Low-Medium | | | **[Blade](todo/blade.md)** | | | | BL8 | [Template signature resolution chain](todo/blade.md#bl8-template-signature-resolution-chain) (Bladestan-compatible contract model) | High | Medium | | BL9 | [`view()` call-site validation](todo/blade.md#bl9-view-call-site-validation) (diagnostics against template signatures) | Medium-High | Medium-High | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index d8fd40a5a..600ada065 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -847,6 +847,27 @@ binding `bind(Gateway::class, StripeGateway::class)` does **not** retype `app(Gateway::class)` to the concrete — the contract is the interface. +#### L40. Backing type for enum validation rules + +**Impact: Low · Effort: Low-Medium** + +`validated()` array shapes type a field from its validation rules, but +an enum rule (`new Enum(Role::class)`, `Rule::enum(Role::class)`) +currently degrades to `mixed`: the rule is an object expression, not a +rule name the token table knows. + +The validated array holds the raw input, not the enum case, so the +right type is the enum's backing type — `string` for a string-backed +enum, `int` for an int-backed one. Both are recoverable: the class name +is written in the rule expression, and the backing type is on the +`ClassInfo`. A pure (non-backed) enum has no raw scalar form and should +stay `mixed`. + +Guessing `string` for every enum rule is the thing to avoid: an +int-backed enum would then be typed as a string, which is worse than +`mixed` because it can produce a false diagnostic rather than merely +missing one. + #### L39. Unused view and translation key detection **Impact: Low · Effort: Medium** diff --git a/src/type_engine/call_resolution/return_types.rs b/src/type_engine/call_resolution/return_types.rs index 0ae6128a1..b901517f1 100644 --- a/src/type_engine/call_resolution/return_types.rs +++ b/src/type_engine/call_resolution/return_types.rs @@ -155,21 +155,16 @@ fn resolve_validated_shape_at_call( owners: &[ResolvedType], ctx: &ResolutionCtx<'_>, ) -> Option { - use crate::virtual_members::laravel::validated_shape::{self, ShapeCall}; + use crate::virtual_members::laravel::validated_shape; 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(), + &|| validated_shape::safe_source_class(base, ctx), ctx.content, ctx.cursor_offset, ctx.class_loader, diff --git a/src/type_engine/variable/rhs_resolution/calls.rs b/src/type_engine/variable/rhs_resolution/calls.rs index f29273fc1..bf2d9f038 100644 --- a/src/type_engine/variable/rhs_resolution/calls.rs +++ b/src/type_engine/variable/rhs_resolution/calls.rs @@ -1209,11 +1209,15 @@ pub(super) fn resolve_rhs_method_call_inner<'b>( // 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()); + // rather than plain `array`. Classifying the call does not depend on the + // receiver, so it happens once rather than once per owner. + // + // `validate([…])` reads its rules from its own argument, so it works + // wherever it is written; every other form asks the scope for them and is + // a no-op without an active resolver. + let shape_call = validated_shape::shape_bearing_method(&method_name).filter(|call| { + *call == validated_shape::ShapeCall::Validate || validated_shape::rules_resolver_active() + }); for owner in &owner_classes { if let Some(result) = @@ -2101,18 +2105,11 @@ fn try_resolve_validated_shape( 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(), + &|| safe_source_owner(object, ctx), ctx.content, object.span().end.offset, ctx.as_resolution_ctx().class_loader, diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 8d3518ff3..835dc534e 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -129,9 +129,7 @@ pub(crate) use request_fields::{request_fields_at_position, resolve_request_fiel 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 validation_rules::{RulesArray, 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 2f340427e..a31cd0863 100644 --- a/src/virtual_members/laravel/request_fields.rs +++ b/src/virtual_members/laravel/request_fields.rs @@ -257,7 +257,7 @@ pub(crate) fn request_fields_at_position( cursor_offset as usize, )?; - let fields = rule_fields(&rules.rules.rules); + let fields = rule_fields(&rules.rules.entries); if fields.is_empty() { return None; } @@ -307,7 +307,7 @@ pub(crate) fn resolve_request_field_definition( // `input('address.city')` lands on that key rather than on `address`. let key_start = rules .rules - .rules + .entries .iter() .find(|rule| rule.key == value) .map(|rule| rule.key_start) diff --git a/src/virtual_members/laravel/validated_shape.rs b/src/virtual_members/laravel/validated_shape.rs index 41071aad2..73a73eccd 100644 --- a/src/virtual_members/laravel/validated_shape.rs +++ b/src/virtual_members/laravel/validated_shape.rs @@ -61,7 +61,7 @@ fn rules_to_shape(rules: &RulesArray) -> Option { } let mut root = Node::default(); - for rule in &rules.rules { + for rule in &rules.entries { root.insert(&rule.key, RuleSpec::parse(rule)); } root.shape() @@ -162,13 +162,16 @@ pub(crate) fn shape_bearing_method(method: &str) -> Option { /// - `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. +/// `safe_source` recovers the request a `ValidatedInput` receiver was narrowed +/// from, in whichever expression form the caller holds. It is a closure +/// because tracing that hop parses the file, and `only`/`except` are common +/// names on `Collection` and `Arr` too: only the arm that reaches a real +/// `ValidatedInput` may pay for it. pub(crate) fn resolve_shape_at_call( receiver: &ClassInfo, call: ShapeCall, args: &[&str], - safe_source: Option<&ClassInfo>, + safe_source: &dyn Fn() -> Option>, content: &str, offset: u32, class_loader: &dyn Fn(&str) -> Option>, @@ -193,7 +196,8 @@ pub(crate) fn resolve_shape_at_call( // 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 source = safe_source()?; + let rules = lookup_rules(&source, content, offset)?; let shape = rules_to_shape(&rules)?; let keys = key_list(args); narrow_shape(&shape, &keys, call == ShapeCall::Only) @@ -447,15 +451,22 @@ impl Node { 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(), + let structured = match &self.wildcard { + Some(wildcard) => Some(PhpType::list(wildcard.value_type())), + None => self.shape(), + }; + match structured { + // The dotted child rules say what a *present* value holds; the + // node's own rules still decide whether it may be null, so + // `'items' => 'nullable|array'` keeps its null. + Some(ty) if self.spec.as_ref().is_some_and(|spec| spec.nullable) => { + PhpType::nullable(ty) + } + Some(ty) => ty, + None => match &self.spec { + Some(spec) => spec.scalar_type(), + None => PhpType::mixed(), + }, } } diff --git a/src/virtual_members/laravel/validated_shape_tests.rs b/src/virtual_members/laravel/validated_shape_tests.rs index 1b73f9994..acd3f7958 100644 --- a/src/virtual_members/laravel/validated_shape_tests.rs +++ b/src/virtual_members/laravel/validated_shape_tests.rs @@ -143,6 +143,21 @@ fn a_bare_wildcard_lists_its_scalar() { ); } +#[test] +fn a_nullable_parent_keeps_its_null_alongside_the_child_shape() { + // The child rules describe what a present `items` holds; `nullable` on + // `items` itself still says the value may be null. + assert_eq!( + shape_of( + "[ + 'items' => 'required|nullable|array', + 'items.*.id' => 'required|integer', + ]" + ), + "array{items: ?list}" + ); +} + #[test] fn dotted_keys_build_a_nested_shape() { assert_eq!( diff --git a/src/virtual_members/laravel/validation_rules.rs b/src/virtual_members/laravel/validation_rules.rs index 31d15cb06..21369f794 100644 --- a/src/virtual_members/laravel/validation_rules.rs +++ b/src/virtual_members/laravel/validation_rules.rs @@ -56,9 +56,9 @@ pub(crate) struct ValidationRule { #[derive(Debug, Clone)] pub(crate) struct RulesArray { /// The field entries, in declaration order. - pub rules: Vec, + pub entries: Vec, /// `false` when a key was not a string literal (`$field => 'required'`), - /// which means entries are missing from [`Self::rules`]. + /// which means entries are missing from [`Self::entries`]. /// /// 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 @@ -69,14 +69,14 @@ pub(crate) struct RulesArray { impl RulesArray { fn new() -> Self { Self { - rules: Vec::new(), + entries: Vec::new(), keys_complete: true, } } /// Whether no entries were recovered at all. pub fn is_empty(&self) -> bool { - self.rules.is_empty() + self.entries.is_empty() } } @@ -157,7 +157,7 @@ fn collect_rules_from_elements<'a>( out.keys_complete = false; continue; } - out.rules.push(ValidationRule { + out.entries.push(ValidationRule { key: key.to_string(), rules: render_rule_value(kv.value, content), key_start, @@ -343,7 +343,11 @@ pub(crate) fn inline_validate_rules(content: &str, offset: usize) -> Option Vec<&str> { - rules.rules.iter().map(|r| r.key.as_str()).collect() + rules.entries.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.rules[0].rules, "required|string|max:255"); - assert_eq!(rules.rules[1].rules, "nullable|integer"); + assert_eq!(rules.entries[0].rules, "required|string|max:255"); + assert_eq!(rules.entries[1].rules, "nullable|integer"); } #[test] @@ -69,7 +69,7 @@ class StoreUserRequest extends FormRequest { } "; let rules = rules_from_class_source(content, "StoreUserRequest"); - assert_eq!(rules.rules[0].rules, "required|new Enum(Role::class)"); + assert_eq!(rules.entries[0].rules, "required|new Enum(Role::class)"); } #[test] @@ -81,7 +81,7 @@ class StoreUserRequest extends FormRequest { "; let rules = rules_from_class_source(content, "StoreUserRequest"); assert_eq!( - &content[rules.rules[0].key_start..rules.rules[0].key_start + 4], + &content[rules.entries[0].key_start..rules.entries[0].key_start + 4], "name" ); } @@ -212,6 +212,27 @@ class UserController { assert_eq!(keys(&rules), vec!["second"]); } +#[test] +fn an_unreadable_nearer_call_does_not_hand_back_the_earlier_one() { + // The second call is the one in force. Its keys cannot be read, so the + // answer is "an incomplete set", never the first call's keys — those + // would describe a different request and, as a shape, claim to be + // complete. + let content = "validate(['first' => 'required']); + $request->validate([$dynamic => 'required']); + $request->input(''); + } +} +"; + let cursor = content.find("input('").unwrap() + 7; + let rules = inline_validate_rules(content, cursor).unwrap(); + assert!(keys(&rules).is_empty()); + assert!(!rules.keys_complete); +} + #[test] fn non_literal_keys_are_skipped() { let content = "validate(['slug' => 'required|string']); + $request->validate([$extra => 'required|string']); + $data§ = $request->validated(); + }", + ); + let hover = hover_text(&source).await; + assert!( + !hover.contains("array{"), + "the rules in force are unreadable, so no shape may be claimed: {hover}" + ); +} + +#[tokio::test] +async fn only_on_an_unrelated_receiver_keeps_its_own_return_type() { + // `only()` and `except()` are ordinary `Collection` methods. Narrowing + // applies to the `ValidatedInput` that `safe()` returns and nothing else, + // even in a file where a `safe()` call is in scope. + let source = controller( + " public function store(StorePostRequest $request) { + $safe = $request->safe(); + $bag = collect(['title' => 1]); + $subset§ = $bag->only(['title']); + }", + ); + let hover = hover_text(&source).await; + assert!( + !hover.contains("array{"), + "a Collection receiver must not pick up the request's shape: {hover}" + ); +} + #[tokio::test] async fn a_computed_rule_key_falls_back_to_plain_array() { let source = controller(