Files
deusdata--codebase-memory-mcp/internal/cbm/lsp/php_lsp.c
T
wehub-resource-sync 41cb1c0170
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:28:05 +08:00

4363 lines
182 KiB
C
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* php_lsp.c — PHP Light Semantic Pass.
*
* In-process type-aware call resolver for PHP. Mirrors go_lsp.c / c_lsp.c
* shape:
* 1. Build a CBMTypeRegistry from file-local definitions + stdlib +
* composer PSR-4 mappings (when present).
* 2. Walk top-level: collect namespace declaration and `use` clauses.
* 3. Walk each function/method body, push scope, bind typed parameters
* and $this, resolve member/static/function call expressions.
*
* Scope is the collide-set attribution problem identified in the
* pre-flight (see docs/PHP_LSP_PRE_FLIGHT.md). Specifically: when a
* short name (e.g. value) exists as both a global helper function and a
* method on a Laravel class, $x->value() must route to the method
* variant whenever the type of $x is statically determinable, and bare
* value() must route to the helper.
*/
#include "php_lsp.h"
#include "lsp_node_iter.h"
#include "../helpers.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define PHP_EVAL_MAX_DEPTH 32
#define PHP_USE_INITIAL_CAP 16
extern const TSLanguage *tree_sitter_php_only(void);
/* Forward decls */
static void php_resolve_calls_in_node_inner(PHPLSPContext *ctx, TSNode node);
/* Depth-guarded entry for the AST call-resolution walk. The walk recurses once
* per nesting level; a deeply-nested or cyclic file can overflow the native
* stack (SIGSEGV) and take down the whole index. Past the cap the subtree is
* skipped — its calls stay unresolved, which is graceful degradation, not a
* crash. The cap is CBM_LSP_MAX_WALK_DEPTH, env-overridable via the same name.
* The walk_depth-- runs after the inner returns, so early returns in the body
* never leak the counter. */
static void php_resolve_calls_in_node(PHPLSPContext *ctx, TSNode node) {
if (ctx->walk_depth >= cbm_lsp_max_walk_depth())
return;
ctx->walk_depth++;
php_resolve_calls_in_node_inner(ctx, node);
ctx->walk_depth--;
}
static void process_function_like(PHPLSPContext *ctx, TSNode node);
static void process_class_decl(PHPLSPContext *ctx, TSNode node);
static const CBMType *php_substitute_template(CBMArena *arena, const CBMType *t,
const char *const *param_names,
const CBMType *const *args);
static const CBMType *eval_function_call_type(PHPLSPContext *ctx, TSNode call_node);
static const CBMType *eval_member_call_type(PHPLSPContext *ctx, TSNode call_node);
static const CBMType *eval_object_creation_type(PHPLSPContext *ctx, TSNode node);
static void bind_phpdoc_var(PHPLSPContext *ctx, const char *docstring);
static void parse_phpdoc_for_params(PHPLSPContext *ctx, const char *docstring, TSNode params);
static const CBMType *resolve_phpdoc_type(PHPLSPContext *ctx, const char *type_text);
static char *fetch_leading_phpdoc(PHPLSPContext *ctx, TSNode node);
/* ── helpers ────────────────────────────────────────────────────── */
static char *php_node_text(PHPLSPContext *ctx, TSNode node) {
return cbm_node_text(ctx->arena, node, ctx->source);
}
static bool node_is(TSNode n, const char *kind) {
if (ts_node_is_null(n))
return false;
return strcmp(ts_node_type(n), kind) == 0;
}
static TSNode child_named(TSNode parent, const char *kind) {
if (ts_node_is_null(parent))
return parent;
uint32_t nc = ts_node_child_count(parent);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(parent, i);
if (!ts_node_is_null(c) && strcmp(ts_node_type(c), kind) == 0)
return c;
}
TSNode null_node;
memset(&null_node, 0, sizeof(null_node));
return null_node;
}
/* PHP qualified names use "." in the graph (project.path.module.class[.method]).
* Convert "App\\Models\\User" to "App.Models.User" so we can compose with
* module_qn (which already uses ".") and look up registry entries. */
static char *php_ns_to_dot(CBMArena *a, const char *ns) {
if (!ns)
return NULL;
size_t n = strlen(ns);
char *out = (char *)cbm_arena_alloc(a, n + 1);
if (!out)
return NULL;
for (size_t i = 0; i < n; i++) {
char c = ns[i];
out[i] = (c == '\\') ? '.' : c;
}
out[n] = '\0';
/* Trim leading dot from "\\Foo". */
if (out[0] == '.')
return out + 1;
return out;
}
/* Return the substring after the last '.' or '\\'. */
static const char *php_short_name(const char *qn) {
if (!qn)
return NULL;
const char *last = qn;
for (const char *p = qn; *p; p++) {
if (*p == '.' || *p == '\\')
last = p + 1;
}
return last;
}
static bool php_is_builtin_type_name(const char *n) {
if (!n)
return false;
static const char *const builtins[] = {
"int", "integer", "float", "double", "string", "bool", "boolean",
"array", "callable", "iterable", "object", "void", "mixed", "never",
"null", "true", "false", "self", "static", "parent", NULL};
for (int i = 0; builtins[i]; i++) {
if (strcmp(n, builtins[i]) == 0)
return true;
}
return false;
}
/* ── init / context ─────────────────────────────────────────────── */
void php_lsp_init(PHPLSPContext *ctx, CBMArena *arena, const char *source, int source_len,
const CBMTypeRegistry *registry, const char *module_qn,
CBMResolvedCallArray *out) {
memset(ctx, 0, sizeof(*ctx));
ctx->arena = arena;
ctx->source = source;
ctx->source_len = source_len;
ctx->registry = registry;
ctx->module_qn = module_qn;
ctx->current_namespace_qn = "";
ctx->resolved_calls = out;
ctx->current_scope = cbm_scope_push(arena, NULL);
const char *dbg = getenv("CBM_LSP_DEBUG");
ctx->debug = (dbg && dbg[0]);
}
void php_lsp_add_use(PHPLSPContext *ctx, const char *local_name, const char *target_qn,
int use_kind) {
if (!local_name || !target_qn)
return;
if (ctx->use_count >= ctx->use_cap) {
int new_cap = ctx->use_cap ? ctx->use_cap * 2 : PHP_USE_INITIAL_CAP;
const char **nl = (const char **)cbm_arena_alloc(ctx->arena, (size_t)new_cap * sizeof(*nl));
const char **nq = (const char **)cbm_arena_alloc(ctx->arena, (size_t)new_cap * sizeof(*nq));
int *nk = (int *)cbm_arena_alloc(ctx->arena, (size_t)new_cap * sizeof(int));
if (!nl || !nq || !nk)
return;
for (int i = 0; i < ctx->use_count; i++) {
nl[i] = ctx->use_local_names[i];
nq[i] = ctx->use_target_qns[i];
nk[i] = (int)ctx->use_kinds[i];
}
ctx->use_local_names = nl;
ctx->use_target_qns = nq;
/* re-cast to the enum-typed pointer */
ctx->use_kinds = (void *)nk;
ctx->use_cap = new_cap;
}
ctx->use_local_names[ctx->use_count] = cbm_arena_strdup(ctx->arena, local_name);
ctx->use_target_qns[ctx->use_count] = cbm_arena_strdup(ctx->arena, target_qn);
((int *)ctx->use_kinds)[ctx->use_count] = use_kind;
ctx->use_count++;
}
static const char *find_use(PHPLSPContext *ctx, const char *local_name, int kind) {
for (int i = 0; i < ctx->use_count; i++) {
if ((int)ctx->use_kinds[i] != kind)
continue;
if (strcmp(ctx->use_local_names[i], local_name) == 0)
return ctx->use_target_qns[i];
}
return NULL;
}
/* Resolve a class identifier to a fully-qualified registry key.
* - Fully-qualified ("\\Foo\\Bar"): FOO.BAR — strip leading slash.
* - Aliased / single segment matches a use: the use target.
* - First segment matches a use: substitute and append remainder.
* - Otherwise: prefix with current_namespace_qn (or module).
*
* Returns an arena-allocated string in dotted form. May return NULL for builtins. */
const char *php_resolve_class_name(PHPLSPContext *ctx, const char *name) {
if (!name || !*name)
return NULL;
/* Self / static / parent are class-relative pseudo-types and must be
* checked BEFORE the builtin-name check, since they're listed as
* builtins above for type-parsing purposes. */
if (strcmp(name, "self") == 0 || strcmp(name, "static") == 0) {
return ctx->enclosing_class_qn;
}
if (strcmp(name, "parent") == 0) {
return ctx->enclosing_parent_qn;
}
if (php_is_builtin_type_name(name))
return NULL;
/* Fully-qualified — leading backslash. */
if (name[0] == '\\') {
return php_ns_to_dot(ctx->arena, name + 1);
}
/* Split first segment. */
const char *sep = name;
while (*sep && *sep != '\\')
sep++;
char *first;
if (*sep) {
first = cbm_arena_strndup(ctx->arena, name, (size_t)(sep - name));
} else {
first = cbm_arena_strdup(ctx->arena, name);
}
const char *use_target = find_use(ctx, first, CBM_PHP_USE_CLASS);
if (use_target) {
if (*sep) {
/* use App\\Models as M; M\\User -> App.Models.User */
return cbm_arena_sprintf(ctx->arena, "%s%s", use_target,
php_ns_to_dot(ctx->arena, sep));
}
return use_target;
}
/* Fallback for a same-file class reference: prefer module_qn-prefixed QN
* because that's what the unified extractor records. The PHP namespace
* declaration is *not* incorporated into the unified extractor's QNs
* (defs are keyed by file path + class name), so building from
* current_namespace_qn here would diverge and miss every same-file
* lookup. */
if (ctx->module_qn && ctx->module_qn[0]) {
return cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn,
php_ns_to_dot(ctx->arena, name));
}
return php_ns_to_dot(ctx->arena, name);
}
/* Try to find a registered type for a "namespaced" QN.
*
* Lookup order:
* 1. exact QN match
* 2. module_qn + "." + qn (same-file class)
* 3. project_root + "." + qn (drop trailing module segments one at a time)
* 4. fall back to a short-name scan of the registry, preferring the
* candidate whose QN shares the longest dot-prefix with module_qn
* (cross-file class in the same project tree).
*
* Step 4 is critical for PHP because the unified extractor builds QNs from
* file paths but `use App\\Models\\User` produces an "App.Models.User" key.
* Without short-name fallback, every cross-file class resolution would miss. */
static const CBMRegisteredType *lookup_type_with_project(PHPLSPContext *ctx, const char *qn) {
if (!qn)
return NULL;
const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, qn);
if (t)
return t;
if (ctx->module_qn && ctx->module_qn[0]) {
const char *combo = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, qn);
t = cbm_registry_lookup_type(ctx->registry, combo);
if (t)
return t;
const char *m = ctx->module_qn;
const char *last_dot = strrchr(m, '.');
while (last_dot) {
char *prefix = cbm_arena_strndup(ctx->arena, m, (size_t)(last_dot - m));
const char *try_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, qn);
t = cbm_registry_lookup_type(ctx->registry, try_qn);
if (t)
return t;
const char *prev = last_dot;
last_dot = NULL;
for (const char *p = m; p < prev; p++) {
if (*p == '.')
last_dot = p;
}
}
}
/* Step 4: short-name fallback. */
const char *short_name = qn;
for (const char *p = qn; *p; p++) {
if (*p == '.')
short_name = p + 1;
}
if (!short_name || !*short_name)
return NULL;
const CBMRegisteredType *best = NULL;
int best_score = -1;
for (int i = 0; ctx->registry && i < ctx->registry->type_count; i++) {
const CBMRegisteredType *cand = &ctx->registry->types[i];
if (!cand->short_name || strcmp(cand->short_name, short_name) != 0)
continue;
int score = 0;
if (cand->qualified_name && ctx->module_qn) {
const char *m = ctx->module_qn;
const char *q = cand->qualified_name;
while (*m && *q && *m == *q) {
if (*m == '.')
score++;
m++;
q++;
}
}
if (score > best_score) {
best_score = score;
best = cand;
}
}
return best;
}
/* ── method lookup with parent walk ─────────────────────────────── */
const CBMRegisteredFunc *php_lookup_method(PHPLSPContext *ctx, const char *class_qn,
const char *method_name) {
if (!class_qn || !method_name)
return NULL;
/* Direct lookup by the registry's receiver_qn -> method_name index. */
const CBMRegisteredFunc *f = cbm_registry_lookup_method(ctx->registry, class_qn, method_name);
if (f)
return f;
/* If the QN we have isn't the registry's canonical key, resolve the
* type to its registered identity and retry. */
const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, class_qn);
if (!t)
t = lookup_type_with_project(ctx, class_qn);
if (!t)
return NULL;
if (strcmp(t->qualified_name, class_qn) != 0) {
f = cbm_registry_lookup_method(ctx->registry, t->qualified_name, method_name);
if (f)
return f;
}
/* Walk the full ancestor chain. PHP only allows single inheritance for
* `extends`, but a class may also pick up methods from `implements` /
* traits, both of which are recorded in embedded_types. We iterate
* across all entries and recurse into each branch with cycle-detection.
*
* Cap depth at 16 across all visited types to bound runtime. */
const char *visited[32];
int visited_count = 0;
const char *frontier[32];
int frontier_count = 0;
if (t->embedded_types) {
for (int i = 0; t->embedded_types[i] && frontier_count < 32; i++) {
frontier[frontier_count++] = t->embedded_types[i];
}
}
while (frontier_count > 0 && visited_count < 32) {
const char *parent = frontier[--frontier_count];
bool seen = false;
for (int v = 0; v < visited_count; v++) {
if (strcmp(visited[v], parent) == 0) {
seen = true;
break;
}
}
if (seen)
continue;
visited[visited_count++] = parent;
f = cbm_registry_lookup_method(ctx->registry, parent, method_name);
if (f)
return f;
const CBMRegisteredType *next = cbm_registry_lookup_type(ctx->registry, parent);
if (!next)
next = lookup_type_with_project(ctx, parent);
if (!next)
continue;
if (strcmp(next->qualified_name, parent) != 0) {
f = cbm_registry_lookup_method(ctx->registry, next->qualified_name, method_name);
if (f)
return f;
}
if (next->method_qns && next->method_names) {
for (int i = 0; next->method_names[i]; i++) {
if (strcmp(next->method_names[i], method_name) == 0) {
const CBMRegisteredFunc *cand =
cbm_registry_lookup_func(ctx->registry, next->method_qns[i]);
if (cand)
return cand;
}
}
}
if (next->embedded_types) {
for (int i = 0; next->embedded_types[i] && frontier_count < 32; i++) {
frontier[frontier_count++] = next->embedded_types[i];
}
}
}
return NULL;
}
/* Detect a __call / __callStatic on a class chain. */
static bool class_has_magic_call(PHPLSPContext *ctx, const char *class_qn, bool is_static) {
const char *magic = is_static ? "__callStatic" : "__call";
return php_lookup_method(ctx, class_qn, magic) != NULL;
}
/* ── type parsing ───────────────────────────────────────────────── */
const CBMType *php_parse_type_node(PHPLSPContext *ctx, TSNode node) {
if (ts_node_is_null(node))
return cbm_type_unknown();
const char *kind = ts_node_type(node);
if (strcmp(kind, "primitive_type") == 0) {
char *t = php_node_text(ctx, node);
return cbm_type_builtin(ctx->arena, t ? t : "mixed");
}
if (strcmp(kind, "named_type") == 0 || strcmp(kind, "qualified_name") == 0 ||
strcmp(kind, "name") == 0) {
char *t = php_node_text(ctx, node);
if (!t)
return cbm_type_unknown();
const char *resolved = php_resolve_class_name(ctx, t);
if (!resolved)
return cbm_type_unknown();
return cbm_type_named(ctx->arena, resolved);
}
if (strcmp(kind, "optional_type") == 0) {
/* ?Foo — strip the '?' and recurse. */
uint32_t nc = ts_node_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
return php_parse_type_node(ctx, c);
}
}
return cbm_type_unknown();
}
if (strcmp(kind, "union_type") == 0 || strcmp(kind, "intersection_type") == 0 ||
strcmp(kind, "disjunctive_normal_form_type") == 0) {
/* Pick the first concrete (non-null) named member as a heuristic;
* full DNF dispatch is Phase 2.5 at best. */
uint32_t nc = ts_node_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const CBMType *t = php_parse_type_node(ctx, c);
if (t && t->kind != CBM_TYPE_UNKNOWN) {
if (t->kind == CBM_TYPE_BUILTIN && strcmp(t->data.builtin.name, "null") == 0) {
continue;
}
return t;
}
}
return cbm_type_unknown();
}
/* Fallback: try to read raw text and treat as a name. */
char *t = php_node_text(ctx, node);
if (!t)
return cbm_type_unknown();
if (php_is_builtin_type_name(t))
return cbm_type_builtin(ctx->arena, t);
const char *resolved = php_resolve_class_name(ctx, t);
if (!resolved)
return cbm_type_unknown();
return cbm_type_named(ctx->arena, resolved);
}
/* ── PHPDoc minimal parser ──────────────────────────────────────── */
/* Strip leading "/**", trailing "*/", and per-line "*" prefixes. Returns a
* mutable arena-allocated cleaned copy. */
static char *phpdoc_clean(CBMArena *a, const char *raw) {
if (!raw)
return NULL;
size_t n = strlen(raw);
char *out = (char *)cbm_arena_alloc(a, n + 1);
if (!out)
return NULL;
size_t w = 0;
size_t i = 0;
/* Skip leading /* and ** */
while (i < n && (raw[i] == '/' || raw[i] == '*' || raw[i] == ' ' || raw[i] == '\t'))
i++;
bool at_line_start = false;
for (; i < n; i++) {
char c = raw[i];
if (c == '\n') {
out[w++] = ' ';
at_line_start = true;
continue;
}
if (at_line_start) {
if (c == ' ' || c == '\t' || c == '*')
continue;
at_line_start = false;
}
out[w++] = c;
}
/* Trim trailing */ if (w >= 2 && out[w - 1] == '/' && out[w - 2] == '*')
w -= 2;
out[w] = '\0';
return out;
}
/* (next_tag helper not currently needed — phpdoc parsers use strstr inline.) */
/* Resolve a PHPDoc-style type spec (e.g. "App\\Foo|null", "?App\\Foo",
* "Collection<User>", "string") to a CBMType. We strip nullables, take the
* leftmost class in unions, drop generic <...> tails, then resolve. */
/* Parse a `<...>` argument list into an arena-allocated NULL-terminated
* array of CBMType*. Caller has already skipped past the leading '<'.
* Returns the number of args parsed; *out_args is the array; *out_close
* is set to the position just after the matching '>'. */
static int parse_phpdoc_template_args(PHPLSPContext *ctx, const char *p, const CBMType ***out_args,
const char **out_close) {
/* Allocate a small buffer; grow if needed. */
int cap = 4;
int count = 0;
const CBMType **buf =
(const CBMType **)cbm_arena_alloc(ctx->arena, (size_t)(cap + 1) * sizeof(*buf));
if (!buf) {
*out_args = NULL;
*out_close = p;
return 0;
}
int depth = 1;
while (*p && depth > 0) {
while (*p == ' ' || *p == '\t' || *p == ',')
p++;
if (*p == '>')
break;
const char *arg_start = p;
while (*p && depth > 0) {
if (*p == '<')
depth++;
else if (*p == '>') {
depth--;
if (depth == 0)
break;
} else if (*p == ',' && depth == 1) {
break;
}
p++;
}
size_t arg_len = (size_t)(p - arg_start);
while (arg_len > 0 && (arg_start[arg_len - 1] == ' ' || arg_start[arg_len - 1] == '\t')) {
arg_len--;
}
if (arg_len > 0) {
char *arg_text = cbm_arena_strndup(ctx->arena, arg_start, arg_len);
const CBMType *arg_type = resolve_phpdoc_type(ctx, arg_text);
if (count >= cap) {
int new_cap = cap * 2;
const CBMType **new_buf = (const CBMType **)cbm_arena_alloc(
ctx->arena, (size_t)(new_cap + 1) * sizeof(*new_buf));
if (new_buf) {
for (int i = 0; i < count; i++)
new_buf[i] = buf[i];
buf = new_buf;
cap = new_cap;
}
}
buf[count++] = arg_type;
}
}
if (*p == '>')
p++;
buf[count] = NULL;
*out_args = buf;
*out_close = p;
return count;
}
/* Add a @phpstan-type alias to ctx (idempotent — keeps first definition). */
static void php_add_phpstan_alias(PHPLSPContext *ctx, const char *name, const CBMType *type) {
if (!ctx || !name || !type)
return;
for (int i = 0; i < ctx->phpstan_alias_count; i++) {
if (strcmp(ctx->phpstan_alias_names[i], name) == 0)
return;
}
if (ctx->phpstan_alias_count >= ctx->phpstan_alias_cap) {
int new_cap = ctx->phpstan_alias_cap ? ctx->phpstan_alias_cap * 2 : 8;
const char **nn =
(const char **)cbm_arena_alloc(ctx->arena, (size_t)(new_cap + 1) * sizeof(*nn));
const CBMType **nt =
(const CBMType **)cbm_arena_alloc(ctx->arena, (size_t)(new_cap + 1) * sizeof(*nt));
if (!nn || !nt)
return;
for (int i = 0; i < ctx->phpstan_alias_count; i++) {
nn[i] = ctx->phpstan_alias_names[i];
nt[i] = ctx->phpstan_alias_types[i];
}
ctx->phpstan_alias_names = nn;
ctx->phpstan_alias_types = nt;
ctx->phpstan_alias_cap = new_cap;
}
ctx->phpstan_alias_names[ctx->phpstan_alias_count] = cbm_arena_strdup(ctx->arena, name);
ctx->phpstan_alias_types[ctx->phpstan_alias_count] = type;
ctx->phpstan_alias_count++;
}
static const CBMType *php_lookup_phpstan_alias(PHPLSPContext *ctx, const char *name) {
if (!ctx || !name)
return NULL;
for (int i = 0; i < ctx->phpstan_alias_count; i++) {
if (strcmp(ctx->phpstan_alias_names[i], name) == 0) {
return ctx->phpstan_alias_types[i];
}
}
return NULL;
}
static const CBMType *resolve_phpdoc_type(PHPLSPContext *ctx, const char *type_text) {
if (!type_text || !*type_text)
return cbm_type_unknown();
/* Skip whitespace + nullable */
while (*type_text == ' ' || *type_text == '\t' || *type_text == '?')
type_text++;
/* Take portion up to first '|', ' ', '\t', '<' */
size_t end = 0;
while (type_text[end] && type_text[end] != '|' && type_text[end] != ' ' &&
type_text[end] != '\t' && type_text[end] != '<' && type_text[end] != '\n' &&
type_text[end] != ',' && type_text[end] != '>' && type_text[end] != '{') {
end++;
}
if (end == 0)
return cbm_type_unknown();
char *first = cbm_arena_strndup(ctx->arena, type_text, end);
if (!first)
return cbm_type_unknown();
if (strcmp(first, "null") == 0) {
/* take next member */
if (type_text[end] == '|')
return resolve_phpdoc_type(ctx, type_text + end + 1);
return cbm_type_unknown();
}
/* Generic? `Foo<Bar>` or `array<int, User>` */
bool has_generic = (type_text[end] == '<');
bool has_array_shape = (type_text[end] == '{');
/* `array{...}` shape — treat as plain array for now. */
if (has_array_shape)
return cbm_type_builtin(ctx->arena, "array");
/* @phpstan-type alias takes precedence over name resolution. */
{
const CBMType *aliased = php_lookup_phpstan_alias(ctx, first);
if (aliased)
return aliased;
}
if (php_is_builtin_type_name(first)) {
if (has_generic) {
/* `array<T>`, `iterable<T>`, `list<T>`, `callable<...>`. We
* preserve the template form so foreach can extract elements
* later. */
const CBMType **args = NULL;
const char *after = NULL;
int n = parse_phpdoc_template_args(ctx, type_text + end + 1, &args, &after);
return cbm_type_template(ctx->arena, first, args, n);
}
return cbm_type_builtin(ctx->arena, first);
}
const char *resolved = php_resolve_class_name(ctx, first);
if (!resolved)
return cbm_type_unknown();
if (has_generic) {
const CBMType **args = NULL;
const char *after = NULL;
int n = parse_phpdoc_template_args(ctx, type_text + end + 1, &args, &after);
return cbm_type_template(ctx->arena, resolved, args, n);
}
return cbm_type_named(ctx->arena, resolved);
}
/* @param Type $name — rebind matching parameter. Tolerates generic
* `<...>` in the type token. */
static void parse_phpdoc_for_params(PHPLSPContext *ctx, const char *docstring, TSNode params) {
(void)params;
if (!docstring || !ctx->current_scope)
return;
const char *p = docstring;
while ((p = strstr(p, "@param")) != NULL) {
p += 6;
while (*p == ' ' || *p == '\t')
p++;
const char *type_start = p;
int depth = 0;
while (*p && *p != '\n') {
if (*p == '<')
depth++;
else if (*p == '>')
depth--;
else if (depth == 0 && (*p == ' ' || *p == '\t' || *p == '$'))
break;
p++;
}
if (p == type_start)
continue;
char *type_text = cbm_arena_strndup(ctx->arena, type_start, (size_t)(p - type_start));
while (*p == ' ' || *p == '\t')
p++;
if (*p != '$')
continue;
p++;
const char *name_start = p;
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
p++;
if (p == name_start)
continue;
char *vname = cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
const CBMType *t = resolve_phpdoc_type(ctx, type_text);
if (vname && t && t->kind != CBM_TYPE_UNKNOWN) {
cbm_scope_bind(ctx->current_scope, vname, t);
}
}
}
/* @var Type $name — bind a variable in current scope.
* Generic types like `array<User>` may contain whitespace-free `<>` — we
* tolerate those by including '<' and '>' in the type token. */
static void bind_phpdoc_var(PHPLSPContext *ctx, const char *docstring) {
if (!docstring || !ctx->current_scope)
return;
const char *p = docstring;
while ((p = strstr(p, "@var")) != NULL) {
p += 4;
while (*p == ' ' || *p == '\t')
p++;
const char *type_start = p;
/* Take a type token: stop at whitespace or '$' (start of var name).
* Allow '<', '>', ',', '|', '?' inside the type. */
int depth = 0;
while (*p && *p != '\n') {
if (*p == '<')
depth++;
else if (*p == '>')
depth--;
else if (depth == 0 && (*p == ' ' || *p == '\t' || *p == '$'))
break;
p++;
}
if (p == type_start)
continue;
char *type_text = cbm_arena_strndup(ctx->arena, type_start, (size_t)(p - type_start));
while (*p == ' ' || *p == '\t')
p++;
if (*p != '$')
continue;
p++;
const char *name_start = p;
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
p++;
if (p == name_start)
continue;
char *vname = cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
const CBMType *t = resolve_phpdoc_type(ctx, type_text);
if (vname && t && t->kind != CBM_TYPE_UNKNOWN) {
cbm_scope_bind(ctx->current_scope, vname, t);
}
}
}
/* Walk siblings backwards from `node` to find a leading PHPDoc comment
* ("/**...*/"). Returns cleaned doc text or NULL. */
static char *fetch_leading_phpdoc(PHPLSPContext *ctx, TSNode node) {
TSNode parent = ts_node_parent(node);
if (ts_node_is_null(parent))
return NULL;
uint32_t nc = ts_node_child_count(parent);
/* Find index of node within parent. */
int idx = -1;
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(parent, i);
if (ts_node_eq(c, node)) {
idx = (int)i;
break;
}
}
if (idx <= 0)
return NULL;
for (int i = idx - 1; i >= 0; i--) {
TSNode c = ts_node_child(parent, (uint32_t)i);
if (ts_node_is_null(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "comment") == 0) {
char *raw = php_node_text(ctx, c);
if (raw && raw[0] == '/' && raw[1] == '*' && raw[2] == '*') {
return phpdoc_clean(ctx->arena, raw);
}
return NULL;
}
/* Skip whitespace-only or attributes; stop at any other node. */
if (strcmp(k, "attribute_list") == 0 || strcmp(k, "attribute_group") == 0)
continue;
return NULL;
}
return NULL;
}
/* ── expression evaluation ──────────────────────────────────────── */
/* Returns the type of a tree-sitter PHP expression node.
* Coverage:
* - variable_name ($x): scope lookup ($this special-cased).
* - object_creation_expression (new X): named X.
* - member_call_expression / nullsafe variant: recurse into return type.
* - scoped_call_expression (X::m()): recurse into return type.
* - function_call_expression: function return type.
* - assignment_expression ($a = ...): type of RHS.
* - cast_expression: casted-to type.
* - parenthesized_expression: inner expression.
* - encapsed_string / string: builtin "string".
* - integer / float: builtin "int"/"float".
* - boolean / true / false: builtin "bool".
* - null: builtin "null".
* - clone_expression: type of operand.
*/
const CBMType *php_eval_expr_type(PHPLSPContext *ctx, TSNode node) {
if (ts_node_is_null(node) || ctx->eval_depth >= PHP_EVAL_MAX_DEPTH) {
return cbm_type_unknown();
}
ctx->eval_depth++;
const CBMType *result = cbm_type_unknown();
const char *kind = ts_node_type(node);
if (strcmp(kind, "variable_name") == 0) {
char *t = php_node_text(ctx, node);
if (t) {
/* Strip leading $. */
const char *name = (t[0] == '$') ? t + 1 : t;
if (strcmp(name, "this") == 0) {
if (ctx->enclosing_class_qn) {
result = cbm_type_named(ctx->arena, ctx->enclosing_class_qn);
}
} else {
const CBMType *bound = cbm_scope_lookup(ctx->current_scope, name);
if (bound)
result = bound;
}
}
} else if (strcmp(kind, "object_creation_expression") == 0) {
result = eval_object_creation_type(ctx, node);
} else if (strcmp(kind, "member_call_expression") == 0 ||
strcmp(kind, "nullsafe_member_call_expression") == 0 ||
strcmp(kind, "scoped_call_expression") == 0) {
result = eval_member_call_type(ctx, node);
} else if (strcmp(kind, "function_call_expression") == 0) {
result = eval_function_call_type(ctx, node);
} else if (strcmp(kind, "assignment_expression") == 0) {
TSNode rhs = ts_node_child_by_field_name(node, "right", 5);
if (!ts_node_is_null(rhs))
result = php_eval_expr_type(ctx, rhs);
} else if (strcmp(kind, "cast_expression") == 0) {
TSNode tnode = ts_node_child_by_field_name(node, "type", 4);
if (!ts_node_is_null(tnode))
result = php_parse_type_node(ctx, tnode);
} else if (strcmp(kind, "parenthesized_expression") == 0) {
uint32_t nc = ts_node_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
result = php_eval_expr_type(ctx, c);
break;
}
}
} else if (strcmp(kind, "clone_expression") == 0) {
uint32_t nc = ts_node_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
result = php_eval_expr_type(ctx, c);
break;
}
}
} else if (strcmp(kind, "class_constant_access_expression") == 0 ||
strcmp(kind, "scoped_property_access_expression") == 0) {
/* `Suit::Hearts` — if the LHS resolves to a registered class with a
* field by that constant name, return the field's type. This makes
* enum cases bind to the enum type. */
TSNode lhs;
memset(&lhs, 0, sizeof(lhs));
TSNode rhs;
memset(&rhs, 0, sizeof(rhs));
uint32_t cnc = ts_node_child_count(node);
int seen = 0;
for (uint32_t i = 0; i < cnc; i++) {
TSNode c = ts_node_child(node, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
if (seen == 0)
lhs = c;
else if (seen == 1)
rhs = c;
seen++;
}
if (!ts_node_is_null(lhs) && !ts_node_is_null(rhs)) {
char *cls_text = php_node_text(ctx, lhs);
char *member = php_node_text(ctx, rhs);
if (cls_text && member) {
const char *cls_qn = NULL;
if (strcmp(cls_text, "self") == 0 || strcmp(cls_text, "static") == 0)
cls_qn = ctx->enclosing_class_qn;
else if (strcmp(cls_text, "parent") == 0)
cls_qn = ctx->enclosing_parent_qn;
else
cls_qn = php_resolve_class_name(ctx, cls_text);
if (cls_qn) {
const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, cls_qn);
if (!t)
t = lookup_type_with_project(ctx, cls_qn);
if (t && t->field_names) {
for (int i = 0; t->field_names[i]; i++) {
if (strcmp(t->field_names[i], member) == 0) {
if (t->field_types && t->field_types[i]) {
result = t->field_types[i];
}
break;
}
}
}
}
}
}
} else if (strcmp(kind, "subscript_expression") == 0) {
/* $arr[$k] — if $arr: TEMPLATE("array"|"list"|..., [V]) then we
* return V (or the value-side of array<K, V>). NAMED collection
* types fall back to UNKNOWN unless the registered type provides
* an offsetGet method. */
TSNode obj = ts_node_child_by_field_name(node, "dereferencable", 14);
if (ts_node_is_null(obj)) {
uint32_t nc = ts_node_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
obj = c;
break;
}
}
}
if (!ts_node_is_null(obj)) {
const CBMType *recv = php_eval_expr_type(ctx, obj);
if (recv && recv->kind == CBM_TYPE_TEMPLATE) {
const CBMType *const *args = recv->data.template_type.template_args;
if (args) {
int n = 0;
while (args[n])
n++;
if (n >= 2 && args[1])
result = args[1];
else if (n >= 1 && args[0])
result = args[0];
}
} else if (recv && recv->kind == CBM_TYPE_NAMED) {
/* offsetGet on a registered ArrayAccess-like class. */
const CBMRegisteredFunc *og =
php_lookup_method(ctx, recv->data.named.qualified_name, "offsetGet");
if (og && og->signature && og->signature->kind == CBM_TYPE_FUNC &&
og->signature->data.func.return_types &&
og->signature->data.func.return_types[0]) {
result = og->signature->data.func.return_types[0];
}
}
}
} else if (strcmp(kind, "match_expression") == 0) {
/* match($v) { ... => arm1, default => arm2 } — result is the
* union of arm result expressions. We take the first arm result
* with a known type, falling back to UNKNOWN. */
uint32_t nc = ts_node_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "match_block") == 0) {
uint32_t bnc = ts_node_child_count(c);
for (uint32_t j = 0; j < bnc; j++) {
TSNode arm = ts_node_child(c, j);
if (ts_node_is_null(arm) || !ts_node_is_named(arm))
continue;
const char *ak = ts_node_type(arm);
if (strcmp(ak, "match_conditional_expression") != 0 &&
strcmp(ak, "match_default_expression") != 0)
continue;
/* The arm expression is the field "value" or the last
* named child after "=>". */
TSNode val = ts_node_child_by_field_name(arm, "return_expression", 17);
if (ts_node_is_null(val))
val = ts_node_child_by_field_name(arm, "value", 5);
if (ts_node_is_null(val)) {
uint32_t vnc = ts_node_child_count(arm);
for (uint32_t k2 = vnc; k2 > 0; k2--) {
TSNode vv = ts_node_child(arm, k2 - 1);
if (!ts_node_is_null(vv) && ts_node_is_named(vv)) {
val = vv;
break;
}
}
}
if (ts_node_is_null(val))
continue;
const CBMType *t = php_eval_expr_type(ctx, val);
if (t && t->kind != CBM_TYPE_UNKNOWN) {
result = t;
goto match_done;
}
}
}
}
match_done:;
} else if (strcmp(kind, "conditional_expression") == 0) {
/* $a ? $b : $c — result is type of $b (preferred) or $c. */
TSNode then_n = ts_node_child_by_field_name(node, "body", 4);
TSNode else_n = ts_node_child_by_field_name(node, "alternative", 11);
if (ts_node_is_null(then_n) || ts_node_is_null(else_n)) {
/* tree-sitter-php names: `consequence` / `alternative` */
then_n = ts_node_child_by_field_name(node, "consequence", 11);
}
if (!ts_node_is_null(then_n)) {
const CBMType *t = php_eval_expr_type(ctx, then_n);
if (t && t->kind != CBM_TYPE_UNKNOWN) {
result = t;
}
}
if ((!result || result->kind == CBM_TYPE_UNKNOWN) && !ts_node_is_null(else_n)) {
const CBMType *t = php_eval_expr_type(ctx, else_n);
if (t && t->kind != CBM_TYPE_UNKNOWN)
result = t;
}
} else if (strcmp(kind, "encapsed_string") == 0 || strcmp(kind, "string") == 0 ||
strcmp(kind, "string_value") == 0 || strcmp(kind, "heredoc") == 0) {
result = cbm_type_builtin(ctx->arena, "string");
} else if (strcmp(kind, "integer") == 0) {
result = cbm_type_builtin(ctx->arena, "int");
} else if (strcmp(kind, "float") == 0) {
result = cbm_type_builtin(ctx->arena, "float");
} else if (strcmp(kind, "boolean") == 0 || strcmp(kind, "true") == 0 ||
strcmp(kind, "false") == 0) {
result = cbm_type_builtin(ctx->arena, "bool");
} else if (strcmp(kind, "null") == 0) {
result = cbm_type_builtin(ctx->arena, "null");
} else if (strcmp(kind, "member_access_expression") == 0 ||
strcmp(kind, "nullsafe_member_access_expression") == 0) {
/* $x->prop — look up field type if known. */
TSNode obj = ts_node_child_by_field_name(node, "object", 6);
TSNode field = ts_node_child_by_field_name(node, "name", 4);
if (!ts_node_is_null(obj) && !ts_node_is_null(field)) {
const CBMType *recv = php_eval_expr_type(ctx, obj);
if (recv && recv->kind == CBM_TYPE_NAMED) {
char *fname = php_node_text(ctx, field);
const CBMRegisteredType *t =
cbm_registry_lookup_type(ctx->registry, recv->data.named.qualified_name);
if (!t)
t = lookup_type_with_project(ctx, recv->data.named.qualified_name);
if (t && t->field_names && fname) {
for (int i = 0; t->field_names[i]; i++) {
if (strcmp(t->field_names[i], fname) == 0) {
if (t->field_types && t->field_types[i]) {
result = t->field_types[i];
}
break;
}
}
}
}
}
}
ctx->eval_depth--;
return result ? result : cbm_type_unknown();
}
static const CBMType *eval_object_creation_type(PHPLSPContext *ctx, TSNode node) {
/* Find the class identifier in `new X(...)`. */
TSNode name_node;
memset(&name_node, 0, sizeof(name_node));
name_node = ts_node_child_by_field_name(node, "name", 4);
if (ts_node_is_null(name_node)) {
/* Fallback: first named child that is a name/qualified_name. */
uint32_t nc = ts_node_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "name") == 0 || strcmp(k, "qualified_name") == 0) {
name_node = c;
break;
}
}
}
if (ts_node_is_null(name_node))
return cbm_type_unknown();
char *name = php_node_text(ctx, name_node);
if (!name)
return cbm_type_unknown();
const char *resolved = php_resolve_class_name(ctx, name);
if (!resolved)
return cbm_type_unknown();
return cbm_type_named(ctx->arena, resolved);
}
static const CBMType *eval_function_call_type(PHPLSPContext *ctx, TSNode call_node) {
TSNode fn = ts_node_child_by_field_name(call_node, "function", 8);
if (ts_node_is_null(fn))
return cbm_type_unknown();
char *name = php_node_text(ctx, fn);
if (!name)
return cbm_type_unknown();
/* Try `use function` map first. */
const char *target = find_use(ctx, name, CBM_PHP_USE_FUNCTION);
const char *qn = NULL;
if (target) {
qn = target;
} else if (ctx->current_namespace_qn && ctx->current_namespace_qn[0]) {
qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->current_namespace_qn, name);
} else {
qn = cbm_arena_strdup(ctx->arena, name);
}
const CBMRegisteredFunc *f = cbm_registry_lookup_func(ctx->registry, qn);
if (!f && ctx->module_qn) {
f = cbm_registry_lookup_func(ctx->registry,
cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, name));
}
if (!f && ctx->registry) {
/* Walk the symbol table by short_name as a last resort. */
for (int i = 0; i < ctx->registry->func_count; i++) {
const CBMRegisteredFunc *cand = &ctx->registry->funcs[i];
if (cand->receiver_type)
continue;
if (cand->short_name && strcmp(cand->short_name, name) == 0) {
f = cand;
break;
}
}
}
if (!f || !f->signature)
return cbm_type_unknown();
/* PHP single-return convention. */
const CBMType *sig = f->signature;
if (sig->kind == CBM_TYPE_FUNC && sig->data.func.return_types &&
sig->data.func.return_types[0]) {
return sig->data.func.return_types[0];
}
return cbm_type_unknown();
}
/* Returns the return type of an instance/static method call expression. */
static const CBMType *eval_member_call_type(PHPLSPContext *ctx, TSNode call_node) {
const char *kind = ts_node_type(call_node);
bool is_static = strcmp(kind, "scoped_call_expression") == 0;
TSNode recv_node;
TSNode name_node;
if (is_static) {
recv_node = ts_node_child_by_field_name(call_node, "scope", 5);
name_node = ts_node_child_by_field_name(call_node, "name", 4);
} else {
recv_node = ts_node_child_by_field_name(call_node, "object", 6);
name_node = ts_node_child_by_field_name(call_node, "name", 4);
}
if (ts_node_is_null(name_node))
return cbm_type_unknown();
char *method_name = php_node_text(ctx, name_node);
if (!method_name)
return cbm_type_unknown();
const char *class_qn = NULL;
if (is_static) {
if (!ts_node_is_null(recv_node)) {
char *cls = php_node_text(ctx, recv_node);
if (cls) {
if (strcmp(cls, "self") == 0 || strcmp(cls, "static") == 0)
class_qn = ctx->enclosing_class_qn;
else if (strcmp(cls, "parent") == 0)
class_qn = ctx->enclosing_parent_qn;
else
class_qn = php_resolve_class_name(ctx, cls);
}
}
} else {
const CBMType *recv_type =
ts_node_is_null(recv_node) ? cbm_type_unknown() : php_eval_expr_type(ctx, recv_node);
if (recv_type && recv_type->kind == CBM_TYPE_NAMED) {
class_qn = recv_type->data.named.qualified_name;
} else if (recv_type && recv_type->kind == CBM_TYPE_TEMPLATE) {
class_qn = recv_type->data.template_type.template_name;
}
if (recv_type && recv_type->kind == CBM_TYPE_TEMPLATE) {
/* Apply generic substitution to method return type. */
const CBMRegisteredFunc *f = php_lookup_method(ctx, class_qn, method_name);
if (!f || !f->signature)
return cbm_type_unknown();
const CBMType *sig = f->signature;
if (sig->kind == CBM_TYPE_FUNC && sig->data.func.return_types &&
sig->data.func.return_types[0]) {
const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, class_qn);
if (!rt)
rt = lookup_type_with_project(ctx, class_qn);
if (rt && rt->type_param_names && recv_type->data.template_type.template_args) {
return php_substitute_template(ctx->arena, sig->data.func.return_types[0],
rt->type_param_names,
recv_type->data.template_type.template_args);
}
return sig->data.func.return_types[0];
}
return cbm_type_unknown();
}
}
if (!class_qn)
return cbm_type_unknown();
const CBMRegisteredFunc *f = php_lookup_method(ctx, class_qn, method_name);
if (!f || !f->signature)
return cbm_type_unknown();
const CBMType *sig = f->signature;
if (sig->kind == CBM_TYPE_FUNC && sig->data.func.return_types &&
sig->data.func.return_types[0]) {
return sig->data.func.return_types[0];
}
return cbm_type_unknown();
}
/* ── emit ───────────────────────────────────────────────────────── */
static void emit_resolved_reason(PHPLSPContext *ctx, const char *callee_qn, const char *strategy,
float confidence, const char *reason) {
if (!ctx->resolved_calls || !callee_qn || !ctx->enclosing_func_qn)
return;
CBMResolvedCall rc;
rc.caller_qn = ctx->enclosing_func_qn;
rc.callee_qn = callee_qn;
rc.strategy = strategy;
rc.confidence = confidence;
rc.reason = reason;
cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc);
}
static void emit_resolved(PHPLSPContext *ctx, const char *callee_qn, const char *strategy,
float confidence) {
emit_resolved_reason(ctx, callee_qn, strategy, confidence, NULL);
}
static void emit_unresolved(PHPLSPContext *ctx, const char *expr_text, const char *reason) {
if (!ctx->resolved_calls || !ctx->enclosing_func_qn)
return;
CBMResolvedCall rc;
rc.caller_qn = ctx->enclosing_func_qn;
rc.callee_qn = expr_text ? expr_text : "?";
rc.strategy = "lsp_unresolved";
rc.confidence = 0.0f;
rc.reason = reason;
cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc);
}
/* ── statement-level scope binding ──────────────────────────────── */
/* Recognise `$x = new Foo(...)` / `$x = Foo::create()` etc. and bind the LHS. */
static void process_assignment(PHPLSPContext *ctx, TSNode node) {
TSNode lhs = ts_node_child_by_field_name(node, "left", 4);
TSNode rhs = ts_node_child_by_field_name(node, "right", 5);
if (ts_node_is_null(lhs) || ts_node_is_null(rhs))
return;
if (!node_is(lhs, "variable_name"))
return;
char *t = php_node_text(ctx, lhs);
if (!t)
return;
const char *name = (t[0] == '$') ? t + 1 : t;
if (strcmp(name, "this") == 0)
return;
const CBMType *rhs_type = php_eval_expr_type(ctx, rhs);
if (rhs_type && rhs_type->kind != CBM_TYPE_UNKNOWN) {
/* Don't override a more-specific (NAMED/TEMPLATE) binding with
* a trivial null literal — preserves `@var T $x; $x = null;`. */
if (rhs_type->kind == CBM_TYPE_BUILTIN && rhs_type->data.builtin.name &&
strcmp(rhs_type->data.builtin.name, "null") == 0) {
const CBMType *existing = cbm_scope_lookup(ctx->current_scope, name);
if (existing &&
(existing->kind == CBM_TYPE_NAMED || existing->kind == CBM_TYPE_TEMPLATE)) {
return;
}
}
cbm_scope_bind(ctx->current_scope, name, rhs_type);
}
}
static void process_foreach(PHPLSPContext *ctx, TSNode node) {
/* Try to extract the iterable expression's element type. tree-sitter-php
* emits the iterable as the first sibling expression after `foreach (`,
* and the loop var is the variable_name child after `as`.
*
* Element-type resolution rules:
* - iterable is array<T> / list<T> / iterable<T> (TEMPLATE) → T
* - iterable is array<K, V> (TEMPLATE arg_count=2) → V (value)
* - iterable is a NAMED collection-like type whose `current()` method
* has a known return type (e.g. Iterator) → that
* - otherwise → UNKNOWN
*/
TSNode iterable;
memset(&iterable, 0, sizeof(iterable));
TSNode loop_vars[4];
int loop_var_count = 0;
memset(loop_vars, 0, sizeof(loop_vars));
/* tree-sitter-php emits foreach_statement children in source order:
* <iterable expression> ... `as` ... <loop var(s)> ... <body>
* The iterable can be ANY expression: variable_name, member_call,
* function_call_expression, member_access_expression, etc. The loop
* vars are always variable_name. The body is compound_statement /
* colon_block / a statement.
*
* We find the iterable as the first named child whose kind is an
* expression-shape; subsequent variable_name children before the body
* are the loop vars. */
uint32_t nc = ts_node_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "compound_statement") == 0 || strcmp(k, "colon_block") == 0 ||
strcmp(k, "expression_statement") == 0 || strcmp(k, "echo_statement") == 0 ||
strcmp(k, "return_statement") == 0 || strcmp(k, "if_statement") == 0 ||
strcmp(k, "for_statement") == 0 || strcmp(k, "while_statement") == 0 ||
strcmp(k, "switch_statement") == 0 || strcmp(k, "try_statement") == 0) {
break; /* reached body */
}
if (ts_node_is_null(iterable)) {
iterable = c;
} else if (strcmp(k, "variable_name") == 0 && loop_var_count < 4) {
loop_vars[loop_var_count++] = c;
} else if (strcmp(k, "pair") == 0) {
/* `$k => $v` — extract both variable_names; the second is the
* value, gets the elem_type. */
uint32_t pnc = ts_node_child_count(c);
for (uint32_t j = 0; j < pnc; j++) {
TSNode pc = ts_node_child(c, j);
if (!ts_node_is_null(pc) && ts_node_is_named(pc) &&
strcmp(ts_node_type(pc), "variable_name") == 0 && loop_var_count < 4) {
loop_vars[loop_var_count++] = pc;
}
}
}
}
const CBMType *elem_type = cbm_type_unknown();
if (!ts_node_is_null(iterable)) {
const CBMType *it_type = php_eval_expr_type(ctx, iterable);
if (it_type && it_type->kind == CBM_TYPE_TEMPLATE) {
const CBMType *const *args = it_type->data.template_type.template_args;
if (args) {
int n = 0;
while (args[n])
n++;
if (n >= 2 && args[1])
elem_type = args[1];
else if (n >= 1 && args[0])
elem_type = args[0];
}
} else if (it_type && it_type->kind == CBM_TYPE_NAMED) {
const CBMRegisteredFunc *cur =
php_lookup_method(ctx, it_type->data.named.qualified_name, "current");
if (cur && cur->signature && cur->signature->kind == CBM_TYPE_FUNC &&
cur->signature->data.func.return_types &&
cur->signature->data.func.return_types[0] &&
cur->signature->data.func.return_types[0]->kind != CBM_TYPE_UNKNOWN) {
elem_type = cur->signature->data.func.return_types[0];
}
}
}
/* Bind the loop variable(s). PHP allows `foreach ($xs as $k => $v)` with
* two: $k is the key, $v is the element. We bind elem_type to the LAST
* variable_name (the value); earlier ones get UNKNOWN as approximation. */
for (int i = 0; i < loop_var_count; i++) {
char *t = php_node_text(ctx, loop_vars[i]);
if (!t)
continue;
const char *name = (t[0] == '$') ? t + 1 : t;
const CBMType *bind_type = (i == loop_var_count - 1) ? elem_type : cbm_type_unknown();
cbm_scope_bind(ctx->current_scope, name, bind_type);
}
}
static void process_catch(PHPLSPContext *ctx, TSNode node) {
/* Bind $e to the caught exception type. */
TSNode type_list;
memset(&type_list, 0, sizeof(type_list));
TSNode var_node;
memset(&var_node, 0, sizeof(var_node));
uint32_t nc = ts_node_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "type_list") == 0)
type_list = c;
if (strcmp(k, "variable_name") == 0)
var_node = c;
}
if (ts_node_is_null(var_node))
return;
char *vt = php_node_text(ctx, var_node);
if (!vt)
return;
const char *vname = (vt[0] == '$') ? vt + 1 : vt;
const CBMType *exc_type = cbm_type_unknown();
if (!ts_node_is_null(type_list)) {
uint32_t tnc = ts_node_child_count(type_list);
for (uint32_t i = 0; i < tnc; i++) {
TSNode tc = ts_node_child(type_list, i);
if (ts_node_is_null(tc) || !ts_node_is_named(tc))
continue;
exc_type = php_parse_type_node(ctx, tc);
break;
}
}
cbm_scope_bind(ctx->current_scope, vname, exc_type);
}
/* ── call resolution ────────────────────────────────────────────── */
static void resolve_function_call(PHPLSPContext *ctx, TSNode call) {
TSNode fn = ts_node_child_by_field_name(call, "function", 8);
if (ts_node_is_null(fn))
return;
char *name = php_node_text(ctx, fn);
if (!name)
return;
/* `use function Foo\\bar;` mapping. */
const char *target = find_use(ctx, name, CBM_PHP_USE_FUNCTION);
if (target) {
const CBMRegisteredFunc *f = cbm_registry_lookup_func(ctx->registry, target);
if (f) {
emit_resolved(ctx, f->qualified_name, "php_function_namespaced", 0.95f);
return;
}
}
/* Current namespace, then global fallback. */
if (ctx->current_namespace_qn && ctx->current_namespace_qn[0]) {
const char *ns_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->current_namespace_qn, name);
const CBMRegisteredFunc *f = cbm_registry_lookup_func(ctx->registry, ns_qn);
if (f) {
emit_resolved(ctx, f->qualified_name, "php_function_namespaced", 0.95f);
return;
}
}
/* Look for any free function with this short_name. Prefer the one in the
* file's project (i.e. with the longest module-prefix overlap). */
const CBMRegisteredFunc *best = NULL;
int best_score = -1;
for (int i = 0; ctx->registry && i < ctx->registry->func_count; i++) {
const CBMRegisteredFunc *cand = &ctx->registry->funcs[i];
if (cand->receiver_type)
continue;
if (!cand->short_name || strcmp(cand->short_name, name) != 0)
continue;
int score = 0;
if (cand->qualified_name && ctx->module_qn) {
const char *m = ctx->module_qn;
const char *q = cand->qualified_name;
while (*m && *q && *m == *q) {
if (*m == '.')
score++;
m++;
q++;
}
}
if (score > best_score) {
best_score = score;
best = cand;
}
}
if (best) {
emit_resolved(ctx, best->qualified_name, "php_function_global_fallback", 0.70f);
return;
}
/* Unresolved — but DO NOT emit unresolved noise here; the unified
* extractor already records the call edge. We only contribute when we
* can correct or refine attribution. */
(void)emit_unresolved;
}
static void resolve_member_call(PHPLSPContext *ctx, TSNode call) {
TSNode obj = ts_node_child_by_field_name(call, "object", 6);
TSNode name_node = ts_node_child_by_field_name(call, "name", 4);
if (ts_node_is_null(name_node))
return;
char *method_name = php_node_text(ctx, name_node);
if (!method_name)
return;
if (ts_node_is_null(obj))
return;
const CBMType *recv = php_eval_expr_type(ctx, obj);
if (!recv)
return;
const char *class_qn = NULL;
if (recv->kind == CBM_TYPE_NAMED) {
class_qn = recv->data.named.qualified_name;
} else if (recv->kind == CBM_TYPE_TEMPLATE) {
/* `Collection<User>` resolves methods on `Collection`. */
class_qn = recv->data.template_type.template_name;
}
if (!class_qn)
return; /* unknown receiver; defer */
const CBMRegisteredFunc *f = php_lookup_method(ctx, class_qn, method_name);
if (f) {
const char *strategy = (f->receiver_type && strcmp(f->receiver_type, class_qn) == 0)
? "php_method_typed"
: "php_method_inherited";
emit_resolved(ctx, f->qualified_name, strategy, 0.95f);
return;
}
/* Receiver known but method missing — magic __call? The call dispatches to
* the class's __call handler, so resolve to <class>.__call (a real node).
* The textual callee is the dynamic method name (`anything`), not `__call`,
* so stash it in reason for the join (lsp_resolve.h, php_method_dynamic).
* Emit above the join's confidence floor — dispatch to __call is certain. */
if (class_has_magic_call(ctx, class_qn, false)) {
emit_resolved_reason(ctx, cbm_arena_sprintf(ctx->arena, "%s.__call", class_qn),
"php_method_dynamic", 0.85f, method_name);
return;
}
/* Receiver known but class not in registry (e.g. vendor type not indexed,
* or method genuinely missing). Emit a synthetic resolved call pointing
* at "<class_qn>.<method>" so the pipeline bridge can BLOCK the unified
* extractor's likely-incorrect short-name fallback. The bridge filters
* unknown targets and yields no edge — better than a wrong edge. */
emit_resolved(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", class_qn, method_name),
"php_method_typed_unindexed", 0.55f);
}
static void resolve_static_call(PHPLSPContext *ctx, TSNode call) {
TSNode scope = ts_node_child_by_field_name(call, "scope", 5);
TSNode name_node = ts_node_child_by_field_name(call, "name", 4);
if (ts_node_is_null(name_node))
return;
char *method_name = php_node_text(ctx, name_node);
if (!method_name)
return;
const char *class_qn = NULL;
const char *strategy = "php_static_resolved";
if (!ts_node_is_null(scope)) {
char *cls = php_node_text(ctx, scope);
if (cls) {
if (strcmp(cls, "self") == 0 || strcmp(cls, "static") == 0) {
class_qn = ctx->enclosing_class_qn;
strategy = "php_self_static";
} else if (strcmp(cls, "parent") == 0) {
class_qn = ctx->enclosing_parent_qn;
strategy = "php_self_static";
} else {
class_qn = php_resolve_class_name(ctx, cls);
}
}
}
if (!class_qn)
return;
const CBMRegisteredFunc *f = php_lookup_method(ctx, class_qn, method_name);
if (f) {
emit_resolved(ctx, f->qualified_name, strategy, 0.95f);
return;
}
/* Facade detection: class has __callStatic in its chain. */
if (class_has_magic_call(ctx, class_qn, true)) {
emit_resolved(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", class_qn, method_name),
"php_dynamic_unresolved", 0.10f);
return;
}
/* Class resolved (e.g., from a `use` clause), method unknown — emit
* synthetic resolved call so the pipeline bridge can suppress the
* unified extractor's name-fallback misroute. */
emit_resolved(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", class_qn, method_name),
"php_static_unindexed", 0.55f);
}
/* ── type narrowing ─────────────────────────────────────────────────
*
* Recognise narrowing predicates inside an `if` / ternary / match condition
* and a few common library calls that act as runtime asserts. When the
* predicate proves a positive constraint about a variable's type, push a
* child scope with that variable rebound to the narrower type before
* walking the body, then pop. For `assert(<predicate>);` we narrow into
* the *current* scope from that point onward, since assert() acts as a
* sequential refinement.
*
* Patterns understood:
* $x instanceof Foo → $x: Foo
* is_string($x) → $x: string (and is_int/is_float/...)
* is_array($x) → $x: array
* is_object($x) → $x: object
* is_callable($x) → $x: callable
* $x !== null / $x != null → $x: <existing> minus null (we drop null)
* $x instanceof FooInterface → $x: FooInterface (interface narrows too)
*
* Negative branches and `else` are not narrowed in Phase 4a; PHP's
* negative-narrowing semantics (e.g. `else` branch of `if (is_string($x))`
* narrows to "not-string") would require subtractive types we don't model.
*/
typedef struct {
const char *var_name; /* "x" without leading $ */
const CBMType *type; /* the narrowed type */
} php_narrowing_t;
static const char *is_func_to_builtin(const char *name) {
if (!name)
return NULL;
if (strcmp(name, "is_string") == 0)
return "string";
if (strcmp(name, "is_int") == 0 || strcmp(name, "is_integer") == 0 ||
strcmp(name, "is_long") == 0)
return "int";
if (strcmp(name, "is_float") == 0 || strcmp(name, "is_double") == 0 ||
strcmp(name, "is_real") == 0)
return "float";
if (strcmp(name, "is_bool") == 0)
return "bool";
if (strcmp(name, "is_array") == 0)
return "array";
if (strcmp(name, "is_object") == 0)
return "object";
if (strcmp(name, "is_callable") == 0)
return "callable";
if (strcmp(name, "is_iterable") == 0)
return "iterable";
if (strcmp(name, "is_numeric") == 0)
return "float";
if (strcmp(name, "is_countable") == 0)
return "iterable";
if (strcmp(name, "is_resource") == 0)
return "resource";
return NULL;
}
/* Parse a narrowing predicate. On success fill out and return true.
*
* For multi-conjunction (`P1 && P2`), we accumulate ALL narrowings into
* the *last out slot, but the caller-side pattern in process_if_statement
* only takes a single php_narrowing_t. To support full conjunction we
* additionally call parse_narrowing_collect which writes into a small
* array. parse_narrowing itself returns the FIRST narrowing it finds. */
static int parse_narrowing_collect(PHPLSPContext *ctx, TSNode cond, php_narrowing_t *out,
int max_out);
static bool parse_narrowing(PHPLSPContext *ctx, TSNode cond, php_narrowing_t *out) {
php_narrowing_t buf[1] = {{0}};
int n = parse_narrowing_collect(ctx, cond, buf, 1);
if (n > 0) {
*out = buf[0];
return true;
}
return false;
}
/* Recursive collector: adds narrowings from the predicate tree into out[]
* up to max_out. Returns the number filled. */
static int parse_narrowing_one(PHPLSPContext *ctx, TSNode cond, php_narrowing_t *out);
static int parse_narrowing_collect(PHPLSPContext *ctx, TSNode cond, php_narrowing_t *out,
int max_out) {
if (ts_node_is_null(cond) || max_out <= 0)
return 0;
const char *kind = ts_node_type(cond);
/* Parenthesized: unwrap. */
if (strcmp(kind, "parenthesized_expression") == 0) {
uint32_t nc = ts_node_child_count(cond);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(cond, i);
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
return parse_narrowing_collect(ctx, c, out, max_out);
}
}
return 0;
}
/* Conjunction `A && B` (or `A and B`) → recurse into both sides. */
if (strcmp(kind, "binary_expression") == 0) {
TSNode op = ts_node_child_by_field_name(cond, "operator", 8);
char *opt = !ts_node_is_null(op) ? php_node_text(ctx, op) : NULL;
if (opt && (strcmp(opt, "&&") == 0 || strcmp(opt, "and") == 0)) {
TSNode left = ts_node_child_by_field_name(cond, "left", 4);
TSNode right = ts_node_child_by_field_name(cond, "right", 5);
int got = parse_narrowing_collect(ctx, left, out, max_out);
got += parse_narrowing_collect(ctx, right, out + got, max_out - got);
return got;
}
}
/* Single-predicate path. */
php_narrowing_t one = {0};
if (parse_narrowing_one(ctx, cond, &one)) {
out[0] = one;
return 1;
}
return 0;
}
static int parse_narrowing_one(PHPLSPContext *ctx, TSNode cond, php_narrowing_t *out) {
if (ts_node_is_null(cond))
return 0;
const char *kind = ts_node_type(cond);
if (strcmp(kind, "parenthesized_expression") == 0) {
uint32_t nc = ts_node_child_count(cond);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(cond, i);
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
return parse_narrowing_one(ctx, c, out);
}
}
return 0;
}
/* `$x instanceof Foo` — emitted as binary_expression with operator
* "instanceof" in tree-sitter-php. */
if (strcmp(kind, "binary_expression") == 0) {
TSNode left = ts_node_child_by_field_name(cond, "left", 4);
TSNode op = ts_node_child_by_field_name(cond, "operator", 8);
TSNode right = ts_node_child_by_field_name(cond, "right", 5);
if (ts_node_is_null(left) || ts_node_is_null(right))
return false;
char *opt = !ts_node_is_null(op) ? php_node_text(ctx, op) : NULL;
if (opt && strcmp(opt, "instanceof") == 0) {
if (!node_is(left, "variable_name"))
return false;
char *vt = php_node_text(ctx, left);
if (!vt)
return false;
const char *vname = (vt[0] == '$') ? vt + 1 : vt;
char *rt = php_node_text(ctx, right);
if (!rt)
return false;
const char *resolved = php_resolve_class_name(ctx, rt);
if (!resolved)
return false;
out->var_name = cbm_arena_strdup(ctx->arena, vname);
out->type = cbm_type_named(ctx->arena, resolved);
return true;
}
/* `$x !== null` / `$x != null` / `null !== $x` — narrow to "non-null":
* we keep the existing scope type, so this is a no-op for type
* purposes but suppresses null branches downstream. We don't
* subtract nullable so just return false here. */
}
/* `is_string($x)` / `is_int($x)` / `array_key_exists(...)` /
* `method_exists($x, 'foo')` / `is_a($x, Foo::class)` / ... */
if (strcmp(kind, "function_call_expression") == 0) {
TSNode fn = ts_node_child_by_field_name(cond, "function", 8);
TSNode args = ts_node_child_by_field_name(cond, "arguments", 9);
if (ts_node_is_null(fn))
return false;
char *name = php_node_text(ctx, fn);
if (!name)
return false;
/* `is_a($x, Foo::class)` and `is_a($x, 'Foo')` narrow $x to Foo. */
if (strcmp(name, "is_a") == 0 && !ts_node_is_null(args)) {
uint32_t anc = ts_node_child_count(args);
TSNode v;
memset(&v, 0, sizeof(v));
TSNode classref;
memset(&classref, 0, sizeof(classref));
int seen = 0;
for (uint32_t i = 0; i < anc; i++) {
TSNode a = ts_node_child(args, i);
if (ts_node_is_null(a) || !ts_node_is_named(a))
continue;
TSNode inner = a;
if (strcmp(ts_node_type(a), "argument") == 0) {
uint32_t bnc = ts_node_child_count(a);
for (uint32_t j = 0; j < bnc; j++) {
TSNode b = ts_node_child(a, j);
if (!ts_node_is_null(b) && ts_node_is_named(b)) {
inner = b;
break;
}
}
}
if (seen == 0)
v = inner;
else if (seen == 1)
classref = inner;
seen++;
}
if (!ts_node_is_null(v) && !ts_node_is_null(classref) && node_is(v, "variable_name")) {
char *vt = php_node_text(ctx, v);
if (vt) {
const char *vname = (vt[0] == '$') ? vt + 1 : vt;
char *cls_text = php_node_text(ctx, classref);
if (cls_text) {
/* Strip `::class`, surrounding quotes. */
size_t cl = strlen(cls_text);
if (cl >= 8 && strcmp(cls_text + cl - 7, "::class") == 0) {
cls_text[cl - 7] = '\0';
} else if (cl >= 2 && (cls_text[0] == '"' || cls_text[0] == '\'')) {
cls_text[cl - 1] = '\0';
cls_text++;
}
const char *resolved = php_resolve_class_name(ctx, cls_text);
if (resolved) {
out->var_name = cbm_arena_strdup(ctx->arena, vname);
out->type = cbm_type_named(ctx->arena, resolved);
return true;
}
}
}
}
}
const char *builtin = is_func_to_builtin(name);
if (!builtin)
return false;
/* First positional argument should be a variable_name. */
if (ts_node_is_null(args))
return false;
uint32_t anc = ts_node_child_count(args);
for (uint32_t i = 0; i < anc; i++) {
TSNode a = ts_node_child(args, i);
if (ts_node_is_null(a) || !ts_node_is_named(a))
continue;
const char *ak = ts_node_type(a);
/* `argument` wrapper has variable_name child. */
TSNode v = a;
if (strcmp(ak, "argument") == 0) {
uint32_t bnc = ts_node_child_count(a);
for (uint32_t j = 0; j < bnc; j++) {
TSNode b = ts_node_child(a, j);
if (!ts_node_is_null(b) && ts_node_is_named(b) && node_is(b, "variable_name")) {
v = b;
break;
}
}
}
if (!node_is(v, "variable_name"))
return false;
char *vt = php_node_text(ctx, v);
if (!vt)
return false;
const char *vname = (vt[0] == '$') ? vt + 1 : vt;
out->var_name = cbm_arena_strdup(ctx->arena, vname);
out->type = cbm_type_builtin(ctx->arena, builtin);
return true;
}
}
return false;
}
/* `assert(<predicate>)` narrows the current scope sequentially. */
static void apply_assert_narrowing(PHPLSPContext *ctx, TSNode call) {
TSNode fn = ts_node_child_by_field_name(call, "function", 8);
if (ts_node_is_null(fn))
return;
char *name = php_node_text(ctx, fn);
if (!name || strcmp(name, "assert") != 0)
return;
TSNode args = ts_node_child_by_field_name(call, "arguments", 9);
if (ts_node_is_null(args))
return;
uint32_t anc = ts_node_child_count(args);
for (uint32_t i = 0; i < anc; i++) {
TSNode a = ts_node_child(args, i);
if (ts_node_is_null(a) || !ts_node_is_named(a))
continue;
TSNode predicate = a;
if (strcmp(ts_node_type(a), "argument") == 0) {
uint32_t bnc = ts_node_child_count(a);
for (uint32_t j = 0; j < bnc; j++) {
TSNode b = ts_node_child(a, j);
if (!ts_node_is_null(b) && ts_node_is_named(b)) {
predicate = b;
break;
}
}
}
php_narrowing_t nw = {0};
if (parse_narrowing(ctx, predicate, &nw) && nw.var_name && nw.type) {
cbm_scope_bind(ctx->current_scope, nw.var_name, nw.type);
}
return;
}
}
/* Walk a body subtree under a narrowed scope (with up to N bindings), then
* restore. */
static void walk_with_narrowings(PHPLSPContext *ctx, TSNode body, const php_narrowing_t *nws,
int nw_count) {
if (ts_node_is_null(body))
return;
if (!nws || nw_count <= 0) {
php_resolve_calls_in_node(ctx, body);
return;
}
CBMScope *saved = ctx->current_scope;
ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope);
for (int i = 0; i < nw_count; i++) {
if (nws[i].var_name && nws[i].type) {
cbm_scope_bind(ctx->current_scope, nws[i].var_name, nws[i].type);
}
}
php_resolve_calls_in_node(ctx, body);
ctx->current_scope = saved;
}
/* Backwards-compat single-binding wrapper. */
static void walk_with_narrowing(PHPLSPContext *ctx, TSNode body, const php_narrowing_t *nw) {
walk_with_narrowings(ctx, body, nw, nw ? 1 : 0);
}
/* Detect whether a statement node is a "leaves the function" terminator:
* return; return $x; throw ...; exit; die; continue (in a loop);
* break (in a loop). For our purposes any of these means the rest of the
* enclosing function does NOT execute the inner narrowed-out branch, so we
* can apply the negation of the inner branch as sequential narrowing. */
static bool stmt_is_terminator(TSNode stmt) {
if (ts_node_is_null(stmt))
return false;
const char *k = ts_node_type(stmt);
if (strcmp(k, "return_statement") == 0)
return true;
if (strcmp(k, "compound_statement") == 0) {
/* Single-child compound that's a terminator. */
uint32_t nc = ts_node_child_count(stmt);
TSNode last;
memset(&last, 0, sizeof(last));
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(stmt, i);
if (!ts_node_is_null(c) && ts_node_is_named(c))
last = c;
}
if (!ts_node_is_null(last))
return stmt_is_terminator(last);
}
if (strcmp(k, "expression_statement") == 0) {
uint32_t nc = ts_node_child_count(stmt);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(stmt, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *ck = ts_node_type(c);
if (strcmp(ck, "throw_expression") == 0)
return true;
if (strcmp(ck, "exit_statement") == 0)
return true;
/* exit() / die() */
if (strcmp(ck, "function_call_expression") == 0) {
TSNode fn = ts_node_child_by_field_name(c, "function", 8);
if (!ts_node_is_null(fn)) {
/* Just take the raw text and check for exit/die — even
* with namespace prefixes these are global calls. */
/* Conservative: don't claim terminator on function calls. */
}
}
}
}
if (strcmp(k, "throw_statement") == 0)
return true;
if (strcmp(k, "exit_statement") == 0)
return true;
return false;
}
/* Walk an `if_statement` (or `else_if_clause`) honoring narrowing.
*
* Tree-sitter-php's `if_statement` emits children as: an optional `if`
* keyword, a parenthesized_expression (condition), and one or more
* statement-shaped bodies (the if-body, optionally followed by
* else_if_clause / else_clause). We narrow on the condition then walk
* EVERY non-condition named child as the body — only the first
* statement after the condition is the if-body and gets the narrowed
* scope; else-branches walk without narrowing.
*
* Negative-narrowing pattern (Phase 4l):
* if (!$x instanceof Foo) { return; }
* $x->method(); // here $x is narrowed to Foo
* If the if-body is a terminator AND the condition is a logical NOT of a
* narrowable predicate, apply the (positive) narrowing into the CURRENT
* scope sequentially for the remainder of the enclosing function.
*/
static void process_if_statement(PHPLSPContext *ctx, TSNode node) {
/* Find condition: first parenthesized_expression child. */
TSNode cond;
memset(&cond, 0, sizeof(cond));
uint32_t nc = ts_node_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
if (strcmp(ts_node_type(c), "parenthesized_expression") == 0) {
cond = c;
break;
}
}
/* Collect all conjunctive narrowings: `$x instanceof Foo && is_int($y)`
* narrows BOTH $x and $y in the if-body. */
php_narrowing_t nws[8] = {{0}};
int nw_count = ts_node_is_null(cond) ? 0 : parse_narrowing_collect(ctx, cond, nws, 8);
bool has_nw = nw_count > 0;
/* Detect negative narrowing: condition is `!P` and P is a narrowing
* predicate. We unwrap one level of unary `!`. */
php_narrowing_t neg_nw = {0};
bool has_neg_nw = false;
if (!ts_node_is_null(cond)) {
TSNode inner;
memset(&inner, 0, sizeof(inner));
/* Unwrap parenthesized_expression. */
TSNode probe = cond;
for (int loop = 0; loop < 4; loop++) {
if (strcmp(ts_node_type(probe), "parenthesized_expression") == 0) {
uint32_t pnc = ts_node_child_count(probe);
TSNode next;
memset(&next, 0, sizeof(next));
for (uint32_t i = 0; i < pnc; i++) {
TSNode c = ts_node_child(probe, i);
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
next = c;
break;
}
}
if (ts_node_is_null(next))
break;
probe = next;
} else {
break;
}
}
if (strcmp(ts_node_type(probe), "unary_op_expression") == 0) {
/* Find the operator child; if it's `!`, parse the inner expr. */
TSNode op = ts_node_child_by_field_name(probe, "operator", 8);
char *opt = !ts_node_is_null(op) ? php_node_text(ctx, op) : NULL;
if (opt && strcmp(opt, "!") == 0) {
TSNode operand = ts_node_child_by_field_name(probe, "operand", 7);
if (ts_node_is_null(operand)) {
uint32_t unc = ts_node_child_count(probe);
for (uint32_t i = 0; i < unc; i++) {
TSNode c = ts_node_child(probe, i);
if (!ts_node_is_null(c) && ts_node_is_named(c) &&
strcmp(ts_node_type(c), "unary_op_expression") != 0) {
operand = c;
break;
}
}
}
if (!ts_node_is_null(operand)) {
has_neg_nw = parse_narrowing(ctx, operand, &neg_nw);
}
}
}
}
/* Walk children. The first NON-condition, NON-else-clause named child
* is the if-body; narrowed scope applies there. Subsequent
* else_if_clause / else_clause walk without narrowing. */
bool walked_body = false;
bool past_cond = false;
bool body_is_terminator = false;
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (!past_cond) {
if (strcmp(k, "parenthesized_expression") == 0) {
past_cond = true;
/* Walk the condition expression so calls inside it (e.g.
* `$it->valid()` in `if ($it->valid())`) get resolved. */
php_resolve_calls_in_node(ctx, c);
continue;
}
/* Non-condition child encountered before the condition: keep walking. */
php_resolve_calls_in_node(ctx, c);
continue;
}
if (!walked_body) {
body_is_terminator = stmt_is_terminator(c);
walk_with_narrowings(ctx, c, has_nw ? nws : NULL, nw_count);
walked_body = true;
} else {
/* else / elseif / colon_block */
php_resolve_calls_in_node(ctx, c);
}
}
/* Negative-narrowing apply: if the if-body unconditionally exits AND
* the condition was `!P` for narrowable P, then for the rest of the
* enclosing scope, P holds. */
if (body_is_terminator && has_neg_nw && neg_nw.var_name && neg_nw.type) {
cbm_scope_bind(ctx->current_scope, neg_nw.var_name, neg_nw.type);
}
}
/* Walk a subtree, binding scope and resolving calls. */
static void php_resolve_calls_in_node_inner(PHPLSPContext *ctx, TSNode node) {
if (ts_node_is_null(node))
return;
const char *kind = ts_node_type(node);
/* Stop descending into nested function-likes — they are processed by
* process_function_like with their own scope. */
if (strcmp(kind, "function_definition") == 0 ||
strcmp(kind, "function_static_declaration") == 0 ||
strcmp(kind, "method_declaration") == 0 || strcmp(kind, "anonymous_function") == 0 ||
strcmp(kind, "arrow_function") == 0) {
process_function_like(ctx, node);
return;
}
/* Statement-level scope-binding observers. */
if (strcmp(kind, "assignment_expression") == 0) {
process_assignment(ctx, node);
} else if (strcmp(kind, "foreach_statement") == 0) {
process_foreach(ctx, node);
} else if (strcmp(kind, "catch_clause") == 0) {
process_catch(ctx, node);
} else if (strcmp(kind, "comment") == 0) {
char *raw = php_node_text(ctx, node);
if (raw && raw[0] == '/' && raw[1] == '*' && raw[2] == '*') {
char *cleaned = phpdoc_clean(ctx->arena, raw);
bind_phpdoc_var(ctx, cleaned);
}
} else if (strcmp(kind, "if_statement") == 0) {
process_if_statement(ctx, node);
return; /* process_if_statement already walked body under narrowing */
}
/* Call-resolution dispatch. */
if (strcmp(kind, "function_call_expression") == 0) {
resolve_function_call(ctx, node);
/* assert(...) in current scope narrows sequentially. */
apply_assert_narrowing(ctx, node);
} else if (strcmp(kind, "member_call_expression") == 0 ||
strcmp(kind, "nullsafe_member_call_expression") == 0) {
resolve_member_call(ctx, node);
/* Closure binding via $f->bindTo($obj). When $f is an inline
* closure literal AND the bindTo call's first arg is a typed
* variable, walk the closure body with $this rebound. We only
* support this for the inline-literal pattern; tracking
* Closure-as-value across assignments is deferred (closure
* value-typing is Phase 6+ infrastructure). */
TSNode obj = ts_node_child_by_field_name(node, "object", 6);
TSNode mname = ts_node_child_by_field_name(node, "name", 4);
if (!ts_node_is_null(obj) && !ts_node_is_null(mname)) {
char *mn = php_node_text(ctx, mname);
const char *ok = ts_node_type(obj);
if (mn && strcmp(mn, "bindTo") == 0 &&
(strcmp(ok, "anonymous_function") == 0 || strcmp(ok, "arrow_function") == 0 ||
strcmp(ok, "parenthesized_expression") == 0)) {
/* Resolve the second-arg's class. */
TSNode args = ts_node_child_by_field_name(node, "arguments", 9);
if (!ts_node_is_null(args)) {
uint32_t anc = ts_node_child_count(args);
for (uint32_t i = 0; i < anc; i++) {
TSNode a = ts_node_child(args, i);
if (ts_node_is_null(a) || !ts_node_is_named(a))
continue;
TSNode v = a;
if (strcmp(ts_node_type(a), "argument") == 0) {
uint32_t bnc = ts_node_child_count(a);
for (uint32_t j = 0; j < bnc; j++) {
TSNode b = ts_node_child(a, j);
if (!ts_node_is_null(b) && ts_node_is_named(b)) {
v = b;
break;
}
}
}
const CBMType *t = php_eval_expr_type(ctx, v);
if (t && t->kind == CBM_TYPE_NAMED) {
const char *saved = ctx->enclosing_class_qn;
ctx->enclosing_class_qn = t->data.named.qualified_name;
php_resolve_calls_in_node(ctx, obj);
ctx->enclosing_class_qn = saved;
}
break;
}
}
}
}
} else if (strcmp(kind, "scoped_call_expression") == 0) {
resolve_static_call(ctx, node);
/* Closure binding via Closure::bind($f, $obj). Same shape as
* bindTo: rebind $this when the first arg is a closure literal
* and the second arg has a known class. */
TSNode scope = ts_node_child_by_field_name(node, "scope", 5);
TSNode mname2 = ts_node_child_by_field_name(node, "name", 4);
if (!ts_node_is_null(scope) && !ts_node_is_null(mname2)) {
char *cls = php_node_text(ctx, scope);
char *mn = php_node_text(ctx, mname2);
bool is_closure_bind = mn && strcmp(mn, "bind") == 0 && cls &&
(strcmp(cls, "Closure") == 0 || strcmp(cls, "\\Closure") == 0);
if (is_closure_bind) {
TSNode args = ts_node_child_by_field_name(node, "arguments", 9);
if (!ts_node_is_null(args)) {
TSNode closure_arg;
memset(&closure_arg, 0, sizeof(closure_arg));
TSNode obj_arg;
memset(&obj_arg, 0, sizeof(obj_arg));
int seen = 0;
uint32_t anc = ts_node_child_count(args);
for (uint32_t i = 0; i < anc; i++) {
TSNode a = ts_node_child(args, i);
if (ts_node_is_null(a) || !ts_node_is_named(a))
continue;
TSNode inner = a;
if (strcmp(ts_node_type(a), "argument") == 0) {
uint32_t bnc = ts_node_child_count(a);
for (uint32_t j = 0; j < bnc; j++) {
TSNode b = ts_node_child(a, j);
if (!ts_node_is_null(b) && ts_node_is_named(b)) {
inner = b;
break;
}
}
}
if (seen == 0)
closure_arg = inner;
else if (seen == 1)
obj_arg = inner;
seen++;
}
if (!ts_node_is_null(closure_arg) && !ts_node_is_null(obj_arg)) {
const char *ck = ts_node_type(closure_arg);
if (strcmp(ck, "anonymous_function") == 0 ||
strcmp(ck, "arrow_function") == 0) {
const CBMType *t = php_eval_expr_type(ctx, obj_arg);
if (t && t->kind == CBM_TYPE_NAMED) {
const char *saved = ctx->enclosing_class_qn;
ctx->enclosing_class_qn = t->data.named.qualified_name;
php_resolve_calls_in_node(ctx, closure_arg);
ctx->enclosing_class_qn = saved;
}
}
}
}
}
}
}
/* Recurse via a cursor (O(n)); ts_node_child(node,i) is O(i) → O(n²) on a wide node. */
{
TSTreeCursor cursor = ts_tree_cursor_new(node);
if (ts_tree_cursor_goto_first_child(&cursor)) {
do {
php_resolve_calls_in_node(ctx, ts_tree_cursor_current_node(&cursor));
} while (ts_tree_cursor_goto_next_sibling(&cursor));
}
ts_tree_cursor_delete(&cursor);
}
}
/* ── function-like processing ───────────────────────────────────── */
static void bind_typed_parameters(PHPLSPContext *ctx, TSNode params) {
if (ts_node_is_null(params))
return;
uint32_t nc = ts_node_child_count(params);
for (uint32_t i = 0; i < nc; i++) {
TSNode p = ts_node_child(params, i);
if (ts_node_is_null(p) || !ts_node_is_named(p))
continue;
const char *pk = ts_node_type(p);
if (strcmp(pk, "simple_parameter") != 0 && strcmp(pk, "variadic_parameter") != 0 &&
strcmp(pk, "property_promotion_parameter") != 0) {
continue;
}
TSNode tnode = ts_node_child_by_field_name(p, "type", 4);
const CBMType *ptype = cbm_type_unknown();
if (!ts_node_is_null(tnode))
ptype = php_parse_type_node(ctx, tnode);
TSNode name_node = ts_node_child_by_field_name(p, "name", 4);
if (ts_node_is_null(name_node)) {
/* fallback: first variable_name child */
uint32_t pnc = ts_node_child_count(p);
for (uint32_t j = 0; j < pnc; j++) {
TSNode c = ts_node_child(p, j);
if (!ts_node_is_null(c) && strcmp(ts_node_type(c), "variable_name") == 0) {
name_node = c;
break;
}
}
}
if (ts_node_is_null(name_node))
continue;
char *vt = php_node_text(ctx, name_node);
if (!vt)
continue;
const char *name = (vt[0] == '$') ? vt + 1 : vt;
/* Variadic parameter — `string ...$args` has type array<string>;
* we don't model array<T> in Phase 4h, so just bind as `array`. */
if (strcmp(pk, "variadic_parameter") == 0) {
ptype = cbm_type_builtin(ctx->arena, "array");
}
cbm_scope_bind(ctx->current_scope, name, ptype);
}
}
static void process_function_like(PHPLSPContext *ctx, TSNode node) {
const char *kind = ts_node_type(node);
bool is_method = (strcmp(kind, "method_declaration") == 0);
bool is_named = is_method || (strcmp(kind, "function_definition") == 0) ||
(strcmp(kind, "function_static_declaration") == 0);
/* Save context. */
CBMScope *saved_scope = ctx->current_scope;
const char *saved_func = ctx->enclosing_func_qn;
ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope);
/* Determine func QN. Mirror what the unified extractor produces: classes
* and free functions are namespaced by file module_qn, NOT the PHP
* namespace declaration (the unified extractor ignores `namespace`).
* Method QN = enclosing_class_qn + "." + method_name. Free function QN =
* module_qn + "." + name. */
if (is_named) {
TSNode name_node = ts_node_child_by_field_name(node, "name", 4);
if (!ts_node_is_null(name_node)) {
char *fname = php_node_text(ctx, name_node);
if (fname) {
if (is_method && ctx->enclosing_class_qn) {
ctx->enclosing_func_qn =
cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->enclosing_class_qn, fname);
} else if (ctx->module_qn) {
ctx->enclosing_func_qn =
cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, fname);
} else {
ctx->enclosing_func_qn = cbm_arena_strdup(ctx->arena, fname);
}
}
}
}
/* Parameters. */
TSNode params = ts_node_child_by_field_name(node, "parameters", 10);
bind_typed_parameters(ctx, params);
/* Closure `use ($a, $b)` clause: copy captured-variable types from the
* parent scope into the closure scope. tree-sitter-php emits an
* `anonymous_function_use_clause` child of `anonymous_function`. */
if (strcmp(kind, "anonymous_function") == 0) {
uint32_t fnc = ts_node_child_count(node);
for (uint32_t i = 0; i < fnc; i++) {
TSNode c = ts_node_child(node, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
if (strcmp(ts_node_type(c), "anonymous_function_use_clause") != 0)
continue;
uint32_t unc = ts_node_child_count(c);
for (uint32_t j = 0; j < unc; j++) {
TSNode v = ts_node_child(c, j);
if (ts_node_is_null(v) || !ts_node_is_named(v))
continue;
if (strcmp(ts_node_type(v), "variable_name") != 0)
continue;
char *vt = php_node_text(ctx, v);
if (!vt)
continue;
const char *vname = (vt[0] == '$') ? vt + 1 : vt;
/* Look up in saved parent scope (captured at this point). */
const CBMType *captured = cbm_scope_lookup(saved_scope, vname);
if (captured) {
cbm_scope_bind(ctx->current_scope, vname, captured);
}
}
}
}
/* PHPDoc @param overrides. */
if (is_named) {
char *doc = fetch_leading_phpdoc(ctx, node);
if (doc)
parse_phpdoc_for_params(ctx, doc, params);
}
/* Walk body. */
TSNode body = ts_node_child_by_field_name(node, "body", 4);
if (ts_node_is_null(body)) {
/* Arrow function body is in field "body" too, but it's an expression.
* Try generic recursion as a fallback. */
uint32_t nc = ts_node_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(node, i);
if (!ts_node_is_null(c))
php_resolve_calls_in_node(ctx, c);
}
} else {
php_resolve_calls_in_node(ctx, body);
}
/* Restore. */
ctx->current_scope = saved_scope;
ctx->enclosing_func_qn = saved_func;
}
/* Find first base class (extends X) in a class_declaration, resolved. */
static const char *find_extends_qn(PHPLSPContext *ctx, TSNode class_node) {
uint32_t nc = ts_node_child_count(class_node);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(class_node, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
if (strcmp(ts_node_type(c), "base_clause") != 0)
continue;
uint32_t bnc = ts_node_child_count(c);
for (uint32_t j = 0; j < bnc; 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) {
char *t = php_node_text(ctx, bc);
if (t)
return php_resolve_class_name(ctx, t);
}
}
}
return NULL;
}
static void process_class_decl(PHPLSPContext *ctx, TSNode node) {
TSNode name_node = ts_node_child_by_field_name(node, "name", 4);
if (ts_node_is_null(name_node))
return;
char *cname = php_node_text(ctx, name_node);
if (!cname)
return;
const char *saved_class = ctx->enclosing_class_qn;
const char *saved_parent = ctx->enclosing_parent_qn;
/* enclosing_class_qn must match what the unified extractor records,
* which uses module_qn (file-path-based), NOT the PHP `namespace`
* declaration. */
if (ctx->module_qn) {
ctx->enclosing_class_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, cname);
} else {
ctx->enclosing_class_qn = cbm_arena_strdup(ctx->arena, cname);
}
ctx->enclosing_parent_qn = find_extends_qn(ctx, node);
/* Walk class body — pick out method_declaration nodes. */
TSNode body = ts_node_child_by_field_name(node, "body", 4);
if (ts_node_is_null(body))
body = child_named(node, "declaration_list");
if (!ts_node_is_null(body)) {
uint32_t nc = ts_node_child_count(body);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(body, i);
if (ts_node_is_null(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "method_declaration") == 0) {
process_function_like(ctx, c);
}
}
}
ctx->enclosing_class_qn = saved_class;
ctx->enclosing_parent_qn = saved_parent;
}
/* ── top-level pass ─────────────────────────────────────────────── */
/* Collect a single namespace_use_clause's local→target mapping and add.
*
* tree-sitter-php emits `use Vendor\X as Y;` either as a use_clause with
* a `namespace_aliasing_clause` child wrapping the alias, or as a bare
* sequence: qualified_name + literal `as` token + name. We accept both
* shapes by treating the *second* name-like child as the alias when no
* aliasing clause is present. */
static void collect_use_clause(PHPLSPContext *ctx, TSNode use_clause, int kind) {
TSNode name_node;
memset(&name_node, 0, sizeof(name_node));
TSNode alias_node;
memset(&alias_node, 0, sizeof(alias_node));
int name_seen = 0;
uint32_t nc = ts_node_child_count(use_clause);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(use_clause, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "qualified_name") == 0 || strcmp(k, "name") == 0) {
if (name_seen == 0) {
name_node = c;
} else if (name_seen == 1 && ts_node_is_null(alias_node)) {
/* Second name-shaped child = `as Alias`. */
alias_node = c;
}
name_seen++;
} else if (strcmp(k, "namespace_aliasing_clause") == 0) {
uint32_t ac = ts_node_child_count(c);
for (uint32_t j = 0; j < ac; j++) {
TSNode ach = ts_node_child(c, j);
if (!ts_node_is_null(ach) && ts_node_is_named(ach) &&
strcmp(ts_node_type(ach), "name") == 0) {
alias_node = ach;
break;
}
}
}
}
if (ts_node_is_null(name_node))
return;
char *full = php_node_text(ctx, name_node);
if (!full)
return;
char *dotted = php_ns_to_dot(ctx->arena, full);
const char *local = NULL;
if (!ts_node_is_null(alias_node)) {
local = php_node_text(ctx, alias_node);
}
if (!local) {
/* Default: last segment. */
local = php_short_name(dotted);
}
php_lsp_add_use(ctx, local, dotted, kind);
}
static void collect_use_declaration(PHPLSPContext *ctx, TSNode use_decl) {
/* Determine kind: function / const / class (default). */
int kind = CBM_PHP_USE_CLASS;
uint32_t nc = ts_node_child_count(use_decl);
/* tree-sitter-php emits `function` or `const` keyword children. */
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(use_decl, i);
if (ts_node_is_null(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "function") == 0)
kind = CBM_PHP_USE_FUNCTION;
if (strcmp(k, "const") == 0)
kind = CBM_PHP_USE_CONST;
}
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(use_decl, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "namespace_use_clause") == 0) {
collect_use_clause(ctx, c, kind);
} else if (strcmp(k, "namespace_use_group") == 0) {
/* use App\\{Foo, Bar as B}; — recurse into clauses inside. */
uint32_t gc = ts_node_child_count(c);
for (uint32_t j = 0; j < gc; j++) {
TSNode gch = ts_node_child(c, j);
if (!ts_node_is_null(gch) && ts_node_is_named(gch) &&
strcmp(ts_node_type(gch), "namespace_use_clause") == 0) {
collect_use_clause(ctx, gch, kind);
}
}
}
}
}
static void set_namespace_from_decl(PHPLSPContext *ctx, TSNode ns_decl) {
uint32_t nc = ts_node_child_count(ns_decl);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(ns_decl, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "namespace_name") == 0) {
char *t = php_node_text(ctx, c);
if (t) {
ctx->current_namespace_qn = php_ns_to_dot(ctx->arena, t);
return;
}
}
}
}
void php_lsp_process_file(PHPLSPContext *ctx, TSNode root) {
if (ts_node_is_null(root))
return;
/* Pass 1: namespace + use declarations. */
// Collect top-level children once (O(n)); ts_node_child(root,i) is O(i) → O(n²).
uint32_t kn = 0;
TSNode *kids = cbm_lsp_collect_children(ctx->arena, root, &kn);
for (uint32_t i = 0; i < kn; i++) {
TSNode c = kids[i];
const char *k = ts_node_type(c);
if (strcmp(k, "namespace_definition") == 0) {
set_namespace_from_decl(ctx, c);
} else if (strcmp(k, "namespace_use_declaration") == 0) {
collect_use_declaration(ctx, c);
}
}
/* Pass 2: process classes and top-level functions. */
for (uint32_t i = 0; i < kn; i++) {
TSNode c = kids[i];
const char *k = ts_node_type(c);
if (strcmp(k, "class_declaration") == 0 || strcmp(k, "trait_declaration") == 0 ||
strcmp(k, "interface_declaration") == 0 || strcmp(k, "enum_declaration") == 0) {
process_class_decl(ctx, c);
} else if (strcmp(k, "function_definition") == 0 ||
strcmp(k, "function_static_declaration") == 0) {
process_function_like(ctx, c);
} else if (strcmp(k, "namespace_definition") == 0) {
/* 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);
}
}
}
}
} else if (strcmp(k, "expression_statement") == 0 || strcmp(k, "if_statement") == 0 ||
strcmp(k, "echo_statement") == 0) {
/* Top-level script code outside any function/class. Without an
* enclosing function we cannot emit resolved calls, but we still
* walk statements to populate scope for any subsequent function
* processing in this file. */
php_resolve_calls_in_node(ctx, c);
}
}
}
/* ── parse return-type text from CBMDefinition.return_type ──────── */
static const CBMType *parse_return_type_text(CBMArena *arena, const char *text,
const char *module_qn,
const char *current_namespace_qn,
const char **use_local_names,
const char **use_target_qns, int use_count) {
if (!text || !*text)
return cbm_type_unknown();
/* Skip leading ":?" / "?". */
while (*text == ':' || *text == ' ' || *text == '?')
text++;
if (php_is_builtin_type_name(text))
return cbm_type_builtin(arena, text);
/* If union/intersection, take leftmost non-null. */
const char *bar = strchr(text, '|');
const char *amp = strchr(text, '&');
const char *cut = NULL;
if (bar && (!amp || bar < amp))
cut = bar;
else if (amp)
cut = amp;
char *first;
if (cut) {
first = cbm_arena_strndup(arena, text, (size_t)(cut - text));
} else {
first = cbm_arena_strdup(arena, text);
}
if (!first)
return cbm_type_unknown();
/* trim. */
while (*first == ' ' || *first == '?')
first++;
char *end = first + strlen(first);
while (end > first && (end[-1] == ' '))
end--;
*end = '\0';
if (!*first || strcmp(first, "null") == 0)
return cbm_type_unknown();
if (php_is_builtin_type_name(first))
return cbm_type_builtin(arena, first);
/* Resolve via use map. */
if (first[0] == '\\') {
return cbm_type_named(arena, php_ns_to_dot(arena, first + 1));
}
/* Find first segment for use lookup. */
const char *bs = first;
while (*bs && *bs != '\\')
bs++;
char *first_seg = (*bs) ? cbm_arena_strndup(arena, first, (size_t)(bs - first))
: cbm_arena_strdup(arena, first);
for (int i = 0; i < use_count; i++) {
if (strcmp(use_local_names[i], first_seg) == 0) {
const char *target = use_target_qns[i];
if (*bs) {
return cbm_type_named(
arena, cbm_arena_sprintf(arena, "%s%s", target, php_ns_to_dot(arena, bs)));
}
return cbm_type_named(arena, target);
}
}
/* Fallback: current namespace prefix. */
if (current_namespace_qn && *current_namespace_qn) {
return cbm_type_named(arena, cbm_arena_sprintf(arena, "%s.%s", current_namespace_qn,
php_ns_to_dot(arena, first)));
}
if (module_qn) {
return cbm_type_named(
arena, cbm_arena_sprintf(arena, "%s.%s", module_qn, php_ns_to_dot(arena, first)));
}
return cbm_type_named(arena, php_ns_to_dot(arena, first));
}
/* ── property type extraction ───────────────────────────────────────
*
* Walks the AST top-level for class/trait/interface bodies and registers
* typed properties on the corresponding CBMRegisteredType.field_names /
* field_types. Handles:
* - typed `public Foo $bar;` declarations
* - constructor property promotion (the `public Foo $bar` parameter form)
* - `$this->bar = $bar` constructor-body inference, where $bar is a
* typed parameter
*
* After this pass `eval_expr_type` for member_access_expression $x->bar
* returns the property's CBMType when known. */
typedef struct {
const char *class_qn;
const char **field_names; /* arena-backed, NULL-terminated */
const CBMType **field_types;
int count;
int cap;
} php_class_fields_t;
typedef struct {
php_class_fields_t *items;
int count;
int cap;
} php_class_field_table_t;
static php_class_fields_t *fields_for(php_class_field_table_t *tab, const char *class_qn,
CBMArena *arena) {
for (int i = 0; i < tab->count; i++) {
if (strcmp(tab->items[i].class_qn, class_qn) == 0)
return &tab->items[i];
}
if (tab->count >= tab->cap) {
int new_cap = tab->cap ? tab->cap * 2 : 16;
php_class_fields_t *next =
(php_class_fields_t *)cbm_arena_alloc(arena, (size_t)new_cap * sizeof(*next));
if (!next)
return NULL;
for (int i = 0; i < tab->count; i++)
next[i] = tab->items[i];
tab->items = next;
tab->cap = new_cap;
}
php_class_fields_t *f = &tab->items[tab->count++];
memset(f, 0, sizeof(*f));
f->class_qn = cbm_arena_strdup(arena, class_qn);
return f;
}
static void add_field(php_class_fields_t *f, CBMArena *arena, const char *name,
const CBMType *type) {
if (!f || !name || !type)
return;
/* dedup: keep first declaration; constructor inference doesn't override
* an explicit declaration. */
for (int i = 0; i < f->count; i++) {
if (strcmp(f->field_names[i], name) == 0)
return;
}
if (f->count >= f->cap) {
int new_cap = f->cap ? f->cap * 2 : 8;
const char **nn =
(const char **)cbm_arena_alloc(arena, (size_t)(new_cap + 1) * sizeof(*nn));
const CBMType **nt =
(const CBMType **)cbm_arena_alloc(arena, (size_t)(new_cap + 1) * sizeof(*nt));
if (!nn || !nt)
return;
for (int i = 0; i < f->count; i++) {
nn[i] = f->field_names[i];
nt[i] = f->field_types[i];
}
f->field_names = nn;
f->field_types = nt;
f->cap = new_cap;
}
f->field_names[f->count] = cbm_arena_strdup(arena, name);
f->field_types[f->count] = type;
f->count++;
f->field_names[f->count] = NULL;
f->field_types[f->count] = NULL;
}
/* Compute the dotted class QN for a given class_declaration node, matching
* how the unified extractor names defs (module_qn-prefixed). */
static const char *class_qn_for_node(PHPLSPContext *ctx, TSNode class_node) {
TSNode name_node = ts_node_child_by_field_name(class_node, "name", 4);
if (ts_node_is_null(name_node))
return NULL;
char *cname = php_node_text(ctx, name_node);
if (!cname)
return NULL;
if (ctx->module_qn) {
return cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, cname);
}
return cbm_arena_strdup(ctx->arena, cname);
}
static void extract_property_decl(PHPLSPContext *ctx, TSNode prop_decl, php_class_fields_t *out) {
TSNode tnode = ts_node_child_by_field_name(prop_decl, "type", 4);
const CBMType *ptype = NULL;
if (!ts_node_is_null(tnode))
ptype = php_parse_type_node(ctx, tnode);
if (!ptype || ptype->kind == CBM_TYPE_UNKNOWN) {
/* Tree-sitter-php may put the type as a named child without field. */
uint32_t nc = ts_node_child_count(prop_decl);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(prop_decl, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "named_type") == 0 || strcmp(k, "primitive_type") == 0 ||
strcmp(k, "union_type") == 0 || strcmp(k, "intersection_type") == 0 ||
strcmp(k, "optional_type") == 0) {
ptype = php_parse_type_node(ctx, c);
break;
}
}
}
if (!ptype || ptype->kind == CBM_TYPE_UNKNOWN)
return;
/* Find the property_element children (each has variable_name + maybe default). */
uint32_t nc = ts_node_child_count(prop_decl);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(prop_decl, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "property_element") == 0) {
uint32_t bnc = ts_node_child_count(c);
for (uint32_t j = 0; j < bnc; j++) {
TSNode b = ts_node_child(c, j);
if (!ts_node_is_null(b) && ts_node_is_named(b) &&
strcmp(ts_node_type(b), "variable_name") == 0) {
char *vt = php_node_text(ctx, b);
if (!vt)
continue;
const char *name = (vt[0] == '$') ? vt + 1 : vt;
add_field(out, ctx->arena, name, ptype);
break;
}
}
} else if (strcmp(k, "variable_name") == 0) {
char *vt = php_node_text(ctx, c);
if (vt) {
const char *name = (vt[0] == '$') ? vt + 1 : vt;
add_field(out, ctx->arena, name, ptype);
}
}
}
}
static void extract_promoted_param(PHPLSPContext *ctx, TSNode param, php_class_fields_t *out) {
/* property_promotion_parameter has: visibility/readonly modifiers,
* type, variable_name. */
TSNode tnode = ts_node_child_by_field_name(param, "type", 4);
const CBMType *ptype = NULL;
if (!ts_node_is_null(tnode))
ptype = php_parse_type_node(ctx, tnode);
if (!ptype || ptype->kind == CBM_TYPE_UNKNOWN) {
uint32_t nc = ts_node_child_count(param);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(param, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "named_type") == 0 || strcmp(k, "primitive_type") == 0 ||
strcmp(k, "union_type") == 0 || strcmp(k, "intersection_type") == 0 ||
strcmp(k, "optional_type") == 0) {
ptype = php_parse_type_node(ctx, c);
break;
}
}
}
if (!ptype || ptype->kind == CBM_TYPE_UNKNOWN)
return;
TSNode vname = ts_node_child_by_field_name(param, "name", 4);
if (ts_node_is_null(vname)) {
uint32_t nc = ts_node_child_count(param);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(param, i);
if (!ts_node_is_null(c) && strcmp(ts_node_type(c), "variable_name") == 0) {
vname = c;
break;
}
}
}
if (ts_node_is_null(vname))
return;
char *vt = php_node_text(ctx, vname);
if (!vt)
return;
const char *n = (vt[0] == '$') ? vt + 1 : vt;
add_field(out, ctx->arena, n, ptype);
}
/* Look at __construct: any `$this->X = $param;` where $param is a typed
* parameter contributes a field type for X. */
static void extract_ctor_assignments(PHPLSPContext *ctx, TSNode ctor_method,
php_class_fields_t *out) {
TSNode params = ts_node_child_by_field_name(ctor_method, "parameters", 10);
/* Build a small param-name -> type map for this constructor. */
const char *names[32];
const CBMType *types[32];
int pc = 0;
if (!ts_node_is_null(params)) {
uint32_t nc = ts_node_child_count(params);
for (uint32_t i = 0; i < nc && pc < 32; i++) {
TSNode p = ts_node_child(params, i);
if (ts_node_is_null(p) || !ts_node_is_named(p))
continue;
const char *pk = ts_node_type(p);
if (strcmp(pk, "simple_parameter") != 0 &&
strcmp(pk, "property_promotion_parameter") != 0) {
continue;
}
TSNode tnode = ts_node_child_by_field_name(p, "type", 4);
const CBMType *ptype = NULL;
if (!ts_node_is_null(tnode))
ptype = php_parse_type_node(ctx, tnode);
if (!ptype || ptype->kind == CBM_TYPE_UNKNOWN)
continue;
TSNode name_node = ts_node_child_by_field_name(p, "name", 4);
if (ts_node_is_null(name_node)) {
uint32_t pnc = ts_node_child_count(p);
for (uint32_t j = 0; j < pnc; j++) {
TSNode c = ts_node_child(p, j);
if (!ts_node_is_null(c) && strcmp(ts_node_type(c), "variable_name") == 0) {
name_node = c;
break;
}
}
}
if (ts_node_is_null(name_node))
continue;
char *vt = php_node_text(ctx, name_node);
if (!vt)
continue;
names[pc] = cbm_arena_strdup(ctx->arena, (vt[0] == '$') ? vt + 1 : vt);
types[pc] = ptype;
pc++;
}
}
if (pc == 0)
return;
/* Walk the body, looking for `$this->X = $name;` patterns. */
TSNode body = ts_node_child_by_field_name(ctor_method, "body", 4);
if (ts_node_is_null(body))
return;
/* Iterative DFS. */
TSNode stack[64];
int top = 0;
stack[top++] = body;
while (top > 0) {
TSNode n = stack[--top];
if (ts_node_is_null(n))
continue;
const char *k = ts_node_type(n);
if (strcmp(k, "assignment_expression") == 0) {
TSNode lhs = ts_node_child_by_field_name(n, "left", 4);
TSNode rhs = ts_node_child_by_field_name(n, "right", 5);
if (!ts_node_is_null(lhs) && !ts_node_is_null(rhs) &&
strcmp(ts_node_type(lhs), "member_access_expression") == 0 &&
strcmp(ts_node_type(rhs), "variable_name") == 0) {
/* lhs = $this->X */
TSNode obj = ts_node_child_by_field_name(lhs, "object", 6);
TSNode fname = ts_node_child_by_field_name(lhs, "name", 4);
if (!ts_node_is_null(obj) && !ts_node_is_null(fname)) {
char *ot = php_node_text(ctx, obj);
char *fn = php_node_text(ctx, fname);
char *rt = php_node_text(ctx, rhs);
if (ot && fn && rt && (ot[0] == '$') && strcmp(ot + 1, "this") == 0) {
const char *rname = (rt[0] == '$') ? rt + 1 : rt;
for (int i = 0; i < pc; i++) {
if (strcmp(names[i], rname) == 0) {
add_field(out, ctx->arena, fn, types[i]);
break;
}
}
}
}
}
}
uint32_t cnt = ts_node_child_count(n);
for (uint32_t i = 0; i < cnt && top < 64; i++) {
TSNode c = ts_node_child(n, i);
if (!ts_node_is_null(c))
stack[top++] = c;
}
}
}
/* Parse a class-level docblock for @property / @method tags and register
* them as virtual fields and methods on the class. Used heavily by
* Eloquent and other dynamic-PHP frameworks.
*
* @property Foo $bar → field bar: Foo
* @property-read Foo $bar → same (treat the same as @property)
* @method Bar foo() → method foo() returning Bar
* @method static Bar foo() → method foo() returning Bar (static)
*
* The signature parsing for @method is lenient — we only care about the
* return type for downstream chain inference. Argument types in the
* tag are ignored. */
/* Parse `@template T` / `@template T of Foo` lines and return the
* NULL-terminated array of type-parameter names. */
static const char **parse_phpdoc_template_params(PHPLSPContext *ctx, const char *docstring) {
if (!docstring)
return NULL;
const char *names[16];
int n = 0;
const char *p = docstring;
while ((p = strstr(p, "@template")) != NULL) {
p += 9;
/* Skip variants that bind a type-param name with a kind suffix:
* @template-covariant T → record T
* @template-contravariant T → record T
* but NOT @template-extends / @template-implements (those declare
* a parent class, not a type param). */
bool is_param_variant = true;
if (*p == '-') {
const char *suffix = p + 1;
if (strncmp(suffix, "covariant", 9) == 0) {
p += 1 + 9;
} else if (strncmp(suffix, "contravariant", 13) == 0) {
p += 1 + 13;
} else {
/* @template-extends / -implements / -use / etc. — skip line. */
is_param_variant = false;
while (*p && *p != ' ' && *p != '\t' && *p != '\n')
p++;
}
}
if (!is_param_variant)
continue;
while (*p == ' ' || *p == '\t')
p++;
const char *name_start = p;
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
p++;
if (p == name_start)
continue;
if (n < 16) {
names[n++] = cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
}
}
if (n == 0)
return NULL;
const char **out = (const char **)cbm_arena_alloc(ctx->arena, (size_t)(n + 1) * sizeof(*out));
if (!out)
return NULL;
for (int i = 0; i < n; i++)
out[i] = names[i];
out[n] = NULL;
return out;
}
/* Substitute the type parameters in `t` according to a parallel
* (param_names, args) mapping, returning a new arena-allocated CBMType. */
static const CBMType *php_substitute_template(CBMArena *arena, const CBMType *t,
const char *const *param_names,
const CBMType *const *args) {
if (!t || !param_names || !args)
return t;
if (t->kind == CBM_TYPE_NAMED && t->data.named.qualified_name) {
const char *qn = t->data.named.qualified_name;
for (int i = 0; param_names[i]; i++) {
if (strcmp(qn, param_names[i]) == 0) {
return args[i] ? args[i] : t;
}
}
return t;
}
if (t->kind == CBM_TYPE_TEMPLATE) {
/* Recurse into args. */
int n = t->data.template_type.arg_count;
if (n <= 0 || !t->data.template_type.template_args)
return t;
const CBMType **new_args =
(const CBMType **)cbm_arena_alloc(arena, (size_t)(n + 1) * sizeof(*new_args));
if (!new_args)
return t;
for (int i = 0; i < n; i++) {
new_args[i] = php_substitute_template(arena, t->data.template_type.template_args[i],
param_names, args);
}
new_args[n] = NULL;
return cbm_type_template(arena, t->data.template_type.template_name, new_args, n);
}
return t;
}
static void apply_phpdoc_class_tags(PHPLSPContext *ctx, CBMTypeRegistry *reg, const char *class_qn,
const char *docstring, php_class_fields_t *out_fields) {
if (!docstring || !class_qn)
return;
/* @template T / @template T of Foo — register on the class. */
const char **tparams = parse_phpdoc_template_params(ctx, docstring);
if (tparams) {
for (int t = 0; t < reg->type_count; t++) {
if (strcmp(reg->types[t].qualified_name, class_qn) == 0) {
reg->types[t].type_param_names = tparams;
break;
}
}
}
/* @phpstan-type Alias TYPE_EXPR — register a per-file alias so
* subsequent @var/@param/@return references to `Alias` resolve to the
* aliased type. This is a phpstan/psalm convention used heavily for
* shape descriptions and value objects.
*
* Also accept the equivalent @psalm-type and @phan-type spellings. */
{
const char *prefixes[] = {"@phpstan-type", "@psalm-type", "@phan-type", NULL};
for (int pi = 0; prefixes[pi]; pi++) {
const char *p = docstring;
size_t plen = strlen(prefixes[pi]);
while ((p = strstr(p, prefixes[pi])) != NULL) {
p += plen;
while (*p == ' ' || *p == '\t')
p++;
/* alias name */
const char *name_start = p;
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
p++;
if (p == name_start)
continue;
char *alias_name =
cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
while (*p == ' ' || *p == '\t' || *p == '=')
p++;
/* type expression, span <...> brackets */
const char *type_start = p;
int depth = 0;
while (*p && *p != '\n') {
if (*p == '<')
depth++;
else if (*p == '>')
depth--;
else if (depth == 0 && (*p == ' ' || *p == '\t') && p > type_start) {
break;
}
p++;
}
if (p == type_start)
continue;
char *type_text =
cbm_arena_strndup(ctx->arena, type_start, (size_t)(p - type_start));
/* Strip trailing whitespace in case. */
size_t tl = strlen(type_text);
while (tl > 0 && (type_text[tl - 1] == ' ' || type_text[tl - 1] == '\t')) {
type_text[--tl] = '\0';
}
if (!*type_text)
continue;
const CBMType *aliased = resolve_phpdoc_type(ctx, type_text);
if (aliased && aliased->kind != CBM_TYPE_UNKNOWN) {
php_add_phpstan_alias(ctx, alias_name, aliased);
}
}
}
}
/* @phpstan-import-type Alias from Other — best-effort. We don't have
* cross-file alias indexing yet, but accept the syntax without
* crashing. The alias becomes UNKNOWN until the source class's
* @phpstan-type ends up in our table. */
{
const char *p = docstring;
while ((p = strstr(p, "@phpstan-import-type")) != NULL) {
p += 20;
while (*p == ' ' || *p == '\t')
p++;
const char *name_start = p;
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
p++;
if (p == name_start)
continue;
(void)name_start; /* declared imported alias — silently accept */
}
}
/* @extends ParentClass<X> / @template-extends ParentClass<X>
* — when a class generically extends a parametric ancestor, we
* record that the ancestor's type-params are bound to the concrete
* args. We model this by replacing the embedded_types entry with
* a synthetic key class_qn + "@@" + parent_with_args, plus storing
* the type-arg mapping in a side table. For Phase 4s we keep it
* minimal: when method lookup walks the ancestor, eval_member_call
* on the receiver doesn't yet know about this binding. The full
* substitution happens in Phase 4s+ via a side table; for now we
* record the @extends parent in embedded_types as a NAMED type so
* inherited methods at least resolve (untyped). */
{
const char *p = docstring;
while ((p = strstr(p, "@extends")) != NULL) {
p += 8;
if (*p == '-') {
while (*p && *p != ' ' && *p != '\t' && *p != '\n')
p++;
continue;
}
while (*p == ' ' || *p == '\t')
p++;
const char *parent_start = p;
int depth = 0;
while (*p && *p != '\n') {
if (*p == '<')
depth++;
else if (*p == '>') {
depth--;
if (depth == 0) {
p++;
break;
}
} else if (depth == 0 && (*p == ' ' || *p == '\t'))
break;
p++;
}
if (p == parent_start)
continue;
char *parent_text =
cbm_arena_strndup(ctx->arena, parent_start, (size_t)(p - parent_start));
const CBMType *parent_type = resolve_phpdoc_type(ctx, parent_text);
const char *parent_qn = NULL;
if (parent_type && parent_type->kind == CBM_TYPE_NAMED) {
parent_qn = parent_type->data.named.qualified_name;
} else if (parent_type && parent_type->kind == CBM_TYPE_TEMPLATE) {
parent_qn = parent_type->data.template_type.template_name;
}
if (!parent_qn)
continue;
/* Append to embedded_types if not already present. */
for (int t = 0; t < reg->type_count; t++) {
if (strcmp(reg->types[t].qualified_name, class_qn) != 0)
continue;
int existing = 0;
if (reg->types[t].embedded_types) {
while (reg->types[t].embedded_types[existing])
existing++;
}
bool found = false;
for (int e = 0; e < existing; e++) {
if (strcmp(reg->types[t].embedded_types[e], parent_qn) == 0) {
found = true;
break;
}
}
if (found)
break;
const char **expanded = (const char **)cbm_arena_alloc(
ctx->arena, (size_t)(existing + 2) * sizeof(*expanded));
if (!expanded)
break;
for (int e = 0; e < existing; e++) {
expanded[e] = reg->types[t].embedded_types[e];
}
expanded[existing] = parent_qn;
expanded[existing + 1] = NULL;
reg->types[t].embedded_types = expanded;
break;
}
}
}
/* @property TYPE $name */
const char *p = docstring;
while ((p = strstr(p, "@property")) != NULL) {
p += 9;
/* Skip "-read" / "-write" suffix variants. */
if (*p == '-') {
while (*p && *p != ' ' && *p != '\t')
p++;
}
while (*p == ' ' || *p == '\t')
p++;
const char *type_start = p;
while (*p && *p != ' ' && *p != '\t' && *p != '\n')
p++;
if (p == type_start)
continue;
char *type_text = cbm_arena_strndup(ctx->arena, type_start, (size_t)(p - type_start));
while (*p == ' ' || *p == '\t')
p++;
if (*p != '$')
continue;
p++;
const char *name_start = p;
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
p++;
if (p == name_start)
continue;
char *fname = cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
const CBMType *t = resolve_phpdoc_type(ctx, type_text);
if (out_fields && fname && t && t->kind != CBM_TYPE_UNKNOWN) {
add_field(out_fields, ctx->arena, fname, t);
}
}
/* @method [static] TYPE name(...) */
p = docstring;
while ((p = strstr(p, "@method")) != NULL) {
p += 7;
while (*p == ' ' || *p == '\t')
p++;
/* optional `static` */
if (strncmp(p, "static", 6) == 0 && (p[6] == ' ' || p[6] == '\t')) {
p += 6;
while (*p == ' ' || *p == '\t')
p++;
}
const char *type_start = p;
while (*p && *p != ' ' && *p != '\t' && *p != '\n')
p++;
if (p == type_start)
continue;
char *type_text = cbm_arena_strndup(ctx->arena, type_start, (size_t)(p - type_start));
while (*p == ' ' || *p == '\t')
p++;
const char *name_start = p;
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
p++;
if (p == name_start)
continue;
char *mname = cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
/* Register as a virtual function whose receiver_type is class_qn.
* Generates a synthetic QN class_qn + "." + mname. */
CBMRegisteredFunc rf;
memset(&rf, 0, sizeof(rf));
rf.qualified_name = cbm_arena_sprintf(ctx->arena, "%s.%s", class_qn, mname);
rf.short_name = mname;
rf.receiver_type = class_qn;
rf.min_params = -1;
const CBMType *ret = resolve_phpdoc_type(ctx, type_text);
const CBMType **rets = (const CBMType **)cbm_arena_alloc(ctx->arena, 2 * sizeof(*rets));
if (rets) {
rets[0] = ret;
rets[1] = NULL;
}
rf.signature = cbm_type_func(ctx->arena, NULL, NULL, rets);
cbm_registry_add_func(reg, rf);
}
}
/* Flatten the methods of a trait into the using class's method table.
*
* Tree-sitter-php emits `use Trait1, Trait2;` inside a class body as a
* `use_declaration` (distinct from the namespace-level
* namespace_use_declaration). The optional conflict resolution block
* uses `use_instead_of_clause` (for `Trait1::foo insteadof Trait2`) and
* `use_as_clause` (for `Trait2::foo as fooAlt`).
*
* Phase 4e basic strategy:
* - copy each method on the trait into the class as a synthetic
* CBMRegisteredFunc with receiver_type = class_qn
* - if the same method exists from multiple traits, last write wins
* (suboptimal but matches PHP's "use the last `use` line" default
* when no insteadof clause exists)
* - handle `as` aliasing: register method under the alias name too
* - skip insteadof (rare in practice; full support deferred)
*/
static void flatten_trait_into_class(PHPLSPContext *ctx, CBMTypeRegistry *reg, const char *class_qn,
const char *trait_qn, const char *alias_for_method,
const char *only_method_name) {
if (!class_qn || !trait_qn)
return;
/* Resolve trait through alias-style lookup if needed. */
const CBMRegisteredType *t = cbm_registry_lookup_type(reg, trait_qn);
if (!t)
t = lookup_type_with_project(ctx, trait_qn);
const char *canonical_trait_qn = t ? t->qualified_name : trait_qn;
/* A trait cannot meaningfully flatten into itself. PHP itself rejects
* `trait T { use T; }` ("Trait T cannot use itself"), and an aliased
* `use X as Y; use Y;` can resolve back onto the enclosing trait by short
* name. Without this guard the loop below copies the trait's own methods
* back onto it with receiver_type == canonical_trait_qn; because each copy
* then re-matches the loop's filter while cbm_registry_add_func() keeps
* growing reg->func_count, the iteration never terminates — it arena-
* allocates a fresh method every pass until the process exhausts all
* memory (observed: 40 GB+, freezing the host). See regression test
* phplsp_trait_self_use_terminates. */
if (strcmp(class_qn, canonical_trait_qn) == 0)
return;
/* Iterate registry funcs whose receiver_type is the trait.
*
* Self-substitution: when the trait's method has a return type that
* names the trait itself (e.g. `tap(): self` registered as
* NAMED(trait_qn)), rewrite it to NAMED(using_class_qn) so chains
* like `$c->tap()->classMethod()` resolve correctly.
*
* Snapshot the count before iterating: cbm_registry_add_func() below
* appends to reg->funcs, so the loop must never visit entries it adds
* during iteration (a mutate-while-iterating hazard). */
const int func_count_before = reg->func_count;
for (int i = 0; i < func_count_before; i++) {
const CBMRegisteredFunc *src = &reg->funcs[i];
if (!src->receiver_type || strcmp(src->receiver_type, canonical_trait_qn) != 0) {
continue;
}
if (only_method_name && src->short_name && strcmp(src->short_name, only_method_name) != 0) {
continue;
}
const char *new_short = alias_for_method ? alias_for_method : src->short_name;
if (!new_short)
continue;
CBMRegisteredFunc rf;
memset(&rf, 0, sizeof(rf));
rf.qualified_name = cbm_arena_sprintf(ctx->arena, "%s.%s", class_qn, new_short);
rf.short_name = cbm_arena_strdup(ctx->arena, new_short);
rf.receiver_type = class_qn;
rf.min_params = src->min_params;
/* Self-substitute the return type: any NAMED(trait_qn) in the
* signature's return becomes NAMED(class_qn). */
const CBMType *new_sig = src->signature;
if (src->signature && src->signature->kind == CBM_TYPE_FUNC &&
src->signature->data.func.return_types) {
const CBMType *ret0 = src->signature->data.func.return_types[0];
if (ret0 && ret0->kind == CBM_TYPE_NAMED && ret0->data.named.qualified_name &&
strcmp(ret0->data.named.qualified_name, canonical_trait_qn) == 0) {
const CBMType **rets =
(const CBMType **)cbm_arena_alloc(ctx->arena, 2 * sizeof(*rets));
if (rets) {
rets[0] = cbm_type_named(ctx->arena, class_qn);
rets[1] = NULL;
new_sig = cbm_type_func(ctx->arena, NULL, NULL, rets);
}
}
}
rf.signature = new_sig;
cbm_registry_add_func(reg, rf);
}
}
static void process_trait_use(PHPLSPContext *ctx, CBMTypeRegistry *reg, const char *class_qn,
TSNode use_decl) {
/* Collect the named traits and any `as` alias clauses. */
const char *trait_qns[16];
int trait_count = 0;
uint32_t nc = ts_node_child_count(use_decl);
for (uint32_t i = 0; i < nc && trait_count < 16; i++) {
TSNode c = ts_node_child(use_decl, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "name") == 0 || strcmp(k, "qualified_name") == 0) {
char *tn = php_node_text(ctx, c);
if (tn) {
const char *resolved = php_resolve_class_name(ctx, tn);
if (resolved)
trait_qns[trait_count++] = resolved;
}
}
}
/* First, do the default flatten for all traits. */
for (int i = 0; i < trait_count; i++) {
flatten_trait_into_class(ctx, reg, class_qn, trait_qns[i], NULL, NULL);
}
/* Process `use_list` block for `as` / `insteadof` clauses. The block is
* either named "body" or a sibling. */
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(use_decl, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "use_list") != 0 && strcmp(k, "use_clause") != 0) {
uint32_t bnc = ts_node_child_count(c);
for (uint32_t j = 0; j < bnc; j++) {
TSNode cc = ts_node_child(c, j);
if (ts_node_is_null(cc) || !ts_node_is_named(cc))
continue;
const char *kk = ts_node_type(cc);
if (strcmp(kk, "use_as_clause") == 0) {
/* Trait::method as alias OR method as alias */
char *trait_text = NULL;
char *method_text = NULL;
char *alias_text = NULL;
uint32_t anc = ts_node_child_count(cc);
for (uint32_t a = 0; a < anc; a++) {
TSNode ch = ts_node_child(cc, a);
if (ts_node_is_null(ch) || !ts_node_is_named(ch))
continue;
const char *ck = ts_node_type(ch);
if (strcmp(ck, "class_constant_access_expression") == 0) {
/* Trait::method */
uint32_t cc2 = ts_node_child_count(ch);
for (uint32_t a2 = 0; a2 < cc2; a2++) {
TSNode subch = ts_node_child(ch, a2);
if (ts_node_is_null(subch) || !ts_node_is_named(subch))
continue;
const char *sck = ts_node_type(subch);
if (strcmp(sck, "name") == 0 ||
strcmp(sck, "qualified_name") == 0) {
if (!trait_text)
trait_text = php_node_text(ctx, subch);
else
method_text = php_node_text(ctx, subch);
}
}
} else if (strcmp(ck, "name") == 0) {
char *t = php_node_text(ctx, ch);
if (!method_text)
method_text = t;
else
alias_text = t;
}
}
if (alias_text && method_text) {
const char *trait_qn_resolved = NULL;
if (trait_text) {
trait_qn_resolved = php_resolve_class_name(ctx, trait_text);
} else if (trait_count > 0) {
trait_qn_resolved = trait_qns[0];
}
if (trait_qn_resolved) {
flatten_trait_into_class(ctx, reg, class_qn, trait_qn_resolved,
alias_text, method_text);
}
}
}
/* `use_instead_of_clause` is recognised but not yet
* implemented — last-flatten-wins semantics already match
* the common case where the resolution clause picks a
* specific implementation. */
}
}
}
}
static void process_class_for_fields(PHPLSPContext *ctx, CBMTypeRegistry *reg, TSNode class_node,
php_class_field_table_t *tab) {
const char *cqn = class_qn_for_node(ctx, class_node);
if (!cqn)
return;
php_class_fields_t *f = fields_for(tab, cqn, ctx->arena);
if (!f)
return;
/* PHPDoc on the class itself: @property + @method tags. */
char *class_doc = fetch_leading_phpdoc(ctx, class_node);
if (class_doc) {
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");
if (ts_node_is_null(body))
return;
uint32_t nc = ts_node_child_count(body);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(body, i);
if (ts_node_is_null(c) || !ts_node_is_named(c))
continue;
const char *k = ts_node_type(c);
if (strcmp(k, "property_declaration") == 0) {
extract_property_decl(ctx, c, f);
} else if (strcmp(k, "enum_case") == 0) {
/* Enum case: Suit::Hearts → typed as the enum itself. */
TSNode case_name;
memset(&case_name, 0, sizeof(case_name));
uint32_t enc = ts_node_child_count(c);
for (uint32_t j = 0; j < enc; j++) {
TSNode cc = ts_node_child(c, j);
if (!ts_node_is_null(cc) && ts_node_is_named(cc) &&
strcmp(ts_node_type(cc), "name") == 0) {
case_name = cc;
break;
}
}
if (!ts_node_is_null(case_name)) {
char *cn = php_node_text(ctx, case_name);
if (cn) {
/* Register as a static field of the enum type. The
* field-name model already handles $this->prop; for
* static access Suit::Hearts the eval of class_constant_access
* handles below. */
add_field(f, ctx->arena, cn, cbm_type_named(ctx->arena, cqn));
}
}
} else if (strcmp(k, "use_declaration") == 0) {
/* Trait `use Foo;` inside a class body. */
process_trait_use(ctx, reg, cqn, c);
} else if (strcmp(k, "method_declaration") == 0) {
/* Constructor: scan params for property_promotion_parameter and
* scan body for $this->X = $param assignments. */
TSNode mname = ts_node_child_by_field_name(c, "name", 4);
char *mn = !ts_node_is_null(mname) ? php_node_text(ctx, mname) : NULL;
bool is_ctor = mn && strcmp(mn, "__construct") == 0;
TSNode params = ts_node_child_by_field_name(c, "parameters", 10);
if (!ts_node_is_null(params)) {
uint32_t pnc = ts_node_child_count(params);
for (uint32_t j = 0; j < pnc; j++) {
TSNode p = ts_node_child(params, j);
if (ts_node_is_null(p) || !ts_node_is_named(p))
continue;
if (strcmp(ts_node_type(p), "property_promotion_parameter") == 0) {
extract_promoted_param(ctx, p, f);
}
}
}
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.
*
* We temporarily set ctx->enclosing_class_qn so that
* `self`/`static` in the return-type position resolve to the
* current class. */
TSNode rt_node = ts_node_child_by_field_name(c, "return_type", 11);
if (!ts_node_is_null(rt_node) && mn) {
const char *saved_class = ctx->enclosing_class_qn;
const char *saved_parent = ctx->enclosing_parent_qn;
ctx->enclosing_class_qn = cqn;
/* ctx->enclosing_parent_qn left as-is (best-effort). */
const CBMType *parsed = php_parse_type_node(ctx, rt_node);
ctx->enclosing_class_qn = saved_class;
ctx->enclosing_parent_qn = saved_parent;
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.
* 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");
if (p) {
p += 7;
while (*p == ' ' || *p == '\t')
p++;
const char *type_start = p;
int depth = 0;
while (*p && *p != '\n') {
if (*p == '<')
depth++;
else if (*p == '>')
depth--;
else if (depth == 0 && (*p == ' ' || *p == '\t'))
break;
p++;
}
if (p > type_start) {
char *type_text =
cbm_arena_strndup(ctx->arena, type_start, (size_t)(p - type_start));
const CBMType *ret_t = NULL;
/* `@return $this` / `@return static` / `@return self`
* → return the enclosing class type. Critical for
* fluent-builder chains. */
if (strcmp(type_text, "$this") == 0 || strcmp(type_text, "static") == 0 ||
strcmp(type_text, "self") == 0) {
ret_t = cbm_type_named(ctx->arena, cqn);
}
/* Else: does the raw token match a class @template
* param name? If so, build NAMED(name) so call-site
* substitution can rewrite it later. */
if (!ret_t)
for (int t = 0; t < reg->type_count; t++) {
if (strcmp(reg->types[t].qualified_name, cqn) != 0)
continue;
if (!reg->types[t].type_param_names)
break;
for (int j = 0; reg->types[t].type_param_names[j]; j++) {
if (strcmp(reg->types[t].type_param_names[j], type_text) == 0) {
ret_t = cbm_type_named(ctx->arena, type_text);
break;
}
}
break;
}
if (!ret_t)
ret_t = resolve_phpdoc_type(ctx, type_text);
if (ret_t && ret_t->kind != CBM_TYPE_UNKNOWN) {
/* Find the registered method by receiver_type=cqn,
* short_name=mn and patch its signature. */
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;
/* Replace the signature's return type. */
const CBMType **rets = (const CBMType **)cbm_arena_alloc(
ctx->arena, 2 * sizeof(*rets));
if (rets) {
rets[0] = ret_t;
rets[1] = NULL;
rf->signature = cbm_type_func(ctx->arena, NULL, NULL, rets);
}
break;
}
}
}
}
}
}
}
}
static void php_lsp_collect_class_fields(PHPLSPContext *ctx, CBMTypeRegistry *reg, TSNode root,
php_class_field_table_t *tab) {
if (ts_node_is_null(root))
return;
/* TWO PASSES so trait/interface declared-return-types get patched
* BEFORE classes that use them — otherwise flatten_trait_into_class
* sees stale (BUILTIN/UNKNOWN) signatures and can't perform the
* trait `self` substitution.
*
* Pass 1: traits and interfaces (declarators that don't pull from
* other classes via `use`).
* Pass 2: classes and enums (which may `use Trait;` and need the
* trait's signatures already patched).
*/
for (int pass = 0; pass < 2; pass++) {
TSNode stack[256];
int top = 0;
stack[top++] = root;
while (top > 0) {
TSNode n = stack[--top];
if (ts_node_is_null(n))
continue;
const char *k = ts_node_type(n);
bool is_trait_or_iface =
(strcmp(k, "trait_declaration") == 0 || strcmp(k, "interface_declaration") == 0);
bool is_class_or_enum =
(strcmp(k, "class_declaration") == 0 || strcmp(k, "enum_declaration") == 0);
if ((pass == 0 && is_trait_or_iface) || (pass == 1 && is_class_or_enum)) {
process_class_for_fields(ctx, reg, n, tab);
}
uint32_t nc = ts_node_child_count(n);
for (uint32_t i = 0; i < nc && top < 256; i++) {
TSNode c = ts_node_child(n, i);
if (!ts_node_is_null(c) && ts_node_is_named(c))
stack[top++] = c;
}
}
}
}
/* ── entry: cbm_run_php_lsp ─────────────────────────────────────── */
void cbm_run_php_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len,
TSNode root) {
if (!result || !arena || ts_node_is_null(root))
return;
CBMTypeRegistry reg;
cbm_registry_init(&reg, arena);
/* Phase A: register stdlib types/functions. */
cbm_php_stdlib_register(&reg, arena);
const char *module_qn = result->module_qn;
/* Phase B: register types and methods/functions from this file's defs.
* We do not yet know the namespace mapping for the type's QN, so we
* trust the QN that the unified extractor already produced. */
for (int i = 0; i < result->defs.count; i++) {
CBMDefinition *d = &result->defs.items[i];
if (!d->qualified_name || !d->name || !d->label)
continue;
if (strcmp(d->label, "Class") == 0 || strcmp(d->label, "Interface") == 0 ||
strcmp(d->label, "Trait") == 0 || strcmp(d->label, "Enum") == 0 ||
strcmp(d->label, "Type") == 0) {
CBMRegisteredType rt;
memset(&rt, 0, sizeof(rt));
rt.qualified_name = d->qualified_name;
rt.short_name = d->name;
rt.is_interface = (strcmp(d->label, "Interface") == 0);
/* Hoist base_classes as embedded_types so php_lookup_method's
* parent walk works. */
if (d->base_classes) {
int bc = 0;
while (d->base_classes[bc])
bc++;
if (bc > 0) {
const char **emb = (const char **)cbm_arena_alloc(
arena, (size_t)(bc + 1) * sizeof(const char *));
if (emb) {
for (int j = 0; j < bc; j++) {
const char *base = d->base_classes[j];
/* Try same-module, then bare. */
const char *qualified = base;
if (base[0] != '\\' && !strchr(base, '.')) {
qualified = cbm_arena_sprintf(arena, "%s.%s", module_qn, base);
} else if (base[0] == '\\') {
qualified = php_ns_to_dot(arena, base + 1);
} else {
qualified = php_ns_to_dot(arena, base);
}
emb[j] = qualified;
}
emb[bc] = NULL;
rt.embedded_types = emb;
}
}
}
cbm_registry_add_type(&reg, rt);
}
if (strcmp(d->label, "Function") == 0 || strcmp(d->label, "Method") == 0) {
CBMRegisteredFunc rf;
memset(&rf, 0, sizeof(rf));
rf.qualified_name = d->qualified_name;
rf.short_name = d->name;
if (strcmp(d->label, "Method") == 0 && d->parent_class) {
rf.receiver_type = d->parent_class;
}
/* Build a minimal signature with just the return type. */
const CBMType *ret_t =
d->return_type
? parse_return_type_text(arena, d->return_type, module_qn, NULL, NULL, NULL, 0)
: cbm_type_unknown();
const CBMType **rets =
(const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(const CBMType *));
if (rets) {
rets[0] = ret_t;
rets[1] = NULL;
}
rf.signature = cbm_type_func(arena, NULL, NULL, rets);
cbm_registry_add_func(&reg, rf);
}
}
/* Phase C: run the resolver. */
PHPLSPContext ctx;
php_lsp_init(&ctx, arena, source, source_len, &reg, module_qn, &result->resolved_calls);
/* Phase B.1: collect typed properties (declared, promoted, ctor-assigned)
* and merge them onto the registered types. Must run after stdlib +
* file defs are registered but before the call walk so eval_expr_type
* for $this->prop and $obj->prop sees the field types. */
{
php_class_field_table_t tab = {0};
/* First pass to populate the use map (so type names in property
* declarations resolve correctly). */
/* Collect top-level children once (O(n)); ts_node_child(root,i) is O(i) → O(n²). */
uint32_t pkn = 0;
TSNode *pkids = cbm_lsp_collect_children(ctx.arena, root, &pkn);
for (uint32_t i = 0; i < pkn; i++) {
TSNode c = pkids[i];
const char *k = ts_node_type(c);
if (strcmp(k, "namespace_definition") == 0) {
set_namespace_from_decl(&ctx, c);
} else if (strcmp(k, "namespace_use_declaration") == 0) {
collect_use_declaration(&ctx, c);
}
}
php_lsp_collect_class_fields(&ctx, &reg, root, &tab);
/* Reset namespace/use state — process_file will re-collect. */
ctx.current_namespace_qn = "";
ctx.use_count = 0;
for (int i = 0; i < tab.count; i++) {
php_class_fields_t *f = &tab.items[i];
if (f->count == 0)
continue;
/* Mutate the existing entry — registered types are stored by
* value in reg.types[], find the index and update it. */
for (int t = 0; t < reg.type_count; t++) {
if (strcmp(reg.types[t].qualified_name, f->class_qn) == 0) {
reg.types[t].field_names = f->field_names;
reg.types[t].field_types = f->field_types;
break;
}
}
}
}
php_lsp_process_file(&ctx, root);
if (ctx.debug) {
fprintf(stderr, "[php_lsp] module=%s defs=%d types=%d funcs=%d resolved=%d\n",
module_qn ? module_qn : "?", result->defs.count, reg.type_count, reg.func_count,
result->resolved_calls.count);
for (int i = 0; i < result->resolved_calls.count; i++) {
const CBMResolvedCall *rc = &result->resolved_calls.items[i];
fprintf(stderr, "[php_lsp] %s -> %s [%s %.2f]\n", rc->caller_qn ? rc->caller_qn : "?",
rc->callee_qn ? rc->callee_qn : "?", rc->strategy ? rc->strategy : "?",
rc->confidence);
}
}
}
/* ── Cross-file + batch ───────────────────────────────────────── */
/* Split a "|"-separated list into a NULL-terminated array of arena copies. */
static const char **php_split_pipe(CBMArena *arena, const char *text) {
if (!text || !text[0])
return NULL;
int count = 1;
for (const char *p = text; *p; p++)
if (*p == '|')
count++;
const char **out =
(const char **)cbm_arena_alloc(arena, (size_t)(count + 1) * sizeof(const char *));
if (!out)
return NULL;
int idx = 0;
const char *start = text;
for (const char *p = text;; p++) {
if (*p == '|' || *p == '\0') {
size_t n = (size_t)(p - start);
char *s = (char *)cbm_arena_alloc(arena, n + 1);
if (!s)
return NULL;
memcpy(s, start, n);
s[n] = '\0';
out[idx++] = s;
if (*p == '\0')
break;
start = p + 1;
}
}
out[idx] = NULL;
return out;
}
/* Build a registry from caller-supplied CBMLSPDef[] — covers both the source
* file's own defs and cross-file referenced defs. PHP labels recognised:
* Class / Interface / Trait / Enum / Type for types; Function / Method for
* functions. Return type strings are parsed via parse_return_type_text using
* each def's def_module_qn so bare type names qualify against the module
* where the def lives, not the importer's module. */
static void php_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRegistry *reg,
CBMLSPDef *defs, int def_count) {
/* Pass 1: types only. The method pass below probes the registry per
* Method def (receiver auto-registration); doing that against an
* unfinalized registry is a LINEAR scan over the growing type table —
* O(methods x types) per file, and PHP cross runs per file: symfony went
* from ~25 s to 416 s on exactly this. Register all types first, build
* the hash, then register funcs/methods with O(1) lookups (post-finalize
* stub additions stay visible via the registry's tail-scan). */
for (int i = 0; i < def_count; i++) {
CBMLSPDef *d = &defs[i];
if (!d->qualified_name || !d->short_name || !d->label)
continue;
if (strcmp(d->label, "Class") == 0 || strcmp(d->label, "Interface") == 0 ||
strcmp(d->label, "Trait") == 0 || strcmp(d->label, "Enum") == 0 ||
strcmp(d->label, "Type") == 0) {
CBMRegisteredType rt;
memset(&rt, 0, sizeof(rt));
rt.qualified_name = d->qualified_name; /* borrowed — d outlives this call */
rt.short_name = d->short_name;
rt.is_interface = d->is_interface || strcmp(d->label, "Interface") == 0;
rt.embedded_types = php_split_pipe(arena, d->embedded_types);
if (d->method_names_str && d->method_names_str[0]) {
rt.method_names = php_split_pipe(arena, d->method_names_str);
}
cbm_registry_add_type(reg, rt);
}
}
/* idx_arena == NULL skips the mid-build finalize (callers that register
* one def at a time would rebuild buckets per def — see py tier-2). */
if (idx_arena) {
cbm_registry_finalize_into(reg, idx_arena);
}
/* Pass 2: functions and methods. */
for (int i = 0; i < def_count; i++) {
CBMLSPDef *d = &defs[i];
if (!d->qualified_name || !d->short_name || !d->label)
continue;
if (strcmp(d->label, "Function") == 0 || strcmp(d->label, "Method") == 0) {
CBMRegisteredFunc rf;
memset(&rf, 0, sizeof(rf));
rf.qualified_name = d->qualified_name; /* borrowed */
rf.short_name = d->short_name;
/* Build FUNC type from "|"-separated return-type texts. Each piece
* is the unqualified return-type expression as it appears in the
* def-defining module's source. parse_return_type_text qualifies
* bare type names against the def's own module_qn. */
const char **ret_strs = php_split_pipe(arena, d->return_types);
const CBMType **ret_types = NULL;
if (ret_strs) {
int n = 0;
while (ret_strs[n])
n++;
if (n > 0) {
ret_types = (const CBMType **)cbm_arena_alloc(
arena, (size_t)(n + 1) * sizeof(const CBMType *));
if (ret_types) {
const char *def_mod = d->def_module_qn ? d->def_module_qn : "";
for (int j = 0; j < n; j++) {
ret_types[j] = parse_return_type_text(arena, ret_strs[j], def_mod, NULL,
NULL, NULL, 0);
}
ret_types[n] = NULL;
}
}
}
rf.signature = cbm_type_func(arena, NULL, NULL, ret_types);
if (strcmp(d->label, "Method") == 0 && d->receiver_type && d->receiver_type[0]) {
rf.receiver_type = d->receiver_type; /* borrowed */
/* Auto-register receiver type if cross-file def chain didn't
* include it explicitly, so php_lookup_method's chain walk
* has somewhere to land. */
if (!cbm_registry_lookup_type(reg, rf.receiver_type)) {
CBMRegisteredType auto_t;
memset(&auto_t, 0, sizeof(auto_t));
auto_t.qualified_name = rf.receiver_type;
const char *dot = strrchr(d->receiver_type, '.');
auto_t.short_name = dot ? dot + 1 : rf.receiver_type; /* borrowed substring */
cbm_registry_add_type(reg, auto_t);
}
}
cbm_registry_add_func(reg, rf);
}
}
}
void cbm_run_php_lsp_cross(CBMArena *arena, const char *source, int source_len,
const char *module_qn, CBMLSPDef *defs, int def_count,
const char **import_names, const char **import_qns, int import_count,
TSTree *cached_tree, CBMResolvedCallArray *out) {
if (!arena || !source || source_len <= 0 || !out)
return;
TSParser *parser = NULL;
TSTree *tree = cached_tree;
bool owns_tree = false;
if (!tree) {
parser = ts_parser_new();
if (!parser)
return;
ts_parser_set_language(parser, tree_sitter_php_only());
tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)source_len);
owns_tree = true;
if (!tree) {
ts_parser_delete(parser);
return;
}
}
TSNode root = ts_tree_root_node(tree);
CBMTypeRegistry reg;
cbm_registry_init(&reg, arena);
cbm_php_stdlib_register(&reg, arena);
/* Index allocations go to a per-call scratch arena: reg's arena is the
* pipeline-lifetime result arena, and per-file bucket allocations there
* accumulate GBs across a large repo. The scratch dies with this call. */
CBMArena idx_arena;
cbm_arena_init(&idx_arena);
php_register_lsp_defs(arena, &idx_arena, &reg, defs, def_count);
/* Finalize registry — O(1) lookups. See go_lsp.c "3c. Finalize"
* comment for the rationale. */
cbm_registry_finalize_into(&reg, &idx_arena);
PHPLSPContext ctx;
php_lsp_init(&ctx, arena, source, source_len, &reg, module_qn, out);
/* Caller-supplied imports register first. process_file's own AST walk
* adds file-internal `use` declarations on top of these. */
for (int i = 0; i < import_count; i++) {
if (import_names && import_qns && import_names[i] && import_qns[i]) {
php_lsp_add_use(&ctx, import_names[i], import_qns[i], CBM_PHP_USE_CLASS);
}
}
/* Class field collection — same flow as cbm_run_php_lsp. Walks the AST
* to populate typed-property field maps so $this->prop and $obj->prop
* resolve to the right type during the call walk. */
{
php_class_field_table_t tab = {0};
/* Collect top-level children once (O(n)); ts_node_child(root,i) is O(i) → O(n²). */
uint32_t pkn = 0;
TSNode *pkids = cbm_lsp_collect_children(ctx.arena, root, &pkn);
for (uint32_t i = 0; i < pkn; i++) {
TSNode c = pkids[i];
const char *k = ts_node_type(c);
if (strcmp(k, "namespace_definition") == 0) {
set_namespace_from_decl(&ctx, c);
} else if (strcmp(k, "namespace_use_declaration") == 0) {
collect_use_declaration(&ctx, c);
}
}
php_lsp_collect_class_fields(&ctx, &reg, root, &tab);
ctx.current_namespace_qn = "";
/* Reset to caller-supplied uses only — process_file re-adds AST uses. */
ctx.use_count = import_count;
for (int i = 0; i < tab.count; i++) {
php_class_fields_t *f = &tab.items[i];
if (f->count == 0)
continue;
for (int t = 0; t < reg.type_count; t++) {
if (strcmp(reg.types[t].qualified_name, f->class_qn) == 0) {
reg.types[t].field_names = f->field_names;
reg.types[t].field_types = f->field_types;
break;
}
}
}
}
php_lsp_process_file(&ctx, root);
cbm_arena_destroy(&idx_arena);
if (owns_tree && tree)
ts_tree_delete(tree);
if (parser)
ts_parser_delete(parser);
}
void cbm_batch_php_lsp_cross(CBMArena *arena, CBMBatchPHPLSPFile *files, int file_count,
CBMResolvedCallArray *out) {
if (!arena || !files || file_count <= 0 || !out)
return;
for (int f = 0; f < file_count; f++) {
CBMBatchPHPLSPFile *file = &files[f];
memset(&out[f], 0, sizeof(CBMResolvedCallArray));
if (!file->source || file->source_len <= 0)
continue;
CBMArena file_arena;
cbm_arena_init(&file_arena);
CBMResolvedCallArray file_out;
memset(&file_out, 0, sizeof(file_out));
cbm_run_php_lsp_cross(&file_arena, file->source, file->source_len, file->module_qn,
file->defs, file->def_count, file->import_names, file->import_qns,
file->import_count, file->cached_tree, &file_out);
if (file_out.count > 0) {
out[f].count = file_out.count;
out[f].items = (CBMResolvedCall *)cbm_arena_alloc(arena, (size_t)file_out.count *
sizeof(CBMResolvedCall));
if (out[f].items) {
for (int j = 0; j < file_out.count; j++) {
CBMResolvedCall *src = &file_out.items[j];
CBMResolvedCall *dst = &out[f].items[j];
dst->caller_qn =
src->caller_qn ? cbm_arena_strdup(arena, src->caller_qn) : NULL;
dst->callee_qn =
src->callee_qn ? cbm_arena_strdup(arena, src->callee_qn) : NULL;
dst->strategy = src->strategy ? cbm_arena_strdup(arena, src->strategy) : NULL;
dst->confidence = src->confidence;
dst->reason = src->reason ? cbm_arena_strdup(arena, src->reason) : NULL;
}
} else {
out[f].count = 0;
}
}
cbm_arena_destroy(&file_arena);
}
}