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
102 changes: 85 additions & 17 deletions laravel-lsp/src/query_chain/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,47 @@ pub enum EloquentReceiver {
var: String,
php_type: Option<String>,
},
/// `$user->competitions->where(...)` — a relationship accessed as a
/// *property* (not a method call) eager-/lazy-loads it into a hydrated
/// `Collection` of the related model, so the chain runs in
/// `BuilderMode::EloquentCollection`. `base_type` is the declared FQCN of
/// the base variable (`$user` → `App\Models\User`), resolved synchronously
/// by the `var_type` resolver; `relation` is the property name
/// (`competitions`). The related model's FQCN can't be resolved in the
/// (synchronous) extractor pass — it needs a model-file read — so the
/// walker seeds `relation` as a pending relation hop
/// A hydrated `Collection` of a *related* model, reached two ways:
///
/// - `$user->competitions->where(...)` — a relationship accessed as a
/// *property* (not a method call) eager-/lazy-loads it into a hydrated
/// `Collection` of the related model.
/// - `$regs = $user->competitions()->…->get(); $regs->whereIn(...)` — an
/// executed relation query assigned to a variable (issue #246), detected
/// by [`crate::query_chain::flow::resolve_collection_relation`].
///
/// Either way the chain runs in `BuilderMode::EloquentCollection`.
/// `base_type` is the resolved FQCN of the base variable (`$user` →
/// `App\Models\User`); `relation` is the relation name (`competitions`).
/// The related model's FQCN can't be resolved in the (synchronous)
/// extractor pass — it needs a model-file read — so the walker seeds
/// `relation` as a pending relation hop
/// ([`ChainContext::pending_relation_hops`]) that the async finalize step
/// resolves into `effective_model`. `base_type` is `None` when the base
/// variable's type can't be determined, in which case completion no-ops.
///
/// `from_call` records which of the two construction sites produced the
/// receiver — `false` for the property access, `true` for the executed
/// relation *call*. The distinction matters on a failed hop: a property
/// name that isn't a relation is an unknown collection (stay quiet), but
/// a *called* name may still be a local scope returning a builder of the
/// base model itself — see [`RelationHopKind::CallClaim`].
///
/// `call_hops` carries the assignment chain's *unrecognised middle
/// calls* (executed-relation form only; always empty for the property
/// access) in source order — scopes on the running model, pivot builder
/// methods we don't model, or further relation hops. The walker queues
/// each as a [`RelationHopKind::Heuristic`] hop after the `relation`
/// claim, mirroring how the inline cursor collector treats unrecognised
/// mid-chain calls.
RelationProperty {
var: String,
base_type: Option<String>,
relation: String,
#[serde(default)]
from_call: bool,
#[serde(default)]
call_hops: Vec<String>,
},
}

Expand Down Expand Up @@ -360,21 +385,64 @@ pub struct ChainContext {
/// Relationship method calls the chain walked through *as builders*
/// (`User::query()->competitions()->whereIn('type', …)`) or a
/// relationship accessed as a property on the receiver
/// (`$user->competitions->where('type', …)`), in source order. Each is a
/// method/property name on the *running* `effective_model`; the synchronous
/// walker can't read model files, so it records the names here and the
/// async finalize step
/// (`$user->competitions->where('type', …)`), in source order. Each hop
/// names a method/property on the *running* `effective_model`; the
/// synchronous walker can't read model files, so it records them here and
/// the async finalize step
/// ([`crate::query_chain::eloquent_completion::apply_relation_method_hops`])
/// walks them via `resolve_related_model`, advancing `effective_model` on
/// each successful hop. A name that doesn't resolve to a relationship is
/// skipped (it was an unrecognised builder call), leaving `effective_model`
/// unchanged — the `ChainEffect::None` fallback. Empty for the common case.
pub pending_relation_hops: Vec<String>,
/// each successful hop. What a *failed* hop means depends on its
/// [`RelationHopKind`] — see that enum for the split. Empty for the
/// common case.
pub pending_relation_hops: Vec<RelationHop>,
/// The quote character the user is typing inside (`'` or `"`). Used so the
/// completion item doesn't double up quotes when inserting.
pub quote: char,
}

/// One deferred relationship hop ([`ChainContext::pending_relation_hops`]):
/// the method/property name to resolve against the running `effective_model`,
/// plus how confident the walker is that it *is* a relation — which decides
/// what a failed resolve means (see [`RelationHopKind`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelationHop {
pub name: String,
pub kind: RelationHopKind,
}

/// How a pending relation hop was collected, deciding the failure semantics in
/// [`crate::query_chain::eloquent_completion::apply_relation_method_hops`].
/// The kinds share one queue but mean very different things on a miss —
/// conflating them silenced diagnostics on local-scope chains
/// (`User::forCurrentTenant()->get()->where('emial', 1)`), first inline
/// (fixed by the `Claim`/`Heuristic` split) and then in the assigned form
/// (`$x = $user->forCurrentTenant()->get()`, fixed by `CallClaim`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelationHopKind {
/// A genuine relation claim, seeded from a *property* access
/// ([`EloquentReceiver::RelationProperty`] with `from_call: false`,
/// `$user->competitions->…`). A property that isn't a relation has no
/// modelled type at all, so a failed resolve means the collection's
/// element type is unknown: `effective_model` is cleared so consumers
/// stay quiet rather than false-positiving against the base model.
Claim,
/// A relation claim seeded from an *executed relation-query assignment*
/// ([`EloquentReceiver::RelationProperty`] with `from_call: true`,
/// `$x = $user->competitions()->get()`, issue #246). The claimed name is
/// a method *call*, so a resolve miss splits further: a **local scope**
/// (`scopeForCurrentTenant` / `#[Scope]`) provably returns a builder of
/// the *same* model — the collection's element type is the running model,
/// which is kept so its columns still validate. Any other miss (an
/// undeclared name, or a declared relation whose related model can't be
/// resolved — AC #7) clears `effective_model`, same as [`Self::Claim`].
CallClaim,
/// A heuristic guess — any unrecognised method name seen mid-chain in
/// `EloquentBuilder` mode (a custom local scope, or a builder method we
/// simply don't model). A failed resolve is skipped, leaving the model
/// unchanged — the `ChainEffect::None` fallback.
Heuristic,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum BuilderMode {
/// Pre-execution Eloquent: full property/relation/cast support.
Expand Down
51 changes: 40 additions & 11 deletions laravel-lsp/src/query_chain/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,22 +417,48 @@ fn initial_receiver_context(
}

/// The relationship hops the walker must apply (deferred to async finalize),
/// assembled in source order: the receiver's property hop first (for a
/// `$var->relation->…` chain), then every unrecognised method call seen *before*
/// `up_to_idx` while the chain is still an `EloquentBuilder`. Collecting only in
/// `EloquentBuilder` mode is deliberate — once the chain flips to a Collection
/// or base builder, an unknown call is a Collection/array method, not a relation
/// on the model.
/// assembled in source order: the receiver's hop first (for a
/// `$var->relation->…` chain or an executed relation assignment, issue #246),
/// then every unrecognised method call seen *before* `up_to_idx` while the
/// chain is still an `EloquentBuilder`. Collecting only in `EloquentBuilder`
/// mode is deliberate — once the chain flips to a Collection or base builder,
/// an unknown call is a Collection/array method, not a relation on the model.
///
/// The receiver's hop is a claim (the walker *knows* it names a
/// relation-shaped access): [`RelationHopKind::Claim`] for the property form,
/// [`RelationHopKind::CallClaim`] for the executed-relation call form (whose
/// miss may still be a local scope). Mid-chain unknowns are
/// [`RelationHopKind::Heuristic`] guesses (they may be local scopes or
/// unmodeled builder methods). The finalize step treats a miss differently
/// per kind — see [`RelationHopKind`].
fn pending_relation_hops_through(
chain: &BuilderChain,
mode: BuilderMode,
up_to_idx: usize,
) -> Vec<String> {
) -> Vec<RelationHop> {
let mut hops = Vec::new();
if let ChainReceiver::Eloquent(EloquentReceiver::RelationProperty { relation, .. }) =
&chain.receiver
if let ChainReceiver::Eloquent(EloquentReceiver::RelationProperty {
relation,
from_call,
call_hops,
..
}) = &chain.receiver
{
hops.push(relation.clone());
hops.push(RelationHop {
name: relation.clone(),
kind: if *from_call {
RelationHopKind::CallClaim
} else {
RelationHopKind::Claim
},
});
// The executed-relation assignment's unrecognised middle calls
// (source order) — heuristic guesses, same as mid-chain unknowns
// collected below: a miss keeps the running model.
hops.extend(call_hops.iter().map(|name| RelationHop {
name: name.clone(),
kind: RelationHopKind::Heuristic,
}));
}
let mut running = mode;
for link in chain.links.iter().take(up_to_idx) {
Expand All @@ -447,7 +473,10 @@ fn pending_relation_hops_through(
ChainEffect::None => {
if running == BuilderMode::EloquentBuilder && !is_known_builder_method(&link.method)
{
hops.push(link.method.clone());
hops.push(RelationHop {
name: link.method.clone(),
kind: RelationHopKind::Heuristic,
});
}
}
}
Expand Down
27 changes: 20 additions & 7 deletions laravel-lsp/src/query_chain/cursor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -542,18 +542,27 @@ fn eloquent_static_with_first_arg_resolves_to_relation_completion() {

// ---- relation hops (issue #211) ------------------------------------------

/// Shorthand for the expected-hops assertions below.
fn hop(name: &str, kind: RelationHopKind) -> RelationHop {
RelationHop {
name: name.to_string(),
kind,
}
}

#[test]
fn unknown_relation_method_call_queues_a_pending_hop() {
// `competitions()` isn't a known builder method, so in EloquentBuilder mode
// the walker queues it as a relation hop to resolve against the model. Mode
// stays EloquentBuilder (a relation method returns a builder).
// the walker queues it as a relation hop to resolve against the model — as
// a Heuristic guess (it could equally be a local scope). Mode stays
// EloquentBuilder (a relation method returns a builder).
let ctx = detect("User::query()->competitions()->whereIn('ty|pe', ['x']);").expect("ctx");
assert_eq!(ctx.mode, BuilderMode::EloquentBuilder);
assert_eq!(ctx.effective_model.as_deref(), Some("User"));
assert!(
ctx.pending_relation_hops
.contains(&"competitions".to_string()),
"competitions() should be queued as a relation hop; got {:?}",
.contains(&hop("competitions", RelationHopKind::Heuristic)),
"competitions() should be queued as a heuristic relation hop; got {:?}",
ctx.pending_relation_hops
);
}
Expand Down Expand Up @@ -581,7 +590,7 @@ fn eloquent_static_starter_methods_do_not_queue_relation_hops() {
.expect("ctx");
assert_eq!(
ctx.pending_relation_hops,
vec!["competitions".to_string()],
vec![hop("competitions", RelationHopKind::Heuristic)],
"only the relation accessor should be queued; got {:?}",
ctx.pending_relation_hops
);
Expand All @@ -591,14 +600,18 @@ fn eloquent_static_starter_methods_do_not_queue_relation_hops() {
fn relation_property_receiver_starts_collection_with_pending_hop() {
// `$user->competitions->where('|')` — the relation read as a property is a
// Collection of the related model; the property name is the first pending
// hop and the chain runs in EloquentCollection mode.
// hop (a genuine Claim, not a heuristic guess) and the chain runs in
// EloquentCollection mode.
let ctx = detect(
"use App\\Models\\User;\n/** @var User $user */\n$user->competitions->where('ty|pe', 'x');",
)
.expect("ctx");
assert_eq!(ctx.mode, BuilderMode::EloquentCollection);
assert_eq!(ctx.effective_model.as_deref(), Some("App\\Models\\User"));
assert_eq!(ctx.pending_relation_hops, vec!["competitions".to_string()]);
assert_eq!(
ctx.pending_relation_hops,
vec![hop("competitions", RelationHopKind::Claim)]
);
}

#[test]
Expand Down
Loading