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
74 changes: 55 additions & 19 deletions laravel-lsp/src/facade_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,6 @@ pub fn resolve_facade_fqcn(
facade_aliases: &HashMap<String, String>,
is_namespaced: bool,
) -> Option<String> {
// Distinguish a root-`\` receiver from a bare one BEFORE `resolve_class_name`
// strips the leading `\` and collapses the two cases. A leading `\` forces
// global resolution regardless of the file's namespace.
let is_root_qualified = receiver.starts_with('\\');

// A receiver that resolves into `Illuminate\Support\Facades\*` IS a facade —
// whether through a `use` import (`use …\Facades\Auth; Auth::…`) or written
// inline fully-qualified (`\Illuminate\Support\Facades\Auth::…`). Return it as
Expand All @@ -220,28 +215,69 @@ pub fn resolve_facade_fqcn(

// Otherwise it's a bare / root-`\` token relying on the global alias. Match
// it (case-insensitively, like the rest of the alias machinery) against our
// facade map — the leading `\` is already stripped by `resolve_class_name`,
// and `via_use` is the unresolved token when no import matched.
let token = via_use.as_str();
// facade map. Reuse the `via_use` we just resolved rather than resolving a
// second time inside `global_alias_token` — the common bare-facade path
// (`Auth::user()` with no `use`) must resolve once (#267).
let token = global_alias_token_resolved(receiver, via_use, is_namespaced)?;
facade_aliases
Comment thread
mikebronner marked this conversation as resolved.
.iter()
.find(|(alias, _)| alias.eq_ignore_ascii_case(&token))
.map(|(_, fqcn)| fqcn.clone())
}

/// The bare global-alias token a receiver would look up in the facade alias map,
/// independent of whether that map currently holds it — `Some("Auth")` for a
/// bare/root-qualified `Auth` / `\Auth`, `None` for a `use`-imported facade, a
/// namespaced class reference, or a bare token in a namespaced file with no
/// import.
///
/// This is the exact gate [`resolve_facade_fqcn`]'s global-alias branch applies
/// *before* consulting the map, factored out so the `alias:<token>` reverse-index
/// attempt key ([`crate::magic_dependency_index::ALIAS_DEP_PREFIX`]) is recorded
/// resolved-or-not against the same tokens — the two must never drift, or an
/// alias retarget would ripple keys no call site recorded (#267).
pub fn global_alias_token(
receiver: &str,
aliases: &UseAliases,
is_namespaced: bool,
) -> Option<String> {
global_alias_token_resolved(
receiver,
resolve_class_name(receiver, aliases),
is_namespaced,
)
}

/// [`global_alias_token`] over an ALREADY-resolved class name — the same gate,
/// factored so `resolve_facade_fqcn`'s common bare-facade path (which has just
/// computed `resolve_class_name`) resolves once rather than twice (#267).
/// `resolved` MUST be `resolve_class_name(receiver, aliases)` for `receiver`;
/// passing any other value silently changes the gate.
fn global_alias_token_resolved(
receiver: &str,
resolved: String,
is_namespaced: bool,
) -> Option<String> {
// A leading `\` forces global resolution regardless of the file's namespace.
let is_root_qualified = receiver.starts_with('\\');
// A receiver that resolves into the facade namespace via a `use` import is a
// direct facade reference, not a global-alias lookup — retargeting the
// `config/app.php` alias can't change it, so it holds no `alias:` attempt.
if resolved.starts_with(FACADE_NAMESPACE) {
return None;
}
// A token that itself contains a namespace separator is a real class
// reference, not a facade alias (`App\Foo::bar()`), so it can't be a
// root-namespace facade alias — bail.
if token.contains('\\') {
// reference, not a facade alias (`App\Foo::bar()`).
if resolved.contains('\\') {
return None;
}

// PHP class-name rule: a bare (non-`\`-qualified) token in a namespaced file
// with no matching `use` import resolves against the current namespace, NOT
// the global alias. Only honor the global alias for a leading-`\` token or a
// file with no namespace declaration.
// the global alias.
if !is_root_qualified && is_namespaced {
return None;
}

facade_aliases
.iter()
.find(|(alias, _)| alias.eq_ignore_ascii_case(token))
.map(|(_, fqcn)| fqcn.clone())
Some(resolved)
}

/// The container binding key a facade proxies — its `getFacadeAccessor()`
Expand Down
24 changes: 24 additions & 0 deletions laravel-lsp/src/magic_dependency_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ use std::path::{Path, PathBuf};
/// no concrete-FQCN dependency the registration diff could otherwise reach.
pub const BINDING_DEP_PREFIX: &str = "binding:";

/// Key-space prefix for a *facade-alias attempt* dependency: a site whose
/// receiver is a bare/root-qualified facade token resolved through the global
/// alias map (`Auth::check()`, `\Cache::get()`) records `alias:<token>` — the
/// alias token lower-cased, resolved or not. The colon keeps the space disjoint
/// from FQCNs, and the analogue to `BINDING_DEP_PREFIX` is deliberate: recording
/// the stable *token* (not the resolved concrete) is what lets an alias
/// **retarget** ripple the OLD target's dependent sites on the very first save of
/// a session, when the registration baseline is still empty and the diff only
/// sees the new target added (#267). The lower-casing mirrors the
/// case-insensitive facade-alias matching in [`crate::facade_resolver`], so both
/// the call site and the registration diff agree on the key regardless of
/// source casing.
pub const ALIAS_DEP_PREFIX: &str = "alias:";

/// Build the `alias:<token>` attempt key for a facade alias `token`, lower-cased
/// to match the case-insensitive facade matching in [`crate::facade_resolver`].
/// Both the call-site recorder ([`crate::member_resolver`]) and the registration
/// diff ([`crate::salsa_impl::registration_ripple_keys`]) MUST build the key
/// through this one function, or the two would drift and an alias retarget would
/// ripple keys no call site recorded (#267).
pub fn alias_dep_key(token: &str) -> String {
format!("{ALIAS_DEP_PREFIX}{}", token.to_ascii_lowercase())
}

#[derive(Default, Debug)]
pub struct MagicDependencyIndex {
/// fqcn → files that resolved a receiver against it.
Expand Down
28 changes: 27 additions & 1 deletion laravel-lsp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7115,7 +7115,10 @@ impl LaravelLanguageServer {
///
/// - **present, non-vendor** files are re-resolved in full
/// (`refresh_file_magic`) — own contribution + per-file changed views —
/// exactly as a save would;
/// AND have their provider-body registrations snapshot/diffed
/// (`file_provider_registrations` → `registration_ripple_keys`) so a
/// body-only registration edit from a branch switch ripples to dependents
/// without a restart (#267), exactly as a save would;
/// - **present, vendor** files get a surfaces-only pass (parse + hierarchy
/// update via `get_patterns`, no own-contribution resolution) so a vendor
/// base-class change still ripples to app subclasses, without eagerly
Expand Down Expand Up @@ -7179,6 +7182,29 @@ impl LaravelLanguageServer {
}

// Present, non-vendor: full per-file refresh, same as a save.
//
// Registration snapshot/diff FIRST, mirroring the save path
// (`refresh_magic_on_save`): a provider-body registration edit that
// arrives here — a `git checkout` of a non-open provider, say —
// leaves the class-surface diff below empty just as an interactive
// edit does, so without this a body-only external edit never ripples
// to dependents until a restart or manual save (#267). `Some(content)`
// re-registers the fresh source (provider or config/app.php input)
// before `after` is read and advances the baseline; `before` is the
// actor-kept baseline. Taken before the per-file refresh so that pass
// already sees the fresh registries. Cheap for the non-provider
// majority: an untracked path yields empty defaults.
let (old_registrations, new_registrations) = self
.salsa
.file_provider_registrations(path.clone(), Some(content.clone()))
.await
.unwrap_or_default();
changed_classes.extend(registration_ripple_keys(
&old_registrations,
&new_registrations,
&path,
));

let views = self.refresh_file_magic(&path, &content).await;
let new_surfaces = self
.salsa
Expand Down
46 changes: 46 additions & 0 deletions laravel-lsp/src/member_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,17 @@ pub fn resolve_and_classify(
}
}

// A facade-alias receiver records its `alias:<token>` attempt dependency —
// resolved-or-not, the exact analogue of the container branch above — so an
// alias RETARGET ripples the OLD target's sites on the first save of a
// session, when the empty registration baseline makes the diff see only the
// new target added (#267). Mirrored in [`resolve_recipe_and_classify`].
if let Some(d) = deps.as_deref_mut() {
if let Some(key) = facade_alias_attempt_key(receiver, bytes, aliases) {
d.insert(key);
}
}
Comment thread
mikebronner marked this conversation as resolved.

// Facade interception, checked first for a static-call name receiver —
// `Auth::check()` — so the "resolved via facade" signal threads cleanly into
// classification. A `Some` here means the concrete came through the facade
Expand Down Expand Up @@ -2454,6 +2465,25 @@ fn container_attempt_key(receiver: Node, bytes: &[u8]) -> Option<String> {
})
}

/// The `alias:<token>` reverse-index attempt key for a facade receiver that
/// resolves through the global alias map (`Auth::check()`, `\Cache::get()`), or
/// `None` for any other receiver shape. The token comes from
/// [`crate::facade_resolver::global_alias_token`] — the exact gate
/// `resolve_facade_fqcn` applies — so the key is recorded against the same tokens
/// the registration diff emits, and resolved-or-not (independent of whether the
/// token is a registered alias today) so a retarget reaches this site on the
/// first empty-baseline save (#267). Mirrors [`container_attempt_key`] for the
/// facade-alias kind.
fn facade_alias_attempt_key(receiver: Node, bytes: &[u8], aliases: &UseAliases) -> Option<String> {
if !matches!(receiver.kind(), "name" | "qualified_name") {
return None;
}
let raw = receiver.utf8_text(bytes).ok()?;
let is_namespaced = file_namespace(receiver, bytes).is_some();
crate::facade_resolver::global_alias_token(raw, aliases, is_namespaced)
.map(|token| crate::magic_dependency_index::alias_dep_key(&token))
}

/// The shape of [`resolve_gate_closure_user`] — a variable that is a Gate
/// ability closure's first parameter — without the auth-model lookup.
fn is_gate_closure_user_shape(var_node: Node, bytes: &[u8]) -> bool {
Expand Down Expand Up @@ -2556,6 +2586,22 @@ fn resolve_recipe_and_classify(
}
}

// Facade-alias attempt dependency (see [`resolve_and_classify`]'s mirror):
// the `alias:<token>` key is recorded resolved-or-not so an alias retarget
// ripples this site on the first (empty-baseline) save (#267).
if let Some(d) = deps.as_deref_mut() {
if let ReceiverRecipeData::StaticName {
raw, is_namespaced, ..
} = &site.recipe
{
if let Some(token) =
crate::facade_resolver::global_alias_token(raw, aliases, *is_namespaced)
{
d.insert(crate::magic_dependency_index::alias_dep_key(&token));
}
}
}

// Outer facade interception — a static-name receiver only.
let facade_concrete = match &site.recipe {
ReceiverRecipeData::StaticName {
Expand Down
56 changes: 50 additions & 6 deletions laravel-lsp/src/salsa_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4336,11 +4336,14 @@ pub struct ProviderRegistrationsData {
/// reaches the sites that previously resolved to nothing — plus the
/// concrete FQCN from both sides, which finds sites already referencing
/// the target directly.
/// - **facade alias**: the target FQCN from both sides of the diff — a site
/// that resolved through an alias recorded the concrete it landed on, so
/// the old target finds the now-stale sites. (An alias site that never
/// resolved recorded nothing and converges on the next full pass; it holds
/// no stale classification either.)
/// - **facade alias**: the `alias:<token>` attempt key
/// ([`crate::magic_dependency_index::ALIAS_DEP_PREFIX`]) from both sides of the
/// diff — every global-alias facade site (`Auth::check()`) records it
/// resolved-or-not, so an alias RETARGET reaches the OLD target's sites even on
/// the first save of a session, when the empty baseline makes the diff see only
/// the new target added (#267) — plus the target FQCN from both sides, which
/// finds any site referencing the aliased class directly (a `use`-import or
/// type-hint recording).
/// - **the provider's own path**: macro classifications record the macro's
/// declaration file as a dependency ([`crate::member_resolver`]), which for
/// inline `::macro()` registrations is the registering provider itself.
Expand Down Expand Up @@ -4374,7 +4377,14 @@ pub fn registration_ripple_keys(
]
},
));
keys.extend(changed(&before.aliases, &after.aliases).map(|(_, target)| target.clone()));
keys.extend(
changed(&before.aliases, &after.aliases).flat_map(|(token, target)| {
[
crate::magic_dependency_index::alias_dep_key(token),
target.clone(),
]
}),
);
if keys.is_empty() {
return Vec::new();
}
Expand Down Expand Up @@ -5076,6 +5086,15 @@ pub enum SalsaRequest {
SnapshotMacros {
reply: oneshot::Sender<Arc<std::collections::HashMap<(String, String), (PathBuf, u32)>>>,
},
/// Snapshot the registered service-provider paths in the deterministic merge
/// order (`sorted_sp_files`): lexicographically ascending, which is what
/// makes an equal-priority collision resolve to the smallest path in BOTH
/// registries. Test-observability for the #255 Bug B guard — asserts the sort
/// directly, so a reverted (raw-`HashMap`) `sorted_sp_files` fails reliably
/// rather than by chance seed (#267).
SnapshotSortedProviderPaths {
reply: oneshot::Sender<Vec<PathBuf>>,
},
/// One provider file's `(before, after)` registration contribution
/// (macros / bindings / facade aliases), for the save path's registration
/// diff (#255). `before` is the actor-kept BASELINE — the contribution as
Expand Down Expand Up @@ -5836,6 +5855,20 @@ impl SalsaHandle {
.map_err(|_| "Salsa actor dropped reply channel")
}

/// Snapshot the registered service-provider paths in `sorted_sp_files` merge
/// order (lexicographically ascending). Test-observability for the #255 Bug B
/// guard — the deterministic sort both registries merge in.
pub async fn snapshot_sorted_provider_paths(&self) -> Result<Vec<PathBuf>, &'static str> {
let (reply_tx, reply_rx) = oneshot::channel();
self.sender
.send(SalsaRequest::SnapshotSortedProviderPaths { reply: reply_tx })
.await
.map_err(|_| "Salsa actor disconnected")?;
reply_rx
.await
.map_err(|_| "Salsa actor dropped reply channel")
}

/// Snapshot the interface→implementors reverse map — `interface FQCN` →
/// directly implementing class FQCNs — for the out-of-actor magic-member
/// build. Mirrors [`Self::snapshot_bindings`].
Expand Down Expand Up @@ -7138,6 +7171,17 @@ impl SalsaActor {
}
let _ = reply.send(Arc::new(map));
}
SalsaRequest::SnapshotSortedProviderPaths { reply } => {
// Route through the real `sorted_sp_files` so a reverted sort
// surfaces here as raw-HashMap order — the whole point of the
// Bug B guard (#267).
let paths = self
.sorted_sp_files()
.into_iter()
.map(|sp_file| sp_file.path(&self.db).clone())
.collect();
let _ = reply.send(paths);
}
SalsaRequest::FileProviderRegistrations {
path,
fresh_text,
Expand Down
Loading