/* * 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 #include #include #include #include #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", "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` or `array` */ 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`, `iterable`, `list`, `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` 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). 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 / list / iterable (TEMPLATE) → T * - iterable is array (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: * ... `as` ... ... * 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` 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 .__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 "." 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();` 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: 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()` 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; * we don't model array 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 / @template-extends ParentClass * — 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 = ®->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` end up as * TEMPLATE types, not broken NAMED("Generator") * 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 = ®->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 = ®->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(®, arena); /* Phase A: register stdlib types/functions. */ cbm_php_stdlib_register(®, 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(®, 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(®, rf); } } /* Phase C: run the resolver. */ PHPLSPContext ctx; php_lsp_init(&ctx, arena, source, source_len, ®, 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, ®, 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(®, arena); cbm_php_stdlib_register(®, 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, ®, defs, def_count); /* Finalize registry — O(1) lookups. See go_lsp.c "3c. Finalize" * comment for the rationale. */ cbm_registry_finalize_into(®, &idx_arena); PHPLSPContext ctx; php_lsp_init(&ctx, arena, source, source_len, ®, 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, ®, 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); } }