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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **A raw Blade echo is read from its own opening brace.** `{!! $html !!}` was only recognised when written as `{{!! $html !!}}`, a spelling Blade does not use, so the expression inside a real raw echo was masked as HTML: a variable declared in a `<?php ?>` block and displayed through `{!! … !!}` was reported unused, and completion, hover, and go-to-definition inside the echo answered nothing. The raw echo now compiles the way Blade compiles it, to a plain `echo` with no `e()` escape around it, and each echo form only closes at its own terminator, so `!!}` no longer ends an escaped `{{ … }}` early. Closes #370.
- **An echo opener with no terminator no longer swallows the rest of the template.** A `{{` with no `}}` anywhere after it, or a `{!!` with no `!!}` — `<script>if (a) {!!b}</script>` was enough — put the rest of the file into PHP mode: every later line was read as code instead of markup and the whole template stopped parsing. Such an opener is now closed at the end of its own line, so at most that line degrades and everything after it works as usual. An echo that is still being typed, or that spans lines with its terminator further down, stays open exactly as before, so completion inside a half-written echo keeps working.
- **A non-Laravel project's own `config()`, `route()`, `view()`, `__()`, or `trans()` function no longer hovers, navigates, or renames as a Laravel string key.** Completion and diagnostics already stood down on a project with no Laravel dependency, but hover, go-to-definition, find-references, and rename did not, so a home-grown micro-framework (or WordPress's `__()`/`_e()` gettext helpers) that happened to declare one of those names got a fabricated "Route name" or "Config key" tooltip, a working-looking jump to a `config/*.php` file that has nothing to do with the call, and a rename that rewrote both. All four now stand down the same way completion and diagnostics do.
- **A workspace opened through a symlink or a path alias behaves the same as one opened directly.** macOS reaches the same directory under both `/var` and `/private/var`, and any workspace opened through a symlink has two spellings of every path inside it. The vendor directory was recorded under one spelling while the walkers compared the other, so `analyze` read `vendor/` as project code and reported errors from third-party packages, and find-references and rename searched it too. Blade had the mirror of the same problem: no template matched any view root, so the variables a template's `view()` callers pass were not typed inside it and hover and completion there answered nothing. Both spellings are now recorded, and a template is matched against its view root under either one. A `composer install` run while the editor is already open registers the new `vendor/` as well, instead of waiting for a restart. Contributed by @shuvroroy.
- **Editing a service provider takes effect immediately.** What a Laravel service provider registers was read once, when the project was first indexed, and never again. A container binding written afterwards did not resolve, hover, or navigate until the editor was restarted, and the same went for the view directories, translation directories, route files, config files, and Blade component namespaces a provider registers. Saving or editing a provider now re-reads it, and adding one to `bootstrap/providers.php` (or `config/app.php`) picks it up as well. A key that two providers bind still ends up with whichever of them the container itself would let win.
- **A request accessor written with named arguments keeps its key.** `$request->file(key: 'photos')` and `$request->header(default: 'x')` read the named argument as whichever positional slot it happened to land in, so a keyed `file()` call resolved as though it named no field at all and a default-only `header()` call resolved as though its default text were the key. `header()`, `query()`, `cookie()`, `input()`, `post()`, and `file()` now bind a named argument to the parameter it actually names, including on an app's own `FormRequest` subclass, which never redeclares the accessor itself.
- **Blade partial variables stay typed during CLI analysis.** `phpantom_lsp analyze` now discovers view and include callers while running without the editor's reference index, and direct variables passed in template data retain nearby `@var` overrides. Contributed by @shuvroroy (#337).
Expand Down
42 changes: 41 additions & 1 deletion src/analyse/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,16 @@ pub(crate) fn discover_user_files(
source_dirs.sort();
source_dirs.dedup();

let vendor_dirs: Vec<PathBuf> = backend.workspace.vendor_dir_paths.lock().clone();
// The walker compares canonical entry paths below. Canonicalize the
// registered roots once as well so path aliases such as macOS's `/var`
// -> `/private/var` do not let vendor files through.
let vendor_dirs = backend.workspace.vendor_dir_paths.lock().clone();
let mut vendor_dirs: Vec<PathBuf> = vendor_dirs
.into_iter()
.map(|path| path.canonicalize().unwrap_or(path))
.collect();
vendor_dirs.sort_unstable();
vendor_dirs.dedup();

// When an explicit path filter points outside all PSR-4 source
// directories (e.g. into vendor/), walk the filter path directly
Expand Down Expand Up @@ -915,6 +924,37 @@ mod tests {
);
}

