From 27e3d57e5664211d94d1018867ddba556c5d454d Mon Sep 17 00:00:00 2001 From: Mike Bronner Date: Sat, 13 Jun 2026 15:06:47 -0700 Subject: [PATCH 1/5] chore: start work on #57 From 2fe3d8a3063a6dd5da5aa59109f5da9de8ecfc94 Mon Sep 17 00:00:00 2001 From: Mike Bronner Date: Sat, 13 Jun 2026 15:12:37 -0700 Subject: [PATCH 2/5] feat: Folio page routing support (goto / completion / find-references) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface Laravel Folio pages — filesystem-derived routes that never call Route:: — across the LSP route features. A new folio_discovery module finds the project's Folio mounts (default resources/views/pages plus any Folio::path('...') registered in a service provider, with chained ->uri() and ->name() prefixes), derives each page's URI from its filename (handling static, dynamic [id], catch-all [...slug], and trailing-index segments), and reads each page's explicit name('...') route name. Named pages are injected into the shared RouteIndex as ordinary RouteDefinitions in build_route_index, so goto-definition, route-name completion, route-not-found diagnostics, and named-route find-references all resolve Folio routes with no per-feature changes. Discovery is gated on folio_in_use (composer.json or a Folio:: facade reference) so non-Folio projects pay nothing, and a conventional route of the same name still wins. Fixes #57 --- laravel-lsp/src/folio_discovery.rs | 325 +++++++++++++++++++++++ laravel-lsp/src/folio_discovery/tests.rs | 279 +++++++++++++++++++ laravel-lsp/src/lib.rs | 1 + laravel-lsp/src/route_discovery.rs | 6 + 4 files changed, 611 insertions(+) create mode 100644 laravel-lsp/src/folio_discovery.rs create mode 100644 laravel-lsp/src/folio_discovery/tests.rs diff --git a/laravel-lsp/src/folio_discovery.rs b/laravel-lsp/src/folio_discovery.rs new file mode 100644 index 00000000..a02cb41a --- /dev/null +++ b/laravel-lsp/src/folio_discovery.rs @@ -0,0 +1,325 @@ +//! Laravel Folio page-route discovery. +//! +//! [Folio](https://laravel.com/docs/folio) derives routes from the filesystem: +//! a Blade file at `resources/views/pages/users/[id].blade.php` becomes the +//! route `/users/{id}`. Because nothing calls `Route::` for these pages, the +//! conventional route discovery in [`crate::route_discovery`] never sees them. +//! +//! This module bridges that gap. It discovers the project's Folio mount +//! directories (the default `resources/views/pages`, plus any registered via +//! `Folio::path('...')` in a service provider), derives each page's URI from +//! its filename — honouring dynamic `[id]` and catch-all `[...slug]` segments +//! and the `index.blade.php` convention — and reads each page's explicit +//! `name('...')` route name. The named pages are then injected into the shared +//! [`crate::route_discovery::RouteIndex`] as ordinary [`RouteDefinition`]s, so +//! goto-definition, route-name completion, route-not-found diagnostics, and +//! find-references all surface Folio pages with no per-feature changes. + +use std::path::{Path, PathBuf}; + +use lazy_static::lazy_static; +use regex::Regex; +use walkdir::WalkDir; + +use crate::route_discovery::{normalize_path, RouteDefinition, RouteIndex, PRIORITY_APP}; + +/// Folio's default page directory, relative to the project root. Used when a +/// project enables Folio but never calls `Folio::path(...)` to relocate it. +pub const DEFAULT_FOLIO_MOUNT: &str = "resources/views/pages"; + +/// A discovered Folio mount: one page directory plus the URI and route-name +/// prefixes it contributes to every page beneath it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FolioMount { + /// Absolute path to the mount's page directory. + pub directory: PathBuf, + /// URI prefix contributed by a chained `->uri('...')` (no surrounding + /// slashes). Empty for the default mount. + pub uri_prefix: String, + /// Route-name prefix contributed by a chained `->name('...')`. Empty when + /// the mount sets none. + pub name_prefix: String, +} + +/// A Folio page resolved to a route. `name` is `None` for pages without an +/// explicit `name('...')` call — those still have a URI but can't be reached +/// via `route('...')`, so only named pages reach the route index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FolioRoute { + /// Absolute path to the backing `.blade.php` page file. + pub file: PathBuf, + /// URI the page responds to, e.g. `/users/{id}`. + pub uri: String, + /// Fully-qualified route name (mount prefix + page name) when the page + /// declares one. + pub name: Option, +} + +lazy_static! { + /// `Folio::path('resources/views/folio')` — captures the directory string. + static ref FOLIO_PATH_RE: Regex = + Regex::new(r#"Folio::path\s*\(\s*['"]([^'"]+)['"]"#).unwrap(); + + /// A chained `->uri('admin')` link. Searched within a single `Folio::path` + /// statement. + static ref FOLIO_URI_RE: Regex = + Regex::new(r#"->\s*uri\s*\(\s*['"]([^'"]+)['"]"#).unwrap(); + + /// A chained `->name('admin.')` link. Searched within a single `Folio::path` + /// statement. + static ref FOLIO_NAME_RE: Regex = + Regex::new(r#"->\s*name\s*\(\s*['"]([^'"]+)['"]"#).unwrap(); + + /// A page-level `name('users.show')` call (Folio's `Laravel\Folio\name` + /// helper). The leading boundary class excludes `>`, `:` and `\` so it + /// never matches the route-chain `->name(`, the static `::name(`, or the + /// `use function Laravel\Folio\name;` import. + static ref PAGE_NAME_RE: Regex = + Regex::new(r#"(?:\A|[^A-Za-z0-9_>:\\])name\s*\(\s*['"]([^'"]+)['"]"#).unwrap(); +} + +/// Whether the project uses Folio at all. Gates the page-directory walk so +/// non-Folio projects that merely happen to have a `pages/` directory never +/// pay for it. True when `composer.json` requires `laravel/folio`, or any +/// service provider references the `Folio::` facade. +pub fn folio_in_use(root: &Path) -> bool { + if let Ok(composer) = std::fs::read_to_string(root.join("composer.json")) { + if composer.contains("laravel/folio") { + return true; + } + } + provider_files(root) + .iter() + .filter_map(|p| std::fs::read_to_string(p).ok()) + .any(|c| c.contains("Folio::")) +} + +/// Service-provider files that may register Folio mounts. Mirrors the +/// route-discovery convention of scanning `bootstrap/app.php` and +/// `app/Providers/*.php`. +fn provider_files(root: &Path) -> Vec { + let mut paths = vec![root.join("bootstrap/app.php")]; + let providers = root.join("app/Providers"); + if providers.exists() { + for entry in WalkDir::new(&providers) + .max_depth(4) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_file()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "php")) + { + paths.push(entry.path().to_path_buf()); + } + } + paths +} + +/// Discover the project's Folio mounts. Every explicit `Folio::path('...')` +/// (with its chained `->uri(...)` / `->name(...)`) becomes a mount; when none +/// are registered the default [`DEFAULT_FOLIO_MOUNT`] is used. +pub fn discover_folio_mounts(root: &Path) -> Vec { + let mut mounts = Vec::new(); + for file in provider_files(root) { + let Ok(content) = std::fs::read_to_string(&file) else { + continue; + }; + mounts.extend(parse_folio_mounts(&content, root)); + } + + if mounts.is_empty() { + mounts.push(FolioMount { + directory: root.join(DEFAULT_FOLIO_MOUNT), + uri_prefix: String::new(), + name_prefix: String::new(), + }); + } + mounts +} + +/// Parse every `Folio::path(...)` statement out of one provider's source, +/// resolving each mount's directory (relative to `root`) and its chained URI / +/// name prefixes. Pulled out of [`discover_folio_mounts`] for direct testing. +fn parse_folio_mounts(content: &str, root: &Path) -> Vec { + let mut mounts = Vec::new(); + for m in FOLIO_PATH_RE.captures_iter(content) { + let whole = m.get(0).unwrap(); + let rel = m.get(1).unwrap().as_str(); + + // The chained `->uri(...)` / `->name(...)` links live between this + // `Folio::path(` call and the statement terminator. Bound the search to + // that statement so a later mount's prefixes don't bleed in. + let stmt_start = whole.end(); + let stmt_end = content[stmt_start..] + .find(';') + .map(|i| stmt_start + i) + .unwrap_or(content.len()); + let statement = &content[stmt_start..stmt_end]; + + let uri_prefix = FOLIO_URI_RE + .captures(statement) + .map(|c| c[1].trim_matches('/').to_string()) + .unwrap_or_default(); + let name_prefix = FOLIO_NAME_RE + .captures(statement) + .map(|c| c[1].to_string()) + .unwrap_or_default(); + + mounts.push(FolioMount { + directory: root.join(rel), + uri_prefix, + name_prefix, + }); + } + mounts +} + +/// Derive a Folio route URI from a page path relative to its mount directory +/// (e.g. `users/[id].blade.php`). Drops the `.blade.php` suffix and a trailing +/// `index` segment, rewrites `[id]` → `{id}` and `[...slug]` → `{slug}`, and +/// joins the rest with `/`. Returns the path segments WITHOUT a leading slash; +/// the empty string denotes the mount root. +pub fn derive_uri(relative: &str) -> String { + let stem = relative + .strip_suffix(".blade.php") + .unwrap_or(relative) + .replace('\\', "/"); + + let raw: Vec<&str> = stem.split('/').filter(|s| !s.is_empty()).collect(); + let mut segments: Vec = Vec::new(); + for (i, seg) in raw.iter().enumerate() { + // A trailing `index` maps to its parent directory's URI. + if *seg == "index" && i == raw.len() - 1 { + continue; + } + segments.push(rewrite_segment(seg)); + } + segments.join("/") +} + +/// Rewrite a single filename segment's Folio placeholder syntax into a route +/// parameter: `[...slug]` (catch-all) and `[id]` both become `{...}`. Literal +/// segments pass through untouched. +fn rewrite_segment(segment: &str) -> String { + if let Some(inner) = segment + .strip_prefix("[...") + .and_then(|s| s.strip_suffix(']')) + { + return format!("{{{}}}", inner); + } + if let Some(inner) = segment.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + return format!("{{{}}}", inner); + } + segment.to_string() +} + +/// Extract a page's explicit Folio route name from its source — the argument of +/// the `name('...')` helper. Returns `None` when the page declares none. +pub fn extract_page_name(content: &str) -> Option { + PAGE_NAME_RE + .captures(content) + .map(|c| c[1].trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Discover every Folio page across the project's mounts and resolve it to a +/// [`FolioRoute`]. Returns an empty vector when the project doesn't use Folio. +pub fn discover_folio_routes(root: &Path) -> Vec { + if !folio_in_use(root) { + return Vec::new(); + } + + let mut routes = Vec::new(); + for mount in discover_folio_mounts(root) { + if !mount.directory.exists() { + continue; + } + for entry in WalkDir::new(&mount.directory) + .max_depth(12) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_file()) + { + let path = entry.path(); + if !is_blade_file(path) { + continue; + } + let Ok(relative) = path.strip_prefix(&mount.directory) else { + continue; + }; + let derived = derive_uri(&relative.to_string_lossy()); + let uri = compose_uri(&mount.uri_prefix, &derived); + + let name = std::fs::read_to_string(path) + .ok() + .and_then(|c| extract_page_name(&c)) + .map(|page_name| compose_name(&mount.name_prefix, &page_name)); + + routes.push(FolioRoute { + file: path.to_path_buf(), + uri, + name, + }); + } + } + routes +} + +/// Inject every *named* Folio page into `index` as a [`RouteDefinition`], so +/// all existing route consumers (goto, completion, diagnostics, references) +/// resolve Folio routes by name. Goto lands at the top of the page file. A +/// conventional route of the same name wins, because [`RouteIndex::insert`] +/// keeps the equal-priority entry inserted first and this runs after the +/// conventional pass. +pub fn inject_folio_routes(root: &Path, index: &mut RouteIndex) { + for route in discover_folio_routes(root) { + index.source_files.insert(normalize_path(&route.file)); + if let Some(name) = route.name { + index.insert( + name, + RouteDefinition { + file: route.file, + line: 0, + column: 0, + end_column: 0, + priority: PRIORITY_APP, + method: Some("get".to_string()), + uri: Some(route.uri), + action: None, + }, + ); + } + } +} + +/// Join a mount's URI prefix and a page's derived URI into a leading-slash +/// absolute URI. An all-empty result (the mount root's `index.blade.php`) +/// collapses to `/`. +fn compose_uri(prefix: &str, derived: &str) -> String { + let body = [prefix.trim_matches('/'), derived] + .iter() + .filter(|s| !s.is_empty()) + .copied() + .collect::>() + .join("/"); + format!("/{}", body) +} + +/// Combine a mount's name prefix with a page's own name. The prefix is honoured +/// verbatim (Folio prefixes typically end in `.`). +fn compose_name(prefix: &str, page_name: &str) -> String { + if prefix.is_empty() { + page_name.to_string() + } else { + format!("{}{}", prefix, page_name) + } +} + +/// Whether `path` is a Blade page file (`*.blade.php`). +fn is_blade_file(path: &Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.ends_with(".blade.php")) +} + +#[cfg(test)] +mod tests; diff --git a/laravel-lsp/src/folio_discovery/tests.rs b/laravel-lsp/src/folio_discovery/tests.rs new file mode 100644 index 00000000..a109a9b9 --- /dev/null +++ b/laravel-lsp/src/folio_discovery/tests.rs @@ -0,0 +1,279 @@ +use super::*; + +use std::fs; +use tempfile::TempDir; + +// --------------------------------------------------------------------------- +// derive_uri — static, dynamic, catch-all, and index segments +// --------------------------------------------------------------------------- + +#[test] +fn derive_uri_static_segment() { + assert_eq!(derive_uri("about.blade.php"), "about"); + assert_eq!(derive_uri("users/profile.blade.php"), "users/profile"); +} + +#[test] +fn derive_uri_dynamic_segment() { + assert_eq!(derive_uri("users/[id].blade.php"), "users/{id}"); + assert_eq!(derive_uri("[post].blade.php"), "{post}"); +} + +#[test] +fn derive_uri_catch_all_segment() { + assert_eq!(derive_uri("docs/[...slug].blade.php"), "docs/{slug}"); + assert_eq!(derive_uri("[...path].blade.php"), "{path}"); +} + +#[test] +fn derive_uri_drops_trailing_index() { + assert_eq!(derive_uri("index.blade.php"), ""); + assert_eq!(derive_uri("users/index.blade.php"), "users"); +} + +#[test] +fn derive_uri_keeps_non_trailing_index() { + // `index` only collapses when it's the final segment. + assert_eq!(derive_uri("index/show.blade.php"), "index/show"); +} + +#[test] +fn derive_uri_mixed_segments() { + assert_eq!( + derive_uri("users/[id]/posts/[...rest].blade.php"), + "users/{id}/posts/{rest}" + ); +} + +// --------------------------------------------------------------------------- +// extract_page_name — the Folio `name('...')` helper +// --------------------------------------------------------------------------- + +#[test] +fn extract_page_name_finds_helper_call() { + let src = "\n
"; + assert_eq!(extract_page_name(src), Some("users.show".to_string())); +} + +#[test] +fn extract_page_name_double_quotes() { + let src = r#""#; + assert_eq!(extract_page_name(src), Some("dashboard".to_string())); +} + +#[test] +fn extract_page_name_none_when_absent() { + let src = "
plain page, no name
"; + assert_eq!(extract_page_name(src), None); +} + +#[test] +fn extract_page_name_ignores_route_chain_name() { + // `->name(` must not be mistaken for the Folio helper. + let src = "name('not.folio'); ?>"; + assert_eq!(extract_page_name(src), None); +} + +#[test] +fn extract_page_name_ignores_static_name_call() { + let src = ""; + assert_eq!(extract_page_name(src), None); +} + +// --------------------------------------------------------------------------- +// parse_folio_mounts — non-default mount paths and chained prefixes +// --------------------------------------------------------------------------- + +#[test] +fn parse_folio_mounts_default_when_none() { + let mounts = parse_folio_mounts("uri('/admin')->name('admin.');"; + let mounts = parse_folio_mounts(src, Path::new("/app")); + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0].uri_prefix, "admin"); + assert_eq!(mounts[0].name_prefix, "admin."); +} + +#[test] +fn parse_folio_mounts_prefixes_do_not_bleed_across_statements() { + let src = "name('a.');\nFolio::path('b');"; + let mounts = parse_folio_mounts(src, Path::new("/app")); + assert_eq!(mounts.len(), 2); + assert_eq!(mounts[0].name_prefix, "a."); + assert_eq!(mounts[1].name_prefix, ""); +} + +// --------------------------------------------------------------------------- +// compose_uri / compose_name +// --------------------------------------------------------------------------- + +#[test] +fn compose_uri_collapses_empty_to_root() { + assert_eq!(compose_uri("", ""), "/"); + assert_eq!(compose_uri("", "users/{id}"), "/users/{id}"); + assert_eq!(compose_uri("admin", "users"), "/admin/users"); +} + +#[test] +fn compose_name_applies_prefix() { + assert_eq!(compose_name("", "users.show"), "users.show"); + assert_eq!(compose_name("admin.", "users"), "admin.users"); +} + +// --------------------------------------------------------------------------- +// End-to-end discovery against a temp project +// --------------------------------------------------------------------------- + +/// Build a temp project that enables Folio (via composer.json) and write the +/// given `(relative-page-path, contents)` pages under `mount`. +fn make_project(mount: &str, pages: &[(&str, &str)]) -> TempDir { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + fs::write( + root.join("composer.json"), + r#"{"require": {"laravel/folio": "^1.0"}}"#, + ) + .unwrap(); + for (rel, contents) in pages { + let full = root.join(mount).join(rel); + fs::create_dir_all(full.parent().unwrap()).unwrap(); + fs::write(full, contents).unwrap(); + } + dir +} + +#[test] +fn folio_in_use_detects_composer_dependency() { + let dir = make_project(DEFAULT_FOLIO_MOUNT, &[]); + assert!(folio_in_use(dir.path())); +} + +#[test] +fn folio_in_use_false_without_folio() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("composer.json"), r#"{"require": {}}"#).unwrap(); + assert!(!folio_in_use(dir.path())); +} + +#[test] +fn discover_folio_routes_resolves_named_pages_default_mount() { + let dir = make_project( + DEFAULT_FOLIO_MOUNT, + &[ + ("users/[id].blade.php", ""), + ("about.blade.php", ""), + ("anonymous.blade.php", "
no name
"), + ], + ); + let routes = discover_folio_routes(dir.path()); + + let show = routes + .iter() + .find(|r| r.name.as_deref() == Some("users.show")) + .expect("named users.show route"); + assert_eq!(show.uri, "/users/{id}"); + assert!(show.file.ends_with("users/[id].blade.php")); + + // The unnamed page is still discovered (with a URI) but carries no name. + assert!(routes + .iter() + .any(|r| r.uri == "/anonymous" && r.name.is_none())); +} + +#[test] +fn discover_folio_routes_honours_non_default_mount() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + fs::create_dir_all(root.join("app/Providers")).unwrap(); + fs::write( + root.join("app/Providers/FolioServiceProvider.php"), + "name('site.');", + ) + .unwrap(); + let page = root.join("resources/views/folio/contact.blade.php"); + fs::create_dir_all(page.parent().unwrap()).unwrap(); + fs::write(&page, "").unwrap(); + + let routes = discover_folio_routes(root); + let contact = routes + .iter() + .find(|r| r.file.ends_with("contact.blade.php")) + .expect("contact page discovered under custom mount"); + assert_eq!(contact.uri, "/contact"); + // The mount's `->name('site.')` prefix is applied. + assert_eq!(contact.name.as_deref(), Some("site.contact")); +} + +#[test] +fn discover_folio_routes_empty_without_folio() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("composer.json"), r#"{"require": {}}"#).unwrap(); + assert!(discover_folio_routes(dir.path()).is_empty()); +} + +#[test] +fn inject_folio_routes_adds_named_routes_to_index() { + let dir = make_project( + DEFAULT_FOLIO_MOUNT, + &[ + ("docs/[...slug].blade.php", ""), + ("index.blade.php", ""), + ], + ); + let mut index = RouteIndex::new(); + inject_folio_routes(dir.path(), &mut index); + + let docs = index.get("docs.show").expect("docs.show injected"); + assert_eq!(docs.uri.as_deref(), Some("/docs/{slug}")); + assert!(docs.file.ends_with("docs/[...slug].blade.php")); + assert_eq!(docs.method.as_deref(), Some("get")); + + let home = index.get("home").expect("home injected"); + assert_eq!(home.uri.as_deref(), Some("/")); +} + +#[test] +fn inject_folio_routes_does_not_clobber_conventional_route() { + let dir = make_project( + DEFAULT_FOLIO_MOUNT, + &[("dashboard.blade.php", "")], + ); + let mut index = RouteIndex::new(); + // A conventional route already owns the name (inserted first, app priority). + index.insert( + "dashboard".to_string(), + RouteDefinition { + file: PathBuf::from("/app/routes/web.php"), + line: 4, + column: 0, + end_column: 10, + priority: PRIORITY_APP, + method: Some("get".to_string()), + uri: Some("/dashboard".to_string()), + action: None, + }, + ); + inject_folio_routes(dir.path(), &mut index); + + let def = index.get("dashboard").unwrap(); + assert!( + def.file.ends_with("routes/web.php"), + "conventional route must win over the Folio page of the same name" + ); +} diff --git a/laravel-lsp/src/lib.rs b/laravel-lsp/src/lib.rs index 0665a87e..56664fe5 100644 --- a/laravel-lsp/src/lib.rs +++ b/laravel-lsp/src/lib.rs @@ -35,6 +35,7 @@ pub mod database; pub mod document_symbols; pub mod env_key_locator; pub mod file_watcher; +pub mod folio_discovery; pub mod hover; pub mod indexing_progress; pub mod laravel_introspector; diff --git a/laravel-lsp/src/route_discovery.rs b/laravel-lsp/src/route_discovery.rs index b689aad1..c926eced 100644 --- a/laravel-lsp/src/route_discovery.rs +++ b/laravel-lsp/src/route_discovery.rs @@ -261,6 +261,12 @@ pub fn build_route_index(root: &Path, files: &[RouteFile]) -> RouteIndex { .external_prefixes .insert(key, dedup_prefixes(&prefixes)); } + + // Surface Laravel Folio pages (filesystem-derived routes that never call + // `Route::`) through the same index, so goto/completion/diagnostics see + // them. No-op for projects that don't use Folio. + crate::folio_discovery::inject_folio_routes(root, &mut index); + index } From b7b003afd7b2cf0c8c2f45c0583ce32845cf0a07 Mon Sep 17 00:00:00 2001 From: Mike Bronner Date: Sat, 13 Jun 2026 17:14:55 -0700 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=F0=9F=94=92=20Harden=20Folio=20moun?= =?UTF-8?q?t=20discovery=20and=20complete=20find-references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Holmes's review of #57 — three blockers plus the three non-blocking follow-ups. Blockers: - Security: contain `Folio::path(...)` mounts to the project root. Raw paths were `root.join`'d and walked, so `Folio::path('/etc')` or `Folio::path('../../x')` turned the LSP into an arbitrary-file reader. `resolve_folio_path` now normalizes and verifies the result stays under root, rejecting absolute/escaping mounts (falls back to the default mount). Mirrors `route_discovery::resolve_path_argument`. - AC #1: parse the `resource_path('...')` / `base_path('...')` helper forms that `folio:install` scaffolds — previously only a bare quoted literal matched, silently dropping (or misresolving) the canonical provider syntax. The arg is now paren-balanced, so chained `->uri()`/`->name()` links survive the helper's inner parens. - AC #4: find-references now fully covers Folio routes. The backing `.blade.php` page is surfaced as the route declaration (via the injected index entry, blade-file-gated so conventional routes aren't double-counted), and `folio_name_at` lets the request trigger from a cursor on the page's own `name('...')` call. Follow-ups: - Tighten `PAGE_NAME_RE` to also exclude `(`/`[` boundaries. - `follow_links(false)` on both Folio WalkDir scans. - Test the `folio_in_use` facade-reference branch. Fixes #57 --- laravel-lsp/src/folio_discovery.rs | 177 +++++++++++++++++++++-- laravel-lsp/src/folio_discovery/tests.rs | 167 ++++++++++++++++++++- laravel-lsp/src/main.rs | 52 +++++++ 3 files changed, 379 insertions(+), 17 deletions(-) diff --git a/laravel-lsp/src/folio_discovery.rs b/laravel-lsp/src/folio_discovery.rs index a02cb41a..2a116e8c 100644 --- a/laravel-lsp/src/folio_discovery.rs +++ b/laravel-lsp/src/folio_discovery.rs @@ -56,9 +56,12 @@ pub struct FolioRoute { } lazy_static! { - /// `Folio::path('resources/views/folio')` — captures the directory string. - static ref FOLIO_PATH_RE: Regex = - Regex::new(r#"Folio::path\s*\(\s*['"]([^'"]+)['"]"#).unwrap(); + /// The head of a `Folio::path(` call. The argument expression — a bare + /// string literal, or a `resource_path(...)` / `base_path(...)` helper + /// wrapping one (the form `php artisan folio:install` scaffolds) — is parsed + /// by balancing parens from the match end (see [`resolve_folio_path`]), so + /// helper-call forms are handled, not only bare literals. + static ref FOLIO_PATH_HEAD_RE: Regex = Regex::new(r#"Folio::path\s*\("#).unwrap(); /// A chained `->uri('admin')` link. Searched within a single `Folio::path` /// statement. @@ -71,11 +74,12 @@ lazy_static! { Regex::new(r#"->\s*name\s*\(\s*['"]([^'"]+)['"]"#).unwrap(); /// A page-level `name('users.show')` call (Folio's `Laravel\Folio\name` - /// helper). The leading boundary class excludes `>`, `:` and `\` so it - /// never matches the route-chain `->name(`, the static `::name(`, or the - /// `use function Laravel\Folio\name;` import. + /// helper). The leading boundary class excludes `>`, `:`, `\`, `(` and `[` + /// so it never matches the route-chain `->name(`, the static `::name(`, the + /// `use function Laravel\Folio\name;` import, or a `name(` nested inside + /// another call/index expression (e.g. `usort($a, name('cmp'))`). static ref PAGE_NAME_RE: Regex = - Regex::new(r#"(?:\A|[^A-Za-z0-9_>:\\])name\s*\(\s*['"]([^'"]+)['"]"#).unwrap(); + Regex::new(r#"(?:\A|[^A-Za-z0-9_>:\\(\[])name\s*\(\s*['"]([^'"]+)['"]"#).unwrap(); } /// Whether the project uses Folio at all. Gates the page-directory walk so @@ -103,6 +107,7 @@ fn provider_files(root: &Path) -> Vec { if providers.exists() { for entry in WalkDir::new(&providers) .max_depth(4) + .follow_links(false) .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.path().is_file()) @@ -137,18 +142,34 @@ pub fn discover_folio_mounts(root: &Path) -> Vec { } /// Parse every `Folio::path(...)` statement out of one provider's source, -/// resolving each mount's directory (relative to `root`) and its chained URI / -/// name prefixes. Pulled out of [`discover_folio_mounts`] for direct testing. +/// resolving each mount's directory (relative to `root`, containment-checked) +/// and its chained URI / name prefixes. Pulled out of [`discover_folio_mounts`] +/// for direct testing. fn parse_folio_mounts(content: &str, root: &Path) -> Vec { + let bytes = content.as_bytes(); let mut mounts = Vec::new(); - for m in FOLIO_PATH_RE.captures_iter(content) { - let whole = m.get(0).unwrap(); - let rel = m.get(1).unwrap().as_str(); + for head in FOLIO_PATH_HEAD_RE.find_iter(content) { + // `head.end()` is one past the opening `(`. Balance parens to find the + // argument's close, so a wrapped helper (`resource_path('...')`) is + // captured whole rather than truncated at its inner `)`. + let open = head.end() - 1; + let Some(close) = matching_paren(bytes, open) else { + continue; + }; + let arg = &content[open + 1..close]; + let Some(directory) = resolve_folio_path(arg, root) else { + // Unparseable argument (a variable, an unsupported helper), or a + // path that escapes the project root (`Folio::path('/etc')`, + // `Folio::path('../../x')`) — a traversal vector we refuse to walk. + // Skip; the default-mount fallback in [`discover_folio_mounts`] + // still applies when nothing else parses. + continue; + }; // The chained `->uri(...)` / `->name(...)` links live between this - // `Folio::path(` call and the statement terminator. Bound the search to + // call's close paren and the statement terminator. Bound the search to // that statement so a later mount's prefixes don't bleed in. - let stmt_start = whole.end(); + let stmt_start = close + 1; let stmt_end = content[stmt_start..] .find(';') .map(|i| stmt_start + i) @@ -165,7 +186,7 @@ fn parse_folio_mounts(content: &str, root: &Path) -> Vec { .unwrap_or_default(); mounts.push(FolioMount { - directory: root.join(rel), + directory, uri_prefix, name_prefix, }); @@ -173,6 +194,93 @@ fn parse_folio_mounts(content: &str, root: &Path) -> Vec { mounts } +/// Find the index of the `)` that closes the `(` at byte `open`, balancing +/// nested parens and skipping over single/double-quoted string contents (so a +/// `)` inside a path literal never ends the scan early). +fn matching_paren(bytes: &[u8], open: usize) -> Option { + let mut depth = 0usize; + let mut quote: Option = None; + for (i, &b) in bytes.iter().enumerate().skip(open) { + match quote { + Some(q) => { + if b == q { + quote = None; + } + } + None => match b { + b'\'' | b'"' => quote = Some(b), + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + _ => {} + }, + } + } + None +} + +/// Parse a single- or double-quoted PHP string literal, returning its inner +/// content. `None` for anything that isn't a bare quoted literal (e.g. a +/// variable or a concatenation we can't resolve statically). +fn parse_literal(expr: &str) -> Option { + let expr = expr.trim(); + let bytes = expr.as_bytes(); + let &first = bytes.first()?; + if (first == b'\'' || first == b'"') && bytes.len() >= 2 && bytes[bytes.len() - 1] == first { + return Some(expr[1..expr.len() - 1].to_string()); + } + None +} + +/// If `expr` is a `('literal')` or `()` call, return the inner +/// literal (the empty string for the no-arg form). `None` when `expr` isn't +/// that helper or its argument isn't a static string. +fn helper_call(expr: &str, name: &str) -> Option { + let rest = expr.strip_prefix(name)?.trim_start(); + let inner = rest.strip_prefix('(')?.strip_suffix(')')?.trim(); + if inner.is_empty() { + return Some(String::new()); + } + parse_literal(inner) +} + +/// Resolve a `Folio::path(...)` argument to an absolute mount directory that is +/// guaranteed to stay under `root`. Handles the bare string literal and the +/// `resource_path('...')` / `base_path('...')` helper-wrapped forms that +/// `php artisan folio:install` scaffolds. +/// +/// Returns `None` when the argument isn't a static path, or when it resolves +/// outside the project: Folio mounts are walked and every `.blade.php` beneath +/// them is read into the route index, so a mount escaping the opened project +/// (`Folio::path('/etc')`, `Folio::path('../../x')`) is a path-traversal vector +/// and is refused. Mirrors the containment convention in +/// [`crate::route_discovery`]'s `resolve_path_argument`. +fn resolve_folio_path(expr: &str, root: &Path) -> Option { + let expr = expr.trim(); + let candidate = if let Some(sub) = helper_call(expr, "resource_path") { + root.join("resources").join(sub.trim_start_matches('/')) + } else if let Some(sub) = helper_call(expr, "base_path") { + root.join(sub.trim_start_matches('/')) + } else { + let literal = parse_literal(expr)?; + let p = Path::new(&literal); + if p.is_absolute() { + p.to_path_buf() + } else { + root.join(p) + } + }; + + let normalized = normalize_path(&candidate); + normalized + .starts_with(normalize_path(root)) + .then_some(normalized) +} + /// Derive a Folio route URI from a page path relative to its mount directory /// (e.g. `users/[id].blade.php`). Drops the `.blade.php` suffix and a trailing /// `index` segment, rewrites `[id]` → `{id}` and `[...slug]` → `{slug}`, and @@ -221,6 +329,42 @@ pub fn extract_page_name(content: &str) -> Option { .filter(|s| !s.is_empty()) } +/// The fully-qualified route name a Folio page declares, IF the cursor at +/// (`line`, `character`) sits on the page's `name('...')` helper call. +/// +/// Powers find-references and rename triggered from *inside* a Folio page: the +/// page lives outside `routes/` and its bare `name(...)` helper isn't tagged by +/// the PHP parser, so without this the request resolves to nothing. The +/// returned name carries the mount's `->name(...)` prefix, matching what the +/// route index is keyed by — so it reaches every `route('...')` usage. Returns +/// `None` when the file isn't a named Folio page or the cursor isn't on its +/// `name(...)` call. +pub fn folio_name_at(root: &Path, file: &Path, line: u32, character: u32) -> Option { + let normalized = normalize_path(file); + let name = discover_folio_routes(root) + .into_iter() + .find(|r| normalize_path(&r.file) == normalized) + .and_then(|r| r.name)?; + + let content = std::fs::read_to_string(file).ok()?; + let (name_line, start_col, end_col) = page_name_span(&content)?; + (line == name_line && character >= start_col && character <= end_col).then_some(name) +} + +/// Source span of a Folio page's `name('...')` argument string, including the +/// surrounding quotes, as `(line, start_column, end_column)`. Used to decide +/// whether a cursor is on the call. `None` when the page declares no name. +fn page_name_span(content: &str) -> Option<(u32, u32, u32)> { + let inner = PAGE_NAME_RE.captures(content)?.get(1)?; + // Widen one byte each side to cover the enclosing quotes. + let start = inner.start().saturating_sub(1); + let end = (inner.end() + 1).min(content.len()); + let start_pos = crate::query_chain::byte_offset_to_position(content, start); + let end_pos = crate::query_chain::byte_offset_to_position(content, end); + // The `name('...')` argument is single-line; anchor to its start line. + Some((start_pos.line, start_pos.character, end_pos.character)) +} + /// Discover every Folio page across the project's mounts and resolve it to a /// [`FolioRoute`]. Returns an empty vector when the project doesn't use Folio. pub fn discover_folio_routes(root: &Path) -> Vec { @@ -235,6 +379,9 @@ pub fn discover_folio_routes(root: &Path) -> Vec { } for entry in WalkDir::new(&mount.directory) .max_depth(12) + // Don't traverse symlinks: a link out of the mount would otherwise + // let the walk read `.blade.php` files outside the opened project. + .follow_links(false) .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.path().is_file()) diff --git a/laravel-lsp/src/folio_discovery/tests.rs b/laravel-lsp/src/folio_discovery/tests.rs index a109a9b9..ba67f429 100644 --- a/laravel-lsp/src/folio_discovery/tests.rs +++ b/laravel-lsp/src/folio_discovery/tests.rs @@ -92,13 +92,88 @@ fn parse_folio_mounts_default_when_none() { #[test] fn parse_folio_mounts_custom_path() { + // Both the `resource_path(...)` helper form (the `folio:install` scaffold + // default) and a bare string literal resolve — neither is dropped. let src = "uri(...)` / `->name(...)` links that follow. + let src = "uri('/admin')->name('admin.');"; + let mounts = parse_folio_mounts(src, Path::new("/app")); assert_eq!(mounts.len(), 1); assert_eq!(mounts[0].directory, Path::new("/app/resources/views/admin")); - assert_eq!(mounts[0].uri_prefix, ""); - assert_eq!(mounts[0].name_prefix, ""); + assert_eq!(mounts[0].uri_prefix, "admin"); + assert_eq!(mounts[0].name_prefix, "admin."); +} + +#[test] +fn parse_folio_mounts_rejects_traversal_paths() { + // A mount that escapes the project root is a path-traversal vector: walking + // it would read `.blade.php` files outside the opened project. Both the + // absolute and the `..`-escaping forms must be rejected (skipped). + assert!(parse_folio_mounts("")], + ); + let mut index = RouteIndex::new(); + inject_folio_routes(dir.path(), &mut index); + + let def = index.get("users.show").expect("named folio route injected"); + assert!(def.file.ends_with("users/[id].blade.php")); + // Declaration anchors at the top of the page. + assert_eq!(def.line, 0); + assert_eq!(def.column, 0); +} + +#[test] +fn folio_name_at_resolves_cursor_on_the_name_call() { + let dir = make_project( + DEFAULT_FOLIO_MOUNT, + &[("about.blade.php", "")], + ); + let page = dir.path().join(DEFAULT_FOLIO_MOUNT).join("about.blade.php"); + + // `` — the `'about'` literal (with quotes) spans + // columns 11..=18 on line 0. A cursor inside it resolves to the name. + assert_eq!( + folio_name_at(dir.path(), &page, 0, 12), + Some("about".to_string()) + ); + // A cursor away from the `name(...)` call (column 0) resolves to nothing. + assert_eq!(folio_name_at(dir.path(), &page, 0, 0), None); +} + +#[test] +fn folio_name_at_applies_mount_name_prefix() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + fs::create_dir_all(root.join("app/Providers")).unwrap(); + fs::write( + root.join("app/Providers/FolioServiceProvider.php"), + "name('site.');", + ) + .unwrap(); + let page = root.join("resources/views/folio/contact.blade.php"); + fs::create_dir_all(page.parent().unwrap()).unwrap(); + fs::write(&page, "").unwrap(); + + // The cursor sits on `'contact'`; the resolved name carries the mount's + // `->name('site.')` prefix, matching the route-index key. + assert_eq!( + folio_name_at(root, &page, 0, 13), + Some("site.contact".to_string()) + ); +} + +#[test] +fn folio_name_at_none_for_unnamed_page() { + let dir = make_project( + DEFAULT_FOLIO_MOUNT, + &[("plain.blade.php", "
no name here
")], + ); + let page = dir.path().join(DEFAULT_FOLIO_MOUNT).join("plain.blade.php"); + assert_eq!(folio_name_at(dir.path(), &page, 0, 0), None); +} diff --git a/laravel-lsp/src/main.rs b/laravel-lsp/src/main.rs index 41ac45da..e0b5a81e 100644 --- a/laravel-lsp/src/main.rs +++ b/laravel-lsp/src/main.rs @@ -17864,6 +17864,21 @@ async fn classify_with_decl_fallback( // aren't tagged by php.scm. Use the mtime-cached decl walker so // subsequent invocations don't re-parse the file. if !is_in_routes_dir(file_path) { + // A Folio page's `name('...')` helper declares its route name, but the + // page lives outside `routes/` and the bare helper isn't tagged by + // php.scm. If the cursor sits on that call, resolve it to the page's + // (mount-prefixed) route name so find-references / rename reach every + // `route('...')` usage of the page. + if let Some(root) = root { + if let Some(name) = laravel_lsp::folio_discovery::folio_name_at( + root, + file_path, + position.line, + position.character, + ) { + return Some(laravel_lsp::references::SymbolRef::Route(name)); + } + } return None; } let decls = server.cached_route_decls(file_path).await?; @@ -18086,6 +18101,43 @@ async fn collect_declaration_locations( } } } + + // Folio pages are filesystem-derived routes injected into the index + // with their backing `.blade.php` file as the declaration site (top + // of file). They live outside `routes/`, so the walk above never + // sees them — surface the page itself. A conventional route of the + // same name shadows the Folio page in the index (keep-first + // insertion), so this only fires for genuinely Folio-owned names; no + // double-counting with the `routes/` walk above. + let folio_decl = { + let guard = server.route_index.read().await; + guard + .as_ref() + .and_then(|idx| idx.get(name)) + .filter(|def| { + def.file + .to_str() + .is_some_and(|s| s.ends_with(".blade.php")) + }) + .map(|def| (def.file.clone(), def.line, def.column, def.end_column)) + }; + if let Some((file, line, column, end_column)) = folio_decl { + if let Ok(uri) = Url::from_file_path(&file) { + out.push(Location { + uri, + range: Range { + start: Position { + line, + character: column, + }, + end: Position { + line, + character: end_column, + }, + }, + }); + } + } } SymbolRef::Config(key) => { if let Some(pos) = laravel_lsp::config_key_locator::locate_key(root, key) { From d33295a9696ccf3e43539af73b12631109818675 Mon Sep 17 00:00:00 2001 From: Mike Bronner Date: Sat, 13 Jun 2026 17:22:22 -0700 Subject: [PATCH 4/5] =?UTF-8?q?style:=20=F0=9F=8E=A8=20Apply=20rustfmt=20t?= =?UTF-8?q?o=20Folio=20review=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo fmt` over the `laravel-lsp` crate — wrap a long `assert_eq!` and collapse a `.filter(...)` closure / `parse_folio_mounts(...)` call to the canonical layout. No behaviour change; satisfies the `cargo fmt --check` CI gate. --- laravel-lsp/src/folio_discovery/tests.rs | 10 +++++----- laravel-lsp/src/main.rs | 6 +----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/laravel-lsp/src/folio_discovery/tests.rs b/laravel-lsp/src/folio_discovery/tests.rs index ba67f429..93deecb3 100644 --- a/laravel-lsp/src/folio_discovery/tests.rs +++ b/laravel-lsp/src/folio_discovery/tests.rs @@ -113,12 +113,12 @@ fn parse_folio_mounts_resolves_path_helpers() { Path::new("/app"), ); assert_eq!(resource.len(), 1); - assert_eq!(resource[0].directory, Path::new("/app/resources/views/pages")); - - let base = parse_folio_mounts( - " Date: Sat, 13 Jun 2026 19:15:32 -0700 Subject: [PATCH 5/5] =?UTF-8?q?perf:=20=E2=9A=A1=EF=B8=8F=20Resolve=20Foli?= =?UTF-8?q?o=20find-references=20from=20the=20in-memory=20route=20index.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The find-references / rename fallback resolved a Folio page's route name by calling discover_folio_routes on every request — a full synchronous walkdir of the blade mount with a read per page, run uncached on a Tokio worker thread. That blocked the worker for the whole walk, stalling concurrent async tasks, and contradicted the repo's own convention (rebuild_route_index walks inside spawn_blocking; cached_route_decls uses an awaited cache). Read the page→name mapping from the already-built in-memory route index instead — the same source add_declaration_locations' Folio branch reads, built once inside spawn_blocking — and read the cursor file via the editor buffer or tokio::fs. The walk no longer runs on the request path. Containment is now structural: the page text is read only after the index match succeeds, and the index holds only mounts that stayed under the project root. Split the resolution into pure, unit-tested helpers (folio_name_for_file, cursor_on_page_name) and add a resource_path('../…') traversal-rejection case. Refs: #57 --- laravel-lsp/src/folio_discovery.rs | 52 ++++++++++------ laravel-lsp/src/folio_discovery/tests.rs | 75 ++++++++++++++++-------- laravel-lsp/src/main.rs | 52 ++++++++++++---- 3 files changed, 127 insertions(+), 52 deletions(-) diff --git a/laravel-lsp/src/folio_discovery.rs b/laravel-lsp/src/folio_discovery.rs index 2a116e8c..dedf4ec4 100644 --- a/laravel-lsp/src/folio_discovery.rs +++ b/laravel-lsp/src/folio_discovery.rs @@ -329,26 +329,44 @@ pub fn extract_page_name(content: &str) -> Option { .filter(|s| !s.is_empty()) } -/// The fully-qualified route name a Folio page declares, IF the cursor at -/// (`line`, `character`) sits on the page's `name('...')` helper call. +/// The fully-qualified route name a `.blade.php` Folio page owns in `index`, if +/// any. A reverse lookup of the in-memory route index that +/// [`inject_folio_routes`] populated during the route-index build: it finds the +/// entry whose definition file is this page. Folio pages are the only +/// `.blade.php`-backed route definitions, so the suffix filter isolates them +/// from a same-named conventional route that shadows the page (keep-first +/// insertion keeps the conventional entry, whose file is a `routes/*.php`). +/// +/// Find-references / rename use this to resolve a page's route name straight +/// from the already-built index instead of re-walking the project filesystem on +/// every request — the walk happened once, inside `spawn_blocking`, when the +/// index was built ([`crate::route_discovery::build_route_index`]). The returned +/// name carries the mount's `->name(...)` prefix, since that's the key the index +/// is stored under, so it reaches every `route('...')` usage of the page. +pub fn folio_name_for_file(index: &RouteIndex, file: &Path) -> Option { + let normalized = normalize_path(file); + index.routes.iter().find_map(|(name, def)| { + (def.file.to_str().is_some_and(|s| s.ends_with(".blade.php")) + && normalize_path(&def.file) == normalized) + .then(|| name.clone()) + }) +} + +/// Whether the cursor at (`line`, `character`) sits on a Folio page's own +/// `name('...')` helper call (the argument string, quotes included). /// /// Powers find-references and rename triggered from *inside* a Folio page: the /// page lives outside `routes/` and its bare `name(...)` helper isn't tagged by -/// the PHP parser, so without this the request resolves to nothing. The -/// returned name carries the mount's `->name(...)` prefix, matching what the -/// route index is keyed by — so it reaches every `route('...')` usage. Returns -/// `None` when the file isn't a named Folio page or the cursor isn't on its -/// `name(...)` call. -pub fn folio_name_at(root: &Path, file: &Path, line: u32, character: u32) -> Option { - let normalized = normalize_path(file); - let name = discover_folio_routes(root) - .into_iter() - .find(|r| normalize_path(&r.file) == normalized) - .and_then(|r| r.name)?; - - let content = std::fs::read_to_string(file).ok()?; - let (name_line, start_col, end_col) = page_name_span(&content)?; - (line == name_line && character >= start_col && character <= end_col).then_some(name) +/// the PHP parser, so the caller pairs this with [`folio_name_for_file`] to +/// recover the route name. Pure over `content` — no filesystem access — so the +/// caller decides how to source the page text (editor buffer or disk). +pub fn cursor_on_page_name(content: &str, line: u32, character: u32) -> bool { + match page_name_span(content) { + Some((name_line, start_col, end_col)) => { + line == name_line && character >= start_col && character <= end_col + } + None => false, + } } /// Source span of a Folio page's `name('...')` argument string, including the diff --git a/laravel-lsp/src/folio_discovery/tests.rs b/laravel-lsp/src/folio_discovery/tests.rs index 93deecb3..b098efe1 100644 --- a/laravel-lsp/src/folio_discovery/tests.rs +++ b/laravel-lsp/src/folio_discovery/tests.rs @@ -151,6 +151,13 @@ fn parse_folio_mounts_rejects_traversal_paths() { Path::new("/app") ) .is_empty()); + // The `resource_path(...)` helper form is normalized + containment-checked + // too, so a `..` escape through it is rejected — not just `base_path`. + assert!(parse_folio_mounts( + "")], - ); - let page = dir.path().join(DEFAULT_FOLIO_MOUNT).join("about.blade.php"); - +fn cursor_on_page_name_true_inside_the_call_false_outside() { // `` — the `'about'` literal (with quotes) spans - // columns 11..=18 on line 0. A cursor inside it resolves to the name. - assert_eq!( - folio_name_at(dir.path(), &page, 0, 12), - Some("about".to_string()) - ); - // A cursor away from the `name(...)` call (column 0) resolves to nothing. - assert_eq!(folio_name_at(dir.path(), &page, 0, 0), None); + // columns 11..=18 on line 0. A cursor inside it is "on the call". + let content = ""; + assert!(cursor_on_page_name(content, 0, 12)); + // A cursor away from the `name(...)` call (column 0) is not. + assert!(!cursor_on_page_name(content, 0, 0)); +} + +#[test] +fn cursor_on_page_name_false_for_unnamed_page() { + // A page with no `name('...')` helper has no span to land on. + assert!(!cursor_on_page_name("
no name here
", 0, 0)); } #[test] -fn folio_name_at_applies_mount_name_prefix() { +fn folio_name_for_file_resolves_from_the_index() { + // Build the in-memory index the same way the route-index build does, then + // recover a page's (mount-prefixed) route name straight from it — no walk. let dir = TempDir::new().unwrap(); let root = dir.path(); fs::create_dir_all(root.join("app/Providers")).unwrap(); @@ -423,20 +430,40 @@ fn folio_name_at_applies_mount_name_prefix() { fs::create_dir_all(page.parent().unwrap()).unwrap(); fs::write(&page, "").unwrap(); - // The cursor sits on `'contact'`; the resolved name carries the mount's - // `->name('site.')` prefix, matching the route-index key. + let mut index = RouteIndex::new(); + inject_folio_routes(root, &mut index); + + // The resolved name carries the mount's `->name('site.')` prefix. assert_eq!( - folio_name_at(root, &page, 0, 13), + folio_name_for_file(&index, &page), Some("site.contact".to_string()) ); + // A file with no entry in the index resolves to nothing. + assert_eq!( + folio_name_for_file(&index, &root.join("resources/views/folio/other.blade.php")), + None + ); } #[test] -fn folio_name_at_none_for_unnamed_page() { - let dir = make_project( - DEFAULT_FOLIO_MOUNT, - &[("plain.blade.php", "
no name here
")], +fn folio_name_for_file_ignores_conventional_non_blade_definition() { + // The reverse lookup only returns `.blade.php`-backed definitions, so a + // conventional route (a `routes/*.php` file) is never mistaken for a Folio + // page — even when it shadows a same-named page in the index. + let mut index = RouteIndex::new(); + let conventional = PathBuf::from("/app/routes/web.php"); + index.insert( + "dashboard".to_string(), + RouteDefinition { + file: conventional.clone(), + line: 4, + column: 0, + end_column: 10, + priority: PRIORITY_APP, + method: Some("get".to_string()), + uri: Some("/dashboard".to_string()), + action: None, + }, ); - let page = dir.path().join(DEFAULT_FOLIO_MOUNT).join("plain.blade.php"); - assert_eq!(folio_name_at(dir.path(), &page, 0, 0), None); + assert_eq!(folio_name_for_file(&index, &conventional), None); } diff --git a/laravel-lsp/src/main.rs b/laravel-lsp/src/main.rs index 8da506ec..dd0cd025 100644 --- a/laravel-lsp/src/main.rs +++ b/laravel-lsp/src/main.rs @@ -4781,6 +4781,39 @@ impl LaravelLanguageServer { Some(decls) } + /// The fully-qualified Folio route name a `.blade.php` page declares, IF the + /// cursor at `position` sits on the page's own `name('...')` helper call. + /// + /// Resolves the name from the already-built in-memory route index — the same + /// source `add_declaration_locations`' Folio branch reads — rather than + /// re-walking the project filesystem, and reads the cursor file from the + /// editor buffer or disk asynchronously. So this find-references / rename hot + /// path never blocks a Tokio worker thread on a synchronous project walk + /// (the walk happens at most once, inside `spawn_blocking`, when the index is + /// built). Containment is structural: the file text is read only after the + /// index match succeeds, and the index holds only mounts that stayed under + /// the project root, so an out-of-tree page is never read here. Returns + /// `None` when the file isn't a named Folio page or the cursor isn't on its + /// `name(...)` call. + async fn folio_route_name_for_cursor( + &self, + file_path: &Path, + position: Position, + ) -> Option { + let name = { + let guard = self.route_index.read().await; + laravel_lsp::folio_discovery::folio_name_for_file(guard.as_ref()?, file_path)? + }; + let uri = Url::from_file_path(file_path).ok()?; + let content = self.document_or_disk_content(&uri, file_path).await?; + laravel_lsp::folio_discovery::cursor_on_page_name( + &content, + position.line, + position.character, + ) + .then_some(name) + } + /// Return the current content of `uri` — the editor buffer if the file is /// open (so unsaved edits are reflected), otherwise the on-disk text. /// Returns `None` only when neither source is readable. @@ -17867,17 +17900,14 @@ async fn classify_with_decl_fallback( // A Folio page's `name('...')` helper declares its route name, but the // page lives outside `routes/` and the bare helper isn't tagged by // php.scm. If the cursor sits on that call, resolve it to the page's - // (mount-prefixed) route name so find-references / rename reach every - // `route('...')` usage of the page. - if let Some(root) = root { - if let Some(name) = laravel_lsp::folio_discovery::folio_name_at( - root, - file_path, - position.line, - position.character, - ) { - return Some(laravel_lsp::references::SymbolRef::Route(name)); - } + // (mount-prefixed) route name — read from the already-built in-memory + // route index, never a fresh filesystem walk — so find-references / + // rename reach every `route('...')` usage of the page. + if let Some(name) = server + .folio_route_name_for_cursor(file_path, position) + .await + { + return Some(laravel_lsp::references::SymbolRef::Route(name)); } return None; }