Skip to content

Commit 4f7ba00

Browse files
committed
Implement two more features from the todo list
1 parent 4d38719 commit 4f7ba00

7 files changed

Lines changed: 534 additions & 53 deletions

File tree

docs/CHANGELOG.md

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

1818
### Fixed
1919

20+
- **Parenthesized RHS expressions now resolved.** Assignments like `$var = (new Foo())` and `$var = ($cond ? $a : $b)` now resolve correctly through the AST path. Previously the `Expression::Parenthesized` wrapper was not unwrapped in `resolve_rhs_expression`.
21+
- **`$var::` completion for class-string variables.** When a variable holds a class-string (e.g. `$cls = User::class`), using `$cls::` now offers the referenced class's static members, constants, and static properties. Handles `self::class`, `static::class`, `parent::class`, and unions from match/ternary/null-coalescing expressions.
2022
- **`?->` chaining fallback now recurses correctly.** The `?->` fallback branch in subject extraction called `extract_simple_variable` instead of `extract_arrow_subject`. The primary `->` branch already handled `?->` chains correctly via a `?` skip, so this was not user-visible, but the fallback is now consistent.
2123
- **Multi-extends interfaces now fully stored.** Interfaces extending multiple parents (e.g. `interface C extends A, B`) now store all parent names, not just the first one.
2224

docs/todo.md

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -11,57 +11,6 @@ section.
1111

1212
## Completion & Go-to-Definition Gaps
1313

14-
### Quick wins
15-
16-
#### 28. Parenthesized RHS expression not unwrapped in variable resolution ✅ **[DONE]**
17-
18-
**Completed:**
19-
`resolve_rhs_expression` now correctly unwraps `Expression::Parenthesized` nodes, so assignments like `$var = (new Foo())` and `$var = ($condition ? $a : $b)` resolve through the AST-based path.
20-
21-
---
22-
23-
#### 29 / 35. Namespace-level `const` declarations not indexed
24-
25-
Only `define('NAME', value)` calls are extracted and stored in
26-
`global_defines`. PHP's `const NAME = value;` syntax at namespace or
27-
top level is not indexed, so these constants don't appear in completion
28-
and can't be navigated to via go-to-definition.
29-
30-
```php
31-
namespace App\Config;
32-
33-
const MAX_RETRIES = 3; // ← not indexed, no completion or go-to-definition
34-
define('APP_NAME', '…'); // ← works fine
35-
```
36-
37-
**Fix:** in `extract_defines_from_statements`, also handle
38-
`Statement::Constant` nodes and store their names (namespace-qualified)
39-
in `global_defines`. This fixes both completion and go-to-definition
40-
(§35) in one change.
41-
42-
---
43-
44-
#### 25. No completion after `$var::` where variable holds a class-string
45-
46-
When a variable holds a class-string value (e.g. from `$cls = User::class`),
47-
using the `::` operator on it (`$cls::`) does not offer static members of
48-
the referenced class. The subject `$cls` is passed to `resolve_target_classes`
49-
which resolves it to its *value type* (`string` / `class-string`) rather than
50-
the *referenced class* (`User`).
51-
52-
```php
53-
$cls = User::class;
54-
$cls:: // ← no completion (should offer User's static members/constants)
55-
```
56-
57-
**Fix:** in `resolve_target_classes`, when the subject is a bare `$var`
58-
and `access_kind` is `DoubleColon`, check if the variable was assigned a
59-
`Foo::class` literal (or a union of `::class` literals from a match
60-
expression). If so, resolve to those class(es) instead of the variable's
61-
value type.
62-
63-
---
64-
6514
### High impact
6615

6716
#### 23. Match-expression class-string not forwarded to conditional return types

example.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@
5151
$ternaryUser->grantPermission('edit'); // only on AdminUser branch
5252

5353

54+
// ── Class-String Variable Static Access ─────────────────────────────────────
55+
// When a variable holds a class-string from `Foo::class`, using `$var::`
56+
// resolves to the referenced class and offers its static members.
57+
58+
$cls = User::class;
59+
$cls::findByEmail('a@b.c'); // static method from User
60+
$cls::TYPE_ADMIN; // class constant
61+
$cls::$defaultRole; // static property
62+
63+
$ref = self::class; // also works with self::class / static::class
64+
5465

5566
// ── Static & Enum Completion ────────────────────────────────────────────────
5667

