Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions laravel-lsp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18241,10 +18241,25 @@ async fn decl_range_at(
/// into the decl walk and never reach its real resolver. When the project root
/// is unknown (`None`), default to `false` so the walk never triggers without a
/// root to anchor against.
///
/// Both `path` and the joined `routes/` dir are canonicalized before the
/// component-wise prefix check (issue #122), mirroring the sibling helper
/// `path_within_root`. `root` is stored once (from an earlier `did_open`) while
/// `path` arrives per-request, so the two can resolve through different symlink
/// states — e.g. macOS `/tmp` → `/private/tmp`. A raw textual `starts_with`
/// would then return a false negative and silently skip the decl-fallback walk
/// for a real conventional route file. When either side can't be canonicalized
/// (the file doesn't exist yet, a permission error) we fall back to the textual
/// prefix check rather than guess — no panic, and the same behaviour as before
/// for in-memory paths that aren't on disk.
fn is_in_routes_dir(root: Option<&Path>, path: &Path) -> bool {
match root {
Some(r) => path.starts_with(r.join("routes")),
None => false,
let Some(r) = root else {
return false;
};
let routes_dir = r.join("routes");
match (path.canonicalize(), routes_dir.canonicalize()) {
(Ok(real_path), Ok(real_routes)) => real_path.starts_with(&real_routes),
_ => path.starts_with(&routes_dir),
}
}

Expand Down
65 changes: 65 additions & 0 deletions laravel-lsp/src/tests/routes_dir_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,68 @@ fn rejects_when_root_unknown() {
// No project root to anchor against → never trigger the fallback walk.
assert!(!is_in_routes_dir(None, Path::new("/any/routes/file.php")));
}

#[cfg(unix)]
#[test]
fn matches_through_symlinked_root() {
// `root` and `path` can resolve through different symlink states (issue
// #122) — e.g. a stored root reached via a symlink vs. a per-request file
// addressed by its real path. A raw textual `starts_with` would return a
// false negative; canonicalizing both sides makes the gate survive it.
use std::os::unix::fs::symlink;

let tmp = tempfile::tempdir().unwrap();
// Real project with a conventional routes/ dir and a route file on disk.
let real_root = tmp.path().join("real_project");
std::fs::create_dir_all(real_root.join("routes")).unwrap();
let route_file = real_root.join("routes").join("web.php");
std::fs::write(&route_file, "<?php\n").unwrap();

// A symlink standing in for the stored project root.
let link_root = tmp.path().join("link_project");
symlink(&real_root, &link_root).unwrap();

// Sanity: the raw textual prefix check fails here — `link_project/routes`
// is not a textual prefix of `real_project/routes/web.php`. Canonicalizing
// both is what makes the gate return true.
assert!(!route_file.starts_with(link_root.join("routes")));
assert!(is_in_routes_dir(Some(&link_root), &route_file));
}

#[test]
fn rejects_real_path_outside_routes_dir() {
// Both the file and the routes/ dir exist on disk, so the canonical branch
// runs — a path outside routes/ must still return false in canonical form.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join("routes")).unwrap();
std::fs::create_dir_all(root.join("app")).unwrap();
let file = root.join("app").join("Model.php");
std::fs::write(&file, "<?php\n").unwrap();

assert!(!is_in_routes_dir(Some(&root), &file));
}

#[test]
fn falls_back_to_textual_when_path_missing() {
// A path that doesn't exist can't be canonicalized; the gate must fall back
// to the textual component check rather than panic (issue #122). Create a
// real `routes/` dir on disk so `routes_dir` canonicalizes but the missing
// `path` does not — genuinely driving the `(Err, Ok)` arm. This is the
// realistic case the production doc comment calls out: a brand-new route
// file still in the editor buffer, not yet saved to disk, sitting inside a
// real `routes/` directory.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
std::fs::create_dir_all(root.join("routes")).unwrap();
let missing = root.join("routes").join("does_not_exist.php");

// No panic, and the textual prefix still matches the project routes/ dir.
assert!(is_in_routes_dir(Some(&root), &missing));

// False-negative side: a missing path *outside* routes/ must still return
// false through the same textual fallback (routes_dir canonicalizes, the
// path does not — again the `(Err, Ok)` arm).
let missing_outside = root.join("app").join("does_not_exist.php");
assert!(!is_in_routes_dir(Some(&root), &missing_outside));
}