From 6da6599d9701b78271367cd6820f8a5b195f8217 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Fri, 31 Jul 2026 21:26:08 +0600 Subject: [PATCH 1/5] refactor: share the array-key call scan across completion providers --- src/completion/command_params.rs | 109 +++-------------------------- src/completion/source/helpers.rs | 115 +++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 99 deletions(-) diff --git a/src/completion/command_params.rs b/src/completion/command_params.rs index ff8db9fe..c545f509 100644 --- a/src/completion/command_params.rs +++ b/src/completion/command_params.rs @@ -132,7 +132,7 @@ fn detect_context(content: &str, position: Position) -> Option // ── Own argument / option: `->argument('|')` / `->option('|')` ───────── if let Some(before_paren) = before_quote.strip_suffix('(') { let before_paren = before_paren.trim_end(); - let (name, rest) = split_trailing_ident(before_paren); + let (name, rest) = crate::completion::source::helpers::split_trailing_ident(before_paren); if !name.is_empty() { let is_method = rest.trim_end().ends_with("->") || rest.trim_end().ends_with("?->"); if is_method { @@ -177,115 +177,26 @@ fn detect_context(content: &str, position: Position) -> Option /// Given the position of an array-key opening quote, resolve the command name /// of the enclosing `Artisan::call('name', [...])`-style call. /// -/// Scans backwards for the `[` that opens the parameter array, then for the -/// preceding `(` that opens the call's argument list, extracts the first -/// string argument (the command name), and confirms the call is a recognised +/// Returns `None` when the enclosing call is not a recognised /// command-running call. fn command_name_for_array_key(content: &str, quote_pos: usize) -> Option { - let bytes = content.as_bytes(); + let call = crate::completion::source::helpers::enclosing_array_key_call(content, quote_pos)?; + let before_callee = call.before_callee; - // Walk back to the `[` that opens the array, balancing nested brackets. - let mut i = quote_pos; - let mut depth = 0i32; - let bracket_open = loop { - if i == 0 { - return None; - } - i -= 1; - match bytes[i] { - b']' => depth += 1, - b'[' => { - if depth == 0 { - break i; - } - depth -= 1; - } - b'\n' if depth == 0 => { - // Allow the array to span lines; only bail on stray brackets. - } - _ => {} - } - }; - - // Before the `[` we expect `... ('command', ` — find the `,` then the - // preceding string literal (the command name) and the `(` and call name. - let before_bracket = content[..bracket_open].trim_end(); - let before_bracket = before_bracket.strip_suffix(',')?.trim_end(); - - // The command name is a trailing string literal. - let (command_name, before_name) = trailing_string_literal(before_bracket)?; - let before_name = before_name.trim_end(); - let before_paren = before_name.strip_suffix('(')?.trim_end(); - - let (method, before_method) = split_trailing_ident(before_paren); - let method = method.to_ascii_lowercase(); - let before_method = before_method.trim_end(); - - let is_static = before_method.ends_with("::"); - let is_instance = before_method.ends_with("->") || before_method.ends_with("?->"); - - let recognised = if is_static { - let subject = trailing_class_name(&before_method[..before_method.len() - 2]); + let recognised = if let Some(receiver) = before_callee.strip_suffix("::") { + let subject = crate::completion::source::helpers::trailing_class_name(receiver); let subject = subject.rsplit('\\').next().unwrap_or(subject); matches!( - (subject.to_ascii_lowercase().as_str(), method.as_str()), + (subject.to_ascii_lowercase().as_str(), call.callee.as_str()), ("artisan", "call" | "queue") | ("schedule", "command") ) - } else if is_instance { - matches!(method.as_str(), "call" | "callsilently") + } else if before_callee.ends_with("->") { + matches!(call.callee.as_str(), "call" | "callsilently") } else { false }; - recognised.then_some(command_name) -} - -/// Split off a trailing PHP identifier (`[A-Za-z0-9_]+`) from `s`, returning -/// `(identifier, remainder_before_it)`. -fn split_trailing_ident(s: &str) -> (&str, &str) { - let bytes = s.as_bytes(); - let mut start = bytes.len(); - while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') { - start -= 1; - } - (&s[start..], &s[..start]) -} - -/// Split off a trailing class-name token (identifier plus `\` separators). -fn trailing_class_name(s: &str) -> &str { - let s = s.trim_end(); - let bytes = s.as_bytes(); - let mut start = bytes.len(); - while start > 0 - && (bytes[start - 1].is_ascii_alphanumeric() - || bytes[start - 1] == b'_' - || bytes[start - 1] == b'\\') - { - start -= 1; - } - &s[start..] -} - -/// If `s` ends with a single- or double-quoted string literal, return its -/// inner value and the text before the opening quote. -fn trailing_string_literal(s: &str) -> Option<(String, &str)> { - let s = s.trim_end(); - let bytes = s.as_bytes(); - let close = *bytes.last()?; - if close != b'\'' && close != b'"' { - return None; - } - // Find the matching opening quote (no escape handling needed for command - // names, which never contain quotes). - let mut i = bytes.len() - 1; - while i > 0 { - i -= 1; - if bytes[i] == close { - let value = s[i + 1..bytes.len() - 1].to_string(); - return Some((value, &s[..i])); - } - } - None + recognised.then_some(call.name) } #[cfg(test)] diff --git a/src/completion/source/helpers.rs b/src/completion/source/helpers.rs index b28f7fe7..5a7eafc5 100644 --- a/src/completion/source/helpers.rs +++ b/src/completion/source/helpers.rs @@ -65,6 +65,121 @@ pub(crate) fn find_open_quote(content: &str, cursor_offset: usize) -> Option<(us None } +/// A call whose argument array holds the key the cursor is typing. +/// +/// Recovered from `foo('name', ['key|' => …])`-shaped source text by +/// [`enclosing_array_key_call`]. +pub(crate) struct ArrayKeyCall<'a> { + /// The call's first argument, which must be a string literal — the name + /// the keys belong to (an Artisan command, a route, …). + pub name: String, + /// The called function or member name, lowercased. + pub callee: String, + /// The source text before the callee, trimmed of trailing whitespace. + /// Ends with `::` for a static call and `->` / `?->` for an instance + /// call; callers inspect it to identify the receiver. + pub before_callee: &'a str, +} + +/// Given the offset of the opening quote of an array key being typed, resolve +/// the enclosing `foo('name', [ '|' => … ])` call. +/// +/// Scans backwards for the `[` that opens the array, then for the preceding +/// `(` that opens the call's argument list, and splits out the first string +/// argument and the callee. Returns `None` when the surrounding text is not +/// that shape. +pub(crate) fn enclosing_array_key_call( + content: &str, + quote_pos: usize, +) -> Option> { + let bytes = content.as_bytes(); + + // Walk back to the `[` that opens the array, balancing nested brackets. + let mut i = quote_pos; + let mut depth = 0i32; + let bracket_open = loop { + if i == 0 { + return None; + } + i -= 1; + match bytes[i] { + b']' => depth += 1, + b'[' => { + if depth == 0 { + break i; + } + depth -= 1; + } + _ => {} + } + }; + + // Before the `[` we expect `... ('name', ` — find the `,` then the + // preceding string literal (the name) and the `(` and callee. + let before_bracket = content[..bracket_open].trim_end(); + let before_bracket = before_bracket.strip_suffix(',')?.trim_end(); + let (name, before_name) = trailing_string_literal(before_bracket)?; + let before_paren = before_name.trim_end().strip_suffix('(')?.trim_end(); + let (callee, before_callee) = split_trailing_ident(before_paren); + if callee.is_empty() { + return None; + } + + Some(ArrayKeyCall { + name, + callee: callee.to_ascii_lowercase(), + before_callee: before_callee.trim_end(), + }) +} + +/// Split off a trailing PHP identifier (`[A-Za-z0-9_]+`) from `s`, returning +/// `(identifier, remainder_before_it)`. +pub(crate) fn split_trailing_ident(s: &str) -> (&str, &str) { + let bytes = s.as_bytes(); + let mut start = bytes.len(); + while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') { + start -= 1; + } + (&s[start..], &s[..start]) +} + +/// Split off a trailing class-name token (identifier plus `\` separators). +pub(crate) fn trailing_class_name(s: &str) -> &str { + let s = s.trim_end(); + let bytes = s.as_bytes(); + let mut start = bytes.len(); + while start > 0 + && (bytes[start - 1].is_ascii_alphanumeric() + || bytes[start - 1] == b'_' + || bytes[start - 1] == b'\\') + { + start -= 1; + } + &s[start..] +} + +/// If `s` ends with a single- or double-quoted string literal, return its +/// inner value and the text before the opening quote. +fn trailing_string_literal(s: &str) -> Option<(String, &str)> { + let s = s.trim_end(); + let bytes = s.as_bytes(); + let close = *bytes.last()?; + if close != b'\'' && close != b'"' { + return None; + } + // Find the matching opening quote (no escape handling needed for the + // command and route names this runs on, which never contain quotes). + let mut i = bytes.len() - 1; + while i > 0 { + i -= 1; + if bytes[i] == close { + let value = s[i + 1..bytes.len() - 1].to_string(); + return Some((value, &s[..i])); + } + } + 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 d46c81b9c4f29de9e6fec53414847af2fe434e0f Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Fri, 31 Jul 2026 21:26:30 +0600 Subject: [PATCH 2/5] feat: complete route parameter names from the route's URI --- docs/todo/laravel.md | 26 +- examples/laravel/app/Demo.php | 8 + src/completion/handler/mod.rs | 13 + src/completion/laravel_route_params.rs | 118 ++++++++ src/completion/laravel_route_params_tests.rs | 134 +++++++++ src/completion/laravel_string_keys.rs | 26 +- src/completion/mod.rs | 1 + src/diagnostics/mod.rs | 5 +- src/lib.rs | 9 +- src/mem_audit.rs | 10 +- src/server.rs | 2 +- src/virtual_members/laravel/helpers.rs | 112 ++++++-- src/virtual_members/laravel/mod.rs | 2 +- src/virtual_members/laravel/route_names.rs | 256 ++++++++++++++---- .../laravel/route_names_tests.rs | 108 ++++++++ 15 files changed, 726 insertions(+), 104 deletions(-) create mode 100644 src/completion/laravel_route_params.rs create mode 100644 src/completion/laravel_route_params_tests.rs create mode 100644 src/virtual_members/laravel/route_names_tests.rs diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 59700e6f..825caaaf 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -632,20 +632,22 @@ pattern set. Gaps by kind: class. No new data needed. Provider-registered binding names stay out of scope (see the table at the top). -#### L23. Route parameter name completion +#### L40. Resource route URIs -**Impact: Medium · Effort: Low-Medium** +**Impact: Low-Medium · Effort: Low-Medium** -`route('users.show', ['user' => 1])` — the second argument's array keys -are the route's URI parameters. The route-name scanner already locates -the `->name()` registration; extend it to record the URI literal from -the same registration chain (`Route::get('/users/{user}/posts/{post}', -…)`) and complete the `{param}` names (minus the `?` optional marker) as -array keys in the parameters argument of `route()`, `to_route()`, -`signedRoute()`, and `temporarySignedRoute()`. The Laravel LSP reads -parameters off booted route objects; the URI literal in the registration -gives us the same data statically. The recorded URI also feeds the L16 -hover (`[GET] /users/{user}` next to the route name). +`Route::resource()` / `apiResource()` registrations name no URI, so the +routes the scanner generates for them carry no URI and their parameters +do not complete. Laravel derives the URI from the resource name +(`Route::resource('photos', …)` → `photos`, `photos/create`, +`photos/{photo}`, `photos/{photo}/edit`), where the parameter is +`Str::singular()` of the last segment — nested names singularize each +prior segment (`photos.comments` → `photos/{photo}/comments/{comment}`) +— and `->parameters(['photos' => 'grid'])` overrides the derived name. +Recording those URIs completes route parameters for resource routes and +gives the L16 hover a URI to show for them. A singularizer is needed; +`pluralize_english_word` in `virtual_members/laravel/mod.rs` is the +existing counterpart. #### L24. Translation depth: JSON lang files, locales, placeholders diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 39c135f1..522b44ed 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -265,6 +265,8 @@ public function laravelConfigEnv(): void * 4. Ctrl+Click "auth.failed" to jump to lang/en/auth.php. * 5. Ctrl+Click "theme.dashboard" to jump to a view under the custom * path registered in config/view.php (resources/theme/views). + * 6. Type a quote inside route('bakeries.show', [ … ]) to complete the + * route's URI parameter names. */ public function laravelNavigation(): void { @@ -285,6 +287,12 @@ public function laravelNavigation(): void // with the route file under app/Modules instead of the routes/ dir. route('reviews.update'); + // Route parameters — the keys of the second argument are the + // {parameters} of the route's URI (here bakeries/{bakery}, whose + // prefix comes from the enclosing Route::prefix('bakeries') group). + route('bakeries.show', ['bakery' => 1]); + route('bakeries.cancel', ['bakery' => 1]); + // Translation Keys __('messages.welcome'); trans('auth.failed'); diff --git a/src/completion/handler/mod.rs b/src/completion/handler/mod.rs index f7227071..11ed880f 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -286,6 +286,19 @@ impl Backend { return Ok(Some(response)); } + // ── Route parameter completion ────────────────────────── + // The keys of `route('users.show', ['|' => 1])` are the URI + // parameters of the named route. + if is_laravel + && matches!( + string_ctx, + StringContext::InStringLiteral | StringContext::NotInString + ) + && let Some(response) = self.try_route_param_completion(&content, position) + { + return Ok(Some(response)); + } + // ── Artisan command parameter completion ──────────────── // `$this->argument('|')` / `$this->option('|')` against the // enclosing command's own signature, and array keys of diff --git a/src/completion/laravel_route_params.rs b/src/completion/laravel_route_params.rs new file mode 100644 index 00000000..948cedad --- /dev/null +++ b/src/completion/laravel_route_params.rs @@ -0,0 +1,118 @@ +//! Completion for route parameter names. +//! +//! The parameters array of `route('users.show', ['|' => 1])` is keyed by the +//! URI parameters of the named route, so the URI recorded alongside each route +//! name (`/users/{user}`) drives array-key completion. The same applies to +//! `to_route()`, `URL::signedRoute()`, and `URL::temporarySignedRoute()`. + +use tower_lsp::lsp_types::*; + +use crate::Backend; +use crate::completion::source::helpers::{enclosing_array_key_call, find_open_quote}; +use crate::text_position::{offset_to_position, position_to_offset}; +use crate::virtual_members::laravel::route_uri_parameters; + +/// The route name whose parameters the cursor is completing, and the key text +/// typed so far. +struct RouteParamContext { + /// Name of the route the parameters array belongs to. + route_name: String, + /// The partial key already typed inside the quotes. + prefix: String, + /// Byte offset just after the opening quote of the key being typed. + content_start_offset: usize, +} + +/// Detect the cursor inside a key of a route parameters array. +fn detect_context(content: &str, position: Position) -> Option { + let cursor_offset = position_to_offset(content, position) as usize; + let (quote_pos, _) = find_open_quote(content, cursor_offset)?; + let before_quote = content[..quote_pos].trim_end(); + + // The character before the key is `[` for the first key and `,` for the + // ones after it. + if !matches!(before_quote.chars().last(), Some('[') | Some(',')) { + return None; + } + + let call = enclosing_array_key_call(content, quote_pos)?; + let receiver_is_call = call.before_callee.ends_with("::") || call.before_callee.ends_with("->"); + let names_a_route = match call.callee.as_str() { + // `signedRoute()` / `temporarySignedRoute()` only exist on the `URL` + // facade and the URL generator, so the name alone identifies them. + "signedroute" | "temporarysignedroute" => true, + // `route()` is the helper as well as a method on the `URL`, `Redirect` + // and `Response` facades and on the `redirect()`/`url()` helpers, all + // of which take the same `(name, parameters)` pair. + "route" => true, + // `to_route()` is a helper function only. + "to_route" => !receiver_is_call, + _ => false, + }; + if !names_a_route { + return None; + } + + Some(RouteParamContext { + route_name: call.name, + prefix: content[quote_pos + 1..cursor_offset].to_string(), + content_start_offset: quote_pos + 1, + }) +} + +impl Backend { + /// Try completing a route parameter name. + /// + /// Returns `None` when the cursor is not inside the parameters array of a + /// route-URL call, or when the route's URI takes no parameters. + pub(crate) fn try_route_param_completion( + &self, + content: &str, + position: Position, + ) -> Option { + let ctx = detect_context(content, position)?; + + let routes = self.cached_routes(); + let route = routes.iter().find(|route| route.name == ctx.route_name)?; + let params = route_uri_parameters(&route.uri); + if params.is_empty() { + return None; + } + + let edit_range = Range { + start: offset_to_position(content, ctx.content_start_offset), + end: position, + }; + let prefix_lower = ctx.prefix.to_lowercase(); + + let items: Vec = params + .into_iter() + .filter(|name| { + prefix_lower.is_empty() || name.to_lowercase().starts_with(&prefix_lower) + }) + .enumerate() + .map(|(i, name)| CompletionItem { + label: name.to_string(), + kind: Some(CompletionItemKind::FIELD), + detail: Some(route.uri.clone()), + sort_text: Some(format!("{:05}", i)), + filter_text: Some(name.to_string()), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: edit_range, + new_text: name.to_string(), + })), + ..Default::default() + }) + .collect(); + + if items.is_empty() { + None + } else { + Some(CompletionResponse::Array(items)) + } + } +} + +#[cfg(test)] +#[path = "laravel_route_params_tests.rs"] +mod tests; diff --git a/src/completion/laravel_route_params_tests.rs b/src/completion/laravel_route_params_tests.rs new file mode 100644 index 00000000..995f24ef --- /dev/null +++ b/src/completion/laravel_route_params_tests.rs @@ -0,0 +1,134 @@ +use super::*; + +fn ctx_at(content: &str, needle: &str) -> Option { + // Place the cursor right after `needle` (which ends inside a quote). + let idx = content.find(needle).expect("needle not found") + needle.len(); + let position = crate::text_position::offset_to_position(content, idx); + detect_context(content, position) +} + +#[test] +fn detects_first_key_of_route_parameters() { + let content = " 1, 'po']);\n"; + let ctx = ctx_at(content, "'po").expect("should detect"); + assert_eq!(ctx.route_name, "users.posts"); + assert_eq!(ctx.prefix, "po"); +} + +#[test] +fn detects_to_route_and_signed_route() { + for source in [ + "route('users.show', ['']);\n", + ] { + let ctx = ctx_at(source, "['").unwrap_or_else(|| panic!("should detect in {source}")); + assert_eq!(ctx.route_name, "users.show"); + } +} + +#[test] +fn detects_key_in_a_multiline_parameters_array() { + let content = "to_route('users.show', ['us']);\n"; + assert!(ctx_at(content, "['us").is_none()); +} + +/// Register a route file on `backend` so the route enumeration picks it up. +fn backend_with_routes(routes: &str) -> crate::Backend { + let backend = crate::Backend::new_test(); + let uri = "file:///app/routes/web.php"; + backend + .open_files + .write() + .insert(uri.to_string(), std::sync::Arc::new(routes.to_string())); + backend.update_ast(uri, routes); + backend +} + +fn labels_at(backend: &crate::Backend, content: &str, needle: &str) -> Vec { + let idx = content.find(needle).expect("needle not found") + needle.len(); + let position = crate::text_position::offset_to_position(content, idx); + match backend.try_route_param_completion(content, position) { + Some(CompletionResponse::Array(items)) => items.into_iter().map(|i| i.label).collect(), + Some(CompletionResponse::List(list)) => list.items.into_iter().map(|i| i.label).collect(), + None => Vec::new(), + } +} + +#[test] +fn completes_uri_parameters_end_to_end() { + let backend = backend_with_routes( + "name('users.posts.show');\n", + ); + let content = "name('users.posts.show');\n", + ); + let content = "name('home');\n"); + let content = "name('home');\n"); + let content = "group(function () {\n Route::patch('{bakery}/cancel', 'cancel')->name('bakeries.cancel');\n});\n", + ); + let content = " Vec { + /// Every named route in the project, with the URI it was registered with. + pub(crate) fn cached_routes( + &self, + ) -> std::sync::Arc> { self.cached_laravel_enumeration( - &self.laravel_string_key_build_locks.route_names, - |cache| cache.route_names.clone(), - |cache, names| cache.route_names = Some(names), - || crate::virtual_members::laravel::enumerate_all_route_names(self), + &self.laravel_string_key_build_locks.routes, + |cache| cache.routes.clone(), + |cache, routes| cache.routes = Some(routes), + || std::sync::Arc::new(crate::virtual_members::laravel::enumerate_all_routes(self)), ) } + pub(crate) fn cached_route_names(&self) -> Vec { + self.cached_routes() + .iter() + .map(|route| route.name.clone()) + .collect() + } + pub(crate) fn cached_config_keys(&self) -> Vec { self.cached_laravel_enumeration( &self.laravel_string_key_build_locks.config_keys, @@ -1023,7 +1033,7 @@ final class UserPermissionController extends BaseController\n\ let builds = AtomicUsize::new(0); let build_lock = parking_lot::Mutex::new(()); - let results: Vec> = std::thread::scope(|scope| { + let results: Vec<_> = std::thread::scope(|scope| { let handles: Vec<_> = (0..16) .map(|_| { let backend = &backend; @@ -1032,8 +1042,8 @@ final class UserPermissionController extends BaseController\n\ scope.spawn(move || { backend.cached_laravel_enumeration( build_lock, - |cache| cache.route_names.clone(), - |cache, names| cache.route_names = Some(names), + |cache| cache.view_names.clone(), + |cache, names| cache.view_names = Some(names), || { builds.fetch_add(1, Ordering::SeqCst); // Long enough that an unguarded diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 346c75f3..7dbc73b1 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -68,6 +68,7 @@ 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_route_params; pub(crate) mod laravel_string_keys; pub mod named_args; pub(crate) mod resolve; diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 9fbce7b4..f7e4a73a 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -452,7 +452,10 @@ impl Backend { // enumerations. Safe to call now that the `symbol_maps` read // lock has been released. let route_keys: HashSet = if has_route { - self.cached_route_names().into_iter().collect() + self.cached_routes() + .iter() + .map(|route| route.name.clone()) + .collect() } else { HashSet::new() }; diff --git a/src/lib.rs b/src/lib.rs index bc86c127..3d87943e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -321,7 +321,10 @@ pub use virtual_members::resolve_class_fully; /// `collect_unknown_member_diagnostics` (includes unresolved-member-access logic) #[derive(Default)] pub(crate) struct LaravelStringKeyCache { - pub route_names: Option>, + /// Named routes with the URI each was registered with. Shared behind an + /// `Arc` because both the name list and the parameter names of one route + /// are read from it, and cloning the whole table per read would be waste. + pub routes: Option>>, pub config_keys: Option>, pub view_names: Option>, pub trans_keys: Option>, @@ -348,7 +351,7 @@ pub(crate) struct LaravelStringKeyCache { /// view names wait out an unrelated route-name build. #[derive(Default)] pub(crate) struct LaravelStringKeyBuildLocks { - pub route_names: parking_lot::Mutex<()>, + pub routes: parking_lot::Mutex<()>, pub config_keys: parking_lot::Mutex<()>, pub view_names: parking_lot::Mutex<()>, pub trans_keys: parking_lot::Mutex<()>, @@ -358,7 +361,7 @@ pub(crate) struct LaravelStringKeyBuildLocks { impl LaravelStringKeyCache { fn invalidate_for_uri(&mut self, uri: &str) { if uri.contains("/routes/") { - self.route_names = None; + self.routes = None; } if uri.contains("/config/") { self.config_keys = None; diff --git a/src/mem_audit.rs b/src/mem_audit.rs index fef61088..fc82ff12 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1427,12 +1427,20 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { let mut laravel_keys = Sz::default(); { let c = backend.laravel_string_key_cache.read(); - for v in [&c.route_names, &c.config_keys, &c.view_names, &c.trans_keys] + for v in [&c.config_keys, &c.view_names, &c.trans_keys] .into_iter() .flatten() { laravel_keys += vs(v); } + if let Some(routes) = &c.routes { + laravel_keys + .add(routes.capacity() * size_of::()); + for route in routes.iter() { + laravel_keys.add(route.name.capacity()); + laravel_keys.add(route.uri.capacity()); + } + } if let Some(trees) = &c.config_trees { // ConfigNode is recursive; count the spine only. laravel_keys.add(trees.capacity() * 64); diff --git a/src/server.rs b/src/server.rs index a549a68e..452c8672 100644 --- a/src/server.rs +++ b/src/server.rs @@ -2419,7 +2419,7 @@ impl Backend { cache.config_trees = None; cache.view_names = None; cache.trans_keys = None; - cache.route_names = None; + cache.routes = None; } tracing::info!( diff --git a/src/virtual_members/laravel/helpers.rs b/src/virtual_members/laravel/helpers.rs index e5f0340b..008196a0 100644 --- a/src/virtual_members/laravel/helpers.rs +++ b/src/virtual_members/laravel/helpers.rs @@ -205,12 +205,14 @@ pub(crate) fn accessor_method_candidates(property_name: &str) -> Vec { ] } -/// Extract the `'as' => 'prefix.'` name prefix from a `Route::group([…], fn(){})` argument list. +/// Extract a string-keyed option (e.g. `'as' => 'admin.'`) from a +/// `Route::group([…], fn(){})` argument list. /// /// The array may be in any position; all non-array arguments are skipped. -pub(crate) fn extract_as_prefix_from_args<'a>( +fn extract_group_option_from_args<'a>( args: impl Iterator>, content: &str, + option: &str, ) -> String { for arg in args { let elements: Vec<&ArrayElement<'_>> = match arg { @@ -225,7 +227,7 @@ pub(crate) fn extract_as_prefix_from_args<'a>( let Some((key, _, _)) = extract_string_literal(kv.key, content) else { continue; }; - if key == "as" + if key == option && let Some((val, _, _)) = extract_string_literal(kv.value, content) { return val.to_string(); @@ -235,29 +237,71 @@ pub(crate) fn extract_as_prefix_from_args<'a>( String::new() } -/// Collect all `->name('...')` values from the call chain that precedes `->group()`. +/// Extract the `'as' => 'prefix.'` name prefix from a `Route::group([…], fn(){})` argument list. +pub(crate) fn extract_as_prefix_from_args<'a>( + args: impl Iterator>, + content: &str, +) -> String { + extract_group_option_from_args(args, content, "as") +} + +/// Extract the `'prefix' => 'admin'` URI prefix from a `Route::group([…], fn(){})` +/// argument list. +pub(crate) fn extract_uri_prefix_from_args<'a>( + args: impl Iterator>, + content: &str, +) -> String { + extract_group_option_from_args(args, content, "prefix") +} + +/// Join two URI segments the way Laravel's route prefixing does: both sides +/// lose their surrounding slashes and are joined with a single `/`. /// -/// Handles both instance method chains (`->name('prefix.')`) and the static -/// entry point (`Route::name('prefix.')`). -pub(crate) fn chain_name_prefix<'a>(expr: &Expression<'a>, content: &str) -> String { +/// An empty side is dropped, so `join_uri_segments("admin", "")` is `"admin"`. +pub(crate) fn join_uri_segments(left: &str, right: &str) -> String { + let left = left.trim_matches('/'); + let right = right.trim_matches('/'); + if left.is_empty() { + right.to_string() + } else if right.is_empty() { + left.to_string() + } else { + format!("{left}/{right}") + } +} + +/// The first argument of a call, when it is a plain string literal. +pub(crate) fn first_string_arg<'c>(args: &ArgumentList<'_>, content: &'c str) -> Option<&'c str> { + args.arguments + .iter() + .next() + .and_then(|a| extract_string_literal(a.value(), content)) + .map(|(value, _, _)| value) +} + +/// Collect the value a call chain accumulates for one group modifier. +/// +/// Walking `Route::name('admin.')->middleware(…)` with `method = b"name"` +/// yields `"admin."`; the same walk with `method = b"prefix"` recovers the URI +/// prefix of `Route::prefix('admin')->…`. Values found on nested chain links +/// are combined outermost-first by `join`. +fn chain_modifier_value( + expr: &Expression<'_>, + content: &str, + method: &[u8], + join: &dyn Fn(&str, &str) -> String, +) -> String { match expr { Expression::Call(Call::Method(mc)) => { let ClassLikeMemberSelector::Identifier(ident) = &mc.method else { - return chain_name_prefix(mc.object, content); + return chain_modifier_value(mc.object, content, method, join); }; - if ident.value.eq_ignore_ascii_case(b"name") { - let arg_name = mc - .argument_list - .arguments - .iter() - .next() - .and_then(|a| extract_string_literal(a.value(), content)) - .map(|(n, _, _)| n) - .unwrap_or(""); - let parent = chain_name_prefix(mc.object, content); - format!("{parent}{arg_name}") + let parent = chain_modifier_value(mc.object, content, method, join); + if ident.value.eq_ignore_ascii_case(method) { + let own = first_string_arg(&mc.argument_list, content).unwrap_or(""); + join(&parent, own) } else { - chain_name_prefix(mc.object, content) + parent } } // Route::name('prefix.') — static entry point of the chain. @@ -265,14 +309,10 @@ pub(crate) fn chain_name_prefix<'a>(expr: &Expression<'a>, content: &str) -> Str let ClassLikeMemberSelector::Identifier(ident) = &sc.method else { return String::new(); }; - if ident.value.eq_ignore_ascii_case(b"name") { - sc.argument_list - .arguments - .iter() - .next() - .and_then(|a| extract_string_literal(a.value(), content)) - .map(|(n, _, _)| n.to_string()) - .unwrap_or_default() + if ident.value.eq_ignore_ascii_case(method) { + first_string_arg(&sc.argument_list, content) + .unwrap_or("") + .to_string() } else { String::new() } @@ -281,6 +321,22 @@ pub(crate) fn chain_name_prefix<'a>(expr: &Expression<'a>, content: &str) -> Str } } +/// Collect all `->name('...')` values from the call chain that precedes `->group()`. +/// +/// Handles both instance method chains (`->name('prefix.')`) and the static +/// entry point (`Route::name('prefix.')`). +pub(crate) fn chain_name_prefix<'a>(expr: &Expression<'a>, content: &str) -> String { + chain_modifier_value(expr, content, b"name", &|parent, own| { + format!("{parent}{own}") + }) +} + +/// Collect all `->prefix('...')` URI segments from the call chain that +/// precedes `->group()`, joined into a single prefix. +pub(crate) fn chain_uri_prefix<'a>(expr: &Expression<'a>, content: &str) -> String { + chain_modifier_value(expr, content, b"prefix", &join_uri_segments) +} + // ─── Tests ────────────────────────────────────────────────────────────────── // ─── Shared PHP AST walker ─────────────────────────────────────────────────── diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index be5660f7..9e9cbaf0 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -125,7 +125,7 @@ pub(crate) use model_extraction::{ }; 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 route_names::{RouteEntry, enumerate_all_routes, route_uri_parameters}; pub(crate) use trans_keys::collect_trans_declarations; pub(crate) use builder_injection::{try_inject_builder_scopes, try_inject_mixin_builder_scopes}; diff --git a/src/virtual_members/laravel/route_names.rs b/src/virtual_members/laravel/route_names.rs index e64529e8..962d4d2b 100644 --- a/src/virtual_members/laravel/route_names.rs +++ b/src/virtual_members/laravel/route_names.rs @@ -402,15 +402,132 @@ fn scan_group_body<'a>( } } -/// Collect all route names defined in `routes/` files. +/// A named route recovered from the project's route files. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct RouteEntry { + /// Fully-qualified route name, with group name prefixes applied + /// (e.g. `admin.users.index`). + pub name: String, + /// The URI the route was registered with, with group URI prefixes + /// applied and reported the way Laravel's `Route::uri()` does: no + /// leading slash, and `/` for the root route (e.g. `users/{user}`). + /// + /// Empty when the URI is not statically recoverable — a variable URI, + /// or a name generated by `Route::resource()` rather than written out. + pub uri: String, +} + +/// The name and URI prefixes a route inherits from the groups enclosing it. +#[derive(Clone, Copy, Default)] +struct GroupPrefix<'a> { + /// Accumulated route-name prefix (`->name('admin.')`, `['as' => …]`). + name: &'a str, + /// Accumulated URI prefix (`->prefix('admin')`, `['prefix' => …]`). + uri: &'a str, +} + +/// Registration methods whose first argument is the route URI. +const URI_FIRST_ARG_METHODS: &[&[u8]] = &[ + b"get", + b"post", + b"put", + b"patch", + b"delete", + b"options", + b"any", + b"view", + b"redirect", + b"permanentredirect", +]; + +/// Recover the URI a route was registered with from its call chain. +/// +/// The walk starts at the receiver of `->name()` and descends through the +/// chain links until it reaches the registration call, so modifiers between +/// the two are skipped: +/// `Route::middleware('auth')->get('/users/{user}', …)->name('users.show')` +/// records `/users/{user}`. `Route::match(['get', 'post'], '/uri', …)` keeps +/// its URI in the second argument. +fn chain_uri<'c>(expr: &Expression<'_>, content: &'c str) -> Option<&'c str> { + let (method, args) = match expr { + Expression::Call(Call::Method(mc)) => match &mc.method { + ClassLikeMemberSelector::Identifier(ident) => (ident.value, &mc.argument_list), + _ => return chain_uri(mc.object, content), + }, + Expression::Call(Call::StaticMethod(sc)) => match &sc.method { + ClassLikeMemberSelector::Identifier(ident) => (ident.value, &sc.argument_list), + _ => return None, + }, + _ => return None, + }; + + let lower = method.to_ascii_lowercase(); + let uri_arg = if URI_FIRST_ARG_METHODS.contains(&lower.as_slice()) { + args.arguments.iter().next() + } else if lower == b"match" { + args.arguments.iter().nth(1) + } else { + None + }; + if let Some(arg) = uri_arg { + return extract_string_literal(arg.value(), content).map(|(uri, _, _)| uri); + } + + // Not a registration link — keep descending the chain. + match expr { + Expression::Call(Call::Method(mc)) => chain_uri(mc.object, content), + _ => None, + } +} + +/// Build the recorded URI of a route from its group prefix and own URI. +fn route_uri(group_uri: &str, own_uri: Option<&str>) -> String { + let Some(own_uri) = own_uri else { + return String::new(); + }; + let joined = join_uri_segments(group_uri, own_uri); + if joined.is_empty() { + "/".to_string() + } else { + joined + } +} + +/// Extract the parameter names from a route URI pattern. +/// +/// `users/{user}/posts/{post?}` yields `["user", "post"]`: the optional +/// marker and the scoped-binding field of `{post:slug}` are stripped, so the +/// result is exactly the keys `route()`'s parameters array accepts. +pub(crate) fn route_uri_parameters(uri: &str) -> Vec<&str> { + let mut params = Vec::new(); + let mut rest = uri; + while let Some(open) = rest.find('{') { + rest = &rest[open + 1..]; + let Some(close) = rest.find('}') else { + break; + }; + let raw = &rest[..close]; + rest = &rest[close + 1..]; + let name = raw + .split_once(':') + .map_or(raw, |(name, _field)| name) + .trim_end_matches('?'); + if !name.is_empty() { + params.push(name); + } + } + params +} + +/// Collect all named routes defined in `routes/` files. /// -/// Returns a sorted, deduplicated list of fully-qualified route names -/// (e.g. `["admin.dashboard", "admin.users.index", "home"]`). +/// Returns a sorted, deduplicated list of fully-qualified route names with +/// the URI each was registered with. /// /// Follows `Route::group([], __DIR__ . '/sub.php')` file includes so -/// that routes in sub-files inherit the parent group's name prefix. -pub(crate) fn enumerate_all_route_names(backend: &Backend) -> Vec { - let mut names = Vec::new(); +/// that routes in sub-files inherit the parent group's prefixes. +pub(crate) fn enumerate_all_routes(backend: &Backend) -> Vec { + let mut routes = Vec::new(); let mut scanned: std::collections::HashSet = std::collections::HashSet::new(); let snapshot = backend.user_file_symbol_maps(); @@ -441,7 +558,7 @@ pub(crate) fn enumerate_all_route_names(backend: &Backend) -> Vec { let file_dir = path .as_ref() .and_then(|p| p.parent().map(|d| d.to_path_buf())); - collect_all_names_from_file(&content, file_dir.as_deref(), &mut names); + collect_all_names_from_file(&content, file_dir.as_deref(), &mut routes); } for route_path in &backend.laravel_provider_resources.read().route_files { @@ -453,13 +570,21 @@ pub(crate) fn enumerate_all_route_names(backend: &Backend) -> Vec { } if let Ok(content) = std::fs::read_to_string(route_path) { let file_dir = route_path.parent(); - collect_all_names_from_file(&content, file_dir, &mut names); + collect_all_names_from_file(&content, file_dir, &mut routes); } } - names.sort(); - names.dedup(); - names + // One entry per name, as the name enumeration has always been. Sorting + // known URIs ahead of unknown ones means a name registered both with a + // literal URI and by `Route::resource()` keeps the URI it has. + routes.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| a.uri.is_empty().cmp(&b.uri.is_empty())) + .then_with(|| a.uri.cmp(&b.uri)) + }); + routes.dedup_by(|a, b| a.name == b.name); + routes } /// Parse a single route file and collect every `->name('...')` value, @@ -467,30 +592,30 @@ pub(crate) fn enumerate_all_route_names(backend: &Backend) -> Vec { fn collect_all_names_from_file( content: &str, file_dir: Option<&std::path::Path>, - out: &mut Vec, + out: &mut 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()); for stmt in program.statements.iter() { - collect_names_from_stmt(stmt, content, "", file_dir, out); + collect_names_from_stmt(stmt, content, GroupPrefix::default(), file_dir, out); } } fn collect_names_from_stmt<'a>( stmt: &Statement<'a>, content: &str, - prefix: &str, + group: GroupPrefix<'_>, file_dir: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { match stmt { Statement::Expression(e) => { - collect_names_from_expr(e.expression, content, prefix, file_dir, out); + collect_names_from_expr(e.expression, content, group, file_dir, out); } Statement::Return(r) => { if let Some(v) = r.value { - collect_names_from_expr(v, content, prefix, file_dir, out); + collect_names_from_expr(v, content, group, file_dir, out); } } _ => {} @@ -500,36 +625,45 @@ fn collect_names_from_stmt<'a>( fn collect_names_from_expr<'a>( expr: &Expression<'a>, content: &str, - prefix: &str, + group: GroupPrefix<'_>, file_dir: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { match expr { Expression::Call(Call::Method(mc)) => { let ClassLikeMemberSelector::Identifier(ident) = &mc.method else { - collect_names_from_expr(mc.object, content, prefix, file_dir, out); + collect_names_from_expr(mc.object, content, group, file_dir, out); return; }; let method = ident.value.to_ascii_lowercase(); if method == b"group" { - let chain_prefix = chain_name_prefix(mc.object, content); - let new_prefix = format!("{prefix}{chain_prefix}"); + let name_prefix = + format!("{}{}", group.name, chain_name_prefix(mc.object, content)); + let uri_prefix = + join_uri_segments(group.uri, &chain_uri_prefix(mc.object, content)); + let inner = GroupPrefix { + name: &name_prefix, + uri: &uri_prefix, + }; for arg in mc.argument_list.arguments.iter() { - collect_names_from_group_body(arg.value(), content, &new_prefix, file_dir, out); + collect_names_from_group_body(arg.value(), content, inner, file_dir, out); } } else if method == b"name" { if let Some(first_arg) = mc.argument_list.arguments.iter().next() && let Some((name_val, _, _)) = extract_string_literal(first_arg.value(), content) { - let full = format!("{prefix}{name_val}"); + let full = format!("{}{name_val}", group.name); // Only collect leaf names (non-prefix names that don't end with '.'). if !full.is_empty() && !full.ends_with('.') { - out.push(full); + out.push(RouteEntry { + name: full, + uri: route_uri(group.uri, chain_uri(mc.object, content)), + }); } } - collect_names_from_expr(mc.object, content, prefix, file_dir, out); + collect_names_from_expr(mc.object, content, group, file_dir, out); } else if method == b"only" || method == b"except" { // ->only() / ->except() on Route::resource() / apiResource() if let Expression::Call(Call::StaticMethod(sc)) = mc.object @@ -542,16 +676,12 @@ fn collect_names_from_expr<'a>( (Vec::new(), filter) }; let suffixes = filtered_resource_suffixes(is_api, &only, &except); - let base = resource_name_base(res_name); - for suffix in suffixes { - let full = format!("{prefix}{base}.{suffix}"); - out.push(full); - } + push_resource_names(group.name, res_name, &suffixes, out); return; } - collect_names_from_expr(mc.object, content, prefix, file_dir, out); + collect_names_from_expr(mc.object, content, group, file_dir, out); } else { - collect_names_from_expr(mc.object, content, prefix, file_dir, out); + collect_names_from_expr(mc.object, content, group, file_dir, out); } } Expression::Call(Call::StaticMethod(sc)) => { @@ -561,24 +691,27 @@ fn collect_names_from_expr<'a>( let method_lower = ident.value.to_ascii_lowercase(); if method_lower == b"group" { - let array_prefix = extract_as_prefix_from_args( - sc.argument_list.arguments.iter().map(|a| a.value()), - content, + let args = || sc.argument_list.arguments.iter().map(|a| a.value()); + let name_prefix = format!( + "{}{}", + group.name, + extract_as_prefix_from_args(args(), content) ); - let new_prefix = format!("{prefix}{array_prefix}"); + let uri_prefix = + join_uri_segments(group.uri, &extract_uri_prefix_from_args(args(), content)); + let inner = GroupPrefix { + name: &name_prefix, + uri: &uri_prefix, + }; for arg in sc.argument_list.arguments.iter() { - collect_names_from_group_body(arg.value(), content, &new_prefix, file_dir, out); + collect_names_from_group_body(arg.value(), content, inner, file_dir, out); } } else if method_lower == b"resource" || method_lower == b"apiresource" { // Route::resource('presses', Controller::class) without only/except // generates all conventional route names. if let Some((res_name, _, is_api)) = extract_resource_info(sc, content) { let suffixes = filtered_resource_suffixes(is_api, &[], &[]); - let base = resource_name_base(res_name); - for suffix in suffixes { - let full = format!("{prefix}{base}.{suffix}"); - out.push(full); - } + push_resource_names(group.name, res_name, &suffixes, out); } } } @@ -586,21 +719,40 @@ fn collect_names_from_expr<'a>( } } +/// Record the conventional route names a `Route::resource()` registration +/// generates. Their URIs are left empty: the registration names no URI, so +/// the `{parameter}` segments Laravel derives from the resource name are not +/// recoverable from the source text. +fn push_resource_names( + name_prefix: &str, + resource_name: &str, + suffixes: &[&str], + out: &mut Vec, +) { + let base = resource_name_base(resource_name); + for suffix in suffixes { + out.push(RouteEntry { + name: format!("{name_prefix}{base}.{suffix}"), + uri: String::new(), + }); + } +} + fn collect_names_from_group_body<'a>( expr: &Expression<'a>, content: &str, - prefix: &str, + group: GroupPrefix<'_>, file_dir: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { match expr { Expression::Closure(closure) => { for stmt in closure.body.statements.iter() { - collect_names_from_stmt(stmt, content, prefix, file_dir, out); + collect_names_from_stmt(stmt, content, group, file_dir, out); } } Expression::ArrowFunction(af) => { - collect_names_from_expr(af.expression, content, prefix, file_dir, out); + collect_names_from_expr(af.expression, content, group, file_dir, out); } _ => { // Handle `Route::group([], __DIR__ . '/sub.php')` file includes. @@ -626,7 +778,7 @@ fn collect_names_from_group_body<'a>( collect_names_from_stmt( stmt, &included_content, - prefix, + group, sub_dir.as_deref(), out, ); @@ -637,6 +789,12 @@ fn collect_names_from_group_body<'a>( } } -use super::helpers::extract_dir_concat_path; +use super::helpers::{ + chain_uri_prefix, extract_dir_concat_path, extract_uri_prefix_from_args, join_uri_segments, +}; pub(crate) use super::helpers::{chain_name_prefix, extract_as_prefix_from_args}; + +#[cfg(test)] +#[path = "route_names_tests.rs"] +mod tests; diff --git a/src/virtual_members/laravel/route_names_tests.rs b/src/virtual_members/laravel/route_names_tests.rs new file mode 100644 index 00000000..8c2e45cb --- /dev/null +++ b/src/virtual_members/laravel/route_names_tests.rs @@ -0,0 +1,108 @@ +use super::*; + +/// Collect the routes of a single route file, as `enumerate_all_routes` does +/// per file (without the workspace walk). +fn routes_of(content: &str) -> Vec { + let mut out = Vec::new(); + collect_all_names_from_file(content, None, &mut out); + out +} + +fn uri_of(content: &str, name: &str) -> String { + routes_of(content) + .into_iter() + .find(|route| route.name == name) + .unwrap_or_else(|| panic!("route {name} not collected")) + .uri +} + +#[test] +fn records_uri_of_simple_registration() { + let content = "name('users.show');\n"; + assert_eq!(uri_of(content, "users.show"), "users/{user}"); +} + +#[test] +fn root_uri_is_reported_as_slash() { + let content = " 'home')->name('home');\n"; + assert_eq!(uri_of(content, "home"), "/"); +} + +#[test] +fn records_uri_through_intermediate_chain_links() { + let content = "get('/orders/{order}', 'show')\n ->whereNumber('order')->name('orders.show');\n"; + assert_eq!(uri_of(content, "orders.show"), "orders/{order}"); +} + +#[test] +fn records_uri_of_match_registration() { + let content = + "name('search');\n"; + assert_eq!(uri_of(content, "search"), "search/{term?}"); +} + +#[test] +fn applies_fluent_group_uri_prefix() { + let content = "name('admin.')->group(function () {\n Route::get('users/{user}', 'show')->name('users.show');\n});\n"; + assert_eq!(uri_of(content, "admin.users.show"), "admin/users/{user}"); +} + +#[test] +fn applies_nested_group_uri_prefixes() { + let content = "group(function () {\n Route::prefix('v1/{tenant}')->group(function () {\n Route::get('/teams/{team}', 'show')->name('teams.show');\n });\n});\n"; + assert_eq!( + uri_of(content, "teams.show"), + "api/v1/{tenant}/teams/{team}" + ); +} + +#[test] +fn applies_array_group_uri_prefix() { + let content = " 'admin', 'as' => 'admin.'], function () {\n Route::patch('/posts/{post}', 'update')->name('posts.update');\n});\n"; + assert_eq!(uri_of(content, "admin.posts.update"), "admin/posts/{post}"); +} + +#[test] +fn unrecoverable_uri_is_left_empty() { + // A variable URI cannot be read from the source text. + let content = "name('dynamic');\n"; + assert_eq!(uri_of(content, "dynamic"), ""); +} + +#[test] +fn resource_routes_have_no_uri() { + let content = " Date: Fri, 31 Jul 2026 21:27:32 +0600 Subject: [PATCH 3/5] chore: retire L23 and file resource route URIs as L40 --- docs/todo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/todo.md b/docs/todo.md index ac4e0eae..b421b65b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -168,13 +168,13 @@ unlikely to move the needle for most users. | L38 | [Typed `validated()` array shapes from rules](todo/laravel.md#l38-typed-validated-array-shapes-from-rules) | Medium-High | Medium-High | | L26 | [Gate ability and policy strings](todo/laravel.md#l26-gate-ability-and-policy-strings) | Medium-High | Medium-High | | L16 | [Hover for Laravel string keys](todo/laravel.md) | Medium | Low-Medium | -| L23 | [Route parameter name completion](todo/laravel.md#l23-route-parameter-name-completion) | Medium | Low-Medium | | L30 | [Eloquent attribute-array key completion](todo/laravel.md#l30-eloquent-attribute-array-key-completion) | Medium | Low-Medium | | 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 | | 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 | +| L40 | [Resource route URIs](todo/laravel.md#l40-resource-route-uris) | Low-Medium | Low-Medium | | L29 | [Livewire and Volt component names](todo/laravel.md#l29-livewire-and-volt-component-names) | Low-Medium | Medium | | L3 | `$dates` array (deprecated) | Low-Medium | Low | | L12 | [`HasUuids` / `HasUlids` trait — `$id` typed as `string`](todo/laravel.md#l12-hasuuids-hasulids-trait-id-typed-as-string) | Low-Medium | Low | From a331e666582686bc5847f40e551adebf98647736 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Fri, 31 Jul 2026 21:30:18 +0600 Subject: [PATCH 4/5] chore: update changelog md file --- docs/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a7d61df2..b39f73c8 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). +- **Route parameter names complete from the route's URI.** The parameters array of `route('users.show', ['user' => $user])` is keyed by the `{parameters}` of the route's URI, so PHPantom now offers those keys as completion, in `to_route()`, `signedRoute()`, and `temporarySignedRoute()` as well. The URI comes from the registration the route name was declared on (`Route::get('/users/{user}/posts/{post}', …)->name('users.posts.show')`), including URI prefixes inherited from enclosing `Route::prefix()` / `Route::group(['prefix' => …])` groups, so nested and grouped routes offer the full parameter set. Optional markers and scoped bindings (`{post:slug}`) are reduced to the key Laravel actually accepts. Contributed by @shuvroroy (#301). - **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). From e36e27478160f57a592849e12375fa92e05e9117 Mon Sep 17 00:00:00 2001 From: Anders Jenbo Date: Fri, 31 Jul 2026 23:15:16 +0200 Subject: [PATCH 5/5] Defer temporarySignedRoute() support, fix a few edge cases --- docs/CHANGELOG.md | 2 +- docs/todo.md | 2 +- docs/todo/laravel.md | 2 +- src/completion/laravel_route_params_tests.rs | 22 ++- src/completion/source/helpers.rs | 150 +++++++++------- src/virtual_members/laravel/route_names.rs | 9 +- .../laravel/route_names_tests.rs | 7 + tests/integration/laravel_route_params.rs | 162 ++++++++++++++++++ tests/integration/main.rs | 1 + 9 files changed, 288 insertions(+), 69 deletions(-) create mode 100644 tests/integration/laravel_route_params.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 925cd4f5..d41f86a7 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). -- **Route parameter names complete from the route's URI.** The parameters array of `route('users.show', ['user' => $user])` is keyed by the `{parameters}` of the route's URI, so PHPantom now offers those keys as completion, in `to_route()`, `signedRoute()`, and `temporarySignedRoute()` as well. The URI comes from the registration the route name was declared on (`Route::get('/users/{user}/posts/{post}', …)->name('users.posts.show')`), including URI prefixes inherited from enclosing `Route::prefix()` / `Route::group(['prefix' => …])` groups, so nested and grouped routes offer the full parameter set. Optional markers and scoped bindings (`{post:slug}`) are reduced to the key Laravel actually accepts. Contributed by @shuvroroy (#301). +- **Route parameter names complete from the route's URI.** The parameters array of `route('users.show', ['user' => $user])` is keyed by the `{parameters}` of the route's URI, so PHPantom now offers those keys as completion, in `to_route()`, `signedRoute()`, and `temporarySignedRoute()` as well. The URI comes from the registration the route name was declared on (`Route::get('/users/{user}/posts/{post}', …)->name('users.posts.show')`), together with every URI prefix that registration inherits, so nested and grouped routes offer the full parameter set. Optional markers and scoped bindings (`{post:slug}`) are reduced to the key Laravel actually accepts. Contributed by @shuvroroy (#301). - **Request input keys complete from validation rules.** The keys of a Laravel validation rules array are the complete set of inputs a request may carry, so PHPantom now offers them as string completion wherever a request accessor names a field: `$request->input('key')`, the typed and presence accessors, the multi-key ones such as `only()` and `except()`, `safe()->only([...])`, and array access `$request['key']`. The rules come from the `rules()` method of the `FormRequest` type-hinted in the enclosing method (following `array_merge()` and the parent chain), or from a `validate()` / `Validator::make()` call earlier in the same method. Each suggestion shows its rule (`required|string|max:255`), and go-to-definition on the key jumps to the line that declares it. Wildcard rules like `items.*.id` complete their root segment, and a computed rule key simply contributes nothing rather than a wrong suggestion. Contributed by @shuvroroy (#292). - **`validated()` is typed from the validation rules.** `$request->validated()` is declared to return `array` and no tool takes it further, but the rules array already says which keys the result holds and what each one is. PHPantom now translates it into an array shape, so `$data['title']` is a `string`, `$data['views']` is an `int`, `'nullable'` adds `null`, a field that is neither required nor nullable becomes an optional key, `'items.*.id'` produces `list`, and an `'image'` rule gives you a real `UploadedFile` to chain from. The keys complete on array access and the whole shape shows in hover. This covers `$request->validate([...])` return values, `$validator->validated()` where the rules are visible, `validated('key')` for a single member, and `safe()->only([...])` / `except([...])`, which narrow the same shape. When a rule key is computed the shape is abandoned for plain `array` rather than claiming a key set it cannot vouch for. Contributed by @shuvroroy (#294). - **Laravel model factory relationship methods.** Eloquent factories now offer the dynamic `has{Relationship}()` and `for{Relationship}()` methods that Laravel resolves through `Factory::__call()`, one per relationship on the associated model, plus `trashed()` when the model uses `SoftDeletes`. They complete, hover, and resolve, and because each returns the factory the fluent chain continues, so `Post::factory()->hasComments(3)->create()` still resolves to the model. The model is derived from the factory naming convention, so no `@extends Factory` generic is required. Contributed by @shuvroroy (#260). diff --git a/docs/todo.md b/docs/todo.md index b6f93ebb..98af5a7b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -172,7 +172,7 @@ unlikely to move the needle for most users. | L36 | [Container binding registrations from service providers](todo/laravel.md#l36-container-binding-registrations-from-service-providers) | 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 | -| L40 | [Resource route URIs](todo/laravel.md#l40-resource-route-uris) | Low-Medium | Low-Medium | +| L43 | [Resource route URIs](todo/laravel.md#l43-resource-route-uris) | Low-Medium | Low-Medium | | L42 | [Morph alias completion in array positions](todo/laravel.md#l42-morph-alias-completion-in-array-positions) | Low-Medium | Low-Medium | | L41 | [Morph aliases in `*_type` column comparisons](todo/laravel.md#l41-morph-aliases-in-_type-column-comparisons) | Low-Medium | 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 4fdcd7ef..c5a1d321 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -687,7 +687,7 @@ pattern set. Gaps by kind: class. No new data needed. Provider-registered binding names stay out of scope (see the table at the top). -#### L40. Resource route URIs +#### L43. Resource route URIs **Impact: Low-Medium · Effort: Low-Medium** diff --git a/src/completion/laravel_route_params_tests.rs b/src/completion/laravel_route_params_tests.rs index 995f24ef..3c43f4f9 100644 --- a/src/completion/laravel_route_params_tests.rs +++ b/src/completion/laravel_route_params_tests.rs @@ -28,7 +28,6 @@ fn detects_to_route_and_signed_route() { for source in [ "route('users.show', ['']);\n", ] { let ctx = ctx_at(source, "['").unwrap_or_else(|| panic!("should detect in {source}")); @@ -36,6 +35,19 @@ fn detects_to_route_and_signed_route() { } } +#[test] +fn detects_parameters_after_an_intervening_argument() { + // `temporarySignedRoute()` takes the expiration between the name and the + // parameters, so the array is the third argument. + for source in [ + "addMinutes(30), ['']);\n", + "temporarySignedRoute('users.show', $expiration, ['']);\n", + ] { + let ctx = ctx_at(source, "['").unwrap_or_else(|| panic!("should detect in {source}")); + assert_eq!(ctx.route_name, "users.show"); + } +} + #[test] fn detects_key_in_a_multiline_parameters_array() { let content = " Option<(us 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'\'' || ch == b'"') && !is_escaped(bytes, i) { + return Some((i, ch as char)); } if ch == b'\n' { return None; @@ -84,43 +76,21 @@ pub(crate) struct ArrayKeyCall<'a> { /// Given the offset of the opening quote of an array key being typed, resolve /// the enclosing `foo('name', [ '|' => … ])` call. /// -/// Scans backwards for the `[` that opens the array, then for the preceding -/// `(` that opens the call's argument list, and splits out the first string -/// argument and the callee. Returns `None` when the surrounding text is not -/// that shape. +/// Scans backwards for the `[` that opens the array, then for the `(` that +/// opens the call's argument list, and reads the callee and the call's first +/// argument. The array need not follow the name directly, so the parameters +/// of `temporarySignedRoute('users.show', $expiration, ['|' => 1])` are found +/// as well. Returns `None` when the surrounding text is not that shape. pub(crate) fn enclosing_array_key_call( content: &str, quote_pos: usize, ) -> Option> { let bytes = content.as_bytes(); + let bracket_open = scan_back_to_opener(bytes, quote_pos, b'[')?; + let paren_open = scan_back_to_opener(bytes, bracket_open, b'(')?; - // Walk back to the `[` that opens the array, balancing nested brackets. - let mut i = quote_pos; - let mut depth = 0i32; - let bracket_open = loop { - if i == 0 { - return None; - } - i -= 1; - match bytes[i] { - b']' => depth += 1, - b'[' => { - if depth == 0 { - break i; - } - depth -= 1; - } - _ => {} - } - }; - - // Before the `[` we expect `... ('name', ` — find the `,` then the - // preceding string literal (the name) and the `(` and callee. - let before_bracket = content[..bracket_open].trim_end(); - let before_bracket = before_bracket.strip_suffix(',')?.trim_end(); - let (name, before_name) = trailing_string_literal(before_bracket)?; - let before_paren = before_name.trim_end().strip_suffix('(')?.trim_end(); - let (callee, before_callee) = split_trailing_ident(before_paren); + let name = first_string_argument(content, paren_open)?; + let (callee, before_callee) = split_trailing_ident(content[..paren_open].trim_end()); if callee.is_empty() { return None; } @@ -132,6 +102,82 @@ pub(crate) fn enclosing_array_key_call( }) } +/// Walk backwards from `from` to the `opener` byte that opens the construct +/// the cursor sits in, skipping over anything nested inside balanced +/// brackets, parentheses, braces, or a string literal. +/// +/// Returns `None` at the start of the file, at a statement boundary, and at +/// the opener of a *different* construct — so the scan stays inside the +/// expression the cursor is in instead of latching onto a bracket in an +/// earlier statement. +fn scan_back_to_opener(bytes: &[u8], from: usize, opener: u8) -> Option { + let (mut brackets, mut parens, mut braces) = (0i32, 0i32, 0i32); + let mut i = from; + while i > 0 { + i -= 1; + let byte = bytes[i]; + let nested = brackets > 0 || parens > 0 || braces > 0; + if byte == opener && !nested { + return Some(i); + } + match byte { + b']' => brackets += 1, + b')' => parens += 1, + b'}' => braces += 1, + // An opener we are not looking for, with nothing of its own kind + // left to close: the scan has left the enclosing expression. + b'[' if brackets == 0 => return None, + b'(' if parens == 0 => return None, + b'{' if braces == 0 => return None, + b'[' => brackets -= 1, + b'(' => parens -= 1, + b'{' => braces -= 1, + b';' if !nested => return None, + b'\'' | b'"' if !is_escaped(bytes, i) => { + // A closing quote: jump to the literal's opening quote so its + // contents cannot unbalance the scan. + let mut j = i; + loop { + if j == 0 { + return None; + } + j -= 1; + if bytes[j] == byte && !is_escaped(bytes, j) { + break; + } + } + i = j; + } + _ => {} + } + } + None +} + +/// Whether the byte at `index` is preceded by an odd number of backslashes. +fn is_escaped(bytes: &[u8], index: usize) -> bool { + let mut backslashes = 0; + let mut j = index; + while j > 0 && bytes[j - 1] == b'\\' { + backslashes += 1; + j -= 1; + } + backslashes % 2 == 1 +} + +/// The value of a call's first argument when it is a plain string literal, +/// given the offset of the `(` that opens the argument list. +fn first_string_argument(content: &str, paren_open: usize) -> Option { + let rest = content.get(paren_open + 1..)?.trim_start(); + let quote = rest.chars().next()?; + if quote != '\'' && quote != '"' { + return None; + } + let inner = &rest[1..]; + let end = inner.find(quote)?; + Some(inner[..end].to_string()) +} + /// Split off a trailing PHP identifier (`[A-Za-z0-9_]+`) from `s`, returning /// `(identifier, remainder_before_it)`. pub(crate) fn split_trailing_ident(s: &str) -> (&str, &str) { @@ -158,28 +204,6 @@ pub(crate) fn trailing_class_name(s: &str) -> &str { &s[start..] } -/// If `s` ends with a single- or double-quoted string literal, return its -/// inner value and the text before the opening quote. -fn trailing_string_literal(s: &str) -> Option<(String, &str)> { - let s = s.trim_end(); - let bytes = s.as_bytes(); - let close = *bytes.last()?; - if close != b'\'' && close != b'"' { - return None; - } - // Find the matching opening quote (no escape handling needed for the - // command and route names this runs on, which never contain quotes). - let mut i = bytes.len() - 1; - while i > 0 { - i -= 1; - if bytes[i] == close { - let value = s[i + 1..bytes.len() - 1].to_string(); - return Some((value, &s[..i])); - } - } - 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). diff --git a/src/virtual_members/laravel/route_names.rs b/src/virtual_members/laravel/route_names.rs index 962d4d2b..7adee945 100644 --- a/src/virtual_members/laravel/route_names.rs +++ b/src/virtual_members/laravel/route_names.rs @@ -403,7 +403,7 @@ fn scan_group_body<'a>( } /// A named route recovered from the project's route files. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug)] pub(crate) struct RouteEntry { /// Fully-qualified route name, with group name prefixes applied /// (e.g. `admin.users.index`). @@ -657,9 +657,14 @@ fn collect_names_from_expr<'a>( let full = format!("{}{name_val}", group.name); // Only collect leaf names (non-prefix names that don't end with '.'). if !full.is_empty() && !full.ends_with('.') { + // A `->prefix()` on the route's own chain + // (`Route::prefix('admin')->get(…)`) prefixes the URI + // just as an enclosing group's does. + let uri_prefix = + join_uri_segments(group.uri, &chain_uri_prefix(mc.object, content)); out.push(RouteEntry { name: full, - uri: route_uri(group.uri, chain_uri(mc.object, content)), + uri: route_uri(&uri_prefix, chain_uri(mc.object, content)), }); } } diff --git a/src/virtual_members/laravel/route_names_tests.rs b/src/virtual_members/laravel/route_names_tests.rs index 8c2e45cb..7e16daad 100644 --- a/src/virtual_members/laravel/route_names_tests.rs +++ b/src/virtual_members/laravel/route_names_tests.rs @@ -56,6 +56,13 @@ fn applies_nested_group_uri_prefixes() { ); } +#[test] +fn applies_chain_uri_prefix_without_a_group() { + let content = + "get('/users/{user}', 'show')->name('users.show');\n"; + assert_eq!(uri_of(content, "users.show"), "{tenant}/users/{user}"); +} + #[test] fn applies_array_group_uri_prefix() { let content = " 'admin', 'as' => 'admin.'], function () {\n Route::patch('/posts/{post}', 'update')->name('posts.update');\n});\n"; diff --git a/tests/integration/laravel_route_params.rs b/tests/integration/laravel_route_params.rs new file mode 100644 index 00000000..a7e06558 --- /dev/null +++ b/tests/integration/laravel_route_params.rs @@ -0,0 +1,162 @@ +//! Tests for route parameter name completion. +//! +//! The keys of the parameters array of `route('users.show', [...])` are the +//! `{parameters}` of the named route's URI, recovered from the registration +//! the name was declared on. The same applies to `to_route()`, +//! `signedRoute()`, and `temporarySignedRoute()`. + +use crate::common::create_psr4_workspace; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +const COMPOSER_JSON: &str = r#"{ + "require": { "laravel/framework": "^11.0" }, + "autoload": { "psr-4": { "App\\": "src/" } } +}"#; + +const ROUTES: &str = "\ +name('home'); +Route::get('/users/{user}/posts/{post}', 'show')->name('users.posts.show'); + +Route::prefix('admin')->name('admin.')->group(function () { + Route::patch('/bakeries/{bakery}/cancel', 'cancel')->name('bakeries.cancel'); +}); +"; + +async fn open(backend: &phpantom_lsp::Backend, uri: &str, text: &str) { + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: Url::parse(uri).unwrap(), + language_id: "php".to_string(), + version: 1, + text: text.to_string(), + }, + }) + .await; +} + +/// Position of the cursor immediately after the first occurrence of `needle`. +fn position_after(content: &str, needle: &str) -> Position { + let idx = content.find(needle).expect("needle not found") + needle.len(); + let mut line = 0u32; + let mut character = 0u32; + for (i, ch) in content.char_indices() { + if i == idx { + break; + } + if ch == '\n' { + line += 1; + character = 0; + } else { + character += 1; + } + } + Position { line, character } +} + +fn completion_labels(response: Option) -> Vec { + match response { + Some(CompletionResponse::Array(items)) => items.into_iter().map(|i| i.label).collect(), + Some(CompletionResponse::List(list)) => list.items.into_iter().map(|i| i.label).collect(), + None => Vec::new(), + } +} + +/// Open a workspace holding `ROUTES` plus a consumer file, and complete at the +/// first position after `needle` in the consumer. +async fn labels_after(consumer: &str, needle: &str) -> Vec { + let (backend, dir) = create_psr4_workspace( + COMPOSER_JSON, + &[("routes/web.php", ROUTES), ("src/Runner.php", consumer)], + ); + backend.initialized(InitializedParams {}).await; + + let routes_uri = Url::from_file_path(dir.path().join("routes/web.php")) + .unwrap() + .to_string(); + open(&backend, &routes_uri, ROUTES).await; + + let uri = Url::from_file_path(dir.path().join("src/Runner.php")) + .unwrap() + .to_string(); + open(&backend, &uri, consumer).await; + + let result = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: Url::parse(&uri).unwrap(), + }, + position: position_after(consumer, needle), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap(); + completion_labels(result) +} + +fn consumer(body: &str) -> String { + format!( + "\ + 1, '']);"); + let labels = labels_after(&source, "'user' => 1, '").await; + assert_eq!(labels, vec!["user".to_string(), "post".to_string()]); +} + +#[tokio::test] +async fn group_prefixed_route_offers_its_parameter() { + let source = consumer("to_route('admin.bakeries.cancel', ['']);"); + let labels = labels_after(&source, "['").await; + assert_eq!(labels, vec!["bakery".to_string()]); +} + +#[tokio::test] +async fn temporary_signed_route_offers_parameters_as_its_third_argument() { + let source = consumer("URL::temporarySignedRoute('users.posts.show', $expiration, ['']);"); + let labels = labels_after(&source, "$expiration, ['").await; + assert_eq!(labels, vec!["user".to_string(), "post".to_string()]); +} + +#[tokio::test] +async fn parameterless_route_offers_no_parameters() { + let source = consumer("route('home', ['']);"); + assert!(labels_after(&source, "['").await.is_empty()); +} + +#[tokio::test] +async fn the_route_name_argument_still_completes_names() { + let source = consumer("route('');"); + let labels = labels_after(&source, "route('").await; + assert!( + labels.contains(&"users.posts.show".to_string()), + "expected the route names, got {labels:?}" + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index d4b86613..5668dd84 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -130,6 +130,7 @@ mod laravel_morph_map; mod laravel_references; mod laravel_request_keys; mod laravel_route_controller; +mod laravel_route_params; mod laravel_validated_shape; mod linked_editing; mod lsp_concurrency;