#[cfg(unix)]
#[test]
fn discover_user_files_normalizes_aliased_vendor_roots() {
use std::os::unix::fs::symlink;

let dir = tempfile::tempdir().expect("failed to create temp dir");
let real_root = dir.path().join("real-project");
let linked_root = dir.path().join("linked-project");
std::fs::create_dir_all(real_root.join("app")).unwrap();
std::fs::create_dir_all(real_root.join("vendor/pkg")).unwrap();
std::fs::write(real_root.join("app/Main.php"), "<?php\n").unwrap();
std::fs::write(real_root.join("vendor/pkg/Dep.php"), "<?php\n").unwrap();
symlink(&real_root, &linked_root).expect("failed to create workspace alias");

let backend = Backend::new_headless();
backend
.workspace
.vendor_dir_paths
.lock()
.push(linked_root.join("vendor"));

let files = discover_user_files(&backend, &real_root, None);
assert!(files.contains(&real_root.join("app/Main.php")), "{files:?}");
assert!(
!files
.iter()
.any(|path| path.starts_with(real_root.join("vendor"))),
"vendor files must be skipped across path aliases: {files:?}"
);
}

/// A single-file path filter returns exactly that file even when
/// the project has no PSR-4 mappings.
#[test]
Expand Down
120 changes: 109 additions & 11 deletions src/blade/call_site_inference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,26 @@ fn join_call_site_types(types: Vec<PhpType>) -> PhpType {
}
}

/// The canonical spelling of a template path, for comparing against a
/// canonical view root.
///
/// A template that has just been deleted has no canonical form of its
/// own, so its directory is canonicalized instead: the file still has to
/// resolve to the view name it had, or the callers that render it are
/// left holding a name nothing answers to.
fn canonical_path_for_comparison(path: &std::path::Path) -> std::path::PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
match (
path.parent().and_then(|parent| parent.canonicalize().ok()),
path.file_name(),
) {
(Some(parent), Some(name)) => parent.join(name),
_ => path.to_path_buf(),
}
}

impl Backend {
/// Compute the variables to inject into a Blade template's virtual
/// PHP: the members of the class backing a component view (see
Expand Down Expand Up @@ -907,21 +927,44 @@ impl Backend {
}
};

// `path` came from a file URI and is absolute; a view root can
// be relative when the workspace root was given relative (the
// analyse CLI passes `--project-root` through as-is), so
// canonicalize each root before comparing.
for root in self.laravel_view_roots() {
let root = root.canonicalize().unwrap_or(root);
// Each root is tried against the raw spelling first, so the common
// case costs no filesystem calls, and only falls back to canonical
// spellings when the raw ones do not line up. Canonicalizing the
// template instead of trying it raw would lose one that is itself
// a symlink into a shared directory, since that resolves out of
// the view root it sits under.
let canonical = std::cell::OnceCell::new();
let mut match_root = |root: &std::path::Path, namespace: &str| {
if let Ok(rel) = path.strip_prefix(root) {
push_name(rel, namespace);
return;
}
// A view root can be relative when the workspace root was
// given relative (the analyse CLI passes `--project-root`
// through as-is), while `path` came from a file URI and is
// always absolute.
let Ok(root) = root.canonicalize() else {
return;
};
if let Ok(rel) = path.strip_prefix(&root) {
push_name(rel, "");
push_name(rel, namespace);
return;
}
// The workspace itself can be reached under an alias: macOS
// exposes the same directory through both `/var` and
// `/private/var`, so a canonical root and a raw template path
// describe the same tree under two names.
let canonical = canonical.get_or_init(|| canonical_path_for_comparison(&path));
if let Ok(rel) = canonical.strip_prefix(&root) {
push_name(rel, namespace);
}
};

for root in self.laravel_view_roots() {
match_root(&root, "");
}
for res in &self.laravel_provider_resources.read().view_dirs {
let res_path = res.path.canonicalize().unwrap_or_else(|_| res.path.clone());
if let Ok(rel) = path.strip_prefix(&res_path) {
push_name(rel, &res.namespace);
}
match_root(&res.path, &res.namespace);
}
names
}
Expand Down Expand Up @@ -2070,6 +2113,9 @@ mod tests {
use crate::php_type::PhpType;
use tower_lsp::lsp_types::Url;

#[cfg(unix)]
use std::os::unix::fs::symlink;

/// The joined union's member order must not depend on the order the
/// call sites were visited in, since that order comes from a
/// `HashMap` snapshot and varies across runs.
Expand Down Expand Up @@ -2159,4 +2205,56 @@ mod tests {
"non-candidate caller must be skipped: {scope:?}"
);
}

#[cfg(unix)]
#[test]
fn view_name_resolution_normalizes_aliased_file_paths() {
let dir = tempfile::tempdir().expect("failed to create test workspace");
let real_root = dir.path().join("real-project");
let linked_root = dir.path().join("linked-project");
let views = real_root.join("resources/views");
std::fs::create_dir_all(&views).expect("failed to create view directory");
symlink(&real_root, &linked_root).expect("failed to create workspace alias");

let real_template = views.join("shop.blade.php");
std::fs::write(&real_template, "").expect("failed to write view");
let linked_template = linked_root.join("resources/views/shop.blade.php");
let linked_uri =
Url::from_file_path(&linked_template).expect("view path should become a file URI");
let backend = Backend::new_test_with_workspace(linked_root, Vec::new());

assert_eq!(
backend.view_names_for_blade_uri(linked_uri.as_str()),
vec!["shop"]
);

std::fs::remove_file(real_template).expect("failed to remove view");
assert_eq!(
backend.view_names_for_blade_uri(linked_uri.as_str()),
vec!["shop"]
);
}