src/completion/resolver.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,26 @@ impl Backend {
297297
&dummy_class
298298
}
299299
};
300+
301+
// ── `$var::` where `$var` holds a class-string ──
302+
// When the access kind is `::`, the user wants static members
303+
// of the class that the variable *references*, not the value
304+
// type (`string`). Scan for `$var = Foo::class` assignments
305+
// and resolve to those class(es).
306+
if _access_kind == AccessKind::DoubleColon {
307+
let class_string_targets = Self::resolve_class_string_targets(
308+
subject,
309+
effective_class,
310+
all_classes,
311+
ctx.content,
312+
ctx.cursor_offset,
313+
class_loader,
314+
);
315+
if !class_string_targets.is_empty() {
316+
return class_string_targets;
317+
}
318+
}
319+
300320
return Self::resolve_variable_types(
301321
subject,
302322
effective_class,

src/completion/variable_resolution.rs

Lines changed: 216 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ use crate::util::{
4242
ARRAY_ELEMENT_FUNCS, ARRAY_PRESERVING_FUNCS, find_semicolon_balanced, short_name,
4343
};
4444

45-
use super::conditional_resolution::{resolve_conditional_with_args, split_call_subject};
45+
use super::conditional_resolution::{
46+
extract_class_string_from_expr, resolve_conditional_with_args, split_call_subject,
47+
};
4648
use super::resolver::{FunctionLoaderFn, VarResolutionCtx};
4749

4850
impl Backend {
@@ -82,6 +84,219 @@ impl Backend {
8284
})
8385
}
8486

