Skip to content

Commit 5774950

Browse files
committed
Add invalid class-like kind diagnostics and fix bulk unused import
action in braced namespaces
1 parent be328ad commit 5774950

21 files changed

Lines changed: 1261 additions & 163 deletions

docs/CHANGELOG.md

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

1010
### Added
1111

12+
- **Invalid class-like kind diagnostics.** Flags class-like names used in positions where their kind is guaranteed to fail at runtime: `new` on abstract classes, interfaces, traits, or enums; `extends` on a final class, interface, or trait; `implements` with a non-interface; trait `use` with a non-trait; `instanceof` with a trait (always false); `catch` with a non-Throwable type or trait; and traits in native type-hint positions. Severity follows PHP semantics: unconditional fatal errors are Error, runtime-conditional failures are Warning.
1213
- **Nested array shape inference from multi-level key assignments.** Assignments like `$b['a']['b'] = 'x'` now produce a nested array shape type (`array{a: array{b: string}}`), enabling array key completion and hover for arrays built incrementally with nested keys. Previously only single-level key assignments were tracked.
1314

1415
### Fixed
1516

17+
- **"Remove all unused imports" missing in braced namespaces.** The bulk action now appears when the cursor is on a `use` line inside a `namespace Foo { … }` block. Previously the brace-depth heuristic treated these as trait-use statements inside a class body.
1618
- **False-positive undefined variable on nested array access assignment.** `$b['a']['a'] = 'a'` no longer flags `$b` as undefined. The scope collector now correctly propagates write context through nested `ArrayAccess` and `ArrayAppend` expressions, so the base variable is recognized as defined.
1719

1820
## [0.7.0] - 2026-04-08

docs/todo.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ within the same impact tier.
3333

