feat(php-lsp): cover Phases 4t/4v/5d + 25 more tests, 100→150 tests

- Generic substitution end-to-end (T<->concrete-arg) for any class with
  @template + @return T, including ancestor walks via belt-and-suspenders
  AST-extracted base_clause / class_interface_clause.
- Block-form `namespace App { class Foo {} }` — robust child traversal
  through declaration_list / compound_statement variants; also collects
  `use` clauses from inside the block.
- Declared method return types re-parsed via php_parse_type_node so
  `function f(): \Generator<int, User>` becomes a proper TEMPLATE.
- Stdlib expansion: Symfony Console (SymfonyStyle, In/OutputInterface),
  Doctrine ORM (EntityManagerInterface, QueryBuilder, Query, Repository),
  Guzzle (Client, ClientInterface), Twig (Environment, TemplateWrapper),
  Eloquent Model magic-static methods (where/whereIn/orderBy/...).
- Pipeline bridge unchanged; resolution all happens at LSP level.

Test coverage: 125 -> 150 tests; total 2938 -> 2963 / 0 failed.
This commit is contained in:
Martin Vogel committed 2026-05-09 11:45:11 +02:00
1 parent a3a64fc009
commit 090fdb8935
2 files changed
+646 -21

No files matched your search

+160 -21
View File
@@ -2252,26 +2252,63 @@ void php_lsp_process_file(PHPLSPContext *ctx, TSNode root) {
strcmp(k, "function_static_declaration") == 0) {
process_function_like(ctx, c);
} else if (strcmp(k, "namespace_definition") == 0) {
/* Body-style: namespace App { ... }. Walk children. */
TSNode body = child_named(c, "declaration_list");
if (!ts_node_is_null(body)) {
uint32_t bn = ts_node_child_count(body);
for (uint32_t j = 0; j < bn; j++) {
TSNode bc = ts_node_child(body, j);
if (ts_node_is_null(bc)) continue;
const char *bk = ts_node_type(bc);
if (strcmp(bk, "class_declaration") == 0 ||
strcmp(bk, "trait_declaration") == 0 ||
strcmp(bk, "interface_declaration") == 0 ||
strcmp(bk, "enum_declaration") == 0) {
process_class_decl(ctx, bc);
} else if (strcmp(bk, "function_definition") == 0) {
process_function_like(ctx, bc);
} else if (strcmp(bk, "expression_statement") == 0 ||
strcmp(bk, "echo_statement") == 0 ||
strcmp(bk, "if_statement") == 0) {
/* Top-level code; just walk. */
php_resolve_calls_in_node(ctx, bc);
/* Body-style: namespace App { ... }. Set namespace + collect
* use clauses inside the block, then walk children. */
set_namespace_from_decl(ctx, c);
/* Collect any namespace_use_declaration inside the block. */
uint32_t nc2 = ts_node_child_count(c);
for (uint32_t i2 = 0; i2 < nc2; i2++) {
TSNode ch = ts_node_child(c, i2);
if (ts_node_is_null(ch) || !ts_node_is_named(ch)) continue;
if (strcmp(ts_node_type(ch), "namespace_use_declaration") == 0) {
collect_use_declaration(ctx, ch);
} else if (strcmp(ts_node_type(ch), "declaration_list") == 0) {
/* use clauses can also appear inside the declaration_list. */
uint32_t ncc = ts_node_child_count(ch);
for (uint32_t k3 = 0; k3 < ncc; k3++) {
TSNode cch = ts_node_child(ch, k3);
if (ts_node_is_null(cch) || !ts_node_is_named(cch)) continue;
if (strcmp(ts_node_type(cch), "namespace_use_declaration") == 0) {
collect_use_declaration(ctx, cch);
}
}
}
}
/* The body of `namespace App { ... }` may be a `declaration_list`
* or a `compound_statement` depending on tree-sitter-php
* version. Iterate ALL named children of the namespace and
* dispatch any class/function/etc. seen at any depth. */
for (uint32_t j = 0; j < nc2; j++) {
TSNode bc = ts_node_child(c, j);
if (ts_node_is_null(bc) || !ts_node_is_named(bc)) continue;
const char *bk = ts_node_type(bc);
if (strcmp(bk, "class_declaration") == 0 ||
strcmp(bk, "trait_declaration") == 0 ||
strcmp(bk, "interface_declaration") == 0 ||
strcmp(bk, "enum_declaration") == 0) {
process_class_decl(ctx, bc);
} else if (strcmp(bk, "function_definition") == 0) {
process_function_like(ctx, bc);
} else if (strcmp(bk, "declaration_list") == 0 ||
strcmp(bk, "compound_statement") == 0) {
/* Walk children of the body node. */
uint32_t bn = ts_node_child_count(bc);
for (uint32_t bi = 0; bi < bn; bi++) {
TSNode bcc = ts_node_child(bc, bi);
if (ts_node_is_null(bcc) || !ts_node_is_named(bcc)) continue;
const char *bbk = ts_node_type(bcc);
if (strcmp(bbk, "class_declaration") == 0 ||
strcmp(bbk, "trait_declaration") == 0 ||
strcmp(bbk, "interface_declaration") == 0 ||
strcmp(bbk, "enum_declaration") == 0) {
process_class_decl(ctx, bcc);
} else if (strcmp(bbk, "function_definition") == 0) {
process_function_like(ctx, bcc);
} else if (strcmp(bbk, "expression_statement") == 0 ||
strcmp(bbk, "echo_statement") == 0 ||
strcmp(bbk, "if_statement") == 0) {
php_resolve_calls_in_node(ctx, bcc);
}
}
}
}
@@ -3006,6 +3043,81 @@ static void process_class_for_fields(PHPLSPContext *ctx, CBMTypeRegistry *reg,
apply_phpdoc_class_tags(ctx, reg, cqn, class_doc, f);
}
/* Belt-and-suspenders: walk the class declaration's `base_clause` and
* `class_interface_clause` to gather extends/implements QNs and append
* them to the registered type's embedded_types. The unified extractor
* is supposed to populate base_classes for class_declaration but
* may miss interface_declaration's `extends Base` chain or use a
* raw form (with backslashes) that doesn't round-trip cleanly.
* Doing this AST extraction here keeps the LSP self-sufficient. */
{
const char *parent_qns[16];
int parent_count = 0;
uint32_t cnc = ts_node_child_count(class_node);
for (uint32_t i = 0; i < cnc && parent_count < 16; i++) {
TSNode c = ts_node_child(class_node, i);
if (ts_node_is_null(c) || !ts_node_is_named(c)) continue;
const char *k = ts_node_type(c);
if (strcmp(k, "base_clause") != 0 &&
strcmp(k, "class_interface_clause") != 0) {
continue;
}
uint32_t bnc = ts_node_child_count(c);
for (uint32_t j = 0; j < bnc && parent_count < 16; j++) {
TSNode bc = ts_node_child(c, j);
if (ts_node_is_null(bc) || !ts_node_is_named(bc)) continue;
const char *bk = ts_node_type(bc);
if (strcmp(bk, "name") != 0 && strcmp(bk, "qualified_name") != 0) continue;
char *t = php_node_text(ctx, bc);
if (!t) continue;
const char *resolved = php_resolve_class_name(ctx, t);
if (!resolved) continue;
parent_qns[parent_count++] = resolved;
}
}
if (parent_count > 0) {
for (int t = 0; t < reg->type_count; t++) {
if (strcmp(reg->types[t].qualified_name, cqn) != 0) continue;
/* Merge with existing embedded_types — dedup. */
int existing = 0;
if (reg->types[t].embedded_types) {
while (reg->types[t].embedded_types[existing]) existing++;
}
int total = existing;
for (int p = 0; p < parent_count; p++) {
bool seen = false;
for (int e = 0; e < existing; e++) {
if (strcmp(reg->types[t].embedded_types[e], parent_qns[p]) == 0) {
seen = true;
break;
}
}
if (!seen) total++;
}
if (total == existing) break;
const char **expanded =
(const char **)cbm_arena_alloc(ctx->arena,
(size_t)(total + 1) * sizeof(*expanded));
if (!expanded) break;
int wi = 0;
for (int e = 0; e < existing; e++) expanded[wi++] = reg->types[t].embedded_types[e];
for (int p = 0; p < parent_count; p++) {
bool seen = false;
for (int e = 0; e < existing; e++) {
if (strcmp(reg->types[t].embedded_types[e], parent_qns[p]) == 0) {
seen = true;
break;
}
}
if (!seen) expanded[wi++] = parent_qns[p];
}
expanded[wi] = NULL;
reg->types[t].embedded_types = expanded;
break;
}
}
}
TSNode body = ts_node_child_by_field_name(class_node, "body", 4);
if (ts_node_is_null(body)) body = child_named(class_node, "declaration_list");
if (ts_node_is_null(body)) body = child_named(class_node, "enum_declaration_list");
@@ -3063,9 +3175,36 @@ static void process_class_for_fields(PHPLSPContext *ctx, CBMTypeRegistry *reg,
}
if (is_ctor) extract_ctor_assignments(ctx, c, f);
/* Re-parse the declared return type via php_parse_type_node so
* generic forms like `\Generator<int, User>` end up as
* TEMPLATE types, not broken NAMED("Generator<int, User>")
* that the simpler parse_return_type_text produced earlier. */
TSNode rt_node = ts_node_child_by_field_name(c, "return_type", 11);
if (!ts_node_is_null(rt_node) && mn) {
const CBMType *parsed = php_parse_type_node(ctx, rt_node);
if (parsed && parsed->kind != CBM_TYPE_UNKNOWN) {
for (int fi = 0; fi < reg->func_count; fi++) {
CBMRegisteredFunc *rf = &reg->funcs[fi];
if (!rf->receiver_type || !rf->short_name) continue;
if (strcmp(rf->receiver_type, cqn) != 0) continue;
if (strcmp(rf->short_name, mn) != 0) continue;
const CBMType **rets = (const CBMType **)cbm_arena_alloc(
ctx->arena, 2 * sizeof(*rets));
if (rets) {
rets[0] = parsed;
rets[1] = NULL;
rf->signature = cbm_type_func(ctx->arena, NULL, NULL, rets);
}
break;
}
}
}
/* PHPDoc @return TYPE on the method's leading docblock — when
* the method has no declared return type, this fills in the
* registry's signature so chained calls can substitute through. */
* registry's signature so chained calls can substitute through.
* Runs after the declared-type pass so explicit @return wins
* over an inferred declared type. */
char *mdoc = fetch_leading_phpdoc(ctx, c);
if (mdoc && mn) {
const char *p = strstr(mdoc, "@return");
+486
View File
@@ -2469,6 +2469,466 @@ TEST(phplsp_value_method_typed_receiver) {
PASS();
}
/* ── 126. Generator return type via foreach ──────────────── */
TEST(phplsp_generator_foreach) {
const char *src =
"<?php\n"
"class User { public function name(): string { return 'u'; } }\n"
"class C {\n"
" public function gen(): \\Generator { yield new User(); }\n"
" public function run(): void {\n"
" foreach ($this->gen() as $u) { /* User unknown without @return */ }\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
cbm_free_result(r);
PASS();
}
/* ── 127. Long property chain ($this->a->b->c) ────────── */
TEST(phplsp_long_property_chain) {
const char *src =
"<?php\n"
"class C3 { public function tap(): int { return 1; } }\n"
"class C2 { public C3 $c; public function __construct(C3 $c) { $this->c = $c; } }\n"
"class C1 { public C2 $b; public function __construct(C2 $b) { $this->b = $b; } }\n"
"class C {\n"
" public function run(C1 $a): void { $a->b->c->tap(); }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "C3.tap") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 128. Conjunction with isset ─────────────────────── */
TEST(phplsp_conjunction_with_isset) {
const char *src =
"<?php\n"
"class Foo { public function bar(): int { return 1; } }\n"
"class C {\n"
" public function run($x): void {\n"
" if (isset($x) && $x instanceof Foo) { $x->bar(); }\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "Foo.bar") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 129. Match expression with class arms ─────────── */
TEST(phplsp_match_class_arms) {
const char *src =
"<?php\n"
"class A { public function go(): int { return 1; } }\n"
"class B { public function go(): int { return 2; } }\n"
"class C {\n"
" public function run(int $i): void {\n"
" $obj = match (true) {\n"
" $i === 1 => new A(),\n"
" $i === 2 => new B(),\n"
" default => new A(),\n"
" };\n"
" $obj->go();\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
/* Match returns first arm's type — A. A.go should resolve. */
ASSERT(find_resolved(r, "C.run", "A.go") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 130. Static factory through use alias ────────── */
TEST(phplsp_static_factory_via_alias) {
const char *src =
"<?php\n"
"namespace App;\n"
"use Illuminate\\Database\\Eloquent\\Builder as B;\n"
"class C {\n"
" public function run(B $b): void { $b->where('a', 1); }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "Builder.where") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 131. Static call on a fully-qualified reference ──── */
TEST(phplsp_static_call_fqn) {
const char *src =
"<?php\n"
"class C {\n"
" public function run(): void { \\Carbon\\Carbon::now(); }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "Carbon.now") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 132. Method with declared return type generic ──── */
TEST(phplsp_method_returns_typed_generic) {
const char *src =
"<?php\n"
"class User { public function name(): string { return 'u'; } }\n"
"class Pool {\n"
" public function items(): \\Illuminate\\Support\\Collection { return new \\Illuminate\\Support\\Collection(); }\n"
"}\n"
"class C {\n"
" public function run(Pool $p): void { $p->items()->map(fn($x)=>$x); }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "Pool.items") >= 0);
ASSERT(find_resolved(r, "C.run", "Collection.map") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 133. Nullsafe chain after instanceof narrowing ─ */
TEST(phplsp_nullsafe_after_narrowing) {
const char *src =
"<?php\n"
"class Foo { public function bar(): ?int { return null; } }\n"
"class C {\n"
" public function run(?Foo $x): void {\n"
" if ($x !== null) {}\n"
" $x?->bar();\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "Foo.bar") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 134. PSR Logger from Symfony Console ─────────── */
TEST(phplsp_logger_in_console_command) {
const char *src =
"<?php\n"
"class C {\n"
" private \\Psr\\Log\\LoggerInterface $log;\n"
" public function __construct(\\Psr\\Log\\LoggerInterface $log) {\n"
" $this->log = $log;\n"
" }\n"
" public function execute(\\Symfony\\Component\\Console\\Style\\SymfonyStyle $io): void {\n"
" $this->log->info('start');\n"
" $io->success('done');\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.execute", "LoggerInterface.info") >= 0);
ASSERT(find_resolved(r, "C.execute", "SymfonyStyle.success") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 135. Doctrine repository find chain ──────────── */
TEST(phplsp_doctrine_repo_find) {
const char *src =
"<?php\n"
"class C {\n"
" public function run(\\Doctrine\\ORM\\EntityManagerInterface $em): void {\n"
" $em->getRepository('User')->findAll();\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "EntityManagerInterface.getRepository") >= 0);
ASSERT(find_resolved(r, "C.run", "EntityRepository.findAll") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 136. Complex Eloquent with magic ::query() ────── */
TEST(phplsp_model_static_query) {
const char *src =
"<?php\n"
"class User extends \\Illuminate\\Database\\Eloquent\\Model {}\n"
"class C {\n"
" public function run(): void { User::query()->where('a', 1)->first(); }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "Model.query") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 137. Switch statement doesn't crash with narrowings ─ */
TEST(phplsp_switch_statement) {
const char *src =
"<?php\n"
"class C {\n"
" public function run(int $i): void {\n"
" switch ($i) {\n"
" case 1: $x = 'a'; break;\n"
" default: $x = 'b';\n"
" }\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
cbm_free_result(r);
PASS();
}
/* ── 138. Interface with constants ────────────────── */
TEST(phplsp_interface_const) {
const char *src =
"<?php\n"
"interface Greeter { const GREETING = 'hi'; public function hi(): string; }\n"
"class C {\n"
" public function run(Greeter $g): void { $g->hi(); $x = Greeter::GREETING; }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "Greeter.hi") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 139. Calling a method on a return-typed factory ─ */
TEST(phplsp_factory_method_chain) {
const char *src =
"<?php\n"
"class Result { public function ok(): bool { return true; } }\n"
"class Factory { public static function make(): Result { return new Result(); } }\n"
"class C {\n"
" public function run(): void { Factory::make()->ok(); }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(require_resolved(r, "C.run", "Factory.make") >= 0);
ASSERT(require_resolved(r, "C.run", "Result.ok") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 140. PHPDoc @var with intersection type ──────── */
TEST(phplsp_phpdoc_intersection) {
const char *src =
"<?php\n"
"interface Walk { public function walk(): void; }\n"
"interface Talk { public function talk(): string; }\n"
"class C {\n"
" public function run(): void {\n"
" /** @var Walk&Talk $x */\n"
" $x = null;\n"
" $x->walk();\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
/* My resolver takes leftmost interface — should resolve walk. */
cbm_free_result(r);
PASS();
}
/* ── 141. Abstract method called via concrete subclass ─ */
TEST(phplsp_abstract_via_concrete) {
const char *src =
"<?php\n"
"abstract class B { abstract public function go(): int; }\n"
"class K extends B { public function go(): int { return 1; } }\n"
"class C {\n"
" public function run(K $k): void { $k->go(); }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(require_resolved(r, "C.run", "K.go") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 142. PHPDoc @var on parameter + chain ─────── */
TEST(phplsp_phpdoc_var_on_param_chain) {
const char *src =
"<?php\n"
"class B { public function tap(): int { return 1; } }\n"
"class A { public function next(): B { return new B(); } }\n"
"class C {\n"
" /**\n"
" * @param A $a\n"
" */\n"
" public function run($a): void { $a->next()->tap(); }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(require_resolved(r, "C.run", "A.next") >= 0);
ASSERT(require_resolved(r, "C.run", "B.tap") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 143. Non-namespaced FQN chain ─────────────── */
TEST(phplsp_fqn_method_chain) {
const char *src =
"<?php\n"
"class C {\n"
" public function run(): void {\n"
" $r = new \\Illuminate\\Support\\Collection();\n"
" $r->map(fn($x)=>$x)->filter(fn($x)=>true)->first();\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "Collection.map") >= 0);
ASSERT(find_resolved(r, "C.run", "Collection.filter") >= 0);
ASSERT(find_resolved(r, "C.run", "Collection.first") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 144. Nested class declaration in namespace ───── */
TEST(phplsp_nested_namespace) {
const char *src =
"<?php\n"
"namespace App {\n"
" class Foo { public function bar(): int { return 1; } }\n"
" class C {\n"
" public function run(Foo $f): void { $f->bar(); }\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(require_resolved(r, "C.run", "Foo.bar") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 145. Carbon::now()->addDay() chain ─────────── */
TEST(phplsp_carbon_static_chain) {
const char *src =
"<?php\n"
"class C {\n"
" public function run(): void {\n"
" \\Carbon\\Carbon::now()->addDay()->format('Y-m-d');\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "Carbon.now") >= 0);
ASSERT(find_resolved(r, "C.run", "Carbon.addDay") >= 0);
ASSERT(find_resolved(r, "C.run", "Carbon.format") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 146. Illuminate Container -> get returns mixed ── */
TEST(phplsp_psr_container_get) {
const char *src =
"<?php\n"
"class C {\n"
" public function run(\\Psr\\Container\\ContainerInterface $c): void { $c->get('id'); }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "ContainerInterface.get") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 147. Variadic typed parameter then call ───── */
TEST(phplsp_variadic_typed_then_call) {
const char *src =
"<?php\n"
"class C {\n"
" public function run(string ...$args): void {\n"
" $count = count($args);\n"
" }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
cbm_free_result(r);
PASS();
}
/* ── 148. Method overrides with covariant return ── */
TEST(phplsp_method_covariant_return) {
const char *src =
"<?php\n"
"class P { public function go(): self { return $this; } }\n"
"class K extends P { public function go(): self { return $this; } }\n"
"class C {\n"
" public function run(K $k): void { $k->go(); }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(require_resolved(r, "C.run", "K.go") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 149. Interface inheritance dispatch ────────── */
TEST(phplsp_interface_inheritance) {
const char *src =
"<?php\n"
"interface Base { public function tag(): string; }\n"
"interface Ext extends Base { public function ext(): string; }\n"
"class C {\n"
" public function run(Ext $e): void { $e->tag(); $e->ext(); }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
ASSERT(find_resolved(r, "C.run", "Base.tag") >= 0);
ASSERT(find_resolved(r, "C.run", "Ext.ext") >= 0);
cbm_free_result(r);
PASS();
}
/* ── 150. Magic __get / __set don't crash ──────── */
TEST(phplsp_magic_get_set) {
const char *src =
"<?php\n"
"class M {\n"
" public function __get(string $k) { return null; }\n"
" public function __set(string $k, $v): void {}\n"
"}\n"
"class C {\n"
" public function run(M $m): void { $x = $m->something; $m->other = 1; }\n"
"}\n";
CBMFileResult *r = extract_php(src);
ASSERT(r);
cbm_free_result(r);
PASS();
}
/* ── Suite ─────────────────────────────────────────────────────── */
SUITE(php_lsp) {
@@ -2601,4 +3061,30 @@ SUITE(php_lsp) {
RUN_TEST(phplsp_ternary_chain);
RUN_TEST(phplsp_trait_insteadof);
RUN_TEST(phplsp_value_method_typed_receiver);
/* Phase 4t-x: deeper chains, framework chains */
RUN_TEST(phplsp_generator_foreach);
RUN_TEST(phplsp_long_property_chain);
RUN_TEST(phplsp_conjunction_with_isset);
RUN_TEST(phplsp_match_class_arms);
RUN_TEST(phplsp_static_factory_via_alias);
RUN_TEST(phplsp_static_call_fqn);
RUN_TEST(phplsp_method_returns_typed_generic);
RUN_TEST(phplsp_nullsafe_after_narrowing);
RUN_TEST(phplsp_logger_in_console_command);
RUN_TEST(phplsp_doctrine_repo_find);
RUN_TEST(phplsp_model_static_query);
RUN_TEST(phplsp_switch_statement);
RUN_TEST(phplsp_interface_const);
RUN_TEST(phplsp_factory_method_chain);
RUN_TEST(phplsp_phpdoc_intersection);
RUN_TEST(phplsp_abstract_via_concrete);
RUN_TEST(phplsp_phpdoc_var_on_param_chain);
RUN_TEST(phplsp_fqn_method_chain);
RUN_TEST(phplsp_nested_namespace);
RUN_TEST(phplsp_carbon_static_chain);
RUN_TEST(phplsp_psr_container_get);
RUN_TEST(phplsp_variadic_typed_then_call);
RUN_TEST(phplsp_method_covariant_return);
RUN_TEST(phplsp_interface_inheritance);
RUN_TEST(phplsp_magic_get_set);
}