/// A template that is itself a symlink into a shared directory is
/// still addressable by the name it has inside the view root.
#[cfg(unix)]
#[test]
fn view_name_resolution_keeps_symlinked_templates() {
let dir = tempfile::tempdir().expect("failed to create test workspace");
let root = dir.path().join("project");
let views = root.join("resources/views");
let shared = dir.path().join("shared");
std::fs::create_dir_all(&views).expect("failed to create view directory");
std::fs::create_dir_all(&shared).expect("failed to create shared directory");

let shared_template = shared.join("shop.blade.php");
std::fs::write(&shared_template, "").expect("failed to write view");
let template = views.join("shop.blade.php");
symlink(&shared_template, &template).expect("failed to link view into the root");

let uri = Url::from_file_path(&template).expect("view path should become a file URI");
let backend = Backend::new_test_with_workspace(root, Vec::new());

assert_eq!(backend.view_names_for_blade_uri(uri.as_str()), vec!["shop"]);
}
}
65 changes: 63 additions & 2 deletions src/indexing/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,22 @@ impl Backend {
/// Register a vendor directory path and its URI prefix for
/// vendor-file detection.
pub(crate) fn add_vendor_dir(&self, vendor_path: &std::path::Path) {
// Store the absolute path for filesystem-level skip logic.
// Keep both filesystem spellings. Walkers normally yield the raw
// workspace spelling, while Composer package discovery canonicalizes
// its files; on macOS those can be `/var` and `/private/var` for the
// same vendor tree. Caching both here keeps the hot lookup paths free
// of filesystem calls.
{
let mut paths = self.workspace.vendor_dir_paths.lock();
paths.push(vendor_path.to_path_buf());
let mut insert = |path: PathBuf| {
if !paths.contains(&path) {
paths.push(path);
}
};
insert(vendor_path.to_path_buf());
if let Ok(canonical) = vendor_path.canonicalize() {
insert(canonical);
}
}
// Store URI prefixes for URI-level skip logic (diagnostics, find
// references, rename). Keep both raw and canonical forms so macOS
Expand Down Expand Up @@ -81,6 +93,10 @@ impl Backend {
*self.workspace.psr4_mappings.write() = mappings;

let vendor_path = root.join(&vendor_dir);
// `vendor/` may not have existed during initialization (a fresh
// clone before `composer install`). Register it again now so the
// shared vendor filters cache both raw and canonical spellings.
self.add_vendor_dir(&vendor_path);

// Rebuild vendor classmap, tracking dependency provenance so
// completion ranking stays accurate after a composer change.
Expand Down Expand Up @@ -629,3 +645,48 @@ impl Backend {
}
}
}

#[cfg(all(test, unix))]
mod tests {
use super::*;

#[test]
fn vendor_registration_caches_raw_and_canonical_path_spellings() {
use std::os::unix::fs::symlink;

let dir = tempfile::tempdir().expect("tempdir");
let canonical_vendor = dir.path().join("packages");
std::fs::create_dir(&canonical_vendor).expect("create package directory");
let aliased_vendor = dir.path().join("vendor");
symlink(&canonical_vendor, &aliased_vendor).expect("create vendor alias");

let backend = Backend::new_test();
backend.add_vendor_dir(&aliased_vendor);
backend.add_vendor_dir(&aliased_vendor);

let paths = backend.workspace.vendor_dir_paths.lock();
assert_eq!(paths.len(), 2, "repeat registration must stay deduplicated");
assert!(paths.contains(&aliased_vendor));
assert!(paths.contains(&canonical_vendor.canonicalize().unwrap()));
}

#[test]
fn composer_rescan_registers_a_vendor_directory_created_after_startup() {
use std::os::unix::fs::symlink;

let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("composer.json"), "{}").expect("write composer.json");
let canonical_vendor = dir.path().join("packages");
std::fs::create_dir(&canonical_vendor).expect("create package directory");
let aliased_vendor = dir.path().join("vendor");
symlink(&canonical_vendor, &aliased_vendor).expect("create vendor alias");

let backend = Backend::new_test();
assert!(backend.workspace.vendor_dir_paths.lock().is_empty());
backend.rescan_composer_indexes(dir.path());

let paths = backend.workspace.vendor_dir_paths.lock();
assert!(paths.contains(&aliased_vendor));
assert!(paths.contains(&canonical_vendor.canonicalize().unwrap()));
}
}
2 changes: 1 addition & 1 deletion src/workspace_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub(crate) struct WorkspaceEnv {
pub(crate) psr4_mappings: Arc<RwLock<Vec<composer::Psr4Mapping>>>,
/// `file://` URI prefixes for all known vendor directories.
pub(crate) vendor_uri_prefixes: Mutex<Vec<String>>,
/// Absolute paths of all known vendor directories.
/// Absolute raw and canonical paths of all known vendor directories.
pub(crate) vendor_dir_paths: Mutex<Vec<PathBuf>>,
/// Canonical vendor package roots paired with completion provenance.
pub(crate) vendor_package_origin_roots:
Expand Down
Loading