3434
| # | Item | Impact | Effort |
3535
| --- | ---------------------------------------------------------------------------------------------------------------------------- | ----------- | ------ |
36-
| | Clear [refactoring gate](todo/refactor.md) |||
37-
| D11 | [Invalid class-like kind in context](todo/diagnostics.md#d11-invalid-class-like-kind-in-context) | Medium | Low |
3836
| F10 | [Linked editing ranges](todo/lsp-features.md#f10-linked-editing-ranges) | Medium | Low |
3937
| A36 | [Import all missing classes](todo/actions.md#a36-import-all-missing-classes) (bulk import) | Medium | Low |
4038
| F6 | [Machine-readable CLI output formats](todo/lsp-features.md#f6-machine-readable-cli-output-formats) (`--format github\|json`) | Medium | Low |

docs/todo/diagnostics.md

Lines changed: 0 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -230,84 +230,6 @@ with `command`, `timeout`, and tool-specific options mirroring the
230230

231231
---
232232

233-
## D11. Invalid class-like kind in context
234-
235-
**Impact: Medium · Effort: Low**
236-
237-
PHP accepts certain class-like names syntactically in positions where
238-
they are guaranteed to fail at runtime or be silently useless. These
239-
are not parse errors, so `php -l` does not catch them. PHPStan catches
240-
some of these (e.g. `new` on an abstract class) but not all. A
241-
dedicated diagnostic rule can flag them all consistently using the
242-
same context knowledge that the completion system already applies
243-
(the `ClassNameContext` enum and `TypeHint` variant).
244-
245-
The rule table:
246-
247-
| Position | What to flag | Severity | Runtime behavior |
248-
| -------------------------- | --------------------------------------------- | -------- | ----------------------------------------- |
249-
| `new X` | Abstract class, interface, trait, enum | Error | Fatal error: Cannot instantiate |
250-
| `throw new X` | Non-Throwable class | Error | Fatal error: Cannot throw |
251-
| `throw new X` | Abstract class, interface, trait, enum | Error | Fatal error: Cannot instantiate |
252-
| `$x instanceof X` | Trait | Warning | Always evaluates to `false` |
253-
| `catch (X $e)` | Trait | Warning | Never catches anything |
254-
| `catch (X $e)` | Non-Throwable class or interface | Error | Never catches, uncaught exception crashes |
255-
| `class A extends X` | Final class | Error | Fatal error: Cannot extend final class |
256-
| `class A implements X` | Class, trait, enum | Error | Fatal error: Not an interface |
257-
| `interface A extends X` | Class, trait, enum | Error | Fatal error: Not an interface |
258-
| `class A { use X; }` | Class, interface, enum | Error | Fatal error: Not a trait |
259-
| `function f(X $p)`, `): X` | Trait | Warning | Type check always fails |
260-
| `public X $prop` | Trait | Warning | Type check always fails |
261-
| `@param X`, `@return X` | Trait | Hint | Documents unsatisfiable constraint |
262-
| `@throws X` | Non-Throwable class or interface, trait, enum | Hint | Documents impossible throw |
263-
264-
**Why Warning for traits in type positions (not Error).** PHP does not
265-
reject the code at parse time or class loading time. The fatal
266-
`TypeError` only occurs at the specific call site when a value actually
267-
reaches the type check. Code paths that are never executed with a
268-
mismatched value will run without error. This is different from `class
269-
extends final` which crashes unconditionally when the class is loaded.
270-
271-
**Why Hint for PHPDoc.** PHPDoc has no runtime enforcement at all. A
272-
trait in `@param` is useless documentation but does not crash anything.
273-
This aligns with the severity philosophy: hints are for code quality
274-
issues that static analysis enthusiasts care about.
275-
276-
**Implementation:**
277-
278-
1. During AST extraction (or as a post-parse diagnostic pass), walk
279-
class declarations and check `extends`, `implements`, and `use`
280-
references against loaded `ClassInfo` entries. If the referenced
281-
class is loaded and its kind does not match the position, emit
282-
a diagnostic.
283-
284-
2. For `new X`, `throw new X`, `instanceof X`, and `catch (X)`, scan
285-
expression nodes in method bodies. Resolve `X` to a `ClassInfo`
286-
(if loaded) and check kind/modifier compatibility.
287-
288-
3. For native type hints, scan parameter types, return types, and
289-
property types. Resolve each class-like reference and check for
290-
trait kind.
291-
292-
4. For PHPDoc types, scan `@param`, `@return`, `@var`, and `@throws`
293-
tags. Resolve each class-like reference. Flag traits in type
294-
positions and non-Throwable types in `@throws`.
295-
296-
5. Only flag references where the target class is loaded (in
297-
`ast_map` or stubs). Unknown classes should not be flagged here
298-
(that is D4's job). This avoids false positives from unloaded
299-
classmap entries where the kind is unknown.
300-
301-
**Relationship to completion filtering.** The completion context
302-
detector (`ClassNameContext` enum in `class_completion.rs`) and this
303-
diagnostic rule use the same underlying knowledge (which kinds are
304-
valid in which positions). The completion system already prevents the
305-
user from inserting a wrong kind; this diagnostic catches wrong kinds
306-
that are already in the code. Both should share the same rule table
307-
to stay in sync.
308-
309-
---
310-
311233
## D12. Mago linter integration (optional diagnostics)
312234

313235
**Impact: Medium · Effort: Medium**

example.php

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,10 @@
1919
namespace Demo {
2020

2121
use Attribute;
22-
use Bug10298\PropAttr;
23-
use Bug5607\Cl;
2422
use Closure;
2523
use Demo\ValidationException;
2624
use Demo\NotFoundException;
2725
use Exception;
28-
use Override;
29-
use PHPStan\DependencyInjection\GenerateFactory;
30-
use PHPStan\Reflection\ClassReflection;
31-
use ReadonlyPropertyAssignPhpDoc\C;
32-
use Stringable;
3326
use Demo\UserProfile as Profile;
3427

3528
// ═══════════════════════════════════════════════════════════════════════════
@@ -3332,6 +3325,44 @@ public function demo(array $pens): void
33323325
}
33333326

33343327

3328+
// ── Invalid Class-Like Kind Diagnostics ─────────────────────────────────────
3329+
// PHPantom flags class-like names used in positions where their kind is
3330+
// guaranteed to fail at runtime. Open demo() and look for Error/Warning
3331+
// squiggles on the class names.
3332+
3333+
class InvalidClassKindDemo
3334+
{
3335+
public function demo(): void
3336+
{
3337+
// Error: cannot instantiate abstract class
3338+
$a = new ScaffoldingAbstractShape();
3339+
3340+
// Error: cannot instantiate enum
3341+
$b = new Status();
3342+
3343+
// Warning: instanceof with a trait always evaluates to false
3344+
$x = new Pen('test');
3345+
$result = $x instanceof JsonSerializer;
3346+
3347+
// Warning: trait in a type hint will always fail type checking
3348+
$this->acceptTrait(new Pen('test'));
3349+
}
3350+
3351+
private function acceptTrait(JsonSerializer $x): JsonSerializer
3352+
{
3353+
return $x;
3354+
}
3355+
3356+
// These also produce diagnostics but would crash at class-load time,
3357+
// so they are shown as comments only:
3358+
//
3359+
// class Bad1 extends ScaffoldingDrawable {} // Error: interface in extends
3360+
// class Bad2 implements Pen {} // Error: class in implements
3361+
// class Bad3 { use ScaffoldingDrawable; } // Error: interface in trait use
3362+
// class Bad4 extends ScaffoldingAbstractShape {} // OK: abstract classes CAN be extended
3363+
}
3364+
3365+
33353366
// ═══════════════════════════════════════════════════════════════════════════
33363367
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
33373368
// ┃ SCAFFOLDING — Supporting definitions below this line. ┃

src/code_actions/import_class.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ impl Backend {
6868
}
6969

7070
let (ref_name, is_fqn) = match &span.kind {
71-
SymbolKind::ClassReference { name, is_fqn } => (name.as_str(), *is_fqn),
71+
SymbolKind::ClassReference { name, is_fqn, .. } => (name.as_str(), *is_fqn),
7272
_ => continue,
7373
};
7474

src/code_actions/remove_unused_import.rs

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -199,20 +199,37 @@ fn cursor_on_use_import_line(content: &str, line: u32) -> bool {
199199
return false;
200200
}
201201

202-
// Heuristic: if we're inside a class/trait/enum body (brace depth > 0
203-
// at this line), this is a trait `use`, not a namespace import.
204-
let mut depth: i32 = 0;
202+
// Heuristic: if we're inside a class/trait/enum body, this is a
203+
// trait `use`, not a namespace import. We track brace depth and
204+
// account for braced `namespace Foo { … }` blocks (where depth 1
205+
// is still "top level" for import purposes).
206+
let mut depth: usize = 0;
207+
let mut namespace_brace_depth: Option<usize> = None;
205208
for l in &lines[..idx] {
206-
for ch in l.chars() {
209+
let code = l.split("//").next().unwrap_or(l);
210+
let code = code.split('#').next().unwrap_or(code);
211+
let ltrimmed = l.trim_start();
212+
213+
if ltrimmed.starts_with("namespace ") && code.contains('{') {
214+
namespace_brace_depth = Some(depth);
215+
}
216+
217+
for ch in code.chars() {
207218
match ch {
208219
'{' => depth += 1,
209-
'}' => depth -= 1,
220+
'}' => {
221+
depth = depth.saturating_sub(1);
222+
if namespace_brace_depth == Some(depth) {
223+
namespace_brace_depth = None;
224+
}
225+
}
210226
_ => {}
211227
}
212228
}
213229
}
214230

215-
depth <= 0
231+
let top_level_depth = namespace_brace_depth.map_or(0, |d| d + 1);
232+
depth <= top_level_depth
216233
}
217234

218235
/// Build a `TextEdit` that deletes the full line(s) covered by `range`,
@@ -847,14 +864,10 @@ mod tests {
847864
#[test]
848865
fn cursor_on_use_in_braced_namespace_returns_true() {
849866
let content = "<?php\nnamespace App {\n use Foo\\Bar;\n}\n";
850-
// Brace depth at line 2 is 1 (opened by namespace), but namespace
851-
// braces are different from class braces. In practice, the
852-
// heuristic counts all braces equally. For braced namespaces the
853-
// depth is 1 which means the check `depth <= 0` fails.
854-
// This is a known limitation — braced namespaces are rare in
855-
// modern PHP. The test documents the current behavior.
856-
// If we later improve the heuristic, flip this assertion.
857-
assert!(!cursor_on_use_import_line(content, 2));
867+
// Brace depth at line 2 is 1 (opened by namespace), but
868+
// namespace braces are tracked separately so depth 1 inside a
869+
// braced namespace is still "top level" for import purposes.
870+
assert!(cursor_on_use_import_line(content, 2));
858871
}
859872

860873
#[test]

src/definition/resolve.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ impl Backend {
239239
self.resolve_self_static_parent(uri, content, position, *ssp_kind)
240240
}
241241

242-
SymbolKind::ClassReference { name, is_fqn } => {
242+
SymbolKind::ClassReference { name, is_fqn, .. } => {
243243
self.resolve_class_reference(uri, content, name, *is_fqn, cursor_offset)
244244
}
245245

src/diagnostics/deprecated.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ impl Backend {
7676
for span in &symbol_map.spans {
7777
match &span.kind {
7878
// ── Class references (type hints, new Foo, extends, etc.) ─
79-
SymbolKind::ClassReference { name, is_fqn } => {
79+
SymbolKind::ClassReference { name, is_fqn, .. } => {
8080
// Prefer mago-names byte-offset lookup when available —
8181
// it applies PHP's full name resolution rules. Fall
8282
// back to the legacy resolve_to_fqn helper otherwise.

0 commit comments

Comments
 (0)