From 8d2d14c535bc6f8faaf366dee01efc07e8bba87c Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Mon, 27 Jul 2026 23:09:16 +0600 Subject: [PATCH 1/7] refactor(completion): share the open-quote scan between string detectors --- src/completion/command_params.rs | 36 +++----------------------------- src/completion/source/helpers.rs | 33 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/completion/command_params.rs b/src/completion/command_params.rs index ea73cc60..ff8db9fe 100644 --- a/src/completion/command_params.rs +++ b/src/completion/command_params.rs @@ -122,41 +122,11 @@ impl Backend { } } -/// Find the opening quote before the cursor and the prefix typed so far. -/// Returns `(quote_pos, prefix)` or `None` when the cursor is not inside an -/// unterminated single-line string. -fn find_open_quote(content: &str, cursor_offset: usize) -> Option<(usize, String)> { - let bytes = content.as_bytes(); - if cursor_offset == 0 || cursor_offset > bytes.len() { - return None; - } - let mut i = cursor_offset; - while i > 0 { - i -= 1; - let ch = bytes[i]; - if ch == b'\'' || ch == b'"' { - // Count preceding backslashes to skip escaped quotes. - let mut bs = 0; - let mut j = i; - while j > 0 && bytes[j - 1] == b'\\' { - bs += 1; - j -= 1; - } - if bs % 2 == 0 { - let prefix = content[i + 1..cursor_offset].to_string(); - return Some((i, prefix)); - } - } - if ch == b'\n' { - return None; - } - } - None -} - fn detect_context(content: &str, position: Position) -> Option { let cursor_offset = position_to_offset(content, position) as usize; - let (quote_pos, prefix) = find_open_quote(content, cursor_offset)?; + let (quote_pos, _) = + crate::completion::source::helpers::find_open_quote(content, cursor_offset)?; + let prefix = content[quote_pos + 1..cursor_offset].to_string(); let before_quote = content[..quote_pos].trim_end(); // ── Own argument / option: `->argument('|')` / `->option('|')` ───────── diff --git a/src/completion/source/helpers.rs b/src/completion/source/helpers.rs index 0a0d4381..b28f7fe7 100644 --- a/src/completion/source/helpers.rs +++ b/src/completion/source/helpers.rs @@ -32,6 +32,39 @@ use crate::type_engine::resolver::ResolutionCtx; pub(crate) use crate::type_engine::subject_expr::parse_new_expression_class as extract_new_expression_class; +/// Find the string literal the cursor sits inside, scanning backwards from +/// `cursor_offset` for the opening quote. +/// +/// Returns `(quote_offset, quote_char)`. Escaped quotes are skipped by +/// counting the preceding backslashes. Returns `None` when a newline is +/// reached first, since the cursor is then not inside a single-line string. +pub(crate) fn find_open_quote(content: &str, cursor_offset: usize) -> Option<(usize, char)> { + let bytes = content.as_bytes(); + if cursor_offset == 0 || cursor_offset > bytes.len() { + return None; + } + let mut i = cursor_offset; + while i > 0 { + i -= 1; + let ch = bytes[i]; + if ch == b'\'' || ch == b'"' { + let mut backslashes = 0; + let mut j = i; + while j > 0 && bytes[j - 1] == b'\\' { + backslashes += 1; + j -= 1; + } + if backslashes % 2 == 0 { + return Some((i, ch as char)); + } + } + if ch == b'\n' { + return None; + } + } + None +} + /// Extract the return type annotation from a closure or arrow-function /// literal, given its own source text (e.g. the closure's span text, or /// the text between a call's parentheses for one argument). From 32b71fa270a9ec29a0df4344116c74803160974d Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Mon, 27 Jul 2026 23:11:37 +0600 Subject: [PATCH 2/7] feat(laravel): complete request input keys from validation rules --- src/completion/eloquent_string.rs | 7 + src/completion/handler/mod.rs | 17 + src/completion/laravel_request_keys.rs | 68 +++ src/completion/mod.rs | 4 + src/definition/resolve.rs | 5 + src/virtual_members/laravel/mod.rs | 3 + src/virtual_members/laravel/request_fields.rs | 323 ++++++++++ .../laravel/request_fields_tests.rs | 97 +++ .../laravel/validation_rules.rs | 512 ++++++++++++++++ .../laravel/validation_rules_tests.rs | 265 +++++++++ tests/integration/laravel_request_keys.rs | 562 ++++++++++++++++++ tests/integration/main.rs | 1 + 12 files changed, 1864 insertions(+) create mode 100644 src/completion/laravel_request_keys.rs create mode 100644 src/virtual_members/laravel/request_fields.rs create mode 100644 src/virtual_members/laravel/request_fields_tests.rs create mode 100644 src/virtual_members/laravel/validation_rules.rs create mode 100644 src/virtual_members/laravel/validation_rules_tests.rs create mode 100644 tests/integration/laravel_request_keys.rs diff --git a/src/completion/eloquent_string.rs b/src/completion/eloquent_string.rs index d415a674..8e140409 100644 --- a/src/completion/eloquent_string.rs +++ b/src/completion/eloquent_string.rs @@ -118,6 +118,12 @@ pub(crate) struct StringCallContext { pub is_static: bool, pub arg_index: usize, pub string_content_start: usize, + /// Byte offset of the `(` that opens the call's argument list. + /// + /// `subject` only captures a bare identifier or `$variable`; callers that + /// need the full receiver expression (e.g. `$request->safe()`) re-read it + /// from the source text preceding this offset. + pub call_open_paren: usize, } /// Detect a string-inside-call context at the cursor position. @@ -195,6 +201,7 @@ pub(crate) fn detect_string_call_context( is_static, arg_index, string_content_start, + call_open_paren: paren_pos, }) } diff --git a/src/completion/handler/mod.rs b/src/completion/handler/mod.rs index 9dc857c5..f7227071 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -326,6 +326,23 @@ impl Backend { return Ok(Some(response)); } + // ── Request input key completion ──────────────────────── + // `$request->input('|')`, `->has('|')`, `$request['|']`, … + // offer the field names the validation rules in scope define. + // Runs after the Eloquent strategies because `has()` is also a + // relation method: a Builder subject resolves there first, and + // only a request-typed subject falls through to here. + if is_laravel + && matches!( + string_ctx, + StringContext::InStringLiteral | StringContext::NotInString + ) + && let Some(response) = + self.try_request_input_key_completion(&uri, &content, position, &ctx) + { + return Ok(Some(response)); + } + // ── Laravel route controller method completion ───────── // Inside `Route::controller(X::class)->group(fn(){…})`, // the 2nd argument string of Route::get/post/patch/… is a diff --git a/src/completion/laravel_request_keys.rs b/src/completion/laravel_request_keys.rs new file mode 100644 index 00000000..922ebff2 --- /dev/null +++ b/src/completion/laravel_request_keys.rs @@ -0,0 +1,68 @@ +//! Request input key completion driven by validation rules. +//! +//! Inside `$request->input('|')`, `->string('|')`, `->has('|')`, +//! `->validated('|')`, `->safe()->only(['|'])`, `$request['|']` and their +//! siblings, the acceptable field names are exactly the keys of the rules +//! array that validates the request — a `FormRequest`'s `rules()` method or +//! a `validate()` / `Validator::make()` call earlier in the same function. +//! +//! The field lookup itself lives in +//! [`crate::virtual_members::laravel::request_fields`], which go-to-definition +//! shares. + +use tower_lsp::lsp_types::*; + +use crate::Backend; +use crate::types::FileContext; +use crate::virtual_members::laravel::request_fields_at_position; + +impl Backend { + /// Try completing a request input field name from the validation rules in + /// scope. + /// + /// Returns `None` when the cursor is not inside a recognised request + /// input-key string, or when no rules describe the request. + pub(crate) fn try_request_input_key_completion( + &self, + uri: &str, + content: &str, + position: Position, + ctx: &FileContext, + ) -> Option { + let (field_ctx, _, fields) = request_fields_at_position(self, uri, content, position, ctx)?; + + // Replace the whole typed prefix so dotted names survive the editor's + // word-based filtering. + let edit_range = Range { + start: crate::text_position::offset_to_position(content, field_ctx.content_start), + end: position, + }; + let prefix_lower = field_ctx.prefix.to_lowercase(); + + let items: Vec = fields + .into_iter() + .filter(|field| { + prefix_lower.is_empty() || field.name.to_lowercase().starts_with(&prefix_lower) + }) + .enumerate() + .map(|(i, field)| CompletionItem { + label: field.name.clone(), + kind: Some(CompletionItemKind::FIELD), + detail: (!field.rules.is_empty()).then(|| field.rules.clone()), + sort_text: Some(format!("{:05}", i)), + filter_text: Some(field.name.clone()), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: edit_range, + new_text: field.name, + })), + ..Default::default() + }) + .collect(); + + if items.is_empty() { + None + } else { + Some(CompletionResponse::Array(items)) + } + } +} diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 97b80a2e..346c75f3 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -18,6 +18,9 @@ /// and raw variable type resolution for array shape value chaining /// - **eloquent_string**: Eloquent relation dot-notation and column name string /// completion inside method arguments like `with('`, `where('`, etc. +/// - **laravel_request_keys**: Request input field name completion inside +/// `$request->input('`, `->has('`, `$request['`, etc., driven by the +/// validation rules in scope /// - **use_edit**: Use-statement insertion and conflict analysis /// /// ## Sub-grouped modules @@ -63,6 +66,7 @@ pub(crate) mod builder; pub(crate) mod command_params; pub(crate) mod eloquent_string; pub(crate) mod handler; +pub(crate) mod laravel_request_keys; pub(crate) mod laravel_route_controller; pub(crate) mod laravel_string_keys; pub mod named_args; diff --git a/src/definition/resolve.rs b/src/definition/resolve.rs index e1a1564c..d4c4e496 100644 --- a/src/definition/resolve.rs +++ b/src/definition/resolve.rs @@ -66,6 +66,11 @@ impl Backend { return vec![loc]; } + // Request input keys: jump to the validation rule that declares them. + if let Some(loc) = laravel::resolve_request_field_definition(self, uri, content, position) { + return vec![loc]; + } + // env() fallback: not yet indexed in the symbol map. laravel::resolve_env_definition(self, content, position) .into_iter() diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index dd00ff0b..be5660f7 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -95,10 +95,12 @@ pub(crate) mod patches; mod pivots; mod provider_resources; mod relationships; +mod request_fields; mod route_names; mod scopes; mod string_keys; mod trans_keys; +pub(crate) mod validation_rules; mod view_names; pub(crate) mod where_property; @@ -122,6 +124,7 @@ pub(crate) use model_extraction::{ extract_laravel_metadata, has_scope_attribute, infer_relationship_from_method, }; pub(crate) use provider_resources::{ProviderResources, extract_provider_resources}; +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; diff --git a/src/virtual_members/laravel/request_fields.rs b/src/virtual_members/laravel/request_fields.rs new file mode 100644 index 00000000..d2919604 --- /dev/null +++ b/src/virtual_members/laravel/request_fields.rs @@ -0,0 +1,323 @@ +//! Request input field names, recovered from validation rules. +//! +//! Inside a controller action or a `FormRequest`, the set of input keys a +//! request may carry is a static fact: it is the key set of the rules array +//! that validates it. This module answers "which field names are in scope +//! at this cursor?" for the string arguments that name one — +//! `$request->input('…')`, `->string('…')`, `->has('…')`, +//! `->validated('…')`, `->safe()->only(['…'])`, `$request['…']`, and +//! friends. +//! +//! Completion and go-to-definition both go through +//! [`request_fields_at_position`]; the rules parsing itself lives in +//! [`super::validation_rules`]. + +use std::sync::Arc; + +use tower_lsp::lsp_types::{Location, Position, Url}; + +use crate::Backend; +use crate::class_lookup::find_class_at_offset; +use crate::completion::eloquent_string::detect_string_call_context; +use crate::completion::source::helpers::find_open_quote; +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, rule_fields, +}; + +/// Request accessors whose *first* argument names a single input field. +const FIELD_METHODS: &[&str] = &[ + "input", + "query", + "post", + "get", + "old", + "string", + "str", + "integer", + "float", + "boolean", + "date", + "enum", + "enums", + "array", + "collect", + "file", + "hasfile", + "whenhas", + "whenfilled", + "whenmissing", + "validated", +]; + +/// Request accessors that take any number of field names, either variadically +/// or as a single array argument. +const MULTI_FIELD_METHODS: &[&str] = &[ + "has", + "hasany", + "filled", + "isnotfilled", + "anyfilled", + "missing", + "only", + "except", +]; + +/// A cursor sitting inside a string that names a request input field. +pub(crate) struct RequestFieldContext { + /// Text of the receiver expression, e.g. `"$request"` or `"$this"`. + pub receiver: String, + /// Text typed so far inside the string. + pub prefix: String, + /// Byte offset of the string content (just after the opening quote). + pub content_start: usize, + /// The quote character that opened the string. + pub quote_char: char, +} + +impl RequestFieldContext { + /// The complete literal value, when the string is closed on this line. + /// + /// Completion only needs [`Self::prefix`], but go-to-definition has to + /// match the whole key. + pub fn full_value<'c>(&self, content: &'c str) -> Option<&'c str> { + let rest = content.get(self.content_start..)?; + let end = rest.find([self.quote_char, '\n'])?; + if rest.as_bytes()[end] == b'\n' { + return None; + } + Some(&rest[..end]) + } +} + +// ─── Detection ────────────────────────────────────────────────────────────── + +/// Detect a cursor inside a request input-field string. +pub(crate) fn detect_request_field_context( + content: &str, + position: Position, +) -> Option { + let cursor_offset = position_to_offset(content, position) as usize; + let (quote_pos, quote_char) = find_open_quote(content, cursor_offset)?; + let prefix = content.get(quote_pos + 1..cursor_offset)?.to_string(); + + // ── Array access: `$request['|']` ─────────────────────────────── + // Checked before the call form because the backwards scan for a call's + // opening paren would otherwise wander into unrelated code. + let before_quote = content.get(..quote_pos)?.trim_end(); + if let Some(before_bracket) = before_quote.strip_suffix('[') + && let Some(receiver) = trailing_variable(before_bracket) + { + return Some(RequestFieldContext { + receiver, + prefix, + content_start: quote_pos + 1, + quote_char, + }); + } + + // ── Method argument: `$request->input('|')` ───────────────────── + let call = detect_string_call_context(content, position)?; + if call.is_static { + return None; + } + let method = call.method_name.to_ascii_lowercase(); + let accepted = if MULTI_FIELD_METHODS.contains(&method.as_str()) { + true + } else { + FIELD_METHODS.contains(&method.as_str()) && call.arg_index == 0 + }; + if !accepted { + return None; + } + + let before_paren = content.get(..call.call_open_paren)?; + let before_method = strip_trailing_ident(before_paren.trim_end()); + let receiver_text = strip_arrow(before_method)?; + // `$request->safe()->only([…])` narrows the same rules array, so look + // through the `safe()` hop to the request it came from. + let receiver_text = strip_safe_call(receiver_text).unwrap_or(receiver_text); + let receiver = trailing_variable(receiver_text)?; + + Some(RequestFieldContext { + receiver, + prefix, + content_start: call.string_content_start, + quote_char: call.quote_char, + }) +} + +/// Strip a trailing PHP identifier, returning the text before it. +fn strip_trailing_ident(text: &str) -> &str { + let bytes = text.as_bytes(); + let mut end = bytes.len(); + while end > 0 && (bytes[end - 1].is_ascii_alphanumeric() || bytes[end - 1] == b'_') { + end -= 1; + } + &text[..end] +} + +/// Strip a trailing `->` or `?->` operator. +fn strip_arrow(text: &str) -> Option<&str> { + let trimmed = text.trim_end(); + trimmed + .strip_suffix("?->") + .or_else(|| trimmed.strip_suffix("->")) + .map(str::trim_end) +} + +/// Strip a trailing `->safe()` hop, returning the text of the object it was +/// called on. +fn strip_safe_call(text: &str) -> Option<&str> { + let trimmed = text.trim_end().strip_suffix(')')?.trim_end(); + let trimmed = trimmed.strip_suffix('(')?.trim_end(); + let before_name = strip_trailing_ident(trimmed); + if !trimmed[before_name.len()..].eq_ignore_ascii_case("safe") { + return None; + } + strip_arrow(before_name) +} + +/// Extract a trailing `$variable` token. +fn trailing_variable(text: &str) -> Option { + let trimmed = text.trim_end(); + let before = strip_trailing_ident(trimmed); + let name = &trimmed[before.len()..]; + if name.is_empty() || !before.ends_with('$') { + return None; + } + Some(format!("${name}")) +} + +// ─── Field resolution ─────────────────────────────────────────────────────── + +/// The input field names in scope at `position`, or `None` when the cursor is +/// not in a request input-field string or no rules describe it. +/// +/// Returns the detected context alongside the fields so callers can filter by +/// the typed prefix and place the replacement edit. +pub(crate) fn request_fields_at_position( + backend: &Backend, + uri: &str, + content: &str, + position: Position, + ctx: &FileContext, +) -> Option<(RequestFieldContext, ResolvedRules, Vec)> { + let field_ctx = detect_request_field_context(content, position)?; + + let class_loader = backend.class_loader(ctx); + let cursor_offset = position_to_offset(content, position); + let current_class = find_class_at_offset(&ctx.classes, cursor_offset); + + let loaded: Option>; + let receiver_class: &ClassInfo = if field_ctx.receiver == "$this" { + current_class? + } else { + loaded = resolve_variable_class( + &field_ctx.receiver, + current_class, + ctx, + content, + cursor_offset, + &class_loader, + ); + loaded.as_deref()? + }; + + if !is_request_like(receiver_class, &class_loader) { + 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); + if fields.is_empty() { + return None; + } + Some((field_ctx, rules, fields)) +} + +/// Resolve a `$variable` receiver to the class it holds. +fn resolve_variable_class( + variable: &str, + current_class: Option<&ClassInfo>, + ctx: &FileContext, + content: &str, + cursor_offset: u32, + class_loader: &dyn Fn(&str) -> Option>, +) -> Option> { + let fallback = ClassInfo::default(); + let types = crate::type_engine::variable::resolution::resolve_variable_types( + variable, + current_class.unwrap_or(&fallback), + &ctx.classes, + content, + cursor_offset, + class_loader, + crate::type_engine::resolver::Loaders::default(), + ); + types + .iter() + .find_map(|resolved| resolved.type_string.base_name().and_then(class_loader)) +} + +// ─── Go to definition ─────────────────────────────────────────────────────── + +/// Resolve go-to-definition on a request input-field string to the rule that +/// declares it. +pub(crate) fn resolve_request_field_definition( + backend: &Backend, + uri: &str, + content: &str, + position: Position, +) -> Option { + let ctx = backend.file_context(uri); + let (field_ctx, rules, fields) = + request_fields_at_position(backend, uri, content, position, &ctx)?; + let value = field_ctx.full_value(content)?; + + // 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 + .iter() + .find(|rule| rule.key == value) + .map(|rule| rule.key_start) + .or_else(|| { + fields + .iter() + .find(|field| field.name == value) + .map(|field| field.key_start) + })?; + + let (target_uri, target_content) = match &rules.source { + RulesSource::CurrentFile => (uri, content), + RulesSource::OtherFile { + uri: other_uri, + content: other_content, + } => (other_uri.as_str(), other_content.as_str()), + }; + + let position = crate::text_position::offset_to_position(target_content, key_start); + Some(crate::definition::point_location( + Url::parse(target_uri).ok()?, + position, + )) +} + +#[cfg(test)] +#[path = "request_fields_tests.rs"] +mod tests; diff --git a/src/virtual_members/laravel/request_fields_tests.rs b/src/virtual_members/laravel/request_fields_tests.rs new file mode 100644 index 00000000..3d3433bf --- /dev/null +++ b/src/virtual_members/laravel/request_fields_tests.rs @@ -0,0 +1,97 @@ +use super::*; + +/// Place the cursor immediately after `needle` and detect the context there. +fn detect_at(content: &str, needle: &str) -> Option { + let offset = content.find(needle).unwrap() + needle.len(); + let position = crate::text_position::offset_to_position(content, offset); + detect_request_field_context(content, position) +} + +#[test] +fn detects_input_call() { + let content = "input('na');\n"; + let ctx = detect_at(content, "input('na").expect("should detect input()"); + assert_eq!(ctx.receiver, "$request"); + assert_eq!(ctx.prefix, "na"); + assert_eq!(ctx.full_value(content), Some("na")); +} + +#[test] +fn detects_nullsafe_call() { + let content = "string('');\n"; + let ctx = detect_at(content, "string('").expect("should detect ?->string()"); + assert_eq!(ctx.receiver, "$request"); +} + +#[test] +fn detects_this_receiver() { + let content = "validated('em');\n"; + let ctx = detect_at(content, "validated('em").expect("should detect $this->validated()"); + assert_eq!(ctx.receiver, "$this"); + assert_eq!(ctx.prefix, "em"); +} + +#[test] +fn detects_array_access() { + let content = "only(['name', 'ag']);\n"; + let ctx = detect_at(content, "'ag").expect("should detect only([…]) key"); + assert_eq!(ctx.receiver, "$request"); + assert_eq!(ctx.prefix, "ag"); +} + +#[test] +fn looks_through_safe_to_the_request() { + let content = "safe()->only(['na']);\n"; + let ctx = detect_at(content, "'na").expect("should detect safe()->only() key"); + assert_eq!(ctx.receiver, "$request"); +} + +#[test] +fn rejects_later_arguments_of_single_key_accessors() { + let content = "input('name', 'defau');\n"; + assert!(detect_at(content, "'defau").is_none()); +} + +#[test] +fn accepts_later_arguments_of_variadic_accessors() { + let content = "hasAny('name', 'em');\n"; + let ctx = detect_at(content, "'em").expect("hasAny() takes any number of field names"); + assert_eq!(ctx.receiver, "$request"); +} + +#[test] +fn rejects_unrelated_methods() { + let content = "merge('na');\n"; + assert!(detect_at(content, "'na").is_none()); +} + +#[test] +fn rejects_static_calls() { + let content = "input('address.city');\n"; + let offset = content.find("address").unwrap() + 3; + let position = crate::text_position::offset_to_position(content, offset); + let ctx = detect_request_field_context(content, position).unwrap(); + assert_eq!(ctx.prefix, "add"); + assert_eq!(ctx.full_value(content), Some("address.city")); +} + +#[test] +fn full_value_is_none_for_an_unterminated_string() { + let content = "input('na\n"; + let ctx = detect_at(content, "'na").unwrap(); + assert_eq!(ctx.full_value(content), None); +} diff --git a/src/virtual_members/laravel/validation_rules.rs b/src/virtual_members/laravel/validation_rules.rs new file mode 100644 index 00000000..80bdfd7c --- /dev/null +++ b/src/virtual_members/laravel/validation_rules.rs @@ -0,0 +1,512 @@ +//! Laravel validation-rule extraction. +//! +//! The keys of a validation rules array are a static contract: they name +//! exactly the input fields a validated request may carry. This module +//! recovers that contract from source so that request-input completion +//! and go-to-definition can use it. +//! +//! Two sources are supported: +//! +//! - The `rules()` method of a `FormRequest` subclass, walking the parent +//! chain and through `array_merge()` so `parent::rules()` composition +//! still yields the locally declared keys. +//! - An inline `$request->validate([…])`, `$this->validate($request, […])`, +//! `$request->validateWithBag('bag', […])`, or `Validator::make($data, […])` +//! call earlier in the same function body. +//! +//! Only literal string keys are recovered. A computed key contributes +//! nothing, which degrades to "fewer suggestions" rather than to wrong +//! ones. + +use std::sync::Arc; + +use mago_allocator::LocalArena; +use mago_database::file::FileId; +use mago_span::HasSpan; +use mago_syntax::cst::*; + +use crate::Backend; +use crate::atom::bytes_to_str; +use crate::types::{ClassInfo, MAX_INHERITANCE_DEPTH}; +use crate::util::short_name; + +use super::helpers::extract_string_literal; + +/// The FQN of Laravel's base form-request class. +pub(crate) const FORM_REQUEST_FQN: &str = "Illuminate\\Foundation\\Http\\FormRequest"; + +/// The FQN of the object `Request::safe()` returns. +pub(crate) const VALIDATED_INPUT_FQN: &str = "Illuminate\\Support\\ValidatedInput"; + +/// One field entry of a validation rules array. +#[derive(Debug, Clone)] +pub(crate) struct ValidationRule { + /// The field name exactly as written, e.g. `"name"` or `"items.*.id"`. + pub key: String, + /// The rule specification rendered for display, e.g. + /// `"required|string|max:255"`. Empty when the value is not literal. + pub rules: String, + /// Byte offset of the key literal's content (just inside the quotes), + /// in the file named by the owning [`RulesSource`]. + pub key_start: usize, +} + +/// Which file a resolved rules array was parsed from. +#[derive(Debug)] +pub(crate) enum RulesSource { + /// The rules live in the file the cursor is in — the caller already + /// holds its content, so nothing is cloned. + CurrentFile, + /// The rules live in another file (a `FormRequest` class). + OtherFile { + /// URI of the file holding the rules array. + uri: String, + /// Content of that file; [`ValidationRule::key_start`] indexes it. + content: String, + }, +} + +/// A validation rules array located for a cursor position. +#[derive(Debug)] +pub(crate) struct ResolvedRules { + /// Where the rules were parsed from. + pub source: RulesSource, + /// The field entries, in declaration order. + pub rules: Vec, +} + +// ─── Array parsing ────────────────────────────────────────────────────────── + +/// Collect the top-level entries of a validation rules array literal. +/// +/// 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) { + match expr { + Expression::Array(arr) => collect_rules_from_elements(arr.elements.iter(), content, out), + Expression::LegacyArray(arr) => { + collect_rules_from_elements(arr.elements.iter(), content, out) + } + Expression::Parenthesized(p) => collect_rules_from_expr(p.expression, content, out), + Expression::Call(Call::Function(fc)) => { + if let Expression::Identifier(ident) = fc.function + && ident.value().eq_ignore_ascii_case(b"array_merge") + { + for arg in fc.argument_list.arguments.iter() { + collect_rules_from_expr(arg.value(), content, out); + } + } + } + _ => {} + } +} + +fn collect_rules_from_elements<'a>( + elements: impl Iterator>, + content: &str, + out: &mut Vec, +) { + for element in elements { + let ArrayElement::KeyValue(kv) = element else { + continue; + }; + let Some((key, key_start, _)) = extract_string_literal(kv.key, content) else { + continue; + }; + if key.is_empty() { + continue; + } + out.push(ValidationRule { + key: key.to_string(), + rules: render_rule_value(kv.value, content), + key_start, + }); + } +} + +/// Render a rule value as the pipe-separated string Laravel documents, +/// regardless of whether it was written as a string or an array. +/// +/// Non-literal entries (`new Enum(Role::class)`, `Rule::unique(...)`) are +/// rendered from their source text so hover/completion detail still says +/// something useful. +fn render_rule_value(expr: &Expression<'_>, content: &str) -> String { + match expr { + Expression::Literal(literal::Literal::String(_)) => extract_string_literal(expr, content) + .map_or_else(String::new, |(v, _, _)| v.to_string()), + Expression::Array(arr) => render_rule_list(arr.elements.iter(), content), + Expression::LegacyArray(arr) => render_rule_list(arr.elements.iter(), content), + other => condense(source_text(other, content)), + } +} + +fn render_rule_list<'a>( + elements: impl Iterator>, + content: &str, +) -> String { + let parts: Vec = elements + .filter_map(|element| match element { + ArrayElement::Value(v) => Some(render_rule_value(v.value, content)), + _ => None, + }) + .filter(|p| !p.is_empty()) + .collect(); + parts.join("|") +} + +fn source_text<'c>(expr: &Expression<'_>, content: &'c str) -> &'c str { + let span = expr.span(); + content + .get(span.start.offset as usize..span.end.offset as usize) + .unwrap_or_default() +} + +/// Collapse runs of whitespace so a multi-line rule expression fits on the +/// single line completion detail and hover show it on. +fn condense(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut prev_space = false; + for ch in text.chars() { + if ch.is_whitespace() { + if !prev_space && !out.is_empty() { + out.push(' '); + } + prev_space = true; + } else { + out.push(ch); + prev_space = false; + } + } + out.truncate(out.trim_end().len()); + out +} + +// ─── Inline `validate()` / `Validator::make()` ────────────────────────────── + +/// Rules from the last `validate()` / `Validator::make()` call that completes +/// before `offset` inside the same function body. +/// +/// 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> { + 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 (body_start, body_end) = enclosing_body_range(program, offset as u32)?; + + let mut best: Option<(u32, Vec)> = None; + collect_validate_calls( + Node::Program(program), + content, + (body_start, body_end), + offset as u32, + &mut best, + ); + best.map(|(_, rules)| rules).filter(|r| !r.is_empty()) +} + +/// The outermost function-like body span containing `offset`. +/// +/// Outermost rather than innermost so that a `validate()` call in a +/// controller action still applies inside a closure nested in that action — +/// "earlier in the same method" covers everything the method wraps. A +/// sibling method's body never contains the offset, so rules stay scoped to +/// the one being edited. +fn enclosing_body_range(program: &Program<'_>, offset: u32) -> Option<(u32, u32)> { + let mut found: Option<(u32, u32)> = None; + walk_bodies(Node::Program(program), offset, &mut found); + found +} + +fn walk_bodies(node: Node<'_, '_>, offset: u32, found: &mut Option<(u32, u32)>) { + if found.is_some() { + return; + } + let body_span = match node { + Node::Method(m) => match &m.body { + MethodBody::Concrete(block) => Some(block.span()), + MethodBody::Abstract(_) => None, + }, + Node::Function(f) => Some(f.body.span()), + Node::Closure(c) => Some(c.body.span()), + _ => None, + }; + if let Some(span) = body_span { + let (start, end) = (span.start.offset, span.end.offset); + if offset >= start && offset <= end { + *found = Some((start, end)); + return; + } + } + node.visit_children(|child| walk_bodies(child, offset, found)); +} + +/// Walk for `validate`-style calls inside `body`, keeping the last one that +/// finishes before `cursor`. +fn collect_validate_calls( + node: Node<'_, '_>, + content: &str, + body: (u32, u32), + cursor: u32, + best: &mut Option<(u32, Vec)>, +) { + let rules_arg = match node { + Node::MethodCall(mc) => method_rules_argument(&mc.method, &mc.argument_list), + Node::NullSafeMethodCall(mc) => method_rules_argument(&mc.method, &mc.argument_list), + Node::StaticMethodCall(smc) => static_rules_argument(smc), + _ => None, + }; + + if let Some(arg) = rules_arg { + let span = node.span(); + let (start, end) = (span.start.offset, span.end.offset); + if start >= body.0 + && end <= body.1 + && end <= cursor + && best.as_ref().is_none_or(|(be, _)| end >= *be) + { + let mut rules = Vec::new(); + collect_rules_from_expr(arg, content, &mut rules); + if !rules.is_empty() { + *best = Some((end, rules)); + } + } + } + + node.visit_children(|child| collect_validate_calls(child, content, body, cursor, best)); +} + +/// The rules argument of `->validate([...])`, `->validate($request, [...])`, +/// or `->validateWithBag('bag', [...])`. +fn method_rules_argument<'ast, 'arena>( + selector: &ClassLikeMemberSelector<'arena>, + arguments: &'ast ArgumentList<'arena>, +) -> Option<&'ast Expression<'arena>> { + let ClassLikeMemberSelector::Identifier(ident) = selector else { + return None; + }; + let name = bytes_to_str(ident.value); + if name.eq_ignore_ascii_case("validate") { + // `$request->validate($rules)` and the `ValidatesRequests` trait's + // `$this->validate($request, $rules)` differ only in arity. + argument_at(arguments, 0) + .filter(|e| is_array_literal(e)) + .or_else(|| argument_at(arguments, 1).filter(|e| is_array_literal(e))) + } else if name.eq_ignore_ascii_case("validateWithBag") { + argument_at(arguments, 1).filter(|e| is_array_literal(e)) + } else { + None + } +} + +/// The rules argument of `Validator::make($data, [...])`. +fn static_rules_argument<'ast, 'arena>( + call: &'ast StaticMethodCall<'arena>, +) -> Option<&'ast Expression<'arena>> { + let ClassLikeMemberSelector::Identifier(ident) = &call.method else { + return None; + }; + if !bytes_to_str(ident.value).eq_ignore_ascii_case("make") { + return None; + } + let Expression::Identifier(class) = call.class else { + return None; + }; + if !short_name(bytes_to_str(class.value())).eq_ignore_ascii_case("Validator") { + return None; + } + argument_at(&call.argument_list, 1).filter(|e| is_array_literal(e)) +} + +fn argument_at<'ast, 'arena>( + arguments: &'ast ArgumentList<'arena>, + index: usize, +) -> Option<&'ast Expression<'arena>> { + arguments.arguments.iter().nth(index).map(|a| a.value()) +} + +fn is_array_literal(expr: &Expression<'_>) -> bool { + match expr { + Expression::Array(_) | Expression::LegacyArray(_) => true, + Expression::Parenthesized(p) => is_array_literal(p.expression), + _ => false, + } +} + +// ─── `FormRequest::rules()` ───────────────────────────────────────────────── + +fn is_form_request_fqn(name: &str) -> bool { + name == FORM_REQUEST_FQN +} + +fn is_request_fqn(name: &str) -> bool { + name == super::REQUEST_FQN || name == VALIDATED_INPUT_FQN +} + +/// Whether `class` is a `FormRequest` subclass (or `FormRequest` itself). +pub(crate) fn is_form_request( + class: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, +) -> bool { + is_form_request_fqn(&class.fqn()) + || super::helpers::walks_parent_chain(class, class_loader, is_form_request_fqn) +} + +/// Whether `class` holds request input — an `Illuminate\Http\Request` +/// (which `FormRequest` extends) or the `ValidatedInput` wrapper that +/// `Request::safe()` returns. +/// +/// `walks_parent_chain` matches parent names, which post-processing has +/// already resolved to FQNs; the class's own `name` is the short name, so +/// it is checked separately against its `fqn()`. +pub(crate) fn is_request_like( + class: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, +) -> bool { + is_request_fqn(&class.fqn()) + || super::helpers::walks_parent_chain(class, class_loader, is_request_fqn) +} + +/// The rules declared by `class`'s own `rules()` method, or the nearest +/// ancestor that declares one. +/// +/// Returns `None` when no ancestor declares a `rules()` method with a literal +/// array return, or when the declaring class's source cannot be located. +pub(crate) fn form_request_rules( + backend: &Backend, + class: &ClassInfo, + current_uri: &str, + current_content: &str, +) -> Option { + let mut fqn = class.fqn().to_string(); + let mut depth = 0u32; + + loop { + let (uri, content) = backend.find_class_file_content(&fqn, current_uri, current_content)?; + let rules = rules_from_class_source(&content, short_name(&fqn)); + if !rules.is_empty() { + let source = if uri == current_uri { + RulesSource::CurrentFile + } else { + RulesSource::OtherFile { uri, content } + }; + return Some(ResolvedRules { source, rules }); + } + + depth += 1; + if depth > MAX_INHERITANCE_DEPTH { + return None; + } + let parent = backend + .find_or_load_class(&fqn) + .and_then(|c| c.parent_class)?; + if parent.as_str() == FORM_REQUEST_FQN { + return None; + } + fqn = parent.to_string(); + } +} + +/// Parse the array returned by `class_name`'s `rules()` method. +pub(crate) fn rules_from_class_source(content: &str, class_name: &str) -> Vec { + 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(); + 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, +) { + if let Node::Class(class) = node { + if !bytes_to_str(class.name.value).eq_ignore_ascii_case(class_name) { + return; + } + for member in class.members.iter() { + if let ClassLikeMember::Method(method) = member + && bytes_to_str(method.name.value).eq_ignore_ascii_case("rules") + && let MethodBody::Concrete(block) = &method.body + { + collect_returned_rules(Node::Block(block), content, out); + } + } + return; + } + node.visit_children(|child| collect_rules_method(child, class_name, content, out)); +} + +fn collect_returned_rules(node: Node<'_, '_>, content: &str, out: &mut Vec) { + if let Node::Return(ret) = node { + if let Some(value) = ret.value { + collect_rules_from_expr(value, content, out); + } + return; + } + node.visit_children(|child| collect_returned_rules(child, content, out)); +} + +// ─── Candidate keys ───────────────────────────────────────────────────────── + +/// A field name offered to the user, paired with the rule that produced it. +pub(crate) struct RuleField { + /// The completable field name, e.g. `"name"`, `"items"`, `"address.city"`. + pub name: String, + /// The rule specification of the entry this name came from. + pub rules: String, + /// Byte offset of the declaring key literal's content. + pub key_start: usize, +} + +/// Expand rule keys into the field names an input accessor accepts. +/// +/// A plain dotted key (`address.city`) is offered whole, since Laravel's dot +/// notation reaches it directly. A wildcard key (`items.*.id`) names array +/// members that no accessor addresses literally, so only its root segment is +/// offered. Roots come after the declared keys so the exact rules — and +/// their rule text — rank first. +pub(crate) fn rule_fields(rules: &[ValidationRule]) -> Vec { + let mut out: Vec = Vec::with_capacity(rules.len()); + + for rule in rules { + if rule.key.split('.').any(|seg| seg == "*") { + continue; + } + if !out.iter().any(|f| f.name == rule.key) { + out.push(RuleField { + name: rule.key.clone(), + rules: rule.rules.clone(), + key_start: rule.key_start, + }); + } + } + + for rule in rules { + let Some(root) = rule.key.split('.').next() else { + continue; + }; + if root.is_empty() || root == "*" || root == rule.key { + continue; + } + if !out.iter().any(|f| f.name == root) { + out.push(RuleField { + name: root.to_string(), + rules: String::new(), + key_start: rule.key_start, + }); + } + } + + out +} + +#[cfg(test)] +#[path = "validation_rules_tests.rs"] +mod tests; diff --git a/src/virtual_members/laravel/validation_rules_tests.rs b/src/virtual_members/laravel/validation_rules_tests.rs new file mode 100644 index 00000000..95f74e8a --- /dev/null +++ b/src/virtual_members/laravel/validation_rules_tests.rs @@ -0,0 +1,265 @@ +use super::*; + +fn keys(rules: &[ValidationRule]) -> Vec<&str> { + rules.iter().map(|r| r.key.as_str()).collect() +} + +#[test] +fn parses_form_request_rules_method() { + let content = " 'required|string|max:255', + 'age' => ['nullable', 'integer'], + ]; + } +} +"; + 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"); +} + +#[test] +fn follows_array_merge_in_rules_method() { + let content = " 'required|email', + ]); + } +} +"; + let rules = rules_from_class_source(content, "UpdateUserRequest"); + assert_eq!(keys(&rules), vec!["email"]); +} + +#[test] +fn ignores_rules_method_of_another_class() { + let content = " 'required']; } +} +class StoreUserRequest extends FormRequest { + public function rules(): array { return ['name' => 'required']; } +} +"; + assert_eq!( + keys(&rules_from_class_source(content, "StoreUserRequest")), + vec!["name"] + ); +} + +#[test] +fn renders_non_literal_rule_entries_from_source() { + let content = " [ + 'required', + new Enum(Role::class), + ], + ]; + } +} +"; + let rules = rules_from_class_source(content, "StoreUserRequest"); + assert_eq!(rules[0].rules, "required|new Enum(Role::class)"); +} + +#[test] +fn key_offsets_point_inside_the_quotes() { + let content = " 'required']; } +} +"; + let rules = rules_from_class_source(content, "StoreUserRequest"); + assert_eq!(&content[rules[0].key_start..rules[0].key_start + 4], "name"); +} + +#[test] +fn finds_inline_validate_call() { + let content = "validate([ + 'title' => 'required|string', + 'body' => 'required', + ]); + $request->input(''); + } +} +"; + let cursor = content.find("input('").unwrap() + 7; + let rules = inline_validate_rules(content, cursor).expect("should find validate() rules"); + assert_eq!(keys(&rules), vec!["title", "body"]); +} + +#[test] +fn finds_validates_requests_trait_form() { + let content = "validate($request, ['title' => 'required']); + $request->input(''); + } +} +"; + let cursor = content.find("input('").unwrap() + 7; + let rules = inline_validate_rules(content, cursor).expect("should find $this->validate rules"); + assert_eq!(keys(&rules), vec!["title"]); +} + +#[test] +fn finds_validator_make_rules() { + let content = "all(), ['title' => 'required']); + $request->input(''); + } +} +"; + let cursor = content.find("input('").unwrap() + 7; + let rules = inline_validate_rules(content, cursor).expect("should find Validator::make rules"); + assert_eq!(keys(&rules), vec!["title"]); +} + +#[test] +fn validator_make_ignores_the_data_argument() { + let content = " 'Hi'], ['headline' => 'required']); + $request->input(''); + } +} +"; + let cursor = content.find("input('").unwrap() + 7; + let rules = inline_validate_rules(content, cursor).expect("should find rules"); + assert_eq!(keys(&rules), vec!["headline"]); +} + +#[test] +fn ignores_validate_calls_after_the_cursor() { + let content = "input(''); + $request->validate(['title' => 'required']); + } +} +"; + let cursor = content.find("input('").unwrap() + 7; + assert!(inline_validate_rules(content, cursor).is_none()); +} + +#[test] +fn ignores_validate_calls_in_a_sibling_method() { + let content = "validate(['title' => 'required']); + } + public function store(Request $request) { + $request->input(''); + } +} +"; + let cursor = content.find("input('").unwrap() + 7; + assert!(inline_validate_rules(content, cursor).is_none()); +} + +#[test] +fn rules_reach_into_a_closure_nested_in_the_same_method() { + let content = "validate(['title' => 'required']); + DB::transaction(function () use ($request) { + $request->input(''); + }); + } +} +"; + let cursor = content.find("input('").unwrap() + 7; + let rules = inline_validate_rules(content, cursor).expect("closure is still the same method"); + assert_eq!(keys(&rules), vec!["title"]); +} + +#[test] +fn prefers_the_nearest_preceding_validate_call() { + let content = "validate(['first' => 'required']); + $request->validate(['second' => 'required']); + $request->input(''); + } +} +"; + let cursor = content.find("input('").unwrap() + 7; + let rules = inline_validate_rules(content, cursor).unwrap(); + assert_eq!(keys(&rules), vec!["second"]); +} + +#[test] +fn non_literal_keys_are_skipped() { + let content = " 'required', + $dynamic => 'required', + ]; + } +} +"; + assert_eq!( + keys(&rules_from_class_source(content, "StoreUserRequest")), + vec!["name"] + ); +} + +// ─── Field expansion ──────────────────────────────────────────────────────── + +fn rule(key: &str) -> ValidationRule { + ValidationRule { + key: key.to_string(), + rules: "required".to_string(), + key_start: 0, + } +} + +#[test] +fn wildcard_keys_collapse_to_their_root() { + let rules = vec![rule("items"), rule("items.*.id")]; + let fields = rule_fields(&rules); + let names: Vec<&String> = fields.iter().map(|f| &f.name).collect(); + assert_eq!(names, vec!["items"]); +} + +#[test] +fn wildcard_root_is_offered_even_without_its_own_rule() { + let rules = vec![rule("items.*.id")]; + let fields = rule_fields(&rules); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].name, "items"); + assert!(fields[0].rules.is_empty()); +} + +#[test] +fn plain_dotted_keys_are_offered_whole_and_by_root() { + let rules = vec![rule("address.city")]; + let fields = rule_fields(&rules); + let names: Vec<&String> = fields.iter().map(|f| &f.name).collect(); + assert_eq!(names, vec!["address.city", "address"]); + assert_eq!(fields[0].rules, "required"); + assert!(fields[1].rules.is_empty()); +} diff --git a/tests/integration/laravel_request_keys.rs b/tests/integration/laravel_request_keys.rs new file mode 100644 index 00000000..0b0fbb85 --- /dev/null +++ b/tests/integration/laravel_request_keys.rs @@ -0,0 +1,562 @@ +//! Integration tests for request input field names recovered from +//! validation rules — completion inside `$request->input('…')` and friends, +//! plus go-to-definition from a key to the rule that declares it. + +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/" + } + } +}"#; + +const REQUEST_PHP: &str = "\ + 'required|string|max:255', + 'published' => 'boolean', + 'tags' => 'array', + 'tags.*.name' => 'required|string', + 'author.email' => 'required|email', + ]; + } +} +"; + +fn base_files() -> Vec<(&'static str, &'static str)> { + vec![ + ("vendor/illuminate/Http/Request.php", REQUEST_PHP), + ( + "vendor/illuminate/Foundation/Http/FormRequest.php", + FORM_REQUEST_PHP, + ), + ( + "vendor/illuminate/Support/ValidatedInput.php", + VALIDATED_INPUT_PHP, + ), + ( + "vendor/illuminate/Support/Facades/Validator.php", + VALIDATOR_FACADE_PHP, + ), + ( + "src/Http/Requests/StorePostRequest.php", + STORE_POST_REQUEST_PHP, + ), + ] +} + +// ─── Harness ──────────────────────────────────────────────────────────────── + +/// Open `content` at `open_path` and return the completion labels at the +/// cursor, which is marked in the source by `§`. +async fn complete_labels(open_path: &str, content: &str) -> Vec { + complete_items(open_path, content) + .await + .into_iter() + .map(|item| item.label) + .collect() +} + +async fn complete_items(open_path: &str, content: &str) -> Vec { + let (backend, _dir, uri, position) = open_at_cursor(open_path, 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(); + + match result { + Some(CompletionResponse::Array(items)) => items, + Some(CompletionResponse::List(list)) => list.items, + _ => Vec::new(), + } +} + +/// Resolve go-to-definition at the `§` cursor and return the target URI and +/// the source line it points at. +async fn definition_line(open_path: &str, content: &str) -> Option<(String, String)> { + let (backend, _dir, uri, position) = open_at_cursor(open_path, content).await; + + let response = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap()?; + + let location = match response { + GotoDefinitionResponse::Scalar(loc) => loc, + GotoDefinitionResponse::Array(mut locs) => locs.drain(..).next()?, + GotoDefinitionResponse::Link(mut links) => { + let link = links.drain(..).next()?; + Location { + uri: link.target_uri, + range: link.target_range, + } + } + }; + + let path = location.uri.to_file_path().ok()?; + let text = std::fs::read_to_string(&path).ok()?; + let line = text.lines().nth(location.range.start.line as usize)?.trim(); + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + Some((name, line.to_string())) +} + +/// Write the workspace, strip the `§` cursor marker from `content`, open the +/// file, and return everything a request needs. +async fn open_at_cursor( + open_path: &str, + 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; + + // The opened file is also written to disk so that go-to-definition can + // read back the line it resolves to. + let mut files = base_files(); + files.push((open_path, stripped.as_str())); + let (backend, dir) = create_psr4_workspace(COMPOSER_JSON, &files); + + let uri = Url::from_file_path(dir.path().join(open_path)).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 }) +} + +// ─── FormRequest-driven completion ────────────────────────────────────────── + +#[tokio::test] +async fn form_request_rules_drive_input_completion() { + let controller = "\ +input('§'); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert!( + labels.contains(&"title".to_string()), + "expected 'title' from StorePostRequest::rules(), got: {labels:?}" + ); + assert!( + labels.contains(&"published".to_string()), + "expected 'published', got: {labels:?}" + ); +} + +#[tokio::test] +async fn wildcard_rule_keys_complete_their_root_segment() { + let controller = "\ +input('tag§'); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert_eq!( + labels, + vec!["tags".to_string()], + "`tags.*.name` should only be reachable through its root segment" + ); +} + +#[tokio::test] +async fn dotted_rule_keys_complete_whole_and_by_root() { + let controller = "\ +input('author§'); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert_eq!( + labels, + vec!["author.email".to_string(), "author".to_string()] + ); +} + +#[tokio::test] +async fn completion_detail_shows_the_rule() { + let controller = "\ +string('titl§'); + } +} +"; + let items = complete_items("src/PostController.php", controller).await; + let title = items + .iter() + .find(|item| item.label == "title") + .expect("expected a 'title' item"); + assert_eq!(title.detail.as_deref(), Some("required|string|max:255")); +} + +#[tokio::test] +async fn form_request_completes_its_own_rules_through_this() { + let request = "\ + 'required|string']; + } + public function withValidator(): void { + $this->input('§'); + } +} +"; + let labels = complete_labels("src/Http/Requests/UpdatePostRequest.php", request).await; + assert!( + labels.contains(&"headline".to_string()), + "expected the class's own rules through $this, got: {labels:?}" + ); +} + +// ─── Inline `validate()` / `Validator::make()` ────────────────────────────── + +#[tokio::test] +async fn inline_validate_call_drives_completion() { + let controller = "\ +validate([ + 'slug' => 'required|string', + 'excerpt' => 'nullable|string', + ]); + $request->input('§'); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert!( + labels.contains(&"slug".to_string()) && labels.contains(&"excerpt".to_string()), + "expected inline validate() keys, got: {labels:?}" + ); +} + +#[tokio::test] +async fn validator_make_drives_completion() { + let controller = "\ +all(), ['nickname' => 'required']); + $request->input('§'); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert!( + labels.contains(&"nickname".to_string()), + "expected Validator::make() keys, got: {labels:?}" + ); +} + +#[tokio::test] +async fn no_rules_in_scope_yields_no_field_completion() { + let controller = "\ +input('§'); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert!( + !labels.contains(&"title".to_string()), + "an unvalidated Request has no known keys, got: {labels:?}" + ); +} + +// ─── Accessor surface ─────────────────────────────────────────────────────── + +#[tokio::test] +async fn array_access_completes_field_names() { + let controller = "\ +safe()->only(['titl§']); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert_eq!(labels, vec!["title".to_string()]); +} + +#[tokio::test] +async fn has_completes_field_names() { + let controller = "\ +has('publishe§')) {} + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert_eq!(labels, vec!["published".to_string()]); +} + +#[tokio::test] +async fn unrelated_method_does_not_complete_field_names() { + let controller = "\ +merge(['titl§' => 'x']); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert!( + !labels.contains(&"title".to_string()), + "merge() writes input, it does not read a validated key: {labels:?}" + ); +} + +#[tokio::test] +async fn non_request_receiver_does_not_complete_field_names() { + let controller = "\ +input('titl§'); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert!( + !labels.contains(&"title".to_string()), + "only request objects carry validated input: {labels:?}" + ); +} + +// ─── Go to definition ─────────────────────────────────────────────────────── + +#[tokio::test] +async fn definition_jumps_to_the_form_request_rule() { + let controller = "\ +input('tit§le'); + } +} +"; + let (file, line) = definition_line("src/PostController.php", controller) + .await + .expect("expected a definition for 'title'"); + assert_eq!(file, "StorePostRequest.php"); + assert_eq!(line, "'title' => 'required|string|max:255',"); +} + +#[tokio::test] +async fn definition_jumps_to_the_inline_rule() { + let controller = "\ +validate(['slug' => 'required|string']); + $request->input('sl§ug'); + } +} +"; + let (file, line) = definition_line("src/PostController.php", controller) + .await + .expect("expected a definition for 'slug'"); + assert_eq!(file, "PostController.php"); + assert_eq!(line, "$request->validate(['slug' => 'required|string']);"); +} + +#[tokio::test] +async fn definition_of_a_wildcard_root_lands_on_the_wildcard_rule() { + let controller = "\ +input('ta§gs'); + } +} +"; + let (file, line) = definition_line("src/PostController.php", controller) + .await + .expect("expected a definition for 'tags'"); + assert_eq!(file, "StorePostRequest.php"); + assert_eq!(line, "'tags' => 'array',"); +} + +#[tokio::test] +async fn definition_of_an_unknown_key_resolves_nothing() { + let controller = "\ +input('nope§'); + } +} +"; + assert!( + definition_line("src/PostController.php", controller) + .await + .is_none(), + "a key that no rule declares has no definition" + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 028b7e03..b61b8358 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -127,6 +127,7 @@ mod laravel_date_factory; mod laravel_macro_facade; mod laravel_macros; mod laravel_references; +mod laravel_request_keys; mod laravel_route_controller; mod linked_editing; mod lsp_concurrency; From 1dfb5e0aa8df26b62559521329b8b7445bc4c0c0 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Mon, 27 Jul 2026 23:12:46 +0600 Subject: [PATCH 3/7] feat(laravel): added example inside demo application --- examples/laravel/app/Demo.php | 40 +++++++++++++++++++ .../app/Http/Requests/StoreBakeryRequest.php | 39 ++++++++++++++++++ examples/laravel/assertions.php | 33 +++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 examples/laravel/app/Http/Requests/StoreBakeryRequest.php diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 92521623..3abd8e0a 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -12,6 +12,7 @@ use App\Models\BlogAuthor; use App\Models\BlogPost; use App\Models\Review; +use App\Http\Requests\StoreBakeryRequest; use Illuminate\Http\Request; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; @@ -413,6 +414,45 @@ public function authUser(Request $request): void } + // ── Request input keys from validation rules ──────────────────────── + + public function requestInputKeys(StoreBakeryRequest $request): void + { + // StoreBakeryRequest::rules() names every input this request can + // carry, so the string arguments below complete to those keys and + // go-to-definition jumps to the rule that declares them. + $request->input('name'); // → 'name' => 'required|string|max:255' + $request->boolean('apricot'); // → 'apricot' => 'boolean' + $request->has('dough_temp'); // → 'dough_temp' => 'nullable|numeric' + $request->validated('name'); // → same keys, validated form + $request['name']; // → array access completes too + + // A wildcard rule is only addressable through its root segment, so + // `notes.*.body` offers `notes`; a plain dotted key is offered whole. + $request->input('notes'); // → root of 'notes.*.body' + $request->input('owner.email'); // → 'owner.email' => 'required|email' + + // safe() narrows the same rule set. + $request->safe()->only(['name', 'apricot']); + } + + + // ── Request input keys from an inline validate() call ─────────────── + + public function inlineValidateKeys(Request $request): void + { + // A plain Request has no rules() to read, so the keys come from the + // validate() call earlier in this same method. + $request->validate([ + 'headline' => 'required|string', + 'published_at' => 'nullable|date', + ]); + + $request->input('headline'); // → from the validate() call above + $request->filled('published_at'); // → from the validate() call above + } + + // ── @mixin of an Eloquent model exposes its virtual members ───────── public function mixinModel(): void diff --git a/examples/laravel/app/Http/Requests/StoreBakeryRequest.php b/examples/laravel/app/Http/Requests/StoreBakeryRequest.php new file mode 100644 index 00000000..3a072723 --- /dev/null +++ b/examples/laravel/app/Http/Requests/StoreBakeryRequest.php @@ -0,0 +1,39 @@ +input('…')` and friends — see `Demo::requestInputKeys()`. + */ +class StoreBakeryRequest extends FormRequest +{ + public function authorize(): bool + { + return true; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'apricot' => 'boolean', + 'dough_temp' => 'nullable|numeric', + 'notes' => 'array', + 'notes.*.body' => 'required|string', + 'owner.email' => 'required|email', + ]; + } + + public function withValidator(): void + { + // Inside the request itself, `$this` resolves the same rule keys. + $this->input('name'); + } +} diff --git a/examples/laravel/assertions.php b/examples/laravel/assertions.php index 53e09994..7eefab50 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -338,6 +338,39 @@ class_uses_recursive(\App\Models\BlogPost::class), (new ReflectionMethod(\Carbon\CarbonImmutable::class, 'this'))->isProtected() ); +// ─── Validation rules are the request's input contract ────────────────────── + +// Demo::requestInputKeys() claims these keys complete inside +// `$request->input('…')`. They come straight from rules(), so assert the +// rule set the demo comments describe is the one the class actually declares. +$bakeryRules = (new \App\Http\Requests\StoreBakeryRequest())->rules(); +check( + 'StoreBakeryRequest::rules() declares the demoed keys', + array_keys($bakeryRules) === [ + 'name', + 'apricot', + 'dough_temp', + 'notes', + 'notes.*.body', + 'owner.email', + ] +); +check( + 'FormRequest extends Request, so its input accessors are inherited', + is_subclass_of( + \Illuminate\Foundation\Http\FormRequest::class, + \Illuminate\Http\Request::class + ) +); +// `safe()` has no native return type — its docblock says +// `ValidatedInput|array` — so the demo's `safe()->only([…])` relies on +// ValidatedInput declaring the narrowing methods. +check( + 'ValidatedInput narrows with only()/except()', + method_exists(\Illuminate\Support\ValidatedInput::class, 'only') + && method_exists(\Illuminate\Support\ValidatedInput::class, 'except') +); + // ─── Summary ──────────────────────────────────────────────────────────────── echo "\n"; From f64b586147d90b928b7671df03dc79b3598455f6 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Mon, 27 Jul 2026 23:13:53 +0600 Subject: [PATCH 4/7] chore: remove, update items from laravel todos --- docs/todo.md | 1 - docs/todo/laravel.md | 23 +++++------------------ 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 305292ab..49181955 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -171,7 +171,6 @@ unlikely to move the needle for most users. | L32 | [Config-backed named-resource strings](todo/laravel.md#l32-config-backed-named-resource-strings) (log channels, cache stores, guards, connections, rate limiters) | Medium | Low-Medium | | L17 | [Additional string contexts without booting](todo/laravel.md#l17-additional-string-contexts-without-booting) (middleware, assets, validation, Inertia) | Medium | Medium | | L36 | [Container binding registrations from service providers](todo/laravel.md#l36-container-binding-registrations-from-service-providers) | Medium | Medium | -| L37 | [Request input key completion from validation rules](todo/laravel.md#l37-request-input-key-completion-from-validation-rules) | Medium | Medium | | L25 | [Storage disk name strings](todo/laravel.md#l25-storage-disk-name-strings) | Low-Medium | Low | | L31 | [String-key rename, highlight, and semantic tokens](todo/laravel.md#l31-string-key-rename-highlight-and-semantic-tokens) | Low-Medium | Low-Medium | | L29 | [Livewire and Volt component names](todo/laravel.md#l29-livewire-and-volt-component-names) | Low-Medium | Medium | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 64038126..59700e6f 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -847,22 +847,6 @@ binding `bind(Gateway::class, StripeGateway::class)` does **not** retype `app(Gateway::class)` to the concrete — the contract is the interface. -#### L37. Request input key completion from validation rules - -**Impact: Medium · Effort: Medium** - -Inside a controller method or FormRequest, the keys of the validated -input are statically known from the rules array: the `rules()` method -of the `FormRequest` in the method's signature, or the `validate()` / -`Validator::make()` call earlier in the same method. Offer those field -names as string completion (with go-to-definition to the rule line) -in: `$request->input()`, `query()`, `post()`, `string()`, `integer()`, -`boolean()`, `date()`, `enum()`, `file()`, `has()`/`filled()`/ -`missing()`, `validated('key')`, `safe()->only([...])`/`except([...])`, -and array access `$request['key']`. Dotted rule keys (`items.*.id`) -complete their root segment. This reuses the rules parsing from L38 — -implement the extraction once, feed both. - #### L38. Typed `validated()` array shapes from rules **Impact: Medium-High · Effort: Medium-High** @@ -889,8 +873,11 @@ member type. `safe()` returns a `ValidatedInput` whose `only()`/ the moment any rule key or rule string is non-literal — a partial shape that claims completeness would produce false unknown-key diagnostics. Array-shape machinery (shapes, optional keys, `list<>`) -already exists in the type engine; the work is the rules-to-shape -translation and wiring it as a conditional return. +already exists in the type engine, and +`virtual_members/laravel/validation_rules.rs` already recovers the +literal `(key, rule)` pairs and locates the rules array in scope for a +cursor; the work is the rules-to-shape translation and wiring it as a +conditional return. #### L39. Unused view and translation key detection From 41eb04f188c18f84fe17d240ae06f857066ff0f3 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Mon, 27 Jul 2026 23:18:08 +0600 Subject: [PATCH 5/7] chore: update the CHANGELOG file --- docs/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 69eb3a30..f8e59397 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Hover for Laravel string keys.** Hovering over a route name, config key, view name, or translation key string now shows the key kind, the key value, and where it's defined (e.g. `routes/web/ems.php`, `config/app.php`). Contributed by @calebdw. - **Diagnostics for invalid Laravel string keys.** A typo in `route('dashbaord')`, `config('app.naem')`, `view('layouts.ap')`, or `__('auth.failedd')` now produces a warning: `Unknown route: 'dashbaord'`. Only plain string literals are flagged; dynamic keys with variables are ignored. Contributed by @calebdw. - **Artisan command names and signature strings.** Command names declared by a command class's `$signature`, `$name`, or `#[AsCommand]` attribute are now recovered from project and vendor command classes, so referencing one as a string completes, navigates to the declaring class, hovers with its arguments and options, and flags unknown names. This covers `Artisan::call()`, `Artisan::queue()`, `Schedule::command()`, and `$this->call()` / `$this->callSilently()` inside a command. The `$signature` grammar (`{user}`, `{user?}`, `{--queue=}`, arrays, defaults, shortcuts, and ` : ` descriptions) is parsed so that, inside a command, `$this->argument('user')` and `$this->option('queue')` complete and hover against that command's own signature and unknown parameter names are flagged, and the parameter array of `Artisan::call('cmd', [...])` completes the target command's argument and `--option` keys. Contributed by @shuvroroy (#274). +- **Request input keys complete from validation rules.** The keys of a Laravel validation rules array are the complete set of inputs a request may carry, so PHPantom now offers them as string completion wherever a field name is named: `$request->input()`, `query()`, `post()`, `string()`, `integer()`, `boolean()`, `date()`, `enum()`, `file()`, `has()`, `filled()`, `missing()`, `only()`, `except()`, `validated('key')`, `safe()->only([…])`, and array access `$request['key']`. The rules come from the `rules()` method of the `FormRequest` type-hinted in the enclosing method (following `array_merge()` and the parent chain), or from a `validate()` / `Validator::make()` call earlier in the same method. Each suggestion shows its rule (`required|string|max:255`) and go-to-definition on the key jumps to the line that declares it. Wildcard rules like `items.*.id` complete their root segment, and a computed rule key simply contributes nothing rather than a wrong suggestion. Contributed by @shuvroroy (#292). - **Laravel model factory relationship methods.** Eloquent factories now offer the dynamic `has{Relationship}()` and `for{Relationship}()` methods that Laravel resolves through `Factory::__call()`, one per relationship on the associated model, plus `trashed()` when the model uses `SoftDeletes`. They complete, hover, and resolve, and because each returns the factory the fluent chain continues, so `Post::factory()->hasComments(3)->create()` still resolves to the model. The model is derived from the factory naming convention, so no `@extends Factory` 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 e8689d4a74edc76263cd99e66dfe43e5744dd817 Mon Sep 17 00:00:00 2001 From: Anders Jenbo Date: Tue, 28 Jul 2026 19:05:24 +0200 Subject: [PATCH 6/7] A bit of clean up --- docs/CHANGELOG.md | 2 +- examples/laravel/app/Demo.php | 2 +- src/completion/eloquent_string.rs | 33 ++----------------------------- 3 files changed, 4 insertions(+), 33 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f8e59397..e60e517b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -25,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Hover for Laravel string keys.** Hovering over a route name, config key, view name, or translation key string now shows the key kind, the key value, and where it's defined (e.g. `routes/web/ems.php`, `config/app.php`). Contributed by @calebdw. - **Diagnostics for invalid Laravel string keys.** A typo in `route('dashbaord')`, `config('app.naem')`, `view('layouts.ap')`, or `__('auth.failedd')` now produces a warning: `Unknown route: 'dashbaord'`. Only plain string literals are flagged; dynamic keys with variables are ignored. Contributed by @calebdw. - **Artisan command names and signature strings.** Command names declared by a command class's `$signature`, `$name`, or `#[AsCommand]` attribute are now recovered from project and vendor command classes, so referencing one as a string completes, navigates to the declaring class, hovers with its arguments and options, and flags unknown names. This covers `Artisan::call()`, `Artisan::queue()`, `Schedule::command()`, and `$this->call()` / `$this->callSilently()` inside a command. The `$signature` grammar (`{user}`, `{user?}`, `{--queue=}`, arrays, defaults, shortcuts, and ` : ` descriptions) is parsed so that, inside a command, `$this->argument('user')` and `$this->option('queue')` complete and hover against that command's own signature and unknown parameter names are flagged, and the parameter array of `Artisan::call('cmd', [...])` completes the target command's argument and `--option` keys. Contributed by @shuvroroy (#274). -- **Request input keys complete from validation rules.** The keys of a Laravel validation rules array are the complete set of inputs a request may carry, so PHPantom now offers them as string completion wherever a field name is named: `$request->input()`, `query()`, `post()`, `string()`, `integer()`, `boolean()`, `date()`, `enum()`, `file()`, `has()`, `filled()`, `missing()`, `only()`, `except()`, `validated('key')`, `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). +- **Request input keys complete from validation rules.** The keys of a Laravel validation rules array are the complete set of inputs a request may carry, so PHPantom now offers them as string completion wherever a request accessor names a field: `$request->input('key')`, the typed and presence accessors, the multi-key ones such as `only()` and `except()`, `safe()->only([...])`, and array access `$request['key']`. The rules come from the `rules()` method of the `FormRequest` type-hinted in the enclosing method (following `array_merge()` and the parent chain), or from a `validate()` / `Validator::make()` call earlier in the same method. Each suggestion shows its rule (`required|string|max:255`), and go-to-definition on the key jumps to the line that declares it. Wildcard rules like `items.*.id` complete their root segment, and a computed rule key simply contributes nothing rather than a wrong suggestion. Contributed by @shuvroroy (#292). - **Laravel model factory relationship methods.** Eloquent factories now offer the dynamic `has{Relationship}()` and `for{Relationship}()` methods that Laravel resolves through `Factory::__call()`, one per relationship on the associated model, plus `trashed()` when the model uses `SoftDeletes`. They complete, hover, and resolve, and because each returns the factory the fluent chain continues, so `Post::factory()->hasComments(3)->create()` still resolves to the model. The model is derived from the factory naming convention, so no `@extends Factory` 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/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 3abd8e0a..54ea26b3 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -8,11 +8,11 @@ namespace App; +use App\Http\Requests\StoreBakeryRequest; use App\Models\Bakery; use App\Models\BlogAuthor; use App\Models\BlogPost; use App\Models\Review; -use App\Http\Requests\StoreBakeryRequest; use Illuminate\Http\Request; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; diff --git a/src/completion/eloquent_string.rs b/src/completion/eloquent_string.rs index 8e140409..431c826d 100644 --- a/src/completion/eloquent_string.rs +++ b/src/completion/eloquent_string.rs @@ -136,37 +136,8 @@ pub(crate) fn detect_string_call_context( position: Position, ) -> Option { let cursor_offset = position_to_offset(content, position) as usize; - let bytes = content.as_bytes(); - - if cursor_offset == 0 || cursor_offset > bytes.len() { - return None; - } - - let mut quote_pos = None; - let mut quote_char = '\''; - let mut i = cursor_offset; - while i > 0 { - i -= 1; - let ch = bytes[i]; - if ch == b'\'' || ch == b'"' { - let mut backslashes = 0; - let mut j = i; - while j > 0 && bytes[j - 1] == b'\\' { - backslashes += 1; - j -= 1; - } - if backslashes % 2 == 0 { - quote_pos = Some(i); - quote_char = ch as char; - break; - } - } - if ch == b'\n' { - return None; - } - } - - let quote_pos = quote_pos?; + let (quote_pos, quote_char) = + crate::completion::source::helpers::find_open_quote(content, cursor_offset)?; let string_content_start = quote_pos + 1; let partial = content[string_content_start..cursor_offset].to_string(); From b104e6b817859fcbbfcbb6c81a535d249354375e Mon Sep 17 00:00:00 2001 From: Anders Jenbo Date: Tue, 28 Jul 2026 19:30:18 +0200 Subject: [PATCH 7/7] Handle safe(), optimize --- examples/laravel/app/Demo.php | 6 +- src/virtual_members/laravel/request_fields.rs | 24 +- .../laravel/validation_rules.rs | 250 +++++++++++++----- .../laravel/validation_rules_tests.rs | 104 ++++++++ tests/integration/laravel_request_keys.rs | 63 +++++ 5 files changed, 370 insertions(+), 77 deletions(-) diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 54ea26b3..39c135f1 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -432,8 +432,12 @@ public function requestInputKeys(StoreBakeryRequest $request): void $request->input('notes'); // → root of 'notes.*.body' $request->input('owner.email'); // → 'owner.email' => 'required|email' - // safe() narrows the same rule set. + // safe() narrows the same rule set, whether it is chained straight + // through or parked in a variable first. $request->safe()->only(['name', 'apricot']); + + $safe = $request->safe(); + $safe->except(['dough_temp']); // → still StoreBakeryRequest's keys } diff --git a/src/virtual_members/laravel/request_fields.rs b/src/virtual_members/laravel/request_fields.rs index d2919604..de90eb95 100644 --- a/src/virtual_members/laravel/request_fields.rs +++ b/src/virtual_members/laravel/request_fields.rs @@ -25,7 +25,7 @@ use crate::types::{ClassInfo, FileContext}; use super::validation_rules::{ ResolvedRules, RuleField, RulesSource, form_request_rules, inline_validate_rules, - is_form_request, is_request_like, rule_fields, + is_form_request, is_request_like, is_validated_input, rule_fields, safe_source_variable, }; /// Request accessors whose *first* argument names a single input field. @@ -216,14 +216,32 @@ pub(crate) fn request_fields_at_position( let receiver_class: &ClassInfo = if field_ctx.receiver == "$this" { current_class? } else { - loaded = resolve_variable_class( + let mut resolved = resolve_variable_class( &field_ctx.receiver, current_class, ctx, content, cursor_offset, &class_loader, - ); + )?; + // `$safe = $request->safe()` narrows the request's own rules array, + // so follow the assignment back to the request: `ValidatedInput` + // itself has no `rules()` to read. + if is_validated_input(&resolved) + && let Some(source) = + safe_source_variable(content, cursor_offset as usize, &field_ctx.receiver) + && let Some(request) = resolve_variable_class( + &source, + current_class, + ctx, + content, + cursor_offset, + &class_loader, + ) + { + resolved = request; + } + loaded = Some(resolved); loaded.as_deref()? }; diff --git a/src/virtual_members/laravel/validation_rules.rs b/src/virtual_members/laravel/validation_rules.rs index 80bdfd7c..3a3d3358 100644 --- a/src/virtual_members/laravel/validation_rules.rs +++ b/src/virtual_members/laravel/validation_rules.rs @@ -22,7 +22,7 @@ use std::sync::Arc; use mago_allocator::LocalArena; use mago_database::file::FileId; -use mago_span::HasSpan; +use mago_span::{HasSpan, Span}; use mago_syntax::cst::*; use crate::Backend; @@ -182,100 +182,132 @@ fn condense(text: &str) -> String { out } -// ─── Inline `validate()` / `Validator::make()` ────────────────────────────── - -/// Rules from the last `validate()` / `Validator::make()` call that completes -/// before `offset` inside the same function body. -/// -/// 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> { - 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 (body_start, body_end) = enclosing_body_range(program, offset as u32)?; +// ─── Scope walking ────────────────────────────────────────────────────────── - let mut best: Option<(u32, Vec)> = None; - collect_validate_calls( - Node::Program(program), - content, - (body_start, body_end), - offset as u32, - &mut best, - ); - best.map(|(_, rules)| rules).filter(|r| !r.is_empty()) +/// Whether `span` covers `offset`, inclusive at both ends. +fn covers(span: Span, offset: u32) -> bool { + offset >= span.start.offset && offset <= span.end.offset } -/// The outermost function-like body span containing `offset`. +/// The outermost function-like body containing `offset`. /// /// Outermost rather than innermost so that a `validate()` call in a /// controller action still applies inside a closure nested in that action — /// "earlier in the same method" covers everything the method wraps. A /// sibling method's body never contains the offset, so rules stay scoped to /// the one being edited. -fn enclosing_body_range(program: &Program<'_>, offset: u32) -> Option<(u32, u32)> { - let mut found: Option<(u32, u32)> = None; - walk_bodies(Node::Program(program), offset, &mut found); - found -} - -fn walk_bodies(node: Node<'_, '_>, offset: u32, found: &mut Option<(u32, u32)>) { - if found.is_some() { - return; - } - let body_span = match node { +/// +/// Spans nest, so only the cursor's own ancestors are descended into: a +/// subtree that does not cover the offset cannot hold the body that does. +/// That keeps the search proportional to nesting depth rather than to file +/// size. +fn enclosing_body<'ast, 'arena>( + node: Node<'ast, 'arena>, + offset: u32, +) -> Option> { + let body = match node { Node::Method(m) => match &m.body { - MethodBody::Concrete(block) => Some(block.span()), + MethodBody::Concrete(block) => Some(Node::Block(block)), MethodBody::Abstract(_) => None, }, - Node::Function(f) => Some(f.body.span()), - Node::Closure(c) => Some(c.body.span()), + Node::Function(f) => Some(Node::Block(&f.body)), + Node::Closure(c) => Some(Node::Block(&c.body)), _ => None, }; - if let Some(span) = body_span { - let (start, end) = (span.start.offset, span.end.offset); - if offset >= start && offset <= end { - *found = Some((start, end)); - return; - } + if let Some(body) = body + && covers(body.span(), offset) + { + return Some(body); } - node.visit_children(|child| walk_bodies(child, offset, found)); + + let mut found = None; + node.visit_children(|child| { + if found.is_none() && covers(child.span(), offset) { + found = enclosing_body(child, offset); + } + }); + found } -/// Walk for `validate`-style calls inside `body`, keeping the last one that -/// finishes before `cursor`. -fn collect_validate_calls( - node: Node<'_, '_>, - content: &str, - body: (u32, u32), +/// Hand every node of `node`'s subtree that starts before `cursor` to +/// `visit`. +/// +/// Callers are looking for a construct that *completes* before the cursor, +/// and one cannot end before the cursor without starting before it, so +/// subtrees that begin at or after the cursor are skipped rather than walked. +fn walk_before_cursor<'ast, 'arena>( + node: Node<'ast, 'arena>, cursor: u32, - best: &mut Option<(u32, Vec)>, + visit: &mut impl FnMut(Node<'ast, 'arena>), ) { - let rules_arg = match node { - Node::MethodCall(mc) => method_rules_argument(&mc.method, &mc.argument_list), - Node::NullSafeMethodCall(mc) => method_rules_argument(&mc.method, &mc.argument_list), - Node::StaticMethodCall(smc) => static_rules_argument(smc), - _ => None, - }; - - if let Some(arg) = rules_arg { - let span = node.span(); - let (start, end) = (span.start.offset, span.end.offset); - if start >= body.0 - && end <= body.1 - && end <= cursor - && best.as_ref().is_none_or(|(be, _)| end >= *be) - { - let mut rules = Vec::new(); - collect_rules_from_expr(arg, content, &mut rules); - if !rules.is_empty() { - *best = Some((end, rules)); - } + visit(node); + node.visit_children(|child| { + if child.span().start.offset < cursor { + walk_before_cursor(child, cursor, visit); } + }); +} + +/// Whether a construct ending at `end` is a better candidate than the one +/// already in `best`: it has to finish before the cursor, and later beats +/// earlier so the nearest preceding construct wins. +fn beats_best(best: &Option<(u32, T)>, end: u32, cursor: u32) -> bool { + end <= cursor && best.as_ref().is_none_or(|(seen, _)| end >= *seen) +} + +// ─── Inline `validate()` / `Validator::make()` ────────────────────────────── + +/// Cheap pre-filter for the shapes [`inline_validate_rules`] recognises. +/// +/// `validate()`, `validateWithBag()` and `Validator::make()` all contain +/// "validat" as written, so a file without it is not worth parsing. PHP +/// 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() +} + +/// Rules from the last `validate()` / `Validator::make()` call that completes +/// before `offset` inside the same function body. +/// +/// 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> { + if !mentions_validation(content) { + return None; } - node.visit_children(|child| collect_validate_calls(child, content, body, cursor, best)); + 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 body = enclosing_body(Node::Program(program), offset as u32)?; + let cursor = offset as u32; + + let mut best: Option<(u32, Vec)> = None; + walk_before_cursor(body, cursor, &mut |node| { + let rules_arg = match node { + Node::MethodCall(mc) => method_rules_argument(&mc.method, &mc.argument_list), + Node::NullSafeMethodCall(mc) => method_rules_argument(&mc.method, &mc.argument_list), + Node::StaticMethodCall(smc) => static_rules_argument(smc), + _ => None, + }; + let Some(arg) = rules_arg else { + return; + }; + let end = node.span().end.offset; + if !beats_best(&best, end, cursor) { + return; + } + let mut rules = Vec::new(); + collect_rules_from_expr(arg, content, &mut rules); + if !rules.is_empty() { + best = Some((end, rules)); + } + }); + best.map(|(_, rules)| rules) } /// The rules argument of `->validate([...])`, `->validate($request, [...])`, @@ -335,6 +367,72 @@ fn is_array_literal(expr: &Expression<'_>) -> bool { } } +// ─── `safe()` provenance ──────────────────────────────────────────────────── + +/// The request variable a `ValidatedInput` was narrowed from, e.g. +/// `"$request"` for `$safe = $request->safe();`. +/// +/// `safe()` hands back the same rules array under a different type, so a +/// validated-input variable completes against the request that produced it. +/// Only the last assignment to `variable` before `offset` in the enclosing +/// body counts, so a reassignment wins. +/// +/// Returns `None` when no such assignment precedes the cursor, or when the +/// object `safe()` was called on is not itself a plain variable. +pub(crate) fn safe_source_variable(content: &str, offset: usize, variable: &str) -> Option { + // Cheap pre-filter: without a `safe(` anywhere there is nothing to trace, + // so the file is not worth parsing. + memchr::memmem::find(content.as_bytes(), b"safe(")?; + + 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 body = enclosing_body(Node::Program(program), offset as u32)?; + let cursor = offset as u32; + + let mut best: Option<(u32, String)> = None; + walk_before_cursor(body, cursor, &mut |node| { + let Node::Assignment(assignment) = node else { + return; + }; + let Expression::Variable(Variable::Direct(target)) = assignment.lhs else { + return; + }; + if bytes_to_str(target.name) != variable { + return; + } + let end = node.span().end.offset; + if !beats_best(&best, end, cursor) { + return; + } + if let Some(source) = safe_call_receiver(assignment.rhs) { + best = Some((end, source)); + } + }); + best.map(|(_, source)| source) +} + +/// The receiver of a no-argument `->safe()` call, when it is a plain +/// variable. +fn safe_call_receiver(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), + _ => return None, + }; + let ClassLikeMemberSelector::Identifier(ident) = method else { + return None; + }; + if !bytes_to_str(ident.value).eq_ignore_ascii_case("safe") { + return None; + } + let Expression::Variable(Variable::Direct(var)) = object else { + return None; + }; + Some(bytes_to_str(var.name).to_string()) +} + // ─── `FormRequest::rules()` ───────────────────────────────────────────────── fn is_form_request_fqn(name: &str) -> bool { @@ -369,6 +467,12 @@ pub(crate) fn is_request_like( || super::helpers::walks_parent_chain(class, class_loader, is_request_fqn) } +/// Whether `class` is the `ValidatedInput` wrapper `Request::safe()` returns, +/// which carries the request's rules but not its `rules()` method. +pub(crate) fn is_validated_input(class: &ClassInfo) -> bool { + class.fqn() == VALIDATED_INPUT_FQN +} + /// The rules declared by `class`'s own `rules()` method, or the nearest /// ancestor that declares one. /// diff --git a/src/virtual_members/laravel/validation_rules_tests.rs b/src/virtual_members/laravel/validation_rules_tests.rs index 95f74e8a..c376726b 100644 --- a/src/virtual_members/laravel/validation_rules_tests.rs +++ b/src/virtual_members/laravel/validation_rules_tests.rs @@ -263,3 +263,107 @@ fn plain_dotted_keys_are_offered_whole_and_by_root() { assert_eq!(fields[0].rules, "required"); assert!(fields[1].rules.is_empty()); } + +// ─── `safe()` provenance ──────────────────────────────────────────────────── + +#[test] +fn traces_a_safe_variable_back_to_its_request() { + let content = "safe(); + $safe->only(['']); + } +} +"; + let cursor = content.find("only(['").unwrap() + 7; + assert_eq!( + safe_source_variable(content, cursor, "$safe").as_deref(), + Some("$request") + ); +} + +#[test] +fn safe_variable_takes_the_nearest_preceding_assignment() { + let content = "safe(); + $safe = $second->safe(); + $safe->only(['']); + } +} +"; + let cursor = content.find("only(['").unwrap() + 7; + assert_eq!( + safe_source_variable(content, cursor, "$safe").as_deref(), + Some("$second") + ); +} + +#[test] +fn safe_assignment_after_the_cursor_is_ignored() { + let content = "only(['']); + $safe = $request->safe(); + } +} +"; + let cursor = content.find("only(['").unwrap() + 7; + assert_eq!(safe_source_variable(content, cursor, "$safe"), None); +} + +#[test] +fn safe_assignment_in_a_sibling_method_is_ignored() { + let content = "safe(); + } + public function store() { + $safe->only(['']); + } +} +"; + let cursor = content.find("only(['").unwrap() + 7; + assert_eq!(safe_source_variable(content, cursor, "$safe"), None); +} + +#[test] +fn a_non_safe_assignment_is_not_traced() { + let content = "validated(); + $safe->only(['']); + } +} +"; + let cursor = content.find("only(['").unwrap() + 7; + assert_eq!(safe_source_variable(content, cursor, "$safe"), None); +} + +// ─── Pre-filter ───────────────────────────────────────────────────────────── + +#[test] +fn a_file_without_validation_is_not_parsed_for_rules() { + let content = "input(''); + } +} +"; + let cursor = content.find("input('").unwrap() + 7; + assert!(!mentions_validation(content)); + assert!(inline_validate_rules(content, cursor).is_none()); +} + +#[test] +fn the_pre_filter_admits_every_recognised_spelling() { + assert!(mentions_validation("$request->validate([]);")); + assert!(mentions_validation("$this->validateWithBag('b', []);")); + assert!(mentions_validation("Validator::make($data, []);")); +} diff --git a/tests/integration/laravel_request_keys.rs b/tests/integration/laravel_request_keys.rs index 0b0fbb85..2c21afdd 100644 --- a/tests/integration/laravel_request_keys.rs +++ b/tests/integration/laravel_request_keys.rs @@ -423,6 +423,69 @@ class PostController { assert_eq!(labels, vec!["title".to_string()]); } +#[tokio::test] +async fn safe_held_in_a_variable_completes_field_names() { + let controller = "\ +safe(); + $data = $safe->only(['titl§']); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert_eq!( + labels, + vec!["title".to_string()], + "a ValidatedInput carries the rules of the request it came from" + ); +} + +#[tokio::test] +async fn safe_variable_reassignment_follows_the_nearest_request() { + let controller = "\ +safe(); + $safe = $request->safe(); + $data = $safe->only(['titl§']); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert_eq!( + labels, + vec!["title".to_string()], + "the last assignment before the cursor decides which request applies" + ); +} + +#[tokio::test] +async fn safe_variable_without_a_traceable_request_completes_nothing() { + let controller = "\ +only(['titl§']); + } +} +"; + let labels = complete_labels("src/PostController.php", controller).await; + assert!( + labels.is_empty(), + "a ValidatedInput with no request in sight has no known keys: {labels:?}" + ); +} + #[tokio::test] async fn has_completes_field_names() { let controller = "\