Skip to content

Commit 0eb1972

Browse files
committed
feat(docblock): recognize @phpstan-sealed tag for class references
Add support for the `@phpstan-sealed FooClass|BarClass` PHPStan tag so that class names referenced in it are treated as type references. This prevents false 'unused import' diagnostics when a use statement is only referenced via `@phpstan-sealed`. Changes: - symbol_map/docblock.rs: add "phpstan-sealed" to TYPE_FIRST_OTHER_NAMES so the tag's description is parsed as a type and emits ClassReference spans - diagnostics/unused_imports.rs: add "@phpstan-sealed" to PHPDOC_TYPE_TAGS so the content safety-net recognizes it - completion/phpdoc/context.rs: add "phpstan-sealed" to type-first tags so docblock type completion works after the tag - completion/phpdoc/mod.rs: add TagDef for @phpstan-sealed autocomplete Closes #185
1 parent 31cc5e2 commit 0eb1972

7 files changed

Lines changed: 58 additions & 1 deletion

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- **Array-callable navigation.** Method-name strings in array callables — `[Controller::class, 'method']` and `[$object, 'method']` — now resolve like a real member reference. This makes go-to-definition, find-references, and rename work on Laravel controller actions such as `Route::get('/', [IndexPageController::class, 'indexPage'])`.
1414
- **Array-callable method completion.** Typing inside the method-name string of an array callable (`[Controller::class, '|']`) now offers method name completions from the resolved class, including inherited and trait methods. Works with `Class::class` constants, `$this`, and typed variables. (thanks @calebdw)
1515
- **Convert arrow function to closure.** A new `refactor.rewrite` code action converts arrow functions to anonymous closures (`fn($x) => $x * 2` to `function($x) { return $x * 2; }`). Variables from the outer scope are automatically captured via a `use()` clause. Preserves `static` and return type hints. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/191.
16+
- **`@phpstan-sealed` tag support.** The `@phpstan-sealed FooClass|BarClass` PHPDoc tag is now recognized. Class names in the tag are treated as type references, preventing false "unused import" diagnostics. Docblock completion also offers the tag. (contributed by @calebdw)
1617
- **Magic methods complete when implemented.** Magic methods declared on a class (`__invoke`, `__toString`, `__call`, and the rest) are now offered in member completion, so explicit calls like `$x->__invoke()` autocomplete and support go-to-definition. They are sorted below the regular methods so they never appear at the top of the list.
1718
- **Staleness detection and auto-refresh.** The class index, function index, and constant index now stay fresh automatically. When PHP files are created or deleted outside the editor (e.g. `git checkout`, code generation), the indices update without a restart, and edits made outside the editor are reflected the next time the file is used. When `composer.json` or `composer.lock` changes (e.g. after `composer install`), vendor packages are rescanned automatically.
1819
- **`#[ArrayShape]` attribute support.** Functions and methods annotated with `#[ArrayShape(["key" => "type", ...])]` (used by ~84 phpstorm-stubs entries) now produce array shape key completions, hover type info, and correct type resolution. Affects commonly used functions like `parse_url`, `stat`, `pathinfo`, `gc_status`, `getimagesize`, and `session_get_cookie_params`.

src/completion/phpdoc/context.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ const TYPE_TAGS: &[&str] = &[
104104
"phpstan-assert-if-false",
105105
"phpstan-require-extends",
106106
"phpstan-require-implements",
107+
"phpstan-sealed",
107108
"psalm-param",
108109
"psalm-return",
109110
];

src/completion/phpdoc/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,11 @@ const PHPSTAN_CLASS_TAGS: &[TagDef] = &[
457457
detail: "PHPStan: require implementing a specific interface",
458458
label: Some("@phpstan-require-implements InterfaceName"),
459459
},
460+
TagDef {
461+
tag: "@phpstan-sealed",
462+
detail: "PHPStan: restrict which classes may extend/implement this class",
463+
label: Some("@phpstan-sealed ClassName|OtherClass"),
464+
},
460465
];
461466

