Skip to content

Commit 5c2caab

Browse files
committed
False-positive unknown-class warnings on PHPStan type syntax
1 parent 5a5c740 commit 5c2caab

3 files changed

Lines changed: 180 additions & 0 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939
- **Sequential assert narrowing.** Multiple `assert($x instanceof A); assert($x instanceof B);` statements now accumulate, showing members from both types. Previously only the last assertion's narrowing applied.
4040
- **`instanceof` narrowing no longer widens specific types.** `assert($zoo instanceof ZooBase)` after `$zoo = new Zoo()` (where `Zoo extends ZooBase`) no longer replaces the type with the less-specific parent. The narrower `Zoo` type is preserved because it already satisfies the check. Previously this produced false-positive "unknown member" warnings for `@property` and `@method` members declared on the child class.
4141
- **Arrow function parameter completion with incomplete expressions.** Typing `$foo->` inside an arrow function body (e.g. `fn(Foo $foo) => $foo->`) now resolves the parameter type even when the expression is incomplete. The parser recovery inserts a dummy token so the surrounding arrow function structure is recognized.
42+
- **False-positive unknown-class warnings on PHPStan type syntax.** String literals in conditional return types (`$param is "foo" ? A : B`), numeric literals, and variance annotations on generic arguments (`Collection<int, covariant array{...}>`) no longer trigger "Class not found" warnings.
4243

4344

4445
- **Project configuration.** PHPantom now reads a `.phpantom.toml` file from the project root (next to `composer.json`) for per-project settings. Currently supports `[php] version` to override the detected PHP version, and `[diagnostics] unresolved-member-access` to enable the new unresolved member access diagnostic. Run `phpantom --init` to generate a default config file with all options commented out. When the file is missing, all settings use their defaults. Parse errors are reported as a warning in the editor.

src/diagnostics/unknown_classes.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,4 +855,145 @@ mod tests {
855855
diags.iter().map(|d| &d.message).collect::<Vec<_>>()
856856
);
857857
}
858+
859+
#[test]
860+
fn no_diagnostic_for_string_literal_in_conditional_return() {
861+
let backend = Backend::new_test();
862+
let uri = "file:///test.php";
863+
let content = concat!(
864+
"<?php\n",
865+
"namespace App;\n",
866+
"\n",
867+
"class Mapper {\n",
868+
" /**\n",
869+
" * @return ($signature is \"foo\" ? Pen : Marker)\n",
870+
" */\n",
871+
" public function map(string $signature): Pen|Marker {\n",
872+
" return new Pen();\n",
873+
" }\n",
874+
"}\n",
875+
"class Pen {}\n",
876+
"class Marker {}\n",
877+
);
878+
879+
let diags = collect(&backend, uri, content);
880+
assert!(
881+
!diags.iter().any(|d| d.message.contains("\"foo\"")),
882+
"should not flag string literal '\"foo\"' as unknown class, got: {:?}",
883+
diags.iter().map(|d| &d.message).collect::<Vec<_>>()
884+
);
885+
}
886+
887+
#[test]
888+
fn no_diagnostic_for_single_quoted_literal_in_conditional_return() {
889+
let backend = Backend::new_test();
890+
let uri = "file:///test.php";
891+
let content = concat!(
892+
"<?php\n",
893+
"namespace App;\n",
894+
"\n",
895+
"class Mapper {\n",
896+
" /**\n",
897+
" * @return ($sig is 'bar' ? Pen : Marker)\n",
898+
" */\n",
899+
" public function map(string $sig): Pen|Marker {\n",
900+
" return new Pen();\n",
901+
" }\n",
902+
"}\n",
903+
"class Pen {}\n",
904+
"class Marker {}\n",
905+
);
906+
907+
let diags = collect(&backend, uri, content);
908+
assert!(
909+
!diags.iter().any(|d| d.message.contains("'bar'")),
910+
"should not flag single-quoted literal as unknown class, got: {:?}",
911+
diags.iter().map(|d| &d.message).collect::<Vec<_>>()
912+
);
913+
}
914+
915+
#[test]
916+
fn no_diagnostic_for_numeric_literal_in_conditional_return() {
917+
let backend = Backend::new_test();
918+
let uri = "file:///test.php";
919+
let content = concat!(
920+
"<?php\n",
921+
"namespace App;\n",
922+
"\n",
923+
"class Mapper {\n",
924+
" /**\n",
925+
" * @return ($count is 0 ? EmptyList : FullList)\n",
926+
" */\n",
927+
" public function get(int $count): EmptyList|FullList {\n",
928+
" return new EmptyList();\n",
929+
" }\n",
930+
"}\n",
931+
"class EmptyList {}\n",
932+
"class FullList {}\n",
933+
);
934+
935+
let diags = collect(&backend, uri, content);
936+
assert!(
937+
!diags.iter().any(|d| d.message.contains("0")),
938+
"should not flag numeric literal as unknown class, got: {:?}",
939+
diags.iter().map(|d| &d.message).collect::<Vec<_>>()
940+
);
941+
}
942+
943+
#[test]
944+
fn no_diagnostic_for_covariant_variance_annotation() {
945+
let backend = Backend::new_test();
946+
let uri = "file:///test.php";
947+
let content = concat!(
948+
"<?php\n",
949+
"namespace App;\n",
950+
"\n",
951+
"class Collection {}\n",
952+
"class Customer {}\n",
953+
"class Contact {}\n",
954+
"\n",
955+
"class Repo {\n",
956+
" /**\n",
957+
" * @return Collection<int, covariant array{customer: Customer, contact: Contact|null}>\n",
958+
" */\n",
959+
" public function getAll(): Collection {\n",
960+
" return new Collection();\n",
961+
" }\n",
962+
"}\n",
963+
);
964+
965+
let diags = collect(&backend, uri, content);
966+
assert!(
967+
!diags.iter().any(|d| d.message.contains("covariant")),
968+
"should not flag 'covariant array' as unknown class, got: {:?}",
969+
diags.iter().map(|d| &d.message).collect::<Vec<_>>()
970+
);
971+
}
972+
973+
#[test]
974+
fn no_diagnostic_for_contravariant_variance_annotation() {
975+
let backend = Backend::new_test();
976+
let uri = "file:///test.php";
977+
let content = concat!(
978+
"<?php\n",
979+
"namespace App;\n",
980+
"\n",
981+
"class Handler {}\n",
982+
"\n",
983+
"class Processor {\n",
984+
" /**\n",
985+
" * @param Consumer<contravariant Handler> $consumer\n",
986+
" */\n",
987+
" public function run($consumer): void {}\n",
988+
"}\n",
989+
"class Consumer {}\n",
990+
);
991+
992+
let diags = collect(&backend, uri, content);
993+
assert!(
994+
!diags.iter().any(|d| d.message.contains("contravariant")),
995+
"should not flag 'contravariant Handler' as unknown class, got: {:?}",
996+
diags.iter().map(|d| &d.message).collect::<Vec<_>>()
997+
);
998+
}
858999
}

