Skip to content

Commit 42d4a6d

Browse files
committed
Nullable type not resolved to its base class, incorrect namespace
applied to @see
1 parent dfa104c commit 42d4a6d

8 files changed

Lines changed: 266 additions & 86 deletions

File tree

docs/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- **Null-safe method chain resolution.** Null-safe method calls (`$obj?->method()`) now resolve the return type correctly for variable type inference, including cross-file chains. Previously `?->` calls were ignored by the RHS resolution pipeline, losing the type for any variable assigned from a null-safe chain.
13+
- **Nullable and generic types in class lookup.** Variables typed as `?ClassName` or `Collection<Item>` now resolve correctly across all code paths. Previously the `?` prefix and generic parameters were not stripped before class lookup, causing the type engine to treat them as unknown types. This fixes completion, hover, go-to-definition, and false-positive diagnostics for any variable whose type uses the nullable shorthand or carries generic arguments.
14+
- **`@see` references with qualified class names.** Docblock `@see Fully\Qualified\ClassName` references no longer have the file's namespace prepended, which previously produced doubled names like `App\Models\App\Models\User`. Qualified names in `@see` tags are now treated as fully-qualified.
1215
- **False-positive diagnostics for same-named variables in different methods.** When two methods in the same class both used a variable like `$order`, the diagnostic cache resolved it once and reused that type for every other method in the class. The second method saw the wrong type and flagged valid member accesses as unknown. Both the per-file subject cache and the cross-collector resolution cache are now scoped to the enclosing function/method/closure body, so each method resolves variables independently.
1316
- **False-positive diagnostics for `$this` inside traits.** Accessing host-class members via `$this->`, `self::`, `static::`, or `parent::` inside a trait method no longer produces "not found" warnings. Traits are incomplete by nature and expect the host class to provide these members.
1417
- **Type narrowing inside `return` statements.** `instanceof` checks in `&&` chains and ternary conditions now narrow the variable type when the expression is the operand of a `return` statement. Previously, `return $e instanceof QueryException && $e->errorInfo;` would flag `errorInfo` as unknown because narrowing only applied inside standalone expression statements and `if` conditions.