462467
const PHPSTAN_PROPERTY_TAGS: &[TagDef] = &[];

src/diagnostics/unused_imports.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ const PHPDOC_TYPE_TAGS: &[&str] = &[
349349
"@phpstan-implements",
350350
"@phpstan-require-extends",
351351
"@phpstan-require-implements",
352+
"@phpstan-sealed",
352353
"@psalm-extends",
353354
"@psalm-implements",
354355
];

src/docblock/parser.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,18 @@ mod tests {
573573
);
574574
}
575575

576+
#[test]
577+
fn phpstan_sealed_tag_parsed() {
578+
let doc = "/**\n * @phpstan-sealed FooClass|BarClass\n */";
579+
let info = parse_docblock_for_tags(doc).expect("should parse");
580+
assert_eq!(info.tags.len(), 1);
581+
// mago-docblock doesn't have a dedicated variant for @phpstan-sealed,
582+
// so it falls through to TagKind::Other.
583+
assert_eq!(info.tags[0].kind, TagKind::Other);
584+
assert_eq!(info.tags[0].name, "phpstan-sealed");
585+
assert_eq!(info.tags[0].description, "FooClass|BarClass");
586+
}
587+
576588
#[test]
577589
fn multiline_return_description_uses_newlines() {
578590
let doc = "/**\n * @return array an array containing all the elements of arr1\n * after applying the callback function to each one.\n */";

src/symbol_map/docblock.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ const TYPE_FIRST_KINDS: &[TagKind] = &[
171171
/// listed here because `mago-docblock` now maps them to dedicated
172172
/// `TagKind::PsalmReturn` / `PsalmParam` / `PsalmVar` variants (handled
173173
/// in `TYPE_FIRST_KINDS` above).
174-
const TYPE_FIRST_OTHER_NAMES: &[&str] = &[];
174+
const TYPE_FIRST_OTHER_NAMES: &[&str] = &["phpstan-sealed"];
175175

176176
use crate::docblock::templates::{TEMPLATE_KINDS, variance_for};
177177

src/symbol_map/tests.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,43 @@ fn docblock_phpstan_require_implements_produces_class_reference() {
744744
}
745745
}
746746

747+
#[test]
748+
fn docblock_phpstan_sealed_produces_class_references() {
749+
let php = concat!(
750+
"<?php\n",
751+
"use App\\FooClass;\n",
752+
"use App\\BarClass;\n",
753+
"/** @phpstan-sealed FooClass|BarClass */\n",
754+
"class BaseClass {}\n"
755+
);
756+
let map = parse_and_extract(php);
757+
let docblock_start = php.find("/**").unwrap();
758+
let foo_in_doc = php[docblock_start..].find("FooClass").unwrap() + docblock_start;
759+
760+
let hit = map.lookup(foo_in_doc as u32);
761+
assert!(
762+
hit.is_some(),
763+
"Should find FooClass in @phpstan-sealed docblock"
764+
);
765+
if let SymbolKind::ClassReference { ref name, .. } = hit.unwrap().kind {
766+
assert_eq!(name, "FooClass");
767+
} else {
768+
panic!("Expected ClassReference for FooClass");
769+
}
770+
771+
let bar_in_doc = php[docblock_start..].find("BarClass").unwrap() + docblock_start;
772+
let hit2 = map.lookup(bar_in_doc as u32);
773+
assert!(
774+
hit2.is_some(),
775+
"Should find BarClass in @phpstan-sealed docblock"
776+
);
777+
if let SymbolKind::ClassReference { ref name, .. } = hit2.unwrap().kind {
778+
assert_eq!(name, "BarClass");
779+
} else {
780+
panic!("Expected ClassReference for BarClass");
781+
}
782+
}
783+
747784
#[test]
748785
fn docblock_fqn_type() {
749786
let php = concat!(

0 commit comments

Comments
 (0)