src/symbol_map/docblock.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,44 @@ pub(super) fn emit_type_spans(
537537
return;
538538
}
539539

540+
// ── Skip string literals ────────────────────────────────────────
541+
// PHPStan conditional return types allow literal strings as the
542+
// condition value, e.g. `($sig is "foo" ? A : B)`. These are not
543+
// class names and must not produce ClassReference spans.
544+
if (type_name.starts_with('"') && type_name.ends_with('"'))
545+
|| (type_name.starts_with('\'') && type_name.ends_with('\''))
546+
{
547+
return;
548+
}
549+
550+
// ── Skip numeric literals ───────────────────────────────────────
551+
// Literal integers/floats (e.g. `123`, `-1`, `3.14`) can appear in
552+
// conditional types and const expressions. They are not class names.
553+
if type_name
554+
.strip_prefix('-')
555+
.unwrap_or(type_name)
556+
.starts_with(|c: char| c.is_ascii_digit())
557+
{
558+
return;
559+
}
560+
561+
// ── Strip PHPStan variance annotations ──────────────────────────
562+
// Generic type arguments may carry a variance prefix, e.g.
563+
// `Collection<int, covariant array{customer: Customer}>`.
564+
// Strip the prefix and adjust the offset so the underlying type is
565+
// processed correctly.
566+
let (type_name, extra_offset) = if let Some(rest) = type_name.strip_prefix("covariant ") {
567+
(rest, extra_offset + "covariant ".len() as u32)
568+
} else if let Some(rest) = type_name.strip_prefix("contravariant ") {
569+
(rest, extra_offset + "contravariant ".len() as u32)
570+
} else {
571+
(type_name, extra_offset)
572+
};
573+
574+
if type_name.is_empty() {
575+
return;
576+
}
577+
540578
// Handle `$this` as a self-reference (equivalent to `static`).
541579
if type_name == "$this" {
542580
let start = token_file_offset + extra_offset;

0 commit comments

Comments
 (0)