docs/todo.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,8 @@ unlikely to move the needle for most users.
101101
| # | Item | Impact | Effort |
102102
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ----------- |
103103
| | **[Bugs](todo/bugs.md)** | | |
104-
| B1 | [Nullable type prefix not stripped during diagnostic class lookup](todo/bugs.md#b1-nullable-type-prefix-not-stripped-during-diagnostic-class-lookup) | Medium-High | Low |
105-
| B2 | [Generic type parameters not stripped during diagnostic class lookup](todo/bugs.md#b2-generic-type-parameters-not-stripped-during-diagnostic-class-lookup) | Medium | Low |
106104
| B3 | [Trait static/self suppression not applied inside closures](todo/bugs.md#b3-trait-staticself-suppression-not-applied-inside-closures) | Medium | Low |
107105
| B4 | [Variable reassignment loses type when parameter name is reused](todo/bugs.md#b4-variable-reassignment-loses-type-when-parameter-name-is-reused) | Medium | Medium |
108-
| B5 | [Docblock `@see` reference prepends file namespace](todo/bugs.md#b5-docblock-see-reference-prepends-file-namespace) | Low | Low |
109106
| B6 | [Empty subject string in diagnostic messages](todo/bugs.md#b6-empty-subject-string-in-diagnostic-messages) | Low | Low |
110107
| B7 | [Overloaded built-in function signatures in stubs](todo/bugs.md#b7-overloaded-built-in-function-signatures-in-stubs) | Low | Low |
111108
| B8 | [`getCode`/`getMessage` not found through deep inheritance chains](todo/bugs.md#b8-getcodemessage-not-found-through-deep-inheritance-chains) | Low | Low |

docs/todo/bugs.md

Lines changed: 0 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -15,66 +15,6 @@ within the same impact tier.
1515

1616
---
1717

18-
#### B1. Nullable type not resolved to its base class
19-
20-
| | |
21-
|---|---|
22-
| **Impact** | Medium-High |
23-
| **Effort** | Low |
24-
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.
31-
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
39-
`?Luxplus\Core\Database\Model\Subscriptions\Subscription`. The class
40-
exists and loads fine as `Subscription`.
41-
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`.
47-
48-
---
49-
50-
#### B2. Generic type parameters prevent class resolution
51-
52-
| | |
53-
|---|---|
54-
| **Impact** | Medium |
55-
| **Effort** | Low |
56-
57-
When a resolved type string includes generic parameters (e.g.
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.
63-
64-
**Observed:** 3 cases in `shared` for
65-
`PaymentOptionLocaleCollection<Luxplus\Core\Database\Model\Payments\PaymentOptionLocale>`.
66-
Methods like `getTotalWeight()`, `isNotEmpty()`, and `first()` all
67-
exist on the class or its parent `Collection`, but the type engine
68-
never reaches them.
69-
70-
**Fix:** Strip everything from the first `<` onward before performing
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.
75-
76-
---
77-
7818
#### B3. Type engine does not resolve `$this`/`static` inside traits
7919

8020
| | |
@@ -139,28 +79,6 @@ within the same scope at the cursor offset.
13979

14080
---
14181

142-
#### B5. Docblock `@see` reference prepends file namespace
143-
144-
| | |
145-
|---|---|
146-
| **Impact** | Low |
147-
| **Effort** | Low |
148-
149-
When a docblock contains `@see Fully\Qualified\ClassName`, PHPantom
150-
prepends the current file's namespace to the reference, producing an
151-
invalid doubled namespace like
152-
`Luxplus\Core\Database\Model\Products\Filters\Luxplus\Core\Elasticsearch\Queries\ProductQuery`.
153-
154-
**Observed:** 1 diagnostic in `ProductFilterTermCollection.php` where
155-
`@see Luxplus\Core\Elasticsearch\Queries\ProductQuery::search_with_filter()`
156-
becomes an unknown class with a doubled namespace prefix.
157-
158-
**Fix:** Treat `@see` references the same as `use` imports: if the
159-
reference is already fully qualified (starts with the root namespace or
160-
matches a known class), do not prepend the file namespace.
161-
162-
---
163-
16482
#### B6. Empty subject string in type resolution
16583

16684
| | |

src/docblock/type_strings.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,8 @@ pub fn clean_type(raw: &str) -> String {
404404
/// - `"Foo|null"` → `"Foo"`
405405
pub fn base_class_name(raw: &str) -> String {
406406
let cleaned = clean_type(raw);
407-
strip_generics(&cleaned)
407+
let stripped = strip_nullable(&cleaned);
408+
strip_generics(stripped)
408409
}
409410

410411
/// Strip generic parameters and array shape braces from a (already

src/resolution.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ use std::path::Path;
3939

4040
use crate::Backend;
4141
use crate::composer;
42+
use crate::docblock::type_strings::{strip_generics, strip_nullable};
4243
use crate::types::{ClassInfo, FileContext, FunctionInfo, PhpVersion};
4344
use crate::util::short_name;
4445

@@ -53,6 +54,18 @@ impl Backend {
5354
///
5455
/// Returns a shared `Arc<ClassInfo>` if found, or `None`.
5556
pub(crate) fn find_or_load_class(&self, class_name: &str) -> Option<Arc<ClassInfo>> {
57+
// Defensively strip nullable prefix (`?Foo` → `Foo`) and generic
58+
// parameters (`Collection<int, User>` → `Collection`) so that
59+
// callers don't need to normalise before lookup.
60+
let class_name = strip_nullable(class_name);
61+
let owned_name;
62+
let class_name = if class_name.contains('<') || class_name.contains('{') {
63+
owned_name = strip_generics(class_name);
64+
owned_name.as_str()
65+
} else {
66+
class_name
67+
};
68+
5669
// The class name stored in ClassInfo is just the short name (e.g. "Customer"),
5770
// so we match against the last segment of the namespace-qualified name.
5871
let last_segment = short_name(class_name);

src/symbol_map/docblock.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,20 @@ fn emit_see_reference(reference: &str, file_offset: u32, spans: &mut Vec<SymbolS
11901190
// Strip trailing `()` if present (used on both methods and functions).
11911191
let reference = reference.strip_suffix("()").unwrap_or(reference);
11921192

1193+
// `@see` references that contain `\` are almost always fully-qualified
1194+
// class names (e.g. `@see App\Models\User`). Without a leading `\`,
1195+
// `class_ref_span` would set `is_fqn = false`, causing downstream
1196+
// consumers to prepend the current file's namespace and produce a
1197+
// doubled name like `App\Models\App\Models\User`. Treat any
1198+
// backslash-containing reference as FQN by prepending `\`.
1199+
let owned_reference;
1200+
let reference = if reference.contains('\\') && !reference.starts_with('\\') {
1201+
owned_reference = format!("\\{reference}");
1202+
&owned_reference
1203+
} else {
1204+
reference
1205+
};
1206+
11931207
// Check for `Class::member` form.
11941208
if let Some(sep_pos) = reference.find("::") {
11951209
let class_part = &reference[..sep_pos];

tests/completion_variables.rs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16888,3 +16888,207 @@ async fn test_completion_nullsafe_method_chain_cross_file() {
1688816888
_ => panic!("Expected CompletionResponse::Array"),
1688916889
}
1689016890
}
16891+
16892+
// ─── Nullable type resolution (B1) ──────────────────────────────────────────
16893+
16894+
/// A parameter typed `?ClassName` should resolve to `ClassName` for completion.
16895+
#[tokio::test]
16896+
async fn test_completion_nullable_param_type_hint() {
16897+
let backend = create_test_backend();
16898+
16899+
let uri = Url::parse("file:///nullable_param.php").unwrap();
16900+
let text = concat!(
16901+
"<?php\n",
16902+
"class Invoice {\n",
16903+
" public function total(): int { return 0; }\n",
16904+
" public function isPaid(): bool { return false; }\n",
16905+
"}\n",
16906+
"class Processor {\n",
16907+
" public function handle(?Invoice $inv): void {\n",
16908+
" $inv->\n",
16909+
" }\n",
16910+
"}\n",
16911+
);
16912+
16913+
let open_params = DidOpenTextDocumentParams {
16914+
text_document: TextDocumentItem {
16915+
uri: uri.clone(),
16916+
language_id: "php".to_string(),
16917+
version: 1,
16918+
text: text.to_string(),
16919+
},
16920+
};
16921+
backend.did_open(open_params).await;
16922+
16923+
let completion_params = CompletionParams {
16924+
text_document_position: TextDocumentPositionParams {
16925+
text_document: TextDocumentIdentifier { uri },
16926+
position: Position {
16927+
line: 7,
16928+
character: 14,
16929+
},
16930+
},
16931+
work_done_progress_params: WorkDoneProgressParams::default(),
16932+
partial_result_params: PartialResultParams::default(),
16933+
context: None,
16934+
};
16935+
16936+
let result = backend.completion(completion_params).await.unwrap();
16937+
assert!(
16938+
result.is_some(),
16939+
"Completion should return results for ?Invoice parameter"
16940+
);
16941+
16942+
match result.unwrap() {
16943+
CompletionResponse::Array(items) => {
16944+
let names: Vec<&str> = items
16945+
.iter()
16946+
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
16947+
.map(|i| i.filter_text.as_deref().unwrap())
16948+
.collect();
16949+
assert!(
16950+
names.contains(&"total"),
16951+
"Should include 'total' from Invoice via ?Invoice param, got: {:?}",
16952+
names
16953+
);
16954+
assert!(
16955+
names.contains(&"isPaid"),
16956+
"Should include 'isPaid' from Invoice via ?Invoice param, got: {:?}",
16957+
names
16958+
);
16959+
}
16960+
_ => panic!("Expected CompletionResponse::Array"),
16961+
}
16962+
}
16963+
16964+
/// A `@var ?ClassName` docblock annotation should resolve for completion.
16965+
#[tokio::test]
16966+
async fn test_completion_nullable_var_docblock() {
16967+
let backend = create_test_backend();
16968+
16969+
let uri = Url::parse("file:///nullable_var.php").unwrap();
16970+
let text = concat!(
16971+
"<?php\n",
16972+
"class Widget {\n",
16973+
" public function render(): string { return ''; }\n",
16974+
"}\n",
16975+
"class Page {\n",
16976+
" public function show(): void {\n",
16977+
" /** @var ?Widget $w */\n",
16978+
" $w = getWidget();\n",
16979+
" $w->\n",
16980+
" }\n",
16981+
"}\n",
16982+
);
16983+
16984+
let open_params = DidOpenTextDocumentParams {
16985+
text_document: TextDocumentItem {
16986+
uri: uri.clone(),
16987+
language_id: "php".to_string(),
16988+
version: 1,
16989+
text: text.to_string(),
16990+
},
16991+
};
16992+
backend.did_open(open_params).await;
16993+
16994+
let completion_params = CompletionParams {
16995+
text_document_position: TextDocumentPositionParams {
16996+
text_document: TextDocumentIdentifier { uri },
16997+
position: Position {
16998+
line: 8,
16999+
character: 12,
17000+
},
17001+
},
17002+
work_done_progress_params: WorkDoneProgressParams::default(),
17003+
partial_result_params: PartialResultParams::default(),
17004+
context: None,
17005+
};
17006+
17007+
let result = backend.completion(completion_params).await.unwrap();
17008+
assert!(
17009+
result.is_some(),
17010+
"Completion should return results for @var ?Widget"
17011+
);
17012+
17013+
match result.unwrap() {
17014+
CompletionResponse::Array(items) => {
17015+
let names: Vec<&str> = items
17016+
.iter()
17017+
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
17018+
.map(|i| i.filter_text.as_deref().unwrap())
17019+
.collect();
17020+
assert!(
17021+
names.contains(&"render"),
17022+
"Should include 'render' from Widget via @var ?Widget, got: {:?}",
17023+
names
17024+
);
17025+
}
17026+
_ => panic!("Expected CompletionResponse::Array"),
17027+
}
17028+
}
17029+
17030+
/// A nullable return type `?ClassName` should resolve when chaining.
17031+
#[tokio::test]
17032+
async fn test_completion_nullable_return_type_chain() {
17033+
let backend = create_test_backend();
17034+
17035+
let uri = Url::parse("file:///nullable_return.php").unwrap();
17036+
let text = concat!(
17037+
"<?php\n",
17038+
"class Order {\n",
17039+
" public function total(): int { return 0; }\n",
17040+
"}\n",
17041+
"class OrderService {\n",
17042+
" public function find(int $id): ?Order { return null; }\n",
17043+
" public function test(): void {\n",
17044+
" $order = $this->find(1);\n",
17045+
" $order->\n",
17046+
" }\n",
17047+
"}\n",
17048+
);
17049+
17050+
let open_params = DidOpenTextDocumentParams {
17051+
text_document: TextDocumentItem {
17052+
uri: uri.clone(),
17053+
language_id: "php".to_string(),
17054+
version: 1,
17055+
text: text.to_string(),
17056+
},
17057+
};
17058+
backend.did_open(open_params).await;
17059+
17060+
let completion_params = CompletionParams {
17061+
text_document_position: TextDocumentPositionParams {
17062+
text_document: TextDocumentIdentifier { uri },
17063+
position: Position {
17064+
line: 8,
17065+
character: 16,
17066+
},
17067+
},
17068+
work_done_progress_params: WorkDoneProgressParams::default(),
17069+
partial_result_params: PartialResultParams::default(),
17070+
context: None,
17071+
};
17072+
17073+
let result = backend.completion(completion_params).await.unwrap();
17074+
assert!(
17075+
result.is_some(),
17076+
"Completion should return results for nullable return type ?Order"
17077+
);
17078+
17079+
match result.unwrap() {
17080+
CompletionResponse::Array(items) => {
17081+
let names: Vec<&str> = items
17082+
.iter()
17083+
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
17084+
.map(|i| i.filter_text.as_deref().unwrap())
17085+
.collect();
17086+
assert!(
17087+
names.contains(&"total"),
17088+
"Should include 'total' from Order via ?Order return, got: {:?}",
17089+
names
17090+
);
17091+
}
17092+
_ => panic!("Expected CompletionResponse::Array"),
17093+
}
17094+
}

0 commit comments

Comments
 (0)