Skip to content

Commit dfa104c

Browse files
committed
Cross-file null-safe method call chain
1 parent 6a740dc commit dfa104c

3 files changed

Lines changed: 178 additions & 55 deletions

File tree

docs/todo/bugs.md

Lines changed: 77 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -15,79 +15,104 @@ within the same impact tier.
1515

1616
---
1717

18-
#### B1. Nullable type prefix not stripped during diagnostic class lookup
18+
#### B1. Nullable type not resolved to its base class
1919

2020
| | |
2121
|---|---|
2222
| **Impact** | Medium-High |
2323
| **Effort** | Low |
2424

25-
When a variable's resolved type is `?ClassName` (nullable shorthand),
26-
the diagnostic pipeline uses the full string including the `?` prefix
27-
as the class lookup key. The lookup fails, producing a spurious
28-
"subject type '?Foo' could not be resolved" warning even though `Foo`
29-
is a valid, loadable class.
25+
When a variable's type is `?ClassName`, the type engine fails to
26+
resolve it to `ClassName`. The `?` prefix is not stripped before class
27+
lookup, so the engine treats `?Foo` as an unknown type even though
28+
`Foo` is a valid, loadable class. This breaks completion, hover, and
29+
go-to-definition for any variable whose type includes the nullable
30+
shorthand.
3031

31-
**Observed:** 3 diagnostics in `shared` for
32+
The completion pipeline's `type_hint_to_classes` already strips `?`
33+
(line 90 of `completion/types/resolution.rs`), but other entry points
34+
into the type engine (e.g. `resolve_variable_assignment_raw_type`,
35+
`resolve_variable_type_string`) can return `?ClassName` strings that
36+
are never cleaned before class lookup.
37+
38+
**Observed:** 3 cases in `shared` for
3239
`?Luxplus\Core\Database\Model\Subscriptions\Subscription`. The class
33-
exists and loads fine without the `?` prefix.
40+
exists and loads fine as `Subscription`.
3441

35-
**Fix:** Strip the leading `?` (and/or `null|` / `|null` union
36-
components) before class lookup in the diagnostic subject resolution
37-
path. The completion pipeline already handles this correctly via
38-
`clean_type()`, so the gap is specific to the diagnostic code path.
42+
**Fix:** Ensure all paths that convert a raw type string to a
43+
`ClassInfo` strip the nullable prefix before class lookup. This may
44+
mean normalising the return value of the raw type resolvers, or
45+
ensuring every consumer calls `strip_nullable` / `clean_type` before
46+
passing type strings to `class_loader`.
3947

4048
---
4149

42-
#### B2. Generic type parameters not stripped during diagnostic class lookup
50+
#### B2. Generic type parameters prevent class resolution
4351

4452
| | |
4553
|---|---|
4654
| **Impact** | Medium |
4755
| **Effort** | Low |
4856

4957
When a resolved type string includes generic parameters (e.g.
50-
`PaymentOptionLocaleCollection<PaymentOptionLocale>`), the diagnostic
51-
pipeline uses the full parameterised string as the class lookup key.
52-
The lookup fails because no class is registered under the name that
53-
includes `<...>`.
58+
`PaymentOptionLocaleCollection<PaymentOptionLocale>`), the type engine
59+
uses the full parameterised string as the class lookup key. The lookup
60+
fails because no class is registered under the name that includes
61+
`<...>`. This breaks completion and hover for any variable whose
62+
resolved type carries generic arguments.
5463

55-
**Observed:** 3 diagnostics in `shared` for
64+
**Observed:** 3 cases in `shared` for
5665
`PaymentOptionLocaleCollection<Luxplus\Core\Database\Model\Payments\PaymentOptionLocale>`.
5766
Methods like `getTotalWeight()`, `isNotEmpty()`, and `first()` all
58-
exist on the class or its parent `Collection`.
67+
exist on the class or its parent `Collection`, but the type engine
68+
never reaches them.
5969

6070
**Fix:** Strip everything from the first `<` onward before performing
61-
class lookup. `clean_type()` already does this for the completion
62-
pipeline; the diagnostic resolution path needs the same treatment.
71+
class lookup. The same raw-type-to-class conversion paths identified
72+
in B1 are affected. `base_class_name()` in `type_strings.rs` already
73+
combines `clean_type` + `strip_generics` and should be used
74+
consistently.
6375

6476
---
6577

66-
#### B3. Trait static/self suppression not applied inside closures
78+
#### B3. Type engine does not resolve `$this`/`static` inside traits
6779

6880
| | |
6981
|---|---|
7082
| **Impact** | Medium |
7183
| **Effort** | Low |
7284