87+
/// Resolve a `$variable` that holds a class-string (e.g. `$cls = User::class`)
88+
/// to the referenced class(es).
89+
///
90+
/// This is used when the access kind is `::` (`$cls::`) — instead of
91+
/// resolving the variable to its *value type* (`string`), we resolve it
92+
/// to the *referenced class* so that static members are offered.
93+
///
94+
/// Handles simple assignments (`$cls = User::class`), match expressions
95+
/// (`$cls = match(...) { ... => A::class, ... => B::class }`), and
96+
/// ternary / null-coalescing branches.
97+
pub(super) fn resolve_class_string_targets(
98+
var_name: &str,
99+
current_class: &ClassInfo,
100+
all_classes: &[ClassInfo],
101+
content: &str,
102+
cursor_offset: u32,
103+
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
104+
) -> Vec<ClassInfo> {
105+
with_parsed_program(
106+
content,
107+
"resolve_class_string_targets",
108+
|program, _content| {
109+
let ctx = VarResolutionCtx {
110+
var_name,
111+
current_class,
112+
all_classes,
113+
content,
114+
cursor_offset,
115+
class_loader,
116+
function_loader: None,
117+
};
118+
Self::resolve_class_string_in_statements(program.statements.iter(), &ctx)
119+
},
120+
)
121+
}
122+
123+
/// Walk statements to find class-string assignments to the target variable.
124+
fn resolve_class_string_in_statements<'b>(
125+
statements: impl Iterator<Item = &'b Statement<'b>>,
126+
ctx: &VarResolutionCtx<'_>,
127+
) -> Vec<ClassInfo> {
128+
let stmts: Vec<&Statement> = statements.collect();
129+
130+
// Check class bodies first (same pattern as resolve_variable_in_statements).
131+
for &stmt in &stmts {
132+
match stmt {
133+
Statement::Class(class) => {
134+
let start = class.left_brace.start.offset;
135+
let end = class.right_brace.end.offset;
136+
if ctx.cursor_offset >= start && ctx.cursor_offset <= end {
137+
return Self::resolve_class_string_in_members(class.members.iter(), ctx);
138+
}
139+
}
140+
Statement::Interface(iface) => {
141+
let start = iface.left_brace.start.offset;
142+
let end = iface.right_brace.end.offset;
143+
if ctx.cursor_offset >= start && ctx.cursor_offset <= end {
144+
return Self::resolve_class_string_in_members(iface.members.iter(), ctx);
145+
}
146+
}
147+
Statement::Enum(enum_def) => {
148+
let start = enum_def.left_brace.start.offset;
149+
let end = enum_def.right_brace.end.offset;
150+
if ctx.cursor_offset >= start && ctx.cursor_offset <= end {
151+
return Self::resolve_class_string_in_members(enum_def.members.iter(), ctx);
152+
}
153+
}
154+
Statement::Namespace(ns) => {
155+
let results =
156+
Self::resolve_class_string_in_statements(ns.statements().iter(), ctx);
157+
if !results.is_empty() {
158+
return results;
159+
}
160+
}
161+
Statement::Function(func) => {
162+
let body_start = func.body.left_brace.start.offset;
163+
let body_end = func.body.right_brace.end.offset;
164+
if ctx.cursor_offset >= body_start && ctx.cursor_offset <= body_end {
165+
let mut results = Vec::new();
166+
Self::walk_class_string_assignments(
167+
func.body.statements.iter(),
168+
ctx,
169+
&mut results,
170+
);
171+
return results;
172+
}
173+
}
174+
_ => {}
175+
}
176+
}
177+
178+
// Top-level code.
179+
let mut results = Vec::new();
180+
Self::walk_class_string_assignments(stmts.into_iter(), ctx, &mut results);
181+
results
182+
}
183+
184+
/// Resolve class-string assignments inside class-like members.
185+
fn resolve_class_string_in_members<'b>(
186+
members: impl Iterator<Item = &'b ClassLikeMember<'b>>,
187+
ctx: &VarResolutionCtx<'_>,
188+
) -> Vec<ClassInfo> {
189+
for member in members {
190+
if let ClassLikeMember::Method(method) = member {
191+
let body = match &method.body {
192+
MethodBody::Concrete(body) => body,
193+
_ => continue,
194+
};
195+
let start = body.left_brace.start.offset;
196+
let end = body.right_brace.end.offset;
197+
if ctx.cursor_offset >= start && ctx.cursor_offset <= end {
198+
let mut results = Vec::new();
199+
Self::walk_class_string_assignments(body.statements.iter(), ctx, &mut results);
200+
return results;
201+
}
202+
}
203+
}
204+
vec![]
205+
}
206+
207+
/// Walk statements collecting class names from `$var = Foo::class` assignments.
208+
fn walk_class_string_assignments<'b>(
209+
statements: impl Iterator<Item = &'b Statement<'b>>,
210+
ctx: &VarResolutionCtx<'_>,
211+
results: &mut Vec<ClassInfo>,
212+
) {
213+
for stmt in statements {
214+
if stmt.span().start.offset >= ctx.cursor_offset {
215+
continue;
216+
}
217+
if let Statement::Expression(expr_stmt) = stmt {
218+
Self::check_class_string_assignment(expr_stmt.expression, ctx, results);
219+
}
220+
}
221+
}
222+
223+
/// Check if an expression is an assignment of a `::class` literal
224+
/// to the target variable, and if so, resolve the class.
225+
fn check_class_string_assignment(
226+
expr: &Expression<'_>,
227+
ctx: &VarResolutionCtx<'_>,
228+
results: &mut Vec<ClassInfo>,
229+
) {
230+
let Expression::Assignment(assignment) = expr else {
231+
return;
232+
};
233+
if !assignment.operator.is_assign() {
234+
return;
235+
}
236+
let lhs_name = match assignment.lhs {
237+
Expression::Variable(Variable::Direct(dv)) => dv.name.to_string(),
238+
_ => return,
239+
};
240+
if lhs_name != ctx.var_name {
241+
return;
242+
}
243+
244+
let class_names = Self::extract_class_string_names(assignment.rhs);
245+
// Clear previous results — the last unconditional assignment wins.
246+
results.clear();
247+
for name in class_names {
248+
let resolved_name = if name == "self" || name == "static" {
249+
ctx.current_class.name.clone()
250+
} else if name == "parent" {
251+
match &ctx.current_class.parent_class {
252+
Some(p) => short_name(p).to_string(),
253+
None => continue,
254+
}
255+
} else {
256+
name
257+
};
258+
let lookup = short_name(&resolved_name);
259+
if let Some(cls) = ctx.all_classes.iter().find(|c| c.name == lookup) {
260+
ClassInfo::push_unique(results, cls.clone());
261+
} else if let Some(cls) = (ctx.class_loader)(&resolved_name) {
262+
ClassInfo::push_unique(results, cls);
263+
}
264+
}
265+
}
266+
267+
/// Extract class names from `::class` expressions, recursing into
268+
/// match arms, ternary branches, null-coalescing, and parenthesized
269+
/// expressions.
270+
fn extract_class_string_names(expr: &Expression<'_>) -> Vec<String> {
271+
if let Some(name) = extract_class_string_from_expr(expr) {
272+
return vec![name];
273+
}
274+
match expr {
275+
Expression::Parenthesized(p) => Self::extract_class_string_names(p.expression),
276+
Expression::Match(match_expr) => {
277+
let mut names = Vec::new();
278+
for arm in match_expr.arms.iter() {
279+
names.extend(Self::extract_class_string_names(arm.expression()));
280+
}
281+
names
282+
}
283+
Expression::Conditional(cond) => {
284+
let mut names = Vec::new();
285+
let then_expr = cond.then.unwrap_or(cond.condition);
286+
names.extend(Self::extract_class_string_names(then_expr));
287+
names.extend(Self::extract_class_string_names(cond.r#else));
288+
names
289+
}
290+
Expression::Binary(binary) if binary.operator.is_null_coalesce() => {
291+
let mut names = Vec::new();
292+
names.extend(Self::extract_class_string_names(binary.lhs));
293+
names.extend(Self::extract_class_string_names(binary.rhs));
294+
names
295+
}
296+
_ => vec![],
297+
}
298+
}
299+
85300
/// Walk a sequence of top-level statements to find the class or
86301
/// function body that contains the cursor, then resolve the target
87302
/// variable's type within that scope.

src/parser/functions.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,12 @@ impl Backend {
223223
defines.push(name);
224224
}
225225
}
226+
// Handle namespace-level const declarations
227+
Statement::Constant(const_decl) => {
228+
for item in const_decl.items.iter() {
229+
defines.push(item.name.value.to_string());
230+
}
231+
}
226232
Statement::Namespace(namespace) => {
227233
Self::extract_defines_from_statements(namespace.statements().iter(), defines);
228234
}

0 commit comments

Comments
 (0)