73-
The diagnostic pass suppresses `$this`/`self`/`static`/`parent` member
74-
access inside traits (since the host class provides those members). This
75-
works for direct method bodies but fails when the access is inside a
76-
closure nested within a trait method. The closure's byte offset is still
77-
within the trait's range, and `find_innermost_enclosing_class` returns
78-
the trait, but something in the suppression path doesn't fire.
79-
80-
**Observed:** `SalesInfoGlobalTrait` has `static::where()` and
81-
`static::query()` inside `retry(3, function() { ... })`. These produce
82-
"Method 'where' not found on class 'SalesInfoGlobalTrait'" (2
83-
diagnostics). Direct trait method bodies are correctly suppressed.
84-
85-
**Fix:** Investigate why the `subject_text == "static"` check on the
86-
`MemberAccess` span doesn't match when the call is inside a closure.
87-
The `expr_to_subject_text` function returns `"static"` for
88-
`Expression::Static`, so the span should have the right subject text.
89-
Possibly the closure introduces a scope boundary that changes how the
90-
span is emitted or how the enclosing class is resolved.
85+
When a trait method (or a closure inside a trait method) accesses
86+
members via `$this`, `self`, `static`, or `parent`, the type engine
87+
resolves the subject to the trait itself rather than to the host class.
88+
Since the trait does not declare the members that the host class
89+
provides, the type engine reports the members as missing. This affects
90+
completion, hover, and go-to-definition for any trait that relies on
91+
host-class members.
92+
93+
The diagnostic pass has a suppression heuristic for this case, but
94+
the underlying problem is in the type engine: it should resolve
95+
`$this`/`static`/`self` inside a trait to the using class (when known)
96+
or defer resolution (when the host class is not known). The
97+
suppression heuristic also fails when the access is inside a closure
98+
nested within a trait method.
99+
100+
**Observed:** 43 cases in `shared`. The largest cluster (41) is
101+
`BusinessCentralErrorHandlerTrait` where `$this->model`,
102+
`$this->eventType`, etc. are properties provided by the host class.
103+
`SalesInfoGlobalTrait` contributes 2 cases where `static::where()` and
104+
`static::query()` are called inside a closure within a trait method.
105+
106+
**Fix:** When the type engine encounters `$this`/`static`/`self` inside
107+
a trait, it should attempt to resolve to the known host class(es). For
108+
the analyze pass where no specific host class is open, the engine
109+
should recognise that trait member accesses are inherently incomplete
110+
and avoid reporting members as missing. The closure nesting issue is a
111+
separate symptom: `find_innermost_enclosing_class` does find the trait
112+
at the closure's offset, but the suppression check does not fire,
113+
suggesting the `subject_text` on the `MemberAccess` span differs from
114+
the expected `"static"` / `"$this"` keywords when emitted from inside
115+
a closure.
91116

92117
---
93118

@@ -136,25 +161,26 @@ matches a known class), do not prepend the file namespace.
136161

137162
---
138163

139-
#### B6. Empty subject string in diagnostic messages
164+
#### B6. Empty subject string in type resolution
140165

141166
| | |
142167
|---|---|
143168
| **Impact** | Low |
144169
| **Effort** | Low |
145170

146-
Some diagnostics display "Cannot resolve type of ''" with an empty
147-
subject string. This happens when the subject extraction fails to
148-
produce a text representation for complex expressions but a diagnostic
149-
is still emitted.
171+
The subject extraction produces an empty string for complex expressions
172+
like `($a ?: $b)?->property`, meaning the type engine has no subject
173+
to resolve. This manifests as "Cannot resolve type of ''" in
174+
diagnostics, but the underlying issue is that `expr_to_subject_text`
175+
does not handle ternary-inside-nullable and similar compound patterns.
150176

151-
**Observed:** 5 diagnostics in `shared` with empty subject strings,
152-
triggered by patterns like `($a ?: $b)?->property` (ternary inside
153-
nullable access) and similar compound expressions.
177+
**Observed:** 5 cases in `shared` with empty subject strings.
154178

155-
**Fix:** Skip emitting the `unresolved_member_access` diagnostic when
156-
the extracted subject text is empty, or improve `expr_to_subject_text`
157-
to handle ternary-in-nullable and similar compound patterns.
179+
**Fix:** Extend `expr_to_subject_text` to handle parenthesised ternary
180+
expressions, short ternary (`?:`), and null-coalesce (`??`) as subject
181+
bases. When the expression is too complex to represent as a subject
182+
string, the type engine should skip the access rather than attempt
183+
resolution with an empty key.
158184

159185
---
160186

src/completion/variable/rhs_resolution.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,8 +1105,7 @@ fn resolve_rhs_method_call_inner<'b>(
11051105
_ => return vec![],
11061106
};
11071107
// Resolve the object expression to candidate owner classes.
1108-
let owner_classes: Vec<ClassInfo> = if let Expression::Variable(Variable::Direct(dv)) =
1109-
object
1108+
let owner_classes: Vec<ClassInfo> = if let Expression::Variable(Variable::Direct(dv)) = object
11101109
&& dv.name == "$this"
11111110
{
11121111
ctx.all_classes
@@ -1132,8 +1131,7 @@ fn resolve_rhs_method_call_inner<'b>(
11321131
resolve_rhs_expression(object, ctx)
11331132
};
11341133

1135-
let text_args =
1136-
super::raw_type_inference::extract_argument_text(argument_list, ctx.content);
1134+
let text_args = super::raw_type_inference::extract_argument_text(argument_list, ctx.content);
11371135
let rctx = ctx.as_resolution_ctx();
11381136
let var_resolver = build_var_resolver_from_ctx(ctx);
11391137

tests/completion_variables.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16789,3 +16789,102 @@ async fn test_completion_nullsafe_mid_chain_assignment() {
1678916789
_ => panic!("Expected CompletionResponse::Array"),
1679016790
}
1679116791
}
16792+
16793+
// ─── Cross-file null-safe method call chain ─────────────────────────────────
16794+
16795+
/// When a variable is assigned from a cross-file null-safe method call chain
16796+
/// like `$x = $obj?->getItems()->last()`, the type engine should resolve
16797+
/// through the `?->` call across file boundaries.
16798+
#[tokio::test]
16799+
async fn test_completion_nullsafe_method_chain_cross_file() {
16800+
let composer = r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#;
16801+
let (backend, _dir) = create_psr4_workspace(
16802+
composer,
16803+
&[
16804+
(
16805+
"src/Subscription.php",
16806+
"<?php\nnamespace App;\nclass Subscription {\n public function price(): float { return 0.0; }\n public function isActive(): bool { return true; }\n}\n",
16807+
),
16808+
(
16809+
"src/SubscriptionCollection.php",
16810+
"<?php\nnamespace App;\nclass SubscriptionCollection {\n public function lastPeriod(): ?Subscription { return null; }\n}\n",
16811+
),
16812+
(
16813+
"src/Agreement.php",
16814+
"<?php\nnamespace App;\nclass Agreement {\n public function getPeriods(): SubscriptionCollection { return new SubscriptionCollection(); }\n}\n",
16815+
),
16816+
(
16817+
"src/Customer.php",
16818+
"<?php\nnamespace App;\nclass Customer {\n public function getLatestAgreement(): ?Agreement { return null; }\n}\n",
16819+
),
16820+
],
16821+
);
16822+
16823+
let uri = "file:///src/ProfileBuilder.php";
16824+
let content = concat!(
16825+
"<?php\n",
16826+
"namespace App;\n",
16827+
"class ProfileBuilder {\n",
16828+
" public function build(Customer $customer): void {\n",
16829+
" $agreement = $customer->getLatestAgreement();\n",
16830+
" $lastPeriod = $agreement?->getPeriods()->lastPeriod();\n",
16831+
" $lastPeriod->\n",
16832+
" }\n",
16833+
"}\n",
16834+
);
16835+
16836+
backend
16837+
.did_open(tower_lsp::lsp_types::DidOpenTextDocumentParams {
16838+
text_document: tower_lsp::lsp_types::TextDocumentItem {
16839+
uri: tower_lsp::lsp_types::Url::parse(uri).unwrap(),
16840+
language_id: "php".to_string(),
16841+
version: 1,
16842+
text: content.to_string(),
16843+
},
16844+
})
16845+
.await;
16846+
16847+
let result = backend
16848+
.completion(CompletionParams {
16849+
text_document_position: TextDocumentPositionParams {
16850+
text_document: TextDocumentIdentifier {
16851+
uri: tower_lsp::lsp_types::Url::parse(uri).unwrap(),
16852+
},
16853+
position: Position {
16854+
line: 6,
16855+
character: 21,
16856+
},
16857+
},
16858+
work_done_progress_params: Default::default(),
16859+
partial_result_params: Default::default(),
16860+
context: None,
16861+
})
16862+
.await
16863+
.unwrap();
16864+
16865+
assert!(
16866+
result.is_some(),
16867+
"Completion should return results for $lastPeriod-> after cross-file null-safe chain"
16868+
);
16869+
16870+
match result.unwrap() {
16871+
CompletionResponse::Array(items) => {
16872+
let names: Vec<&str> = items
16873+
.iter()
16874+
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
16875+
.map(|i| i.filter_text.as_deref().unwrap())
16876+
.collect();
16877+
assert!(
16878+
names.contains(&"price"),
16879+
"Should include 'price' from Subscription, got: {:?}",
16880+
names
16881+
);
16882+
assert!(
16883+
names.contains(&"isActive"),
16884+
"Should include 'isActive' from Subscription, got: {:?}",
16885+
names
16886+
);
16887+
}
16888+
_ => panic!("Expected CompletionResponse::Array"),
16889+
}
16890+
}

0 commit comments

Comments
 (0)