mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
The class-field pass stored the address of a stack-local index in ctx for the duration of the pass. It was restored before returning, but the pattern is what cppcheck's autoVariables check rejects. The index now lives in the call's arena; on an allocation failure it stays NULL and flattening runs the full scan, as before. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
4934 lines
208 KiB
C
4934 lines
208 KiB
C
/*
|
||
* 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 "lsp_work.h"
|
||
#include "../helpers.h"
|
||
#include "../../../src/foundation/hash_table.h"
|
||
#include <ctype.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <unistd.h>
|
||
|
||
#define PHP_EVAL_MAX_DEPTH 32
|
||
#define PHP_USE_INITIAL_CAP 16
|
||
|
||
extern const TSLanguage *tree_sitter_php_only(void);
|
||
|
||
/* Forward decls */
|
||
static void php_resolve_calls_in_node_inner(PHPLSPContext *ctx, TSNode node);
|
||
|
||
/* Depth-guarded entry for the AST call-resolution walk. The walk recurses once
|
||
* per nesting level; a deeply-nested or cyclic file can overflow the native
|
||
* stack (SIGSEGV) and take down the whole index. Past the cap the subtree is
|
||
* skipped — its calls stay unresolved, which is graceful degradation, not a
|
||
* crash. The cap is CBM_LSP_MAX_WALK_DEPTH, env-overridable via the same name.
|
||
* The walk_depth-- runs after the inner returns, so early returns in the body
|
||
* never leak the counter. */
|
||
static void php_resolve_calls_in_node(PHPLSPContext *ctx, TSNode node) {
|
||
if (ctx->walk_depth >= cbm_lsp_max_walk_depth())
|
||
return;
|
||
ctx->walk_depth++;
|
||
php_resolve_calls_in_node_inner(ctx, node);
|
||
ctx->walk_depth--;
|
||
}
|
||
static void process_function_like(PHPLSPContext *ctx, TSNode node);
|
||
static void process_class_decl(PHPLSPContext *ctx, TSNode node);
|
||
static const CBMType *php_substitute_template(CBMArena *arena, const CBMType *t,
|
||
const char *const *param_names,
|
||
const CBMType *const *args);
|
||
static const CBMType *eval_function_call_type(PHPLSPContext *ctx, TSNode call_node);
|
||
static const CBMType *eval_member_call_type(PHPLSPContext *ctx, TSNode call_node);
|
||
static const CBMType *eval_object_creation_type(PHPLSPContext *ctx, TSNode node);
|
||
static void bind_phpdoc_var(PHPLSPContext *ctx, const char *docstring);
|
||
static void parse_phpdoc_for_params(PHPLSPContext *ctx, const char *docstring, TSNode params);
|
||
static const CBMType *resolve_phpdoc_type(PHPLSPContext *ctx, const char *type_text);
|
||
static char *fetch_leading_phpdoc(PHPLSPContext *ctx, TSNode node);
|
||
|
||
/* ── helpers ────────────────────────────────────────────────────── */
|
||
|
||
static char *php_node_text(PHPLSPContext *ctx, TSNode node) {
|
||
return cbm_node_text(ctx->arena, node, ctx->source);
|
||
}
|
||
|
||
static bool node_is(TSNode n, const char *kind) {
|
||
if (ts_node_is_null(n))
|
||
return false;
|
||
return strcmp(ts_node_type(n), kind) == 0;
|
||
}
|
||
|
||
static TSNode child_named(TSNode parent, const char *kind) {
|
||
if (ts_node_is_null(parent))
|
||
return parent;
|
||
uint32_t nc = ts_node_child_count(parent);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(parent, i);
|
||
if (!ts_node_is_null(c) && strcmp(ts_node_type(c), kind) == 0)
|
||
return c;
|
||
}
|
||
TSNode null_node;
|
||
memset(&null_node, 0, sizeof(null_node));
|
||
return null_node;
|
||
}
|
||
|
||
/* PHP qualified names use "." in the graph (project.path.module.class[.method]).
|
||
* Convert "App\\Models\\User" to "App.Models.User" so we can compose with
|
||
* module_qn (which already uses ".") and look up registry entries. */
|
||
static char *php_ns_to_dot(CBMArena *a, const char *ns) {
|
||
if (!ns)
|
||
return NULL;
|
||
size_t n = strlen(ns);
|
||
char *out = (char *)cbm_arena_alloc(a, n + 1);
|
||
if (!out)
|
||
return NULL;
|
||
for (size_t i = 0; i < n; i++) {
|
||
char c = ns[i];
|
||
out[i] = (c == '\\') ? '.' : c;
|
||
}
|
||
out[n] = '\0';
|
||
/* Trim leading dot from "\\Foo". */
|
||
if (out[0] == '.')
|
||
return out + 1;
|
||
return out;
|
||
}
|
||
|
||
/* Return the substring after the last '.' or '\\'. */
|
||
static const char *php_short_name(const char *qn) {
|
||
if (!qn)
|
||
return NULL;
|
||
const char *last = qn;
|
||
for (const char *p = qn; *p; p++) {
|
||
if (*p == '.' || *p == '\\')
|
||
last = p + 1;
|
||
}
|
||
return last;
|
||
}
|
||
|
||
static bool php_is_builtin_type_name(const char *n) {
|
||
if (!n)
|
||
return false;
|
||
static const char *const builtins[] = {
|
||
"int", "integer", "float", "double", "string", "bool", "boolean",
|
||
"array", "callable", "iterable", "object", "void", "mixed", "never",
|
||
"null", "true", "false", "self", "static", "parent", NULL};
|
||
for (int i = 0; builtins[i]; i++) {
|
||
if (strcmp(n, builtins[i]) == 0)
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* ── init / context ─────────────────────────────────────────────── */
|
||
|
||
void php_lsp_init(PHPLSPContext *ctx, CBMArena *arena, const char *source, int source_len,
|
||
const CBMTypeRegistry *registry, const char *module_qn,
|
||
CBMResolvedCallArray *out) {
|
||
memset(ctx, 0, sizeof(*ctx));
|
||
ctx->arena = arena;
|
||
ctx->source = source;
|
||
ctx->source_len = source_len;
|
||
ctx->registry = registry;
|
||
ctx->module_qn = module_qn;
|
||
ctx->current_namespace_qn = "";
|
||
ctx->resolved_calls = out;
|
||
ctx->current_scope = cbm_scope_push(arena, NULL);
|
||
|
||
const char *dbg = getenv("CBM_LSP_DEBUG");
|
||
ctx->debug = (dbg && dbg[0]);
|
||
}
|
||
|
||
void php_lsp_add_use(PHPLSPContext *ctx, const char *local_name, const char *target_qn,
|
||
int use_kind) {
|
||
if (!local_name || !target_qn)
|
||
return;
|
||
if (ctx->use_count >= ctx->use_cap) {
|
||
int new_cap = ctx->use_cap ? ctx->use_cap * 2 : PHP_USE_INITIAL_CAP;
|
||
const char **nl = (const char **)cbm_arena_alloc(ctx->arena, (size_t)new_cap * sizeof(*nl));
|
||
const char **nq = (const char **)cbm_arena_alloc(ctx->arena, (size_t)new_cap * sizeof(*nq));
|
||
int *nk = (int *)cbm_arena_alloc(ctx->arena, (size_t)new_cap * sizeof(int));
|
||
if (!nl || !nq || !nk)
|
||
return;
|
||
for (int i = 0; i < ctx->use_count; i++) {
|
||
nl[i] = ctx->use_local_names[i];
|
||
nq[i] = ctx->use_target_qns[i];
|
||
nk[i] = (int)ctx->use_kinds[i];
|
||
}
|
||
ctx->use_local_names = nl;
|
||
ctx->use_target_qns = nq;
|
||
/* re-cast to the enum-typed pointer */
|
||
ctx->use_kinds = (void *)nk;
|
||
ctx->use_cap = new_cap;
|
||
}
|
||
ctx->use_local_names[ctx->use_count] = cbm_arena_strdup(ctx->arena, local_name);
|
||
ctx->use_target_qns[ctx->use_count] = cbm_arena_strdup(ctx->arena, target_qn);
|
||
((int *)ctx->use_kinds)[ctx->use_count] = use_kind;
|
||
ctx->use_count++;
|
||
}
|
||
|
||
static const char *find_use(PHPLSPContext *ctx, const char *local_name, int kind) {
|
||
for (int i = 0; i < ctx->use_count; i++) {
|
||
if ((int)ctx->use_kinds[i] != kind)
|
||
continue;
|
||
if (strcmp(ctx->use_local_names[i], local_name) == 0)
|
||
return ctx->use_target_qns[i];
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
/* Resolve a class identifier to a fully-qualified registry key.
|
||
* - Fully-qualified ("\\Foo\\Bar"): FOO.BAR — strip leading slash.
|
||
* - Aliased / single segment matches a use: the use target.
|
||
* - First segment matches a use: substitute and append remainder.
|
||
* - Otherwise: prefix with current_namespace_qn (or module).
|
||
*
|
||
* Returns an arena-allocated string in dotted form. May return NULL for builtins. */
|
||
const char *php_resolve_class_name(PHPLSPContext *ctx, const char *name) {
|
||
if (!name || !*name)
|
||
return NULL;
|
||
|
||
/* Self / static / parent are class-relative pseudo-types and must be
|
||
* checked BEFORE the builtin-name check, since they're listed as
|
||
* builtins above for type-parsing purposes. */
|
||
if (strcmp(name, "self") == 0 || strcmp(name, "static") == 0) {
|
||
return ctx->enclosing_class_qn;
|
||
}
|
||
if (strcmp(name, "parent") == 0) {
|
||
return ctx->enclosing_parent_qn;
|
||
}
|
||
if (php_is_builtin_type_name(name))
|
||
return NULL;
|
||
|
||
/* Fully-qualified — leading backslash. */
|
||
if (name[0] == '\\') {
|
||
return php_ns_to_dot(ctx->arena, name + 1);
|
||
}
|
||
|
||
/* Split first segment. */
|
||
const char *sep = name;
|
||
while (*sep && *sep != '\\')
|
||
sep++;
|
||
char *first;
|
||
if (*sep) {
|
||
first = cbm_arena_strndup(ctx->arena, name, (size_t)(sep - name));
|
||
} else {
|
||
first = cbm_arena_strdup(ctx->arena, name);
|
||
}
|
||
|
||
const char *use_target = find_use(ctx, first, CBM_PHP_USE_CLASS);
|
||
if (use_target) {
|
||
if (*sep) {
|
||
/* use App\\Models as M; M\\User -> App.Models.User */
|
||
return cbm_arena_sprintf(ctx->arena, "%s%s", use_target,
|
||
php_ns_to_dot(ctx->arena, sep));
|
||
}
|
||
return use_target;
|
||
}
|
||
|
||
/* Fallback for a same-file class reference: prefer module_qn-prefixed QN
|
||
* because that's what the unified extractor records. The PHP namespace
|
||
* declaration is *not* incorporated into the unified extractor's QNs
|
||
* (defs are keyed by file path + class name), so building from
|
||
* current_namespace_qn here would diverge and miss every same-file
|
||
* lookup. */
|
||
if (ctx->module_qn && ctx->module_qn[0]) {
|
||
return cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn,
|
||
php_ns_to_dot(ctx->arena, name));
|
||
}
|
||
return php_ns_to_dot(ctx->arena, name);
|
||
}
|
||
|
||
/* True when `qn` is, or lies under, the target of a class `use` the pipeline
|
||
* left unresolved: a type outside the project (cross-file mode only). */
|
||
static bool php_external_qn(const PHPLSPContext *ctx, const char *qn) {
|
||
for (int i = 0; qn && i < ctx->external_use_count; i++) {
|
||
size_t n = strlen(ctx->external_use_qns[i]);
|
||
if (strncmp(qn, ctx->external_use_qns[i], n) == 0 && (qn[n] == '\0' || qn[n] == '.'))
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* 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.
|
||
* A QN named through a vendor `use` stops after step 1: `Saloon.Http.Request`
|
||
* is not the project's App.Http.Requests.Request (#1186). */
|
||
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 (php_external_qn(ctx, qn))
|
||
return NULL;
|
||
|
||
if (ctx->module_qn && ctx->module_qn[0]) {
|
||
const char *combo = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, qn);
|
||
t = cbm_registry_lookup_type(ctx->registry, combo);
|
||
if (t)
|
||
return t;
|
||
|
||
const char *m = ctx->module_qn;
|
||
const char *last_dot = strrchr(m, '.');
|
||
while (last_dot) {
|
||
char *prefix = cbm_arena_strndup(ctx->arena, m, (size_t)(last_dot - m));
|
||
const char *try_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, qn);
|
||
t = cbm_registry_lookup_type(ctx->registry, try_qn);
|
||
if (t)
|
||
return t;
|
||
const char *prev = last_dot;
|
||
last_dot = NULL;
|
||
for (const char *p = m; p < prev; p++) {
|
||
if (*p == '.')
|
||
last_dot = p;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Step 4: short-name fallback. */
|
||
const char *short_name = qn;
|
||
for (const char *p = qn; *p; p++) {
|
||
if (*p == '.')
|
||
short_name = p + 1;
|
||
}
|
||
if (!short_name || !*short_name)
|
||
return NULL;
|
||
|
||
const CBMRegisteredType *best = NULL;
|
||
int best_score = -1;
|
||
for (int i = 0; ctx->registry && i < ctx->registry->type_count; i++) {
|
||
const CBMRegisteredType *cand = &ctx->registry->types[i];
|
||
if (!cand->short_name || strcmp(cand->short_name, short_name) != 0)
|
||
continue;
|
||
int score = 0;
|
||
if (cand->qualified_name && ctx->module_qn) {
|
||
const char *m = ctx->module_qn;
|
||
const char *q = cand->qualified_name;
|
||
while (*m && *q && *m == *q) {
|
||
if (*m == '.')
|
||
score++;
|
||
m++;
|
||
q++;
|
||
}
|
||
}
|
||
if (score > best_score) {
|
||
best_score = score;
|
||
best = cand;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/* ── method lookup with parent walk ─────────────────────────────── */
|
||
|
||
const CBMRegisteredFunc *php_lookup_method(PHPLSPContext *ctx, const char *class_qn,
|
||
const char *method_name) {
|
||
if (!class_qn || !method_name)
|
||
return NULL;
|
||
|
||
/* Direct lookup by the registry's receiver_qn -> method_name index. */
|
||
const CBMRegisteredFunc *f = cbm_registry_lookup_method(ctx->registry, class_qn, method_name);
|
||
if (f)
|
||
return f;
|
||
|
||
/* If the QN we have isn't the registry's canonical key, resolve the
|
||
* type to its registered identity and retry. */
|
||
const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, class_qn);
|
||
if (!t)
|
||
t = lookup_type_with_project(ctx, class_qn);
|
||
if (!t)
|
||
return NULL;
|
||
if (strcmp(t->qualified_name, class_qn) != 0) {
|
||
f = cbm_registry_lookup_method(ctx->registry, t->qualified_name, method_name);
|
||
if (f)
|
||
return f;
|
||
}
|
||
|
||
/* Walk the full ancestor chain. PHP only allows single inheritance for
|
||
* `extends`, but a class may also pick up methods from `implements` /
|
||
* traits, both of which are recorded in embedded_types. We iterate
|
||
* across all entries and recurse into each branch with cycle-detection.
|
||
*
|
||
* Cap depth at 16 across all visited types to bound runtime. */
|
||
const char *visited[32];
|
||
int visited_count = 0;
|
||
const char *frontier[32];
|
||
int frontier_count = 0;
|
||
if (t->embedded_types) {
|
||
for (int i = 0; t->embedded_types[i] && frontier_count < 32; i++) {
|
||
frontier[frontier_count++] = t->embedded_types[i];
|
||
}
|
||
}
|
||
while (frontier_count > 0 && visited_count < 32) {
|
||
const char *parent = frontier[--frontier_count];
|
||
bool seen = false;
|
||
for (int v = 0; v < visited_count; v++) {
|
||
if (strcmp(visited[v], parent) == 0) {
|
||
seen = true;
|
||
break;
|
||
}
|
||
}
|
||
if (seen)
|
||
continue;
|
||
visited[visited_count++] = parent;
|
||
|
||
f = cbm_registry_lookup_method(ctx->registry, parent, method_name);
|
||
if (f)
|
||
return f;
|
||
|
||
const CBMRegisteredType *next = cbm_registry_lookup_type(ctx->registry, parent);
|
||
if (!next)
|
||
next = lookup_type_with_project(ctx, parent);
|
||
if (!next)
|
||
continue;
|
||
|
||
if (strcmp(next->qualified_name, parent) != 0) {
|
||
f = cbm_registry_lookup_method(ctx->registry, next->qualified_name, method_name);
|
||
if (f)
|
||
return f;
|
||
}
|
||
if (next->method_qns && next->method_names) {
|
||
for (int i = 0; next->method_names[i]; i++) {
|
||
if (strcmp(next->method_names[i], method_name) == 0) {
|
||
const CBMRegisteredFunc *cand =
|
||
cbm_registry_lookup_func(ctx->registry, next->method_qns[i]);
|
||
if (cand)
|
||
return cand;
|
||
}
|
||
}
|
||
}
|
||
if (next->embedded_types) {
|
||
for (int i = 0; next->embedded_types[i] && frontier_count < 32; i++) {
|
||
frontier[frontier_count++] = next->embedded_types[i];
|
||
}
|
||
}
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
/* Detect a __call / __callStatic on a class chain. */
|
||
static bool class_has_magic_call(PHPLSPContext *ctx, const char *class_qn, bool is_static) {
|
||
const char *magic = is_static ? "__callStatic" : "__call";
|
||
return php_lookup_method(ctx, class_qn, magic) != NULL;
|
||
}
|
||
|
||
/* ── type parsing ───────────────────────────────────────────────── */
|
||
|
||
const CBMType *php_parse_type_node(PHPLSPContext *ctx, TSNode node) {
|
||
if (ts_node_is_null(node))
|
||
return cbm_type_unknown();
|
||
|
||
const char *kind = ts_node_type(node);
|
||
|
||
if (strcmp(kind, "primitive_type") == 0) {
|
||
char *t = php_node_text(ctx, node);
|
||
return cbm_type_builtin(ctx->arena, t ? t : "mixed");
|
||
}
|
||
if (strcmp(kind, "named_type") == 0 || strcmp(kind, "qualified_name") == 0 ||
|
||
strcmp(kind, "name") == 0) {
|
||
char *t = php_node_text(ctx, node);
|
||
if (!t)
|
||
return cbm_type_unknown();
|
||
const char *resolved = php_resolve_class_name(ctx, t);
|
||
if (!resolved)
|
||
return cbm_type_unknown();
|
||
return cbm_type_named(ctx->arena, resolved);
|
||
}
|
||
if (strcmp(kind, "optional_type") == 0) {
|
||
/* ?Foo — strip the '?' and recurse. */
|
||
uint32_t nc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
|
||
return php_parse_type_node(ctx, c);
|
||
}
|
||
}
|
||
return cbm_type_unknown();
|
||
}
|
||
if (strcmp(kind, "union_type") == 0 || strcmp(kind, "intersection_type") == 0 ||
|
||
strcmp(kind, "disjunctive_normal_form_type") == 0) {
|
||
/* Pick the first concrete (non-null) named member as a heuristic;
|
||
* full DNF dispatch is Phase 2.5 at best. */
|
||
uint32_t nc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const CBMType *t = php_parse_type_node(ctx, c);
|
||
if (t && t->kind != CBM_TYPE_UNKNOWN) {
|
||
if (t->kind == CBM_TYPE_BUILTIN && strcmp(t->data.builtin.name, "null") == 0) {
|
||
continue;
|
||
}
|
||
return t;
|
||
}
|
||
}
|
||
return cbm_type_unknown();
|
||
}
|
||
/* Fallback: try to read raw text and treat as a name. */
|
||
char *t = php_node_text(ctx, node);
|
||
if (!t)
|
||
return cbm_type_unknown();
|
||
if (php_is_builtin_type_name(t))
|
||
return cbm_type_builtin(ctx->arena, t);
|
||
const char *resolved = php_resolve_class_name(ctx, t);
|
||
if (!resolved)
|
||
return cbm_type_unknown();
|
||
return cbm_type_named(ctx->arena, resolved);
|
||
}
|
||
|
||
/* ── PHPDoc minimal parser ──────────────────────────────────────── */
|
||
|
||
/* Strip leading "/**", trailing "*/", and per-line "*" prefixes. Returns a
|
||
* mutable arena-allocated cleaned copy. */
|
||
static char *phpdoc_clean(CBMArena *a, const char *raw) {
|
||
if (!raw)
|
||
return NULL;
|
||
size_t n = strlen(raw);
|
||
char *out = (char *)cbm_arena_alloc(a, n + 1);
|
||
if (!out)
|
||
return NULL;
|
||
size_t w = 0;
|
||
size_t i = 0;
|
||
/* Skip leading /* and ** */
|
||
while (i < n && (raw[i] == '/' || raw[i] == '*' || raw[i] == ' ' || raw[i] == '\t'))
|
||
i++;
|
||
bool at_line_start = false;
|
||
for (; i < n; i++) {
|
||
char c = raw[i];
|
||
if (c == '\n') {
|
||
out[w++] = ' ';
|
||
at_line_start = true;
|
||
continue;
|
||
}
|
||
if (at_line_start) {
|
||
if (c == ' ' || c == '\t' || c == '*')
|
||
continue;
|
||
at_line_start = false;
|
||
}
|
||
out[w++] = c;
|
||
}
|
||
/* Trim trailing */ if (w >= 2 && out[w - 1] == '/' && out[w - 2] == '*')
|
||
w -= 2;
|
||
out[w] = '\0';
|
||
return out;
|
||
}
|
||
|
||
/* (next_tag helper not currently needed — phpdoc parsers use strstr inline.) */
|
||
|
||
/* Resolve a PHPDoc-style type spec (e.g. "App\\Foo|null", "?App\\Foo",
|
||
* "Collection<User>", "string") to a CBMType. We strip nullables, take the
|
||
* leftmost class in unions, drop generic <...> tails, then resolve. */
|
||
/* Parse a `<...>` argument list into an arena-allocated NULL-terminated
|
||
* array of CBMType*. Caller has already skipped past the leading '<'.
|
||
* Returns the number of args parsed; *out_args is the array; *out_close
|
||
* is set to the position just after the matching '>'. */
|
||
static int parse_phpdoc_template_args(PHPLSPContext *ctx, const char *p, const CBMType ***out_args,
|
||
const char **out_close) {
|
||
/* Allocate a small buffer; grow if needed. */
|
||
int cap = 4;
|
||
int count = 0;
|
||
const CBMType **buf =
|
||
(const CBMType **)cbm_arena_alloc(ctx->arena, (size_t)(cap + 1) * sizeof(*buf));
|
||
if (!buf) {
|
||
*out_args = NULL;
|
||
*out_close = p;
|
||
return 0;
|
||
}
|
||
int depth = 1;
|
||
while (*p && depth > 0) {
|
||
while (*p == ' ' || *p == '\t' || *p == ',')
|
||
p++;
|
||
if (*p == '>')
|
||
break;
|
||
const char *arg_start = p;
|
||
while (*p && depth > 0) {
|
||
if (*p == '<')
|
||
depth++;
|
||
else if (*p == '>') {
|
||
depth--;
|
||
if (depth == 0)
|
||
break;
|
||
} else if (*p == ',' && depth == 1) {
|
||
break;
|
||
}
|
||
p++;
|
||
}
|
||
size_t arg_len = (size_t)(p - arg_start);
|
||
while (arg_len > 0 && (arg_start[arg_len - 1] == ' ' || arg_start[arg_len - 1] == '\t')) {
|
||
arg_len--;
|
||
}
|
||
if (arg_len > 0) {
|
||
char *arg_text = cbm_arena_strndup(ctx->arena, arg_start, arg_len);
|
||
const CBMType *arg_type = resolve_phpdoc_type(ctx, arg_text);
|
||
if (count >= cap) {
|
||
int new_cap = cap * 2;
|
||
const CBMType **new_buf = (const CBMType **)cbm_arena_alloc(
|
||
ctx->arena, (size_t)(new_cap + 1) * sizeof(*new_buf));
|
||
if (new_buf) {
|
||
for (int i = 0; i < count; i++)
|
||
new_buf[i] = buf[i];
|
||
buf = new_buf;
|
||
cap = new_cap;
|
||
}
|
||
}
|
||
buf[count++] = arg_type;
|
||
}
|
||
}
|
||
if (*p == '>')
|
||
p++;
|
||
buf[count] = NULL;
|
||
*out_args = buf;
|
||
*out_close = p;
|
||
return count;
|
||
}
|
||
|
||
/* Add a @phpstan-type alias to ctx (idempotent — keeps first definition). */
|
||
static void php_add_phpstan_alias(PHPLSPContext *ctx, const char *name, const CBMType *type) {
|
||
if (!ctx || !name || !type)
|
||
return;
|
||
for (int i = 0; i < ctx->phpstan_alias_count; i++) {
|
||
if (strcmp(ctx->phpstan_alias_names[i], name) == 0)
|
||
return;
|
||
}
|
||
if (ctx->phpstan_alias_count >= ctx->phpstan_alias_cap) {
|
||
int new_cap = ctx->phpstan_alias_cap ? ctx->phpstan_alias_cap * 2 : 8;
|
||
const char **nn =
|
||
(const char **)cbm_arena_alloc(ctx->arena, (size_t)(new_cap + 1) * sizeof(*nn));
|
||
const CBMType **nt =
|
||
(const CBMType **)cbm_arena_alloc(ctx->arena, (size_t)(new_cap + 1) * sizeof(*nt));
|
||
if (!nn || !nt)
|
||
return;
|
||
for (int i = 0; i < ctx->phpstan_alias_count; i++) {
|
||
nn[i] = ctx->phpstan_alias_names[i];
|
||
nt[i] = ctx->phpstan_alias_types[i];
|
||
}
|
||
ctx->phpstan_alias_names = nn;
|
||
ctx->phpstan_alias_types = nt;
|
||
ctx->phpstan_alias_cap = new_cap;
|
||
}
|
||
ctx->phpstan_alias_names[ctx->phpstan_alias_count] = cbm_arena_strdup(ctx->arena, name);
|
||
ctx->phpstan_alias_types[ctx->phpstan_alias_count] = type;
|
||
ctx->phpstan_alias_count++;
|
||
}
|
||
|
||
static const CBMType *php_lookup_phpstan_alias(PHPLSPContext *ctx, const char *name) {
|
||
if (!ctx || !name)
|
||
return NULL;
|
||
for (int i = 0; i < ctx->phpstan_alias_count; i++) {
|
||
if (strcmp(ctx->phpstan_alias_names[i], name) == 0) {
|
||
return ctx->phpstan_alias_types[i];
|
||
}
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
static const CBMType *resolve_phpdoc_type(PHPLSPContext *ctx, const char *type_text) {
|
||
if (!type_text || !*type_text)
|
||
return cbm_type_unknown();
|
||
/* Skip whitespace + nullable */
|
||
while (*type_text == ' ' || *type_text == '\t' || *type_text == '?')
|
||
type_text++;
|
||
/* Take portion up to first '|', ' ', '\t', '<' */
|
||
size_t end = 0;
|
||
while (type_text[end] && type_text[end] != '|' && type_text[end] != ' ' &&
|
||
type_text[end] != '\t' && type_text[end] != '<' && type_text[end] != '\n' &&
|
||
type_text[end] != ',' && type_text[end] != '>' && type_text[end] != '{') {
|
||
end++;
|
||
}
|
||
if (end == 0)
|
||
return cbm_type_unknown();
|
||
char *first = cbm_arena_strndup(ctx->arena, type_text, end);
|
||
if (!first)
|
||
return cbm_type_unknown();
|
||
if (strcmp(first, "null") == 0) {
|
||
/* take next member */
|
||
if (type_text[end] == '|')
|
||
return resolve_phpdoc_type(ctx, type_text + end + 1);
|
||
return cbm_type_unknown();
|
||
}
|
||
|
||
/* Generic? `Foo<Bar>` or `array<int, User>` */
|
||
bool has_generic = (type_text[end] == '<');
|
||
bool has_array_shape = (type_text[end] == '{');
|
||
|
||
/* `array{...}` shape — treat as plain array for now. */
|
||
if (has_array_shape)
|
||
return cbm_type_builtin(ctx->arena, "array");
|
||
|
||
/* @phpstan-type alias takes precedence over name resolution. */
|
||
{
|
||
const CBMType *aliased = php_lookup_phpstan_alias(ctx, first);
|
||
if (aliased)
|
||
return aliased;
|
||
}
|
||
|
||
if (php_is_builtin_type_name(first)) {
|
||
if (has_generic) {
|
||
/* `array<T>`, `iterable<T>`, `list<T>`, `callable<...>`. We
|
||
* preserve the template form so foreach can extract elements
|
||
* later. */
|
||
const CBMType **args = NULL;
|
||
const char *after = NULL;
|
||
int n = parse_phpdoc_template_args(ctx, type_text + end + 1, &args, &after);
|
||
return cbm_type_template(ctx->arena, first, args, n);
|
||
}
|
||
return cbm_type_builtin(ctx->arena, first);
|
||
}
|
||
|
||
const char *resolved = php_resolve_class_name(ctx, first);
|
||
if (!resolved)
|
||
return cbm_type_unknown();
|
||
|
||
if (has_generic) {
|
||
const CBMType **args = NULL;
|
||
const char *after = NULL;
|
||
int n = parse_phpdoc_template_args(ctx, type_text + end + 1, &args, &after);
|
||
return cbm_type_template(ctx->arena, resolved, args, n);
|
||
}
|
||
return cbm_type_named(ctx->arena, resolved);
|
||
}
|
||
|
||
/* @param Type $name — rebind matching parameter. Tolerates generic
|
||
* `<...>` in the type token. */
|
||
static void parse_phpdoc_for_params(PHPLSPContext *ctx, const char *docstring, TSNode params) {
|
||
(void)params;
|
||
if (!docstring || !ctx->current_scope)
|
||
return;
|
||
const char *p = docstring;
|
||
while ((p = strstr(p, "@param")) != NULL) {
|
||
p += 6;
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
const char *type_start = p;
|
||
int depth = 0;
|
||
while (*p && *p != '\n') {
|
||
if (*p == '<')
|
||
depth++;
|
||
else if (*p == '>')
|
||
depth--;
|
||
else if (depth == 0 && (*p == ' ' || *p == '\t' || *p == '$'))
|
||
break;
|
||
p++;
|
||
}
|
||
if (p == type_start)
|
||
continue;
|
||
char *type_text = cbm_arena_strndup(ctx->arena, type_start, (size_t)(p - type_start));
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
if (*p != '$')
|
||
continue;
|
||
p++;
|
||
const char *name_start = p;
|
||
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
|
||
p++;
|
||
if (p == name_start)
|
||
continue;
|
||
char *vname = cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
|
||
const CBMType *t = resolve_phpdoc_type(ctx, type_text);
|
||
if (vname && t && t->kind != CBM_TYPE_UNKNOWN) {
|
||
cbm_scope_bind(ctx->current_scope, vname, t);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* @var Type $name — bind a variable in current scope.
|
||
* Generic types like `array<User>` may contain whitespace-free `<>` — we
|
||
* tolerate those by including '<' and '>' in the type token. */
|
||
static void bind_phpdoc_var(PHPLSPContext *ctx, const char *docstring) {
|
||
if (!docstring || !ctx->current_scope)
|
||
return;
|
||
const char *p = docstring;
|
||
while ((p = strstr(p, "@var")) != NULL) {
|
||
p += 4;
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
const char *type_start = p;
|
||
/* Take a type token: stop at whitespace or '$' (start of var name).
|
||
* Allow '<', '>', ',', '|', '?' inside the type. */
|
||
int depth = 0;
|
||
while (*p && *p != '\n') {
|
||
if (*p == '<')
|
||
depth++;
|
||
else if (*p == '>')
|
||
depth--;
|
||
else if (depth == 0 && (*p == ' ' || *p == '\t' || *p == '$'))
|
||
break;
|
||
p++;
|
||
}
|
||
if (p == type_start)
|
||
continue;
|
||
char *type_text = cbm_arena_strndup(ctx->arena, type_start, (size_t)(p - type_start));
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
if (*p != '$')
|
||
continue;
|
||
p++;
|
||
const char *name_start = p;
|
||
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
|
||
p++;
|
||
if (p == name_start)
|
||
continue;
|
||
char *vname = cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
|
||
const CBMType *t = resolve_phpdoc_type(ctx, type_text);
|
||
if (vname && t && t->kind != CBM_TYPE_UNKNOWN) {
|
||
cbm_scope_bind(ctx->current_scope, vname, t);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Walk siblings backwards from `node` to find a leading PHPDoc comment
|
||
* ("/**...*/"). Returns cleaned doc text or NULL. */
|
||
static char *fetch_leading_phpdoc(PHPLSPContext *ctx, TSNode node) {
|
||
TSNode parent = ts_node_parent(node);
|
||
if (ts_node_is_null(parent))
|
||
return NULL;
|
||
uint32_t nc = ts_node_child_count(parent);
|
||
/* Find index of node within parent. */
|
||
int idx = -1;
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(parent, i);
|
||
if (ts_node_eq(c, node)) {
|
||
idx = (int)i;
|
||
break;
|
||
}
|
||
}
|
||
if (idx <= 0)
|
||
return NULL;
|
||
for (int i = idx - 1; i >= 0; i--) {
|
||
TSNode c = ts_node_child(parent, (uint32_t)i);
|
||
if (ts_node_is_null(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "comment") == 0) {
|
||
char *raw = php_node_text(ctx, c);
|
||
if (raw && raw[0] == '/' && raw[1] == '*' && raw[2] == '*') {
|
||
return phpdoc_clean(ctx->arena, raw);
|
||
}
|
||
return NULL;
|
||
}
|
||
/* Skip whitespace-only or attributes; stop at any other node. */
|
||
if (strcmp(k, "attribute_list") == 0 || strcmp(k, "attribute_group") == 0)
|
||
continue;
|
||
return NULL;
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
/* PHP 8.1 first-class callable syntax reuses call-expression nodes but its
|
||
* argument list contains only a `variadic_placeholder`: `target(...)`,
|
||
* `$obj->method(...)`, or `Type::method(...)`. It creates a Closure; it does
|
||
* not invoke the target. */
|
||
static bool php_is_first_class_callable(TSNode node) {
|
||
TSNode args = ts_node_child_by_field_name(node, "arguments", 9);
|
||
if (ts_node_is_null(args))
|
||
return false;
|
||
|
||
bool saw_placeholder = false;
|
||
int semantic_children = 0;
|
||
uint32_t child_count = ts_node_child_count(args);
|
||
for (uint32_t i = 0; i < child_count; i++) {
|
||
TSNode child = ts_node_child(args, i);
|
||
if (ts_node_is_null(child) || !ts_node_is_named(child))
|
||
continue;
|
||
const char *kind = ts_node_type(child);
|
||
if (strcmp(kind, "comment") == 0)
|
||
continue;
|
||
semantic_children++;
|
||
if (strcmp(kind, "variadic_placeholder") != 0)
|
||
return false;
|
||
saw_placeholder = true;
|
||
}
|
||
return saw_placeholder && semantic_children == 1;
|
||
}
|
||
|
||
/* ── 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, "function_call_expression") == 0 ||
|
||
strcmp(kind, "member_call_expression") == 0 ||
|
||
strcmp(kind, "nullsafe_member_call_expression") == 0 ||
|
||
strcmp(kind, "scoped_call_expression") == 0) &&
|
||
php_is_first_class_callable(node)) {
|
||
result = cbm_type_named(ctx->arena, "Closure");
|
||
} else if (strcmp(kind, "member_call_expression") == 0 ||
|
||
strcmp(kind, "nullsafe_member_call_expression") == 0 ||
|
||
strcmp(kind, "scoped_call_expression") == 0) {
|
||
result = eval_member_call_type(ctx, node);
|
||
} else if (strcmp(kind, "function_call_expression") == 0) {
|
||
result = eval_function_call_type(ctx, node);
|
||
} else if (strcmp(kind, "assignment_expression") == 0) {
|
||
TSNode rhs = ts_node_child_by_field_name(node, "right", 5);
|
||
if (!ts_node_is_null(rhs))
|
||
result = php_eval_expr_type(ctx, rhs);
|
||
} else if (strcmp(kind, "cast_expression") == 0) {
|
||
TSNode tnode = ts_node_child_by_field_name(node, "type", 4);
|
||
if (!ts_node_is_null(tnode))
|
||
result = php_parse_type_node(ctx, tnode);
|
||
} else if (strcmp(kind, "parenthesized_expression") == 0) {
|
||
uint32_t nc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
|
||
result = php_eval_expr_type(ctx, c);
|
||
break;
|
||
}
|
||
}
|
||
} else if (strcmp(kind, "clone_expression") == 0) {
|
||
uint32_t nc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
|
||
result = php_eval_expr_type(ctx, c);
|
||
break;
|
||
}
|
||
}
|
||
} else if (strcmp(kind, "class_constant_access_expression") == 0 ||
|
||
strcmp(kind, "scoped_property_access_expression") == 0) {
|
||
/* `Suit::Hearts` — if the LHS resolves to a registered class with a
|
||
* field by that constant name, return the field's type. This makes
|
||
* enum cases bind to the enum type. */
|
||
TSNode lhs;
|
||
memset(&lhs, 0, sizeof(lhs));
|
||
TSNode rhs;
|
||
memset(&rhs, 0, sizeof(rhs));
|
||
uint32_t cnc = ts_node_child_count(node);
|
||
int seen = 0;
|
||
for (uint32_t i = 0; i < cnc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
if (seen == 0)
|
||
lhs = c;
|
||
else if (seen == 1)
|
||
rhs = c;
|
||
seen++;
|
||
}
|
||
if (!ts_node_is_null(lhs) && !ts_node_is_null(rhs)) {
|
||
char *cls_text = php_node_text(ctx, lhs);
|
||
char *member = php_node_text(ctx, rhs);
|
||
if (cls_text && member) {
|
||
const char *cls_qn = NULL;
|
||
if (strcmp(cls_text, "self") == 0 || strcmp(cls_text, "static") == 0)
|
||
cls_qn = ctx->enclosing_class_qn;
|
||
else if (strcmp(cls_text, "parent") == 0)
|
||
cls_qn = ctx->enclosing_parent_qn;
|
||
else
|
||
cls_qn = php_resolve_class_name(ctx, cls_text);
|
||
if (cls_qn) {
|
||
const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, cls_qn);
|
||
if (!t)
|
||
t = lookup_type_with_project(ctx, cls_qn);
|
||
if (t && t->field_names) {
|
||
for (int i = 0; t->field_names[i]; i++) {
|
||
if (strcmp(t->field_names[i], member) == 0) {
|
||
if (t->field_types && t->field_types[i]) {
|
||
result = t->field_types[i];
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if (strcmp(kind, "subscript_expression") == 0) {
|
||
/* $arr[$k] — if $arr: TEMPLATE("array"|"list"|..., [V]) then we
|
||
* return V (or the value-side of array<K, V>). NAMED collection
|
||
* types fall back to UNKNOWN unless the registered type provides
|
||
* an offsetGet method. */
|
||
TSNode obj = ts_node_child_by_field_name(node, "dereferencable", 14);
|
||
if (ts_node_is_null(obj)) {
|
||
uint32_t nc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
|
||
obj = c;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!ts_node_is_null(obj)) {
|
||
const CBMType *recv = php_eval_expr_type(ctx, obj);
|
||
if (recv && recv->kind == CBM_TYPE_TEMPLATE) {
|
||
const CBMType *const *args = recv->data.template_type.template_args;
|
||
if (args) {
|
||
int n = 0;
|
||
while (args[n])
|
||
n++;
|
||
if (n >= 2 && args[1])
|
||
result = args[1];
|
||
else if (n >= 1 && args[0])
|
||
result = args[0];
|
||
}
|
||
} else if (recv && recv->kind == CBM_TYPE_NAMED) {
|
||
/* offsetGet on a registered ArrayAccess-like class. */
|
||
const CBMRegisteredFunc *og =
|
||
php_lookup_method(ctx, recv->data.named.qualified_name, "offsetGet");
|
||
if (og && og->signature && og->signature->kind == CBM_TYPE_FUNC &&
|
||
og->signature->data.func.return_types &&
|
||
og->signature->data.func.return_types[0]) {
|
||
result = og->signature->data.func.return_types[0];
|
||
}
|
||
}
|
||
}
|
||
} else if (strcmp(kind, "match_expression") == 0) {
|
||
/* match($v) { ... => arm1, default => arm2 } — result is the
|
||
* union of arm result expressions. We take the first arm result
|
||
* with a known type, falling back to UNKNOWN. */
|
||
uint32_t nc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "match_block") == 0) {
|
||
uint32_t bnc = ts_node_child_count(c);
|
||
for (uint32_t j = 0; j < bnc; j++) {
|
||
TSNode arm = ts_node_child(c, j);
|
||
if (ts_node_is_null(arm) || !ts_node_is_named(arm))
|
||
continue;
|
||
const char *ak = ts_node_type(arm);
|
||
if (strcmp(ak, "match_conditional_expression") != 0 &&
|
||
strcmp(ak, "match_default_expression") != 0)
|
||
continue;
|
||
/* The arm expression is the field "value" or the last
|
||
* named child after "=>". */
|
||
TSNode val = ts_node_child_by_field_name(arm, "return_expression", 17);
|
||
if (ts_node_is_null(val))
|
||
val = ts_node_child_by_field_name(arm, "value", 5);
|
||
if (ts_node_is_null(val)) {
|
||
uint32_t vnc = ts_node_child_count(arm);
|
||
for (uint32_t k2 = vnc; k2 > 0; k2--) {
|
||
TSNode vv = ts_node_child(arm, k2 - 1);
|
||
if (!ts_node_is_null(vv) && ts_node_is_named(vv)) {
|
||
val = vv;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (ts_node_is_null(val))
|
||
continue;
|
||
const CBMType *t = php_eval_expr_type(ctx, val);
|
||
if (t && t->kind != CBM_TYPE_UNKNOWN) {
|
||
result = t;
|
||
goto match_done;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
match_done:;
|
||
} else if (strcmp(kind, "conditional_expression") == 0) {
|
||
/* $a ? $b : $c — result is type of $b (preferred) or $c. */
|
||
TSNode then_n = ts_node_child_by_field_name(node, "body", 4);
|
||
TSNode else_n = ts_node_child_by_field_name(node, "alternative", 11);
|
||
if (ts_node_is_null(then_n) || ts_node_is_null(else_n)) {
|
||
/* tree-sitter-php names: `consequence` / `alternative` */
|
||
then_n = ts_node_child_by_field_name(node, "consequence", 11);
|
||
}
|
||
if (!ts_node_is_null(then_n)) {
|
||
const CBMType *t = php_eval_expr_type(ctx, then_n);
|
||
if (t && t->kind != CBM_TYPE_UNKNOWN) {
|
||
result = t;
|
||
}
|
||
}
|
||
if ((!result || result->kind == CBM_TYPE_UNKNOWN) && !ts_node_is_null(else_n)) {
|
||
const CBMType *t = php_eval_expr_type(ctx, else_n);
|
||
if (t && t->kind != CBM_TYPE_UNKNOWN)
|
||
result = t;
|
||
}
|
||
} else if (strcmp(kind, "encapsed_string") == 0 || strcmp(kind, "string") == 0 ||
|
||
strcmp(kind, "string_value") == 0 || strcmp(kind, "heredoc") == 0) {
|
||
result = cbm_type_builtin(ctx->arena, "string");
|
||
} else if (strcmp(kind, "integer") == 0) {
|
||
result = cbm_type_builtin(ctx->arena, "int");
|
||
} else if (strcmp(kind, "float") == 0) {
|
||
result = cbm_type_builtin(ctx->arena, "float");
|
||
} else if (strcmp(kind, "boolean") == 0 || strcmp(kind, "true") == 0 ||
|
||
strcmp(kind, "false") == 0) {
|
||
result = cbm_type_builtin(ctx->arena, "bool");
|
||
} else if (strcmp(kind, "null") == 0) {
|
||
result = cbm_type_builtin(ctx->arena, "null");
|
||
} else if (strcmp(kind, "member_access_expression") == 0 ||
|
||
strcmp(kind, "nullsafe_member_access_expression") == 0) {
|
||
/* $x->prop — look up field type if known. */
|
||
TSNode obj = ts_node_child_by_field_name(node, "object", 6);
|
||
TSNode field = ts_node_child_by_field_name(node, "name", 4);
|
||
if (!ts_node_is_null(obj) && !ts_node_is_null(field)) {
|
||
const CBMType *recv = php_eval_expr_type(ctx, obj);
|
||
if (recv && recv->kind == CBM_TYPE_NAMED) {
|
||
char *fname = php_node_text(ctx, field);
|
||
const CBMRegisteredType *t =
|
||
cbm_registry_lookup_type(ctx->registry, recv->data.named.qualified_name);
|
||
if (!t)
|
||
t = lookup_type_with_project(ctx, recv->data.named.qualified_name);
|
||
if (t && t->field_names && fname) {
|
||
for (int i = 0; t->field_names[i]; i++) {
|
||
if (strcmp(t->field_names[i], fname) == 0) {
|
||
if (t->field_types && t->field_types[i]) {
|
||
result = t->field_types[i];
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ctx->eval_depth--;
|
||
return result ? result : cbm_type_unknown();
|
||
}
|
||
|
||
static const CBMType *eval_object_creation_type(PHPLSPContext *ctx, TSNode node) {
|
||
/* Find the class identifier in `new X(...)`. */
|
||
TSNode name_node;
|
||
memset(&name_node, 0, sizeof(name_node));
|
||
name_node = ts_node_child_by_field_name(node, "name", 4);
|
||
if (ts_node_is_null(name_node)) {
|
||
/* Fallback: first named child that is a name/qualified_name. */
|
||
uint32_t nc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "name") == 0 || strcmp(k, "qualified_name") == 0) {
|
||
name_node = c;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (ts_node_is_null(name_node))
|
||
return cbm_type_unknown();
|
||
char *name = php_node_text(ctx, name_node);
|
||
if (!name)
|
||
return cbm_type_unknown();
|
||
const char *resolved = php_resolve_class_name(ctx, name);
|
||
if (!resolved)
|
||
return cbm_type_unknown();
|
||
return cbm_type_named(ctx->arena, resolved);
|
||
}
|
||
|
||
static const CBMType *eval_function_call_type(PHPLSPContext *ctx, TSNode call_node) {
|
||
TSNode fn = ts_node_child_by_field_name(call_node, "function", 8);
|
||
if (ts_node_is_null(fn))
|
||
return cbm_type_unknown();
|
||
char *name = php_node_text(ctx, fn);
|
||
if (!name)
|
||
return cbm_type_unknown();
|
||
/* Try `use function` map first. */
|
||
const char *target = find_use(ctx, name, CBM_PHP_USE_FUNCTION);
|
||
const char *qn = NULL;
|
||
if (target) {
|
||
qn = target;
|
||
} else if (ctx->current_namespace_qn && ctx->current_namespace_qn[0]) {
|
||
qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->current_namespace_qn, name);
|
||
} else {
|
||
qn = cbm_arena_strdup(ctx->arena, name);
|
||
}
|
||
const CBMRegisteredFunc *f = cbm_registry_lookup_func(ctx->registry, qn);
|
||
if (!f && ctx->module_qn) {
|
||
f = cbm_registry_lookup_func(ctx->registry,
|
||
cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, name));
|
||
}
|
||
if (!f && ctx->registry) {
|
||
/* Walk the symbol table by short_name as a last resort. */
|
||
for (int i = 0; i < ctx->registry->func_count; i++) {
|
||
const CBMRegisteredFunc *cand = &ctx->registry->funcs[i];
|
||
if (cand->receiver_type)
|
||
continue;
|
||
if (cand->short_name && strcmp(cand->short_name, name) == 0) {
|
||
f = cand;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!f || !f->signature)
|
||
return cbm_type_unknown();
|
||
/* PHP single-return convention. */
|
||
const CBMType *sig = f->signature;
|
||
if (sig->kind == CBM_TYPE_FUNC && sig->data.func.return_types &&
|
||
sig->data.func.return_types[0]) {
|
||
return sig->data.func.return_types[0];
|
||
}
|
||
return cbm_type_unknown();
|
||
}
|
||
|
||
/* Returns the return type of an instance/static method call expression. */
|
||
static const CBMType *eval_member_call_type(PHPLSPContext *ctx, TSNode call_node) {
|
||
const char *kind = ts_node_type(call_node);
|
||
bool is_static = strcmp(kind, "scoped_call_expression") == 0;
|
||
|
||
TSNode recv_node;
|
||
TSNode name_node;
|
||
if (is_static) {
|
||
recv_node = ts_node_child_by_field_name(call_node, "scope", 5);
|
||
name_node = ts_node_child_by_field_name(call_node, "name", 4);
|
||
} else {
|
||
recv_node = ts_node_child_by_field_name(call_node, "object", 6);
|
||
name_node = ts_node_child_by_field_name(call_node, "name", 4);
|
||
}
|
||
if (ts_node_is_null(name_node))
|
||
return cbm_type_unknown();
|
||
char *method_name = php_node_text(ctx, name_node);
|
||
if (!method_name)
|
||
return cbm_type_unknown();
|
||
|
||
const char *class_qn = NULL;
|
||
if (is_static) {
|
||
if (!ts_node_is_null(recv_node)) {
|
||
char *cls = php_node_text(ctx, recv_node);
|
||
if (cls) {
|
||
if (strcmp(cls, "self") == 0 || strcmp(cls, "static") == 0)
|
||
class_qn = ctx->enclosing_class_qn;
|
||
else if (strcmp(cls, "parent") == 0)
|
||
class_qn = ctx->enclosing_parent_qn;
|
||
else
|
||
class_qn = php_resolve_class_name(ctx, cls);
|
||
}
|
||
}
|
||
} else {
|
||
const CBMType *recv_type =
|
||
ts_node_is_null(recv_node) ? cbm_type_unknown() : php_eval_expr_type(ctx, recv_node);
|
||
if (recv_type && recv_type->kind == CBM_TYPE_NAMED) {
|
||
class_qn = recv_type->data.named.qualified_name;
|
||
} else if (recv_type && recv_type->kind == CBM_TYPE_TEMPLATE) {
|
||
class_qn = recv_type->data.template_type.template_name;
|
||
}
|
||
if (recv_type && recv_type->kind == CBM_TYPE_TEMPLATE) {
|
||
/* Apply generic substitution to method return type. */
|
||
const CBMRegisteredFunc *f = php_lookup_method(ctx, class_qn, method_name);
|
||
if (!f || !f->signature)
|
||
return cbm_type_unknown();
|
||
const CBMType *sig = f->signature;
|
||
if (sig->kind == CBM_TYPE_FUNC && sig->data.func.return_types &&
|
||
sig->data.func.return_types[0]) {
|
||
const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, class_qn);
|
||
if (!rt)
|
||
rt = lookup_type_with_project(ctx, class_qn);
|
||
if (rt && rt->type_param_names && recv_type->data.template_type.template_args) {
|
||
return php_substitute_template(ctx->arena, sig->data.func.return_types[0],
|
||
rt->type_param_names,
|
||
recv_type->data.template_type.template_args);
|
||
}
|
||
return sig->data.func.return_types[0];
|
||
}
|
||
return cbm_type_unknown();
|
||
}
|
||
}
|
||
if (!class_qn)
|
||
return cbm_type_unknown();
|
||
|
||
const CBMRegisteredFunc *f = php_lookup_method(ctx, class_qn, method_name);
|
||
if (!f || !f->signature)
|
||
return cbm_type_unknown();
|
||
const CBMType *sig = f->signature;
|
||
if (sig->kind == CBM_TYPE_FUNC && sig->data.func.return_types &&
|
||
sig->data.func.return_types[0]) {
|
||
return sig->data.func.return_types[0];
|
||
}
|
||
return cbm_type_unknown();
|
||
}
|
||
|
||
/* ── emit ───────────────────────────────────────────────────────── */
|
||
|
||
static void emit_resolved_reason_kind(PHPLSPContext *ctx, const char *callee_qn,
|
||
const char *strategy, float confidence, const char *reason,
|
||
CBMResolvedKind kind) {
|
||
if (!ctx->resolved_calls || !callee_qn || !ctx->enclosing_func_qn)
|
||
return;
|
||
CBMResolvedCall rc = {0};
|
||
rc.caller_qn = ctx->enclosing_func_qn;
|
||
rc.callee_qn = callee_qn;
|
||
rc.strategy = strategy;
|
||
rc.confidence = confidence;
|
||
rc.reason = reason;
|
||
rc.kind = kind;
|
||
cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc);
|
||
}
|
||
|
||
static void emit_resolved_kind(PHPLSPContext *ctx, const char *callee_qn, const char *strategy,
|
||
float confidence, CBMResolvedKind kind) {
|
||
emit_resolved_reason_kind(ctx, callee_qn, strategy, confidence, NULL, kind);
|
||
}
|
||
|
||
static bool php_dynamic_reference_expr(PHPLSPContext *ctx, TSNode expr) {
|
||
if (ts_node_is_null(expr)) {
|
||
return false;
|
||
}
|
||
const char *kind = ts_node_type(expr);
|
||
if (strstr(kind, "subscript") || strstr(kind, "element_access") ||
|
||
strstr(kind, "index_expression") || strstr(kind, "computed")) {
|
||
return true;
|
||
}
|
||
|
||
static const char *const terminal_fields[] = {"property", "field", "attribute",
|
||
"name", "method", NULL};
|
||
for (int i = 0; terminal_fields[i]; i++) {
|
||
TSNode terminal = ts_node_child_by_field_name(expr, terminal_fields[i],
|
||
(uint32_t)strlen(terminal_fields[i]));
|
||
if (ts_node_is_null(terminal)) {
|
||
continue;
|
||
}
|
||
uint32_t start = ts_node_start_byte(terminal);
|
||
uint32_t expr_start = ts_node_start_byte(expr);
|
||
while (start > expr_start && isspace((unsigned char)ctx->source[start - 1])) {
|
||
start--;
|
||
}
|
||
return start > expr_start && ctx->source[start - 1] == '[';
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool php_callable_reference_has_dynamic_member_name(PHPLSPContext *ctx, TSNode call) {
|
||
const char *call_kind = ts_node_type(call);
|
||
if (strcmp(call_kind, "member_call_expression") != 0 &&
|
||
strcmp(call_kind, "nullsafe_member_call_expression") != 0 &&
|
||
strcmp(call_kind, "scoped_call_expression") != 0) {
|
||
return false;
|
||
}
|
||
|
||
TSNode name = ts_node_child_by_field_name(call, "name", 4);
|
||
if (ts_node_is_null(name)) {
|
||
return false;
|
||
}
|
||
const char *name_kind = ts_node_type(name);
|
||
return strcmp(name_kind, "variable_name") == 0 ||
|
||
strcmp(name_kind, "dynamic_variable_name") == 0 || php_dynamic_reference_expr(ctx, name);
|
||
}
|
||
|
||
/* Mirror extract_calls.c's terminal_callee_leaf policy so the PHP LSP and
|
||
* USAGE passes attach the same exact token occurrence to a first-class
|
||
* callable, including qualified free-function references. */
|
||
static TSNode php_reference_callee_leaf(PHPLSPContext *ctx, TSNode expr) {
|
||
static const char *const terminal_fields[] = {"property", "field", "attribute",
|
||
"method", "name", NULL};
|
||
/* A loop for the same reason as terminal_callee_leaf: recursing once per
|
||
* wrapper level of the callee made stack use follow the nesting depth of
|
||
* the file. Same nodes, same order. */
|
||
for (;;) {
|
||
if (ts_node_is_null(expr) || php_dynamic_reference_expr(ctx, expr)) {
|
||
return (TSNode){0};
|
||
}
|
||
|
||
TSNode terminal = {0};
|
||
for (int i = 0; terminal_fields[i] && ts_node_is_null(terminal); i++) {
|
||
terminal = ts_node_child_by_field_name(expr, terminal_fields[i],
|
||
(uint32_t)strlen(terminal_fields[i]));
|
||
}
|
||
if (!ts_node_is_null(terminal)) {
|
||
expr = terminal;
|
||
continue;
|
||
}
|
||
|
||
const char *kind = ts_node_type(expr);
|
||
if (strcmp(kind, "identifier") == 0 || strcmp(kind, "simple_identifier") == 0 ||
|
||
strcmp(kind, "type_identifier") == 0 || strcmp(kind, "field_identifier") == 0 ||
|
||
strcmp(kind, "property_identifier") == 0 || strcmp(kind, "name") == 0 ||
|
||
strcmp(kind, "variable_name") == 0 || strcmp(kind, "attribute") == 0 ||
|
||
strcmp(kind, "symbol") == 0 || strcmp(kind, "sym_lit") == 0 ||
|
||
strcmp(kind, "user_symbol") == 0) {
|
||
return expr;
|
||
}
|
||
|
||
uint32_t count = ts_node_named_child_count(expr);
|
||
if (count == 0) {
|
||
return (TSNode){0};
|
||
}
|
||
bool terminal_is_last = strstr(kind, "qualified") || strstr(kind, "scoped") ||
|
||
strstr(kind, "member") || strstr(kind, "selector") ||
|
||
strstr(kind, "navigation") || strstr(kind, "field_expression");
|
||
expr = ts_node_named_child(expr, terminal_is_last ? count - 1 : 0);
|
||
}
|
||
}
|
||
|
||
static TSNode php_callable_reference_leaf(PHPLSPContext *ctx, TSNode call, const char *kind) {
|
||
if (php_callable_reference_has_dynamic_member_name(ctx, call)) {
|
||
return (TSNode){0};
|
||
}
|
||
TSNode expr = {0};
|
||
if (strcmp(kind, "function_call_expression") == 0) {
|
||
expr = ts_node_child_by_field_name(call, "function", 8);
|
||
} else {
|
||
expr = ts_node_child_by_field_name(call, "name", 4);
|
||
}
|
||
return php_reference_callee_leaf(ctx, expr);
|
||
}
|
||
|
||
static void php_stamp_resolved_site(PHPLSPContext *ctx, int first, TSNode site) {
|
||
if (!ctx->resolved_calls || ts_node_is_null(site) || first < 0) {
|
||
return;
|
||
}
|
||
for (int i = first; i < ctx->resolved_calls->count; i++) {
|
||
ctx->resolved_calls->items[i].site_start_byte = ts_node_start_byte(site);
|
||
ctx->resolved_calls->items[i].site_end_byte = ts_node_end_byte(site);
|
||
}
|
||
}
|
||
|
||
/* The method of a class the index does not hold. When the class came from a
|
||
* vendor `use` (cross-file mode), the row is emitted above the pipeline's
|
||
* confidence floor as php_external_receiver: the receiver is PROVEN to be a
|
||
* type outside the project, so no name-only fallback may bind the call to a
|
||
* same-named project method (#1186). Otherwise the existing low-confidence
|
||
* `unindexed_strategy` row. */
|
||
static void emit_external_or_unindexed(PHPLSPContext *ctx, const char *class_qn,
|
||
const char *method_name, const char *unindexed_strategy,
|
||
CBMResolvedKind kind) {
|
||
const char *target = cbm_arena_sprintf(ctx->arena, "%s.%s", class_qn, method_name);
|
||
if (php_external_qn(ctx, class_qn)) {
|
||
emit_resolved_kind(ctx, target, "php_external_receiver", 0.95f, kind);
|
||
return;
|
||
}
|
||
emit_resolved_kind(ctx, target, unindexed_strategy, 0.55f, kind);
|
||
}
|
||
|
||
/* A method the registry DOES hold on a class reached through a vendor `use`
|
||
* (a seeded library type such as GuzzleHttp.Client) is just as far outside
|
||
* the project: the graph has no node for it either, so its row must carry the
|
||
* same php_external_receiver proof, or the pipeline falls through to a
|
||
* name-only binding of the call to a same-named project method (#1186). */
|
||
static const char *php_receiver_strategy(const PHPLSPContext *ctx, const char *class_qn,
|
||
const char *strategy) {
|
||
return php_external_qn(ctx, class_qn) ? "php_external_receiver" : strategy;
|
||
}
|
||
|
||
static void emit_unresolved(PHPLSPContext *ctx, const char *expr_text, const char *reason) {
|
||
if (!ctx->resolved_calls || !ctx->enclosing_func_qn)
|
||
return;
|
||
CBMResolvedCall rc = {0};
|
||
rc.caller_qn = ctx->enclosing_func_qn;
|
||
rc.callee_qn = expr_text ? expr_text : "?";
|
||
rc.strategy = "lsp_unresolved";
|
||
rc.confidence = 0.0f;
|
||
rc.reason = reason;
|
||
cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc);
|
||
}
|
||
|
||
/* ── statement-level scope binding ──────────────────────────────── */
|
||
|
||
/* Recognise `$x = new Foo(...)` / `$x = Foo::create()` etc. and bind the LHS. */
|
||
static void process_assignment(PHPLSPContext *ctx, TSNode node) {
|
||
TSNode lhs = ts_node_child_by_field_name(node, "left", 4);
|
||
TSNode rhs = ts_node_child_by_field_name(node, "right", 5);
|
||
if (ts_node_is_null(lhs) || ts_node_is_null(rhs))
|
||
return;
|
||
if (!node_is(lhs, "variable_name"))
|
||
return;
|
||
char *t = php_node_text(ctx, lhs);
|
||
if (!t)
|
||
return;
|
||
const char *name = (t[0] == '$') ? t + 1 : t;
|
||
if (strcmp(name, "this") == 0)
|
||
return;
|
||
const CBMType *rhs_type = php_eval_expr_type(ctx, rhs);
|
||
if (rhs_type && rhs_type->kind != CBM_TYPE_UNKNOWN) {
|
||
/* Don't override a more-specific (NAMED/TEMPLATE) binding with
|
||
* a trivial null literal — preserves `@var T $x; $x = null;`. */
|
||
if (rhs_type->kind == CBM_TYPE_BUILTIN && rhs_type->data.builtin.name &&
|
||
strcmp(rhs_type->data.builtin.name, "null") == 0) {
|
||
const CBMType *existing = cbm_scope_lookup(ctx->current_scope, name);
|
||
if (existing &&
|
||
(existing->kind == CBM_TYPE_NAMED || existing->kind == CBM_TYPE_TEMPLATE)) {
|
||
return;
|
||
}
|
||
}
|
||
cbm_scope_bind(ctx->current_scope, name, rhs_type);
|
||
}
|
||
}
|
||
|
||
static void process_foreach(PHPLSPContext *ctx, TSNode node) {
|
||
/* Try to extract the iterable expression's element type. tree-sitter-php
|
||
* emits the iterable as the first sibling expression after `foreach (`,
|
||
* and the loop var is the variable_name child after `as`.
|
||
*
|
||
* Element-type resolution rules:
|
||
* - iterable is array<T> / list<T> / iterable<T> (TEMPLATE) → T
|
||
* - iterable is array<K, V> (TEMPLATE arg_count=2) → V (value)
|
||
* - iterable is a NAMED collection-like type whose `current()` method
|
||
* has a known return type (e.g. Iterator) → that
|
||
* - otherwise → UNKNOWN
|
||
*/
|
||
TSNode iterable;
|
||
memset(&iterable, 0, sizeof(iterable));
|
||
TSNode loop_vars[4];
|
||
int loop_var_count = 0;
|
||
memset(loop_vars, 0, sizeof(loop_vars));
|
||
|
||
/* tree-sitter-php emits foreach_statement children in source order:
|
||
* <iterable expression> ... `as` ... <loop var(s)> ... <body>
|
||
* The iterable can be ANY expression: variable_name, member_call,
|
||
* function_call_expression, member_access_expression, etc. The loop
|
||
* vars are always variable_name. The body is compound_statement /
|
||
* colon_block / a statement.
|
||
*
|
||
* We find the iterable as the first named child whose kind is an
|
||
* expression-shape; subsequent variable_name children before the body
|
||
* are the loop vars. */
|
||
uint32_t nc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "compound_statement") == 0 || strcmp(k, "colon_block") == 0 ||
|
||
strcmp(k, "expression_statement") == 0 || strcmp(k, "echo_statement") == 0 ||
|
||
strcmp(k, "return_statement") == 0 || strcmp(k, "if_statement") == 0 ||
|
||
strcmp(k, "for_statement") == 0 || strcmp(k, "while_statement") == 0 ||
|
||
strcmp(k, "switch_statement") == 0 || strcmp(k, "try_statement") == 0) {
|
||
break; /* reached body */
|
||
}
|
||
if (ts_node_is_null(iterable)) {
|
||
iterable = c;
|
||
} else if (strcmp(k, "variable_name") == 0 && loop_var_count < 4) {
|
||
loop_vars[loop_var_count++] = c;
|
||
} else if (strcmp(k, "pair") == 0) {
|
||
/* `$k => $v` — extract both variable_names; the second is the
|
||
* value, gets the elem_type. */
|
||
uint32_t pnc = ts_node_child_count(c);
|
||
for (uint32_t j = 0; j < pnc; j++) {
|
||
TSNode pc = ts_node_child(c, j);
|
||
if (!ts_node_is_null(pc) && ts_node_is_named(pc) &&
|
||
strcmp(ts_node_type(pc), "variable_name") == 0 && loop_var_count < 4) {
|
||
loop_vars[loop_var_count++] = pc;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const CBMType *elem_type = cbm_type_unknown();
|
||
if (!ts_node_is_null(iterable)) {
|
||
const CBMType *it_type = php_eval_expr_type(ctx, iterable);
|
||
if (it_type && it_type->kind == CBM_TYPE_TEMPLATE) {
|
||
const CBMType *const *args = it_type->data.template_type.template_args;
|
||
if (args) {
|
||
int n = 0;
|
||
while (args[n])
|
||
n++;
|
||
if (n >= 2 && args[1])
|
||
elem_type = args[1];
|
||
else if (n >= 1 && args[0])
|
||
elem_type = args[0];
|
||
}
|
||
} else if (it_type && it_type->kind == CBM_TYPE_NAMED) {
|
||
const CBMRegisteredFunc *cur =
|
||
php_lookup_method(ctx, it_type->data.named.qualified_name, "current");
|
||
if (cur && cur->signature && cur->signature->kind == CBM_TYPE_FUNC &&
|
||
cur->signature->data.func.return_types &&
|
||
cur->signature->data.func.return_types[0] &&
|
||
cur->signature->data.func.return_types[0]->kind != CBM_TYPE_UNKNOWN) {
|
||
elem_type = cur->signature->data.func.return_types[0];
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Bind the loop variable(s). PHP allows `foreach ($xs as $k => $v)` with
|
||
* two: $k is the key, $v is the element. We bind elem_type to the LAST
|
||
* variable_name (the value); earlier ones get UNKNOWN as approximation. */
|
||
for (int i = 0; i < loop_var_count; i++) {
|
||
char *t = php_node_text(ctx, loop_vars[i]);
|
||
if (!t)
|
||
continue;
|
||
const char *name = (t[0] == '$') ? t + 1 : t;
|
||
const CBMType *bind_type = (i == loop_var_count - 1) ? elem_type : cbm_type_unknown();
|
||
cbm_scope_bind(ctx->current_scope, name, bind_type);
|
||
}
|
||
}
|
||
|
||
static void process_catch(PHPLSPContext *ctx, TSNode node) {
|
||
/* Bind $e to the caught exception type. */
|
||
TSNode type_list;
|
||
memset(&type_list, 0, sizeof(type_list));
|
||
TSNode var_node;
|
||
memset(&var_node, 0, sizeof(var_node));
|
||
uint32_t nc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "type_list") == 0)
|
||
type_list = c;
|
||
if (strcmp(k, "variable_name") == 0)
|
||
var_node = c;
|
||
}
|
||
if (ts_node_is_null(var_node))
|
||
return;
|
||
char *vt = php_node_text(ctx, var_node);
|
||
if (!vt)
|
||
return;
|
||
const char *vname = (vt[0] == '$') ? vt + 1 : vt;
|
||
const CBMType *exc_type = cbm_type_unknown();
|
||
if (!ts_node_is_null(type_list)) {
|
||
uint32_t tnc = ts_node_child_count(type_list);
|
||
for (uint32_t i = 0; i < tnc; i++) {
|
||
TSNode tc = ts_node_child(type_list, i);
|
||
if (ts_node_is_null(tc) || !ts_node_is_named(tc))
|
||
continue;
|
||
exc_type = php_parse_type_node(ctx, tc);
|
||
break;
|
||
}
|
||
}
|
||
cbm_scope_bind(ctx->current_scope, vname, exc_type);
|
||
}
|
||
|
||
/* ── call resolution ────────────────────────────────────────────── */
|
||
|
||
static void resolve_function_call(PHPLSPContext *ctx, TSNode call, CBMResolvedKind kind) {
|
||
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;
|
||
|
||
/* In PHP, `$handler(...)` is syntactically a function-call expression but
|
||
* a typed object value dispatches to its materialized `__invoke` method.
|
||
* This is precise only for a first-class callable and a receiver type whose
|
||
* exact method exists in the registry; otherwise leave it unresolved so
|
||
* the usage pipeline can retain ordinary value semantics. */
|
||
if (kind == CBM_RESOLVED_CALL_REFERENCE && strcmp(ts_node_type(fn), "variable_name") == 0) {
|
||
const CBMType *receiver = php_eval_expr_type(ctx, fn);
|
||
const char *class_qn = NULL;
|
||
if (receiver && receiver->kind == CBM_TYPE_NAMED) {
|
||
class_qn = receiver->data.named.qualified_name;
|
||
} else if (receiver && receiver->kind == CBM_TYPE_TEMPLATE) {
|
||
class_qn = receiver->data.template_type.template_name;
|
||
}
|
||
const CBMRegisteredFunc *invoke =
|
||
class_qn ? php_lookup_method(ctx, class_qn, "__invoke") : NULL;
|
||
if (invoke && invoke->qualified_name) {
|
||
emit_resolved_reason_kind(ctx, invoke->qualified_name, "php_invokable_reference", 0.95f,
|
||
name, kind);
|
||
}
|
||
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_kind(ctx, f->qualified_name, "php_function_namespaced", 0.95f, kind);
|
||
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_kind(ctx, f->qualified_name, "php_function_namespaced", 0.95f, kind);
|
||
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_kind(ctx, best->qualified_name, "php_function_global_fallback", 0.70f, kind);
|
||
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, CBMResolvedKind kind) {
|
||
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;
|
||
if (kind == CBM_RESOLVED_CALL_REFERENCE &&
|
||
php_callable_reference_has_dynamic_member_name(ctx, call)) {
|
||
return;
|
||
}
|
||
char *method_name = php_node_text(ctx, name_node);
|
||
if (!method_name)
|
||
return;
|
||
if (ts_node_is_null(obj))
|
||
return;
|
||
|
||
const CBMType *recv = php_eval_expr_type(ctx, obj);
|
||
if (!recv)
|
||
return;
|
||
const char *class_qn = NULL;
|
||
if (recv->kind == CBM_TYPE_NAMED) {
|
||
class_qn = recv->data.named.qualified_name;
|
||
} else if (recv->kind == CBM_TYPE_TEMPLATE) {
|
||
/* `Collection<User>` resolves methods on `Collection`. */
|
||
class_qn = recv->data.template_type.template_name;
|
||
}
|
||
if (!class_qn)
|
||
return; /* unknown receiver; defer */
|
||
const CBMRegisteredFunc *f = php_lookup_method(ctx, class_qn, method_name);
|
||
if (f) {
|
||
const char *strategy = (f->receiver_type && strcmp(f->receiver_type, class_qn) == 0)
|
||
? "php_method_typed"
|
||
: "php_method_inherited";
|
||
emit_resolved_kind(ctx, f->qualified_name, php_receiver_strategy(ctx, class_qn, strategy),
|
||
0.95f, kind);
|
||
return;
|
||
}
|
||
/* Receiver known but method missing — magic __call? The call dispatches to
|
||
* the class's __call handler, so resolve to <class>.__call (a real node).
|
||
* The textual callee is the dynamic method name (`anything`), not `__call`,
|
||
* so stash it in reason for the join (lsp_resolve.h, php_method_dynamic).
|
||
* Emit above the join's confidence floor — dispatch to __call is certain. */
|
||
if (class_has_magic_call(ctx, class_qn, false)) {
|
||
emit_resolved_reason_kind(ctx, cbm_arena_sprintf(ctx->arena, "%s.__call", class_qn),
|
||
"php_method_dynamic", 0.85f, method_name, kind);
|
||
return;
|
||
}
|
||
/* Receiver known but class not in registry (e.g. vendor type not indexed,
|
||
* or method genuinely missing). Emit a synthetic resolved call pointing
|
||
* at "<class_qn>.<method>" so the pipeline bridge can BLOCK the unified
|
||
* extractor's likely-incorrect short-name fallback. The bridge filters
|
||
* unknown targets and yields no edge — better than a wrong edge. */
|
||
emit_external_or_unindexed(ctx, class_qn, method_name, "php_method_typed_unindexed", kind);
|
||
}
|
||
|
||
static void resolve_static_call(PHPLSPContext *ctx, TSNode call, CBMResolvedKind kind) {
|
||
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;
|
||
if (kind == CBM_RESOLVED_CALL_REFERENCE &&
|
||
php_callable_reference_has_dynamic_member_name(ctx, call)) {
|
||
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);
|
||
strategy = php_receiver_strategy(ctx, class_qn, strategy);
|
||
}
|
||
}
|
||
}
|
||
if (!class_qn)
|
||
return;
|
||
|
||
const CBMRegisteredFunc *f = php_lookup_method(ctx, class_qn, method_name);
|
||
if (f) {
|
||
emit_resolved_kind(ctx, f->qualified_name, strategy, 0.95f, kind);
|
||
return;
|
||
}
|
||
/* A first-class reference to an absent static method has an exact target
|
||
* only when a materialized `__callStatic` exists. Preserve the source name
|
||
* as the occurrence-join reason, but point at the real method node. Normal
|
||
* facade invocations retain their existing low-confidence behavior. */
|
||
const CBMRegisteredFunc *magic_static = php_lookup_method(ctx, class_qn, "__callStatic");
|
||
if (magic_static && kind == CBM_RESOLVED_CALL_REFERENCE) {
|
||
emit_resolved_reason_kind(ctx, magic_static->qualified_name, "php_static_magic_reference",
|
||
0.95f, method_name, kind);
|
||
return;
|
||
}
|
||
/* Facade detection: class has __callStatic in its chain. */
|
||
if (magic_static) {
|
||
emit_resolved_kind(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", class_qn, method_name),
|
||
"php_dynamic_unresolved", 0.10f, kind);
|
||
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_external_or_unindexed(ctx, class_qn, method_name, "php_static_unindexed", kind);
|
||
}
|
||
|
||
/* ── type narrowing ─────────────────────────────────────────────────
|
||
*
|
||
* Recognise narrowing predicates inside an `if` / ternary / match condition
|
||
* and a few common library calls that act as runtime asserts. When the
|
||
* predicate proves a positive constraint about a variable's type, push a
|
||
* child scope with that variable rebound to the narrower type before
|
||
* walking the body, then pop. For `assert(<predicate>);` we narrow into
|
||
* the *current* scope from that point onward, since assert() acts as a
|
||
* sequential refinement.
|
||
*
|
||
* Patterns understood:
|
||
* $x instanceof Foo → $x: Foo
|
||
* is_string($x) → $x: string (and is_int/is_float/...)
|
||
* is_array($x) → $x: array
|
||
* is_object($x) → $x: object
|
||
* is_callable($x) → $x: callable
|
||
* $x !== null / $x != null → $x: <existing> minus null (we drop null)
|
||
* $x instanceof FooInterface → $x: FooInterface (interface narrows too)
|
||
*
|
||
* Negative branches and `else` are not narrowed in Phase 4a; PHP's
|
||
* negative-narrowing semantics (e.g. `else` branch of `if (is_string($x))`
|
||
* narrows to "not-string") would require subtractive types we don't model.
|
||
*/
|
||
|
||
typedef struct {
|
||
const char *var_name; /* "x" without leading $ */
|
||
const CBMType *type; /* the narrowed type */
|
||
} php_narrowing_t;
|
||
|
||
static const char *is_func_to_builtin(const char *name) {
|
||
if (!name)
|
||
return NULL;
|
||
if (strcmp(name, "is_string") == 0)
|
||
return "string";
|
||
if (strcmp(name, "is_int") == 0 || strcmp(name, "is_integer") == 0 ||
|
||
strcmp(name, "is_long") == 0)
|
||
return "int";
|
||
if (strcmp(name, "is_float") == 0 || strcmp(name, "is_double") == 0 ||
|
||
strcmp(name, "is_real") == 0)
|
||
return "float";
|
||
if (strcmp(name, "is_bool") == 0)
|
||
return "bool";
|
||
if (strcmp(name, "is_array") == 0)
|
||
return "array";
|
||
if (strcmp(name, "is_object") == 0)
|
||
return "object";
|
||
if (strcmp(name, "is_callable") == 0)
|
||
return "callable";
|
||
if (strcmp(name, "is_iterable") == 0)
|
||
return "iterable";
|
||
if (strcmp(name, "is_numeric") == 0)
|
||
return "float";
|
||
if (strcmp(name, "is_countable") == 0)
|
||
return "iterable";
|
||
if (strcmp(name, "is_resource") == 0)
|
||
return "resource";
|
||
return NULL;
|
||
}
|
||
|
||
/* Parse a narrowing predicate. On success fill out and return true.
|
||
*
|
||
* For multi-conjunction (`P1 && P2`), we accumulate ALL narrowings into
|
||
* the *last out slot, but the caller-side pattern in process_if_statement
|
||
* only takes a single php_narrowing_t. To support full conjunction we
|
||
* additionally call parse_narrowing_collect which writes into a small
|
||
* array. parse_narrowing itself returns the FIRST narrowing it finds. */
|
||
static int parse_narrowing_collect(PHPLSPContext *ctx, TSNode cond, php_narrowing_t *out,
|
||
int max_out);
|
||
|
||
static bool parse_narrowing(PHPLSPContext *ctx, TSNode cond, php_narrowing_t *out) {
|
||
php_narrowing_t buf[1] = {{0}};
|
||
int n = parse_narrowing_collect(ctx, cond, buf, 1);
|
||
if (n > 0) {
|
||
*out = buf[0];
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* Recursive collector: adds narrowings from the predicate tree into out[]
|
||
* up to max_out. Returns the number filled. */
|
||
static int parse_narrowing_one(PHPLSPContext *ctx, TSNode cond, php_narrowing_t *out);
|
||
|
||
static int parse_narrowing_collect(PHPLSPContext *ctx, TSNode cond, php_narrowing_t *out,
|
||
int max_out) {
|
||
if (ts_node_is_null(cond) || max_out <= 0)
|
||
return 0;
|
||
const char *kind = ts_node_type(cond);
|
||
|
||
/* Parenthesized: unwrap. */
|
||
if (strcmp(kind, "parenthesized_expression") == 0) {
|
||
uint32_t nc = ts_node_child_count(cond);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(cond, i);
|
||
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
|
||
return parse_narrowing_collect(ctx, c, out, max_out);
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Conjunction `A && B` (or `A and B`) → recurse into both sides. */
|
||
if (strcmp(kind, "binary_expression") == 0) {
|
||
TSNode op = ts_node_child_by_field_name(cond, "operator", 8);
|
||
char *opt = !ts_node_is_null(op) ? php_node_text(ctx, op) : NULL;
|
||
if (opt && (strcmp(opt, "&&") == 0 || strcmp(opt, "and") == 0)) {
|
||
TSNode left = ts_node_child_by_field_name(cond, "left", 4);
|
||
TSNode right = ts_node_child_by_field_name(cond, "right", 5);
|
||
int got = parse_narrowing_collect(ctx, left, out, max_out);
|
||
got += parse_narrowing_collect(ctx, right, out + got, max_out - got);
|
||
return got;
|
||
}
|
||
}
|
||
|
||
/* Single-predicate path. */
|
||
php_narrowing_t one = {0};
|
||
if (parse_narrowing_one(ctx, cond, &one)) {
|
||
out[0] = one;
|
||
return 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static int parse_narrowing_one(PHPLSPContext *ctx, TSNode cond, php_narrowing_t *out) {
|
||
if (ts_node_is_null(cond))
|
||
return 0;
|
||
const char *kind = ts_node_type(cond);
|
||
if (strcmp(kind, "parenthesized_expression") == 0) {
|
||
uint32_t nc = ts_node_child_count(cond);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(cond, i);
|
||
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
|
||
return parse_narrowing_one(ctx, c, out);
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* `$x instanceof Foo` — emitted as binary_expression with operator
|
||
* "instanceof" in tree-sitter-php. */
|
||
if (strcmp(kind, "binary_expression") == 0) {
|
||
TSNode left = ts_node_child_by_field_name(cond, "left", 4);
|
||
TSNode op = ts_node_child_by_field_name(cond, "operator", 8);
|
||
TSNode right = ts_node_child_by_field_name(cond, "right", 5);
|
||
if (ts_node_is_null(left) || ts_node_is_null(right))
|
||
return false;
|
||
char *opt = !ts_node_is_null(op) ? php_node_text(ctx, op) : NULL;
|
||
if (opt && strcmp(opt, "instanceof") == 0) {
|
||
if (!node_is(left, "variable_name"))
|
||
return false;
|
||
char *vt = php_node_text(ctx, left);
|
||
if (!vt)
|
||
return false;
|
||
const char *vname = (vt[0] == '$') ? vt + 1 : vt;
|
||
char *rt = php_node_text(ctx, right);
|
||
if (!rt)
|
||
return false;
|
||
const char *resolved = php_resolve_class_name(ctx, rt);
|
||
if (!resolved)
|
||
return false;
|
||
out->var_name = cbm_arena_strdup(ctx->arena, vname);
|
||
out->type = cbm_type_named(ctx->arena, resolved);
|
||
return true;
|
||
}
|
||
/* `$x !== null` / `$x != null` / `null !== $x` — narrow to "non-null":
|
||
* we keep the existing scope type, so this is a no-op for type
|
||
* purposes but suppresses null branches downstream. We don't
|
||
* subtract nullable so just return false here. */
|
||
}
|
||
|
||
/* `is_string($x)` / `is_int($x)` / `array_key_exists(...)` /
|
||
* `method_exists($x, 'foo')` / `is_a($x, Foo::class)` / ... */
|
||
if (strcmp(kind, "function_call_expression") == 0) {
|
||
TSNode fn = ts_node_child_by_field_name(cond, "function", 8);
|
||
TSNode args = ts_node_child_by_field_name(cond, "arguments", 9);
|
||
if (ts_node_is_null(fn))
|
||
return false;
|
||
char *name = php_node_text(ctx, fn);
|
||
if (!name)
|
||
return false;
|
||
/* `is_a($x, Foo::class)` and `is_a($x, 'Foo')` narrow $x to Foo. */
|
||
if (strcmp(name, "is_a") == 0 && !ts_node_is_null(args)) {
|
||
uint32_t anc = ts_node_child_count(args);
|
||
TSNode v;
|
||
memset(&v, 0, sizeof(v));
|
||
TSNode classref;
|
||
memset(&classref, 0, sizeof(classref));
|
||
int seen = 0;
|
||
for (uint32_t i = 0; i < anc; i++) {
|
||
TSNode a = ts_node_child(args, i);
|
||
if (ts_node_is_null(a) || !ts_node_is_named(a))
|
||
continue;
|
||
TSNode inner = a;
|
||
if (strcmp(ts_node_type(a), "argument") == 0) {
|
||
uint32_t bnc = ts_node_child_count(a);
|
||
for (uint32_t j = 0; j < bnc; j++) {
|
||
TSNode b = ts_node_child(a, j);
|
||
if (!ts_node_is_null(b) && ts_node_is_named(b)) {
|
||
inner = b;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (seen == 0)
|
||
v = inner;
|
||
else if (seen == 1)
|
||
classref = inner;
|
||
seen++;
|
||
}
|
||
if (!ts_node_is_null(v) && !ts_node_is_null(classref) && node_is(v, "variable_name")) {
|
||
char *vt = php_node_text(ctx, v);
|
||
if (vt) {
|
||
const char *vname = (vt[0] == '$') ? vt + 1 : vt;
|
||
char *cls_text = php_node_text(ctx, classref);
|
||
if (cls_text) {
|
||
/* Strip `::class`, surrounding quotes. */
|
||
size_t cl = strlen(cls_text);
|
||
if (cl >= 8 && strcmp(cls_text + cl - 7, "::class") == 0) {
|
||
cls_text[cl - 7] = '\0';
|
||
} else if (cl >= 2 && (cls_text[0] == '"' || cls_text[0] == '\'')) {
|
||
cls_text[cl - 1] = '\0';
|
||
cls_text++;
|
||
}
|
||
const char *resolved = php_resolve_class_name(ctx, cls_text);
|
||
if (resolved) {
|
||
out->var_name = cbm_arena_strdup(ctx->arena, vname);
|
||
out->type = cbm_type_named(ctx->arena, resolved);
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const char *builtin = is_func_to_builtin(name);
|
||
if (!builtin)
|
||
return false;
|
||
/* First positional argument should be a variable_name. */
|
||
if (ts_node_is_null(args))
|
||
return false;
|
||
uint32_t anc = ts_node_child_count(args);
|
||
for (uint32_t i = 0; i < anc; i++) {
|
||
TSNode a = ts_node_child(args, i);
|
||
if (ts_node_is_null(a) || !ts_node_is_named(a))
|
||
continue;
|
||
const char *ak = ts_node_type(a);
|
||
/* `argument` wrapper has variable_name child. */
|
||
TSNode v = a;
|
||
if (strcmp(ak, "argument") == 0) {
|
||
uint32_t bnc = ts_node_child_count(a);
|
||
for (uint32_t j = 0; j < bnc; j++) {
|
||
TSNode b = ts_node_child(a, j);
|
||
if (!ts_node_is_null(b) && ts_node_is_named(b) && node_is(b, "variable_name")) {
|
||
v = b;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!node_is(v, "variable_name"))
|
||
return false;
|
||
char *vt = php_node_text(ctx, v);
|
||
if (!vt)
|
||
return false;
|
||
const char *vname = (vt[0] == '$') ? vt + 1 : vt;
|
||
out->var_name = cbm_arena_strdup(ctx->arena, vname);
|
||
out->type = cbm_type_builtin(ctx->arena, builtin);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* `assert(<predicate>)` narrows the current scope sequentially. */
|
||
static void apply_assert_narrowing(PHPLSPContext *ctx, TSNode call) {
|
||
TSNode fn = ts_node_child_by_field_name(call, "function", 8);
|
||
if (ts_node_is_null(fn))
|
||
return;
|
||
char *name = php_node_text(ctx, fn);
|
||
if (!name || strcmp(name, "assert") != 0)
|
||
return;
|
||
TSNode args = ts_node_child_by_field_name(call, "arguments", 9);
|
||
if (ts_node_is_null(args))
|
||
return;
|
||
uint32_t anc = ts_node_child_count(args);
|
||
for (uint32_t i = 0; i < anc; i++) {
|
||
TSNode a = ts_node_child(args, i);
|
||
if (ts_node_is_null(a) || !ts_node_is_named(a))
|
||
continue;
|
||
TSNode predicate = a;
|
||
if (strcmp(ts_node_type(a), "argument") == 0) {
|
||
uint32_t bnc = ts_node_child_count(a);
|
||
for (uint32_t j = 0; j < bnc; j++) {
|
||
TSNode b = ts_node_child(a, j);
|
||
if (!ts_node_is_null(b) && ts_node_is_named(b)) {
|
||
predicate = b;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
php_narrowing_t nw = {0};
|
||
if (parse_narrowing(ctx, predicate, &nw) && nw.var_name && nw.type) {
|
||
cbm_scope_bind(ctx->current_scope, nw.var_name, nw.type);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
/* Walk a body subtree under a narrowed scope (with up to N bindings), then
|
||
* restore. */
|
||
static void walk_with_narrowings(PHPLSPContext *ctx, TSNode body, const php_narrowing_t *nws,
|
||
int nw_count) {
|
||
if (ts_node_is_null(body))
|
||
return;
|
||
if (!nws || nw_count <= 0) {
|
||
php_resolve_calls_in_node(ctx, body);
|
||
return;
|
||
}
|
||
CBMScope *saved = ctx->current_scope;
|
||
ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope);
|
||
for (int i = 0; i < nw_count; i++) {
|
||
if (nws[i].var_name && nws[i].type) {
|
||
cbm_scope_bind(ctx->current_scope, nws[i].var_name, nws[i].type);
|
||
}
|
||
}
|
||
php_resolve_calls_in_node(ctx, body);
|
||
ctx->current_scope = saved;
|
||
}
|
||
|
||
/* Backwards-compat single-binding wrapper. */
|
||
static void walk_with_narrowing(PHPLSPContext *ctx, TSNode body, const php_narrowing_t *nw) {
|
||
walk_with_narrowings(ctx, body, nw, nw ? 1 : 0);
|
||
}
|
||
|
||
/* Detect whether a statement node is a "leaves the function" terminator:
|
||
* return; return $x; throw ...; exit; die; continue (in a loop);
|
||
* break (in a loop). For our purposes any of these means the rest of the
|
||
* enclosing function does NOT execute the inner narrowed-out branch, so we
|
||
* can apply the negation of the inner branch as sequential narrowing. */
|
||
static bool stmt_is_terminator(TSNode stmt) {
|
||
if (ts_node_is_null(stmt))
|
||
return false;
|
||
const char *k = ts_node_type(stmt);
|
||
if (strcmp(k, "return_statement") == 0)
|
||
return true;
|
||
if (strcmp(k, "compound_statement") == 0) {
|
||
/* Single-child compound that's a terminator. */
|
||
uint32_t nc = ts_node_child_count(stmt);
|
||
TSNode last;
|
||
memset(&last, 0, sizeof(last));
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(stmt, i);
|
||
if (!ts_node_is_null(c) && ts_node_is_named(c))
|
||
last = c;
|
||
}
|
||
if (!ts_node_is_null(last))
|
||
return stmt_is_terminator(last);
|
||
}
|
||
if (strcmp(k, "expression_statement") == 0) {
|
||
uint32_t nc = ts_node_child_count(stmt);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(stmt, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *ck = ts_node_type(c);
|
||
if (strcmp(ck, "throw_expression") == 0)
|
||
return true;
|
||
if (strcmp(ck, "exit_statement") == 0)
|
||
return true;
|
||
/* exit() / die() */
|
||
if (strcmp(ck, "function_call_expression") == 0) {
|
||
TSNode fn = ts_node_child_by_field_name(c, "function", 8);
|
||
if (!ts_node_is_null(fn)) {
|
||
/* Just take the raw text and check for exit/die — even
|
||
* with namespace prefixes these are global calls. */
|
||
/* Conservative: don't claim terminator on function calls. */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (strcmp(k, "throw_statement") == 0)
|
||
return true;
|
||
if (strcmp(k, "exit_statement") == 0)
|
||
return true;
|
||
return false;
|
||
}
|
||
|
||
/* Walk an `if_statement` (or `else_if_clause`) honoring narrowing.
|
||
*
|
||
* Tree-sitter-php's `if_statement` emits children as: an optional `if`
|
||
* keyword, a parenthesized_expression (condition), and one or more
|
||
* statement-shaped bodies (the if-body, optionally followed by
|
||
* else_if_clause / else_clause). We narrow on the condition then walk
|
||
* EVERY non-condition named child as the body — only the first
|
||
* statement after the condition is the if-body and gets the narrowed
|
||
* scope; else-branches walk without narrowing.
|
||
*
|
||
* Negative-narrowing pattern (Phase 4l):
|
||
* if (!$x instanceof Foo) { return; }
|
||
* $x->method(); // here $x is narrowed to Foo
|
||
* If the if-body is a terminator AND the condition is a logical NOT of a
|
||
* narrowable predicate, apply the (positive) narrowing into the CURRENT
|
||
* scope sequentially for the remainder of the enclosing function.
|
||
*/
|
||
static void process_if_statement(PHPLSPContext *ctx, TSNode node) {
|
||
/* Find condition: first parenthesized_expression child. */
|
||
TSNode cond;
|
||
memset(&cond, 0, sizeof(cond));
|
||
uint32_t nc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
if (strcmp(ts_node_type(c), "parenthesized_expression") == 0) {
|
||
cond = c;
|
||
break;
|
||
}
|
||
}
|
||
/* Collect all conjunctive narrowings: `$x instanceof Foo && is_int($y)`
|
||
* narrows BOTH $x and $y in the if-body. */
|
||
php_narrowing_t nws[8] = {{0}};
|
||
int nw_count = ts_node_is_null(cond) ? 0 : parse_narrowing_collect(ctx, cond, nws, 8);
|
||
bool has_nw = nw_count > 0;
|
||
|
||
/* Detect negative narrowing: condition is `!P` and P is a narrowing
|
||
* predicate. We unwrap one level of unary `!`. */
|
||
php_narrowing_t neg_nw = {0};
|
||
bool has_neg_nw = false;
|
||
if (!ts_node_is_null(cond)) {
|
||
TSNode inner;
|
||
memset(&inner, 0, sizeof(inner));
|
||
/* Unwrap parenthesized_expression. */
|
||
TSNode probe = cond;
|
||
for (int loop = 0; loop < 4; loop++) {
|
||
if (strcmp(ts_node_type(probe), "parenthesized_expression") == 0) {
|
||
uint32_t pnc = ts_node_child_count(probe);
|
||
TSNode next;
|
||
memset(&next, 0, sizeof(next));
|
||
for (uint32_t i = 0; i < pnc; i++) {
|
||
TSNode c = ts_node_child(probe, i);
|
||
if (!ts_node_is_null(c) && ts_node_is_named(c)) {
|
||
next = c;
|
||
break;
|
||
}
|
||
}
|
||
if (ts_node_is_null(next))
|
||
break;
|
||
probe = next;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
if (strcmp(ts_node_type(probe), "unary_op_expression") == 0) {
|
||
/* Find the operator child; if it's `!`, parse the inner expr. */
|
||
TSNode op = ts_node_child_by_field_name(probe, "operator", 8);
|
||
char *opt = !ts_node_is_null(op) ? php_node_text(ctx, op) : NULL;
|
||
if (opt && strcmp(opt, "!") == 0) {
|
||
TSNode operand = ts_node_child_by_field_name(probe, "operand", 7);
|
||
if (ts_node_is_null(operand)) {
|
||
uint32_t unc = ts_node_child_count(probe);
|
||
for (uint32_t i = 0; i < unc; i++) {
|
||
TSNode c = ts_node_child(probe, i);
|
||
if (!ts_node_is_null(c) && ts_node_is_named(c) &&
|
||
strcmp(ts_node_type(c), "unary_op_expression") != 0) {
|
||
operand = c;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!ts_node_is_null(operand)) {
|
||
has_neg_nw = parse_narrowing(ctx, operand, &neg_nw);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Walk children. The first NON-condition, NON-else-clause named child
|
||
* is the if-body; narrowed scope applies there. Subsequent
|
||
* else_if_clause / else_clause walk without narrowing. */
|
||
bool walked_body = false;
|
||
bool past_cond = false;
|
||
bool body_is_terminator = false;
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (!past_cond) {
|
||
if (strcmp(k, "parenthesized_expression") == 0) {
|
||
past_cond = true;
|
||
/* Walk the condition expression so calls inside it (e.g.
|
||
* `$it->valid()` in `if ($it->valid())`) get resolved. */
|
||
php_resolve_calls_in_node(ctx, c);
|
||
continue;
|
||
}
|
||
/* Non-condition child encountered before the condition: keep walking. */
|
||
php_resolve_calls_in_node(ctx, c);
|
||
continue;
|
||
}
|
||
if (!walked_body) {
|
||
body_is_terminator = stmt_is_terminator(c);
|
||
walk_with_narrowings(ctx, c, has_nw ? nws : NULL, nw_count);
|
||
walked_body = true;
|
||
} else {
|
||
/* else / elseif / colon_block */
|
||
php_resolve_calls_in_node(ctx, c);
|
||
}
|
||
}
|
||
|
||
/* Negative-narrowing apply: if the if-body unconditionally exits AND
|
||
* the condition was `!P` for narrowable P, then for the rest of the
|
||
* enclosing scope, P holds. */
|
||
if (body_is_terminator && has_neg_nw && neg_nw.var_name && neg_nw.type) {
|
||
cbm_scope_bind(ctx->current_scope, neg_nw.var_name, neg_nw.type);
|
||
}
|
||
}
|
||
|
||
/* Walk a subtree, binding scope and resolving calls. */
|
||
static void php_resolve_calls_in_node_inner(PHPLSPContext *ctx, TSNode node) {
|
||
if (ts_node_is_null(node))
|
||
return;
|
||
const char *kind = ts_node_type(node);
|
||
|
||
/* Stop descending into nested function-likes — they are processed by
|
||
* process_function_like with their own scope. */
|
||
if (strcmp(kind, "function_definition") == 0 ||
|
||
strcmp(kind, "function_static_declaration") == 0 ||
|
||
strcmp(kind, "method_declaration") == 0 || strcmp(kind, "anonymous_function") == 0 ||
|
||
strcmp(kind, "arrow_function") == 0) {
|
||
process_function_like(ctx, node);
|
||
return;
|
||
}
|
||
|
||
/* Statement-level scope-binding observers. */
|
||
if (strcmp(kind, "assignment_expression") == 0) {
|
||
process_assignment(ctx, node);
|
||
} else if (strcmp(kind, "foreach_statement") == 0) {
|
||
process_foreach(ctx, node);
|
||
} else if (strcmp(kind, "catch_clause") == 0) {
|
||
process_catch(ctx, node);
|
||
} else if (strcmp(kind, "comment") == 0) {
|
||
char *raw = php_node_text(ctx, node);
|
||
if (raw && raw[0] == '/' && raw[1] == '*' && raw[2] == '*') {
|
||
char *cleaned = phpdoc_clean(ctx->arena, raw);
|
||
bind_phpdoc_var(ctx, cleaned);
|
||
}
|
||
} else if (strcmp(kind, "if_statement") == 0) {
|
||
process_if_statement(ctx, node);
|
||
return; /* process_if_statement already walked body under narrowing */
|
||
}
|
||
|
||
/* Call-resolution dispatch. A first-class callable has the same outer AST
|
||
* node as an invocation, so carry the semantic kind alongside resolution
|
||
* and skip invocation-only narrowing/binding effects. */
|
||
bool is_callable_reference = php_is_first_class_callable(node);
|
||
CBMResolvedKind resolved_kind =
|
||
is_callable_reference ? CBM_RESOLVED_CALL_REFERENCE : CBM_RESOLVED_INVOCATION;
|
||
if (strcmp(kind, "function_call_expression") == 0) {
|
||
int first = ctx->resolved_calls ? ctx->resolved_calls->count : -1;
|
||
resolve_function_call(ctx, node, resolved_kind);
|
||
if (is_callable_reference) {
|
||
php_stamp_resolved_site(ctx, first, php_callable_reference_leaf(ctx, node, kind));
|
||
} else {
|
||
php_stamp_resolved_site(ctx, first, node);
|
||
}
|
||
/* assert(...) in current scope narrows sequentially. */
|
||
if (!is_callable_reference)
|
||
apply_assert_narrowing(ctx, node);
|
||
} else if (strcmp(kind, "member_call_expression") == 0 ||
|
||
strcmp(kind, "nullsafe_member_call_expression") == 0) {
|
||
int first = ctx->resolved_calls ? ctx->resolved_calls->count : -1;
|
||
resolve_member_call(ctx, node, resolved_kind);
|
||
if (is_callable_reference) {
|
||
php_stamp_resolved_site(ctx, first, php_callable_reference_leaf(ctx, node, kind));
|
||
} else {
|
||
php_stamp_resolved_site(ctx, first, 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 (!is_callable_reference && !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) {
|
||
int first = ctx->resolved_calls ? ctx->resolved_calls->count : -1;
|
||
resolve_static_call(ctx, node, resolved_kind);
|
||
if (is_callable_reference) {
|
||
php_stamp_resolved_site(ctx, first, php_callable_reference_leaf(ctx, node, kind));
|
||
} else {
|
||
php_stamp_resolved_site(ctx, first, 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 (!is_callable_reference && !ts_node_is_null(scope) && !ts_node_is_null(mname2)) {
|
||
char *cls = php_node_text(ctx, scope);
|
||
char *mn = php_node_text(ctx, mname2);
|
||
bool is_closure_bind = mn && strcmp(mn, "bind") == 0 && cls &&
|
||
(strcmp(cls, "Closure") == 0 || strcmp(cls, "\\Closure") == 0);
|
||
if (is_closure_bind) {
|
||
TSNode args = ts_node_child_by_field_name(node, "arguments", 9);
|
||
if (!ts_node_is_null(args)) {
|
||
TSNode closure_arg;
|
||
memset(&closure_arg, 0, sizeof(closure_arg));
|
||
TSNode obj_arg;
|
||
memset(&obj_arg, 0, sizeof(obj_arg));
|
||
int seen = 0;
|
||
uint32_t anc = ts_node_child_count(args);
|
||
for (uint32_t i = 0; i < anc; i++) {
|
||
TSNode a = ts_node_child(args, i);
|
||
if (ts_node_is_null(a) || !ts_node_is_named(a))
|
||
continue;
|
||
TSNode inner = a;
|
||
if (strcmp(ts_node_type(a), "argument") == 0) {
|
||
uint32_t bnc = ts_node_child_count(a);
|
||
for (uint32_t j = 0; j < bnc; j++) {
|
||
TSNode b = ts_node_child(a, j);
|
||
if (!ts_node_is_null(b) && ts_node_is_named(b)) {
|
||
inner = b;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (seen == 0)
|
||
closure_arg = inner;
|
||
else if (seen == 1)
|
||
obj_arg = inner;
|
||
seen++;
|
||
}
|
||
if (!ts_node_is_null(closure_arg) && !ts_node_is_null(obj_arg)) {
|
||
const char *ck = ts_node_type(closure_arg);
|
||
if (strcmp(ck, "anonymous_function") == 0 ||
|
||
strcmp(ck, "arrow_function") == 0) {
|
||
const CBMType *t = php_eval_expr_type(ctx, obj_arg);
|
||
if (t && t->kind == CBM_TYPE_NAMED) {
|
||
const char *saved = ctx->enclosing_class_qn;
|
||
ctx->enclosing_class_qn = t->data.named.qualified_name;
|
||
php_resolve_calls_in_node(ctx, closure_arg);
|
||
ctx->enclosing_class_qn = saved;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Recurse via a cursor (O(n)); ts_node_child(node,i) is O(i) → O(n²) on a wide node. */
|
||
{
|
||
TSTreeCursor cursor = ts_tree_cursor_new(node);
|
||
if (ts_tree_cursor_goto_first_child(&cursor)) {
|
||
do {
|
||
php_resolve_calls_in_node(ctx, ts_tree_cursor_current_node(&cursor));
|
||
} while (ts_tree_cursor_goto_next_sibling(&cursor));
|
||
}
|
||
ts_tree_cursor_delete(&cursor);
|
||
}
|
||
}
|
||
|
||
/* ── function-like processing ───────────────────────────────────── */
|
||
|
||
static void bind_typed_parameters(PHPLSPContext *ctx, TSNode params) {
|
||
if (ts_node_is_null(params))
|
||
return;
|
||
uint32_t nc = ts_node_child_count(params);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode p = ts_node_child(params, i);
|
||
if (ts_node_is_null(p) || !ts_node_is_named(p))
|
||
continue;
|
||
const char *pk = ts_node_type(p);
|
||
if (strcmp(pk, "simple_parameter") != 0 && strcmp(pk, "variadic_parameter") != 0 &&
|
||
strcmp(pk, "property_promotion_parameter") != 0) {
|
||
continue;
|
||
}
|
||
TSNode tnode = ts_node_child_by_field_name(p, "type", 4);
|
||
const CBMType *ptype = cbm_type_unknown();
|
||
if (!ts_node_is_null(tnode))
|
||
ptype = php_parse_type_node(ctx, tnode);
|
||
TSNode name_node = ts_node_child_by_field_name(p, "name", 4);
|
||
if (ts_node_is_null(name_node)) {
|
||
/* fallback: first variable_name child */
|
||
uint32_t pnc = ts_node_child_count(p);
|
||
for (uint32_t j = 0; j < pnc; j++) {
|
||
TSNode c = ts_node_child(p, j);
|
||
if (!ts_node_is_null(c) && strcmp(ts_node_type(c), "variable_name") == 0) {
|
||
name_node = c;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (ts_node_is_null(name_node))
|
||
continue;
|
||
char *vt = php_node_text(ctx, name_node);
|
||
if (!vt)
|
||
continue;
|
||
const char *name = (vt[0] == '$') ? vt + 1 : vt;
|
||
/* Variadic parameter — `string ...$args` has type array<string>;
|
||
* we don't model array<T> in Phase 4h, so just bind as `array`. */
|
||
if (strcmp(pk, "variadic_parameter") == 0) {
|
||
ptype = cbm_type_builtin(ctx->arena, "array");
|
||
}
|
||
cbm_scope_bind(ctx->current_scope, name, ptype);
|
||
}
|
||
}
|
||
|
||
static void process_function_like(PHPLSPContext *ctx, TSNode node) {
|
||
const char *kind = ts_node_type(node);
|
||
bool is_method = (strcmp(kind, "method_declaration") == 0);
|
||
bool is_named = is_method || (strcmp(kind, "function_definition") == 0) ||
|
||
(strcmp(kind, "function_static_declaration") == 0);
|
||
|
||
/* Save context. */
|
||
CBMScope *saved_scope = ctx->current_scope;
|
||
const char *saved_func = ctx->enclosing_func_qn;
|
||
|
||
ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope);
|
||
|
||
/* Determine func QN. Mirror what the unified extractor produces: classes
|
||
* and free functions are namespaced by file module_qn, NOT the PHP
|
||
* namespace declaration (the unified extractor ignores `namespace`).
|
||
* Method QN = enclosing_class_qn + "." + method_name. Free function QN =
|
||
* module_qn + "." + name. */
|
||
if (is_named) {
|
||
TSNode name_node = ts_node_child_by_field_name(node, "name", 4);
|
||
if (!ts_node_is_null(name_node)) {
|
||
char *fname = php_node_text(ctx, name_node);
|
||
if (fname) {
|
||
if (is_method && ctx->enclosing_class_qn) {
|
||
ctx->enclosing_func_qn =
|
||
cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->enclosing_class_qn, fname);
|
||
} else if (ctx->module_qn) {
|
||
ctx->enclosing_func_qn =
|
||
cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, fname);
|
||
} else {
|
||
ctx->enclosing_func_qn = cbm_arena_strdup(ctx->arena, fname);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Parameters. */
|
||
TSNode params = ts_node_child_by_field_name(node, "parameters", 10);
|
||
bind_typed_parameters(ctx, params);
|
||
|
||
/* Closure `use ($a, $b)` clause: copy captured-variable types from the
|
||
* parent scope into the closure scope. tree-sitter-php emits an
|
||
* `anonymous_function_use_clause` child of `anonymous_function`. */
|
||
if (strcmp(kind, "anonymous_function") == 0) {
|
||
uint32_t fnc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < fnc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
if (strcmp(ts_node_type(c), "anonymous_function_use_clause") != 0)
|
||
continue;
|
||
uint32_t unc = ts_node_child_count(c);
|
||
for (uint32_t j = 0; j < unc; j++) {
|
||
TSNode v = ts_node_child(c, j);
|
||
if (ts_node_is_null(v) || !ts_node_is_named(v))
|
||
continue;
|
||
if (strcmp(ts_node_type(v), "variable_name") != 0)
|
||
continue;
|
||
char *vt = php_node_text(ctx, v);
|
||
if (!vt)
|
||
continue;
|
||
const char *vname = (vt[0] == '$') ? vt + 1 : vt;
|
||
/* Look up in saved parent scope (captured at this point). */
|
||
const CBMType *captured = cbm_scope_lookup(saved_scope, vname);
|
||
if (captured) {
|
||
cbm_scope_bind(ctx->current_scope, vname, captured);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* PHPDoc @param overrides. */
|
||
if (is_named) {
|
||
char *doc = fetch_leading_phpdoc(ctx, node);
|
||
if (doc)
|
||
parse_phpdoc_for_params(ctx, doc, params);
|
||
}
|
||
|
||
/* Walk body. */
|
||
TSNode body = ts_node_child_by_field_name(node, "body", 4);
|
||
if (ts_node_is_null(body)) {
|
||
/* Arrow function body is in field "body" too, but it's an expression.
|
||
* Try generic recursion as a fallback. */
|
||
uint32_t nc = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(node, i);
|
||
if (!ts_node_is_null(c))
|
||
php_resolve_calls_in_node(ctx, c);
|
||
}
|
||
} else {
|
||
php_resolve_calls_in_node(ctx, body);
|
||
}
|
||
|
||
/* Restore. */
|
||
ctx->current_scope = saved_scope;
|
||
ctx->enclosing_func_qn = saved_func;
|
||
}
|
||
|
||
/* Find first base class (extends X) in a class_declaration, resolved. */
|
||
static const char *find_extends_qn(PHPLSPContext *ctx, TSNode class_node) {
|
||
uint32_t nc = ts_node_child_count(class_node);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(class_node, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
if (strcmp(ts_node_type(c), "base_clause") != 0)
|
||
continue;
|
||
uint32_t bnc = ts_node_child_count(c);
|
||
for (uint32_t j = 0; j < bnc; j++) {
|
||
TSNode bc = ts_node_child(c, j);
|
||
if (ts_node_is_null(bc) || !ts_node_is_named(bc))
|
||
continue;
|
||
const char *bk = ts_node_type(bc);
|
||
if (strcmp(bk, "name") == 0 || strcmp(bk, "qualified_name") == 0) {
|
||
char *t = php_node_text(ctx, bc);
|
||
if (t)
|
||
return php_resolve_class_name(ctx, t);
|
||
}
|
||
}
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
static void process_class_decl(PHPLSPContext *ctx, TSNode node) {
|
||
TSNode name_node = ts_node_child_by_field_name(node, "name", 4);
|
||
if (ts_node_is_null(name_node))
|
||
return;
|
||
char *cname = php_node_text(ctx, name_node);
|
||
if (!cname)
|
||
return;
|
||
|
||
const char *saved_class = ctx->enclosing_class_qn;
|
||
const char *saved_parent = ctx->enclosing_parent_qn;
|
||
|
||
/* enclosing_class_qn must match what the unified extractor records,
|
||
* which uses module_qn (file-path-based), NOT the PHP `namespace`
|
||
* declaration. */
|
||
if (ctx->module_qn) {
|
||
ctx->enclosing_class_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, cname);
|
||
} else {
|
||
ctx->enclosing_class_qn = cbm_arena_strdup(ctx->arena, cname);
|
||
}
|
||
ctx->enclosing_parent_qn = find_extends_qn(ctx, node);
|
||
|
||
/* Walk class body — pick out method_declaration nodes. */
|
||
TSNode body = ts_node_child_by_field_name(node, "body", 4);
|
||
if (ts_node_is_null(body))
|
||
body = child_named(node, "declaration_list");
|
||
if (!ts_node_is_null(body)) {
|
||
uint32_t nc = ts_node_child_count(body);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(body, i);
|
||
if (ts_node_is_null(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "method_declaration") == 0) {
|
||
process_function_like(ctx, c);
|
||
}
|
||
}
|
||
}
|
||
|
||
ctx->enclosing_class_qn = saved_class;
|
||
ctx->enclosing_parent_qn = saved_parent;
|
||
}
|
||
|
||
/* ── top-level pass ─────────────────────────────────────────────── */
|
||
|
||
/* Collect a single namespace_use_clause's local→target mapping and add.
|
||
*
|
||
* tree-sitter-php emits `use Vendor\X as Y;` either as a use_clause with
|
||
* a `namespace_aliasing_clause` child wrapping the alias, or as a bare
|
||
* sequence: qualified_name + literal `as` token + name. We accept both
|
||
* shapes by treating the *second* name-like child as the alias when no
|
||
* aliasing clause is present. A grouped clause (`use A\B\{C as D}`) names
|
||
* its target relative to the group's namespace `prefix`. */
|
||
static void collect_use_clause(PHPLSPContext *ctx, TSNode use_clause, int kind,
|
||
const char *prefix) {
|
||
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;
|
||
if (prefix && prefix[0]) {
|
||
full = cbm_arena_sprintf(ctx->arena, "%s\\%s", prefix, full);
|
||
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);
|
||
}
|
||
|
||
/* The `function` / `const` keyword of a use: an anonymous direct child of the
|
||
* declaration (flat form) or of one clause (grouped form
|
||
* `use A\{function f, const C, K}`). `fallback` when neither is present.
|
||
* Mirrors the extractor's php_use_kind (extract_imports.c). */
|
||
static int php_use_keyword_kind(TSNode node, int fallback) {
|
||
uint32_t n = ts_node_child_count(node);
|
||
for (uint32_t i = 0; i < n; 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, "function") == 0)
|
||
return CBM_PHP_USE_FUNCTION;
|
||
if (strcmp(k, "const") == 0)
|
||
return CBM_PHP_USE_CONST;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
static void collect_use_declaration(PHPLSPContext *ctx, TSNode use_decl) {
|
||
/* Determine kind: function / const / class (default). */
|
||
int kind = php_use_keyword_kind(use_decl, CBM_PHP_USE_CLASS);
|
||
uint32_t nc = ts_node_child_count(use_decl);
|
||
const char *prefix = NULL;
|
||
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, NULL);
|
||
} else if (strcmp(k, "namespace_name") == 0 || strcmp(k, "qualified_name") == 0 ||
|
||
strcmp(k, "name") == 0) {
|
||
/* use App\{Foo}; — the group's namespace precedes the group. */
|
||
prefix = php_node_text(ctx, c);
|
||
} else if (strcmp(k, "namespace_use_group") == 0) {
|
||
/* use App\{Foo, Bar as B}; — recurse into clauses inside. Each
|
||
* clause names its target under the prefix, and carries its own
|
||
* `function` / `const` keyword when the declaration has none. */
|
||
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))
|
||
continue;
|
||
const char *gk = ts_node_type(gch);
|
||
if (strcmp(gk, "namespace_use_clause") == 0 ||
|
||
strcmp(gk, "namespace_use_group_clause") == 0) {
|
||
int clause_kind =
|
||
kind == CBM_PHP_USE_CLASS ? php_use_keyword_kind(gch, kind) : kind;
|
||
collect_use_clause(ctx, gch, clause_kind, prefix);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
typedef struct {
|
||
const char *module_qn;
|
||
const char *namespace_qn;
|
||
const char **use_local_names;
|
||
const char **use_target_qns;
|
||
int use_count;
|
||
} PHPSignatureParseContext;
|
||
|
||
static const CBMType *php_signature_parse_type(CBMArena *arena, const char *text,
|
||
void *parser_ctx) {
|
||
const PHPSignatureParseContext *ctx = (const PHPSignatureParseContext *)parser_ctx;
|
||
return parse_return_type_text(arena, text, ctx ? ctx->module_qn : NULL,
|
||
ctx ? ctx->namespace_qn : NULL, ctx ? ctx->use_local_names : NULL,
|
||
ctx ? ctx->use_target_qns : NULL, ctx ? ctx->use_count : 0);
|
||
}
|
||
|
||
/* ── property type extraction ───────────────────────────────────────
|
||
*
|
||
* Walks the AST top-level for class/trait/interface bodies and registers
|
||
* typed properties on the corresponding CBMRegisteredType.field_names /
|
||
* field_types. Handles:
|
||
* - typed `public Foo $bar;` declarations
|
||
* - constructor property promotion (the `public Foo $bar` parameter form)
|
||
* - `$this->bar = $bar` constructor-body inference, where $bar is a
|
||
* typed parameter
|
||
*
|
||
* After this pass `eval_expr_type` for member_access_expression $x->bar
|
||
* returns the property's CBMType when known. */
|
||
|
||
typedef struct {
|
||
const char *class_qn;
|
||
const char **field_names; /* arena-backed, NULL-terminated */
|
||
const CBMType **field_types;
|
||
int count;
|
||
int cap;
|
||
} php_class_fields_t;
|
||
|
||
typedef struct {
|
||
php_class_fields_t *items;
|
||
int count;
|
||
int cap;
|
||
} php_class_field_table_t;
|
||
|
||
static php_class_fields_t *fields_for(php_class_field_table_t *tab, const char *class_qn,
|
||
CBMArena *arena) {
|
||
for (int i = 0; i < tab->count; i++) {
|
||
if (strcmp(tab->items[i].class_qn, class_qn) == 0)
|
||
return &tab->items[i];
|
||
}
|
||
if (tab->count >= tab->cap) {
|
||
int new_cap = tab->cap ? tab->cap * 2 : 16;
|
||
php_class_fields_t *next =
|
||
(php_class_fields_t *)cbm_arena_alloc(arena, (size_t)new_cap * sizeof(*next));
|
||
if (!next)
|
||
return NULL;
|
||
for (int i = 0; i < tab->count; i++)
|
||
next[i] = tab->items[i];
|
||
tab->items = next;
|
||
tab->cap = new_cap;
|
||
}
|
||
php_class_fields_t *f = &tab->items[tab->count++];
|
||
memset(f, 0, sizeof(*f));
|
||
f->class_qn = cbm_arena_strdup(arena, class_qn);
|
||
return f;
|
||
}
|
||
|
||
static void add_field(php_class_fields_t *f, CBMArena *arena, const char *name,
|
||
const CBMType *type) {
|
||
if (!f || !name || !type)
|
||
return;
|
||
/* dedup: keep first declaration; constructor inference doesn't override
|
||
* an explicit declaration. */
|
||
for (int i = 0; i < f->count; i++) {
|
||
if (strcmp(f->field_names[i], name) == 0)
|
||
return;
|
||
}
|
||
if (f->count >= f->cap) {
|
||
int new_cap = f->cap ? f->cap * 2 : 8;
|
||
const char **nn =
|
||
(const char **)cbm_arena_alloc(arena, (size_t)(new_cap + 1) * sizeof(*nn));
|
||
const CBMType **nt =
|
||
(const CBMType **)cbm_arena_alloc(arena, (size_t)(new_cap + 1) * sizeof(*nt));
|
||
if (!nn || !nt)
|
||
return;
|
||
for (int i = 0; i < f->count; i++) {
|
||
nn[i] = f->field_names[i];
|
||
nt[i] = f->field_types[i];
|
||
}
|
||
f->field_names = nn;
|
||
f->field_types = nt;
|
||
f->cap = new_cap;
|
||
}
|
||
f->field_names[f->count] = cbm_arena_strdup(arena, name);
|
||
f->field_types[f->count] = type;
|
||
f->count++;
|
||
f->field_names[f->count] = NULL;
|
||
f->field_types[f->count] = NULL;
|
||
}
|
||
|
||
/* Compute the dotted class QN for a given class_declaration node, matching
|
||
* how the unified extractor names defs (module_qn-prefixed). */
|
||
static const char *class_qn_for_node(PHPLSPContext *ctx, TSNode class_node) {
|
||
TSNode name_node = ts_node_child_by_field_name(class_node, "name", 4);
|
||
if (ts_node_is_null(name_node))
|
||
return NULL;
|
||
char *cname = php_node_text(ctx, name_node);
|
||
if (!cname)
|
||
return NULL;
|
||
if (ctx->module_qn) {
|
||
return cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, cname);
|
||
}
|
||
return cbm_arena_strdup(ctx->arena, cname);
|
||
}
|
||
|
||
static void extract_property_decl(PHPLSPContext *ctx, TSNode prop_decl, php_class_fields_t *out) {
|
||
TSNode tnode = ts_node_child_by_field_name(prop_decl, "type", 4);
|
||
const CBMType *ptype = NULL;
|
||
if (!ts_node_is_null(tnode))
|
||
ptype = php_parse_type_node(ctx, tnode);
|
||
if (!ptype || ptype->kind == CBM_TYPE_UNKNOWN) {
|
||
/* Tree-sitter-php may put the type as a named child without field. */
|
||
uint32_t nc = ts_node_child_count(prop_decl);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(prop_decl, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "named_type") == 0 || strcmp(k, "primitive_type") == 0 ||
|
||
strcmp(k, "union_type") == 0 || strcmp(k, "intersection_type") == 0 ||
|
||
strcmp(k, "optional_type") == 0) {
|
||
ptype = php_parse_type_node(ctx, c);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!ptype || ptype->kind == CBM_TYPE_UNKNOWN)
|
||
return;
|
||
|
||
/* Find the property_element children (each has variable_name + maybe default). */
|
||
uint32_t nc = ts_node_child_count(prop_decl);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(prop_decl, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "property_element") == 0) {
|
||
uint32_t bnc = ts_node_child_count(c);
|
||
for (uint32_t j = 0; j < bnc; j++) {
|
||
TSNode b = ts_node_child(c, j);
|
||
if (!ts_node_is_null(b) && ts_node_is_named(b) &&
|
||
strcmp(ts_node_type(b), "variable_name") == 0) {
|
||
char *vt = php_node_text(ctx, b);
|
||
if (!vt)
|
||
continue;
|
||
const char *name = (vt[0] == '$') ? vt + 1 : vt;
|
||
add_field(out, ctx->arena, name, ptype);
|
||
break;
|
||
}
|
||
}
|
||
} else if (strcmp(k, "variable_name") == 0) {
|
||
char *vt = php_node_text(ctx, c);
|
||
if (vt) {
|
||
const char *name = (vt[0] == '$') ? vt + 1 : vt;
|
||
add_field(out, ctx->arena, name, ptype);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
static void extract_promoted_param(PHPLSPContext *ctx, TSNode param, php_class_fields_t *out) {
|
||
/* property_promotion_parameter has: visibility/readonly modifiers,
|
||
* type, variable_name. */
|
||
TSNode tnode = ts_node_child_by_field_name(param, "type", 4);
|
||
const CBMType *ptype = NULL;
|
||
if (!ts_node_is_null(tnode))
|
||
ptype = php_parse_type_node(ctx, tnode);
|
||
if (!ptype || ptype->kind == CBM_TYPE_UNKNOWN) {
|
||
uint32_t nc = ts_node_child_count(param);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(param, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "named_type") == 0 || strcmp(k, "primitive_type") == 0 ||
|
||
strcmp(k, "union_type") == 0 || strcmp(k, "intersection_type") == 0 ||
|
||
strcmp(k, "optional_type") == 0) {
|
||
ptype = php_parse_type_node(ctx, c);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!ptype || ptype->kind == CBM_TYPE_UNKNOWN)
|
||
return;
|
||
TSNode vname = ts_node_child_by_field_name(param, "name", 4);
|
||
if (ts_node_is_null(vname)) {
|
||
uint32_t nc = ts_node_child_count(param);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(param, i);
|
||
if (!ts_node_is_null(c) && strcmp(ts_node_type(c), "variable_name") == 0) {
|
||
vname = c;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (ts_node_is_null(vname))
|
||
return;
|
||
char *vt = php_node_text(ctx, vname);
|
||
if (!vt)
|
||
return;
|
||
const char *n = (vt[0] == '$') ? vt + 1 : vt;
|
||
add_field(out, ctx->arena, n, ptype);
|
||
}
|
||
|
||
/* Look at __construct: any `$this->X = $param;` where $param is a typed
|
||
* parameter contributes a field type for X. */
|
||
static void extract_ctor_assignments(PHPLSPContext *ctx, TSNode ctor_method,
|
||
php_class_fields_t *out) {
|
||
TSNode params = ts_node_child_by_field_name(ctor_method, "parameters", 10);
|
||
/* Build a small param-name -> type map for this constructor. */
|
||
const char *names[32];
|
||
const CBMType *types[32];
|
||
int pc = 0;
|
||
if (!ts_node_is_null(params)) {
|
||
uint32_t nc = ts_node_child_count(params);
|
||
for (uint32_t i = 0; i < nc && pc < 32; i++) {
|
||
TSNode p = ts_node_child(params, i);
|
||
if (ts_node_is_null(p) || !ts_node_is_named(p))
|
||
continue;
|
||
const char *pk = ts_node_type(p);
|
||
if (strcmp(pk, "simple_parameter") != 0 &&
|
||
strcmp(pk, "property_promotion_parameter") != 0) {
|
||
continue;
|
||
}
|
||
TSNode tnode = ts_node_child_by_field_name(p, "type", 4);
|
||
const CBMType *ptype = NULL;
|
||
if (!ts_node_is_null(tnode))
|
||
ptype = php_parse_type_node(ctx, tnode);
|
||
if (!ptype || ptype->kind == CBM_TYPE_UNKNOWN)
|
||
continue;
|
||
TSNode name_node = ts_node_child_by_field_name(p, "name", 4);
|
||
if (ts_node_is_null(name_node)) {
|
||
uint32_t pnc = ts_node_child_count(p);
|
||
for (uint32_t j = 0; j < pnc; j++) {
|
||
TSNode c = ts_node_child(p, j);
|
||
if (!ts_node_is_null(c) && strcmp(ts_node_type(c), "variable_name") == 0) {
|
||
name_node = c;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (ts_node_is_null(name_node))
|
||
continue;
|
||
char *vt = php_node_text(ctx, name_node);
|
||
if (!vt)
|
||
continue;
|
||
names[pc] = cbm_arena_strdup(ctx->arena, (vt[0] == '$') ? vt + 1 : vt);
|
||
types[pc] = ptype;
|
||
pc++;
|
||
}
|
||
}
|
||
if (pc == 0)
|
||
return;
|
||
|
||
/* Walk the body, looking for `$this->X = $name;` patterns. */
|
||
TSNode body = ts_node_child_by_field_name(ctor_method, "body", 4);
|
||
if (ts_node_is_null(body))
|
||
return;
|
||
/* Iterative DFS. */
|
||
TSNode stack[64];
|
||
int top = 0;
|
||
stack[top++] = body;
|
||
while (top > 0) {
|
||
TSNode n = stack[--top];
|
||
if (ts_node_is_null(n))
|
||
continue;
|
||
const char *k = ts_node_type(n);
|
||
if (strcmp(k, "assignment_expression") == 0) {
|
||
TSNode lhs = ts_node_child_by_field_name(n, "left", 4);
|
||
TSNode rhs = ts_node_child_by_field_name(n, "right", 5);
|
||
if (!ts_node_is_null(lhs) && !ts_node_is_null(rhs) &&
|
||
strcmp(ts_node_type(lhs), "member_access_expression") == 0 &&
|
||
strcmp(ts_node_type(rhs), "variable_name") == 0) {
|
||
/* lhs = $this->X */
|
||
TSNode obj = ts_node_child_by_field_name(lhs, "object", 6);
|
||
TSNode fname = ts_node_child_by_field_name(lhs, "name", 4);
|
||
if (!ts_node_is_null(obj) && !ts_node_is_null(fname)) {
|
||
char *ot = php_node_text(ctx, obj);
|
||
char *fn = php_node_text(ctx, fname);
|
||
char *rt = php_node_text(ctx, rhs);
|
||
if (ot && fn && rt && (ot[0] == '$') && strcmp(ot + 1, "this") == 0) {
|
||
const char *rname = (rt[0] == '$') ? rt + 1 : rt;
|
||
for (int i = 0; i < pc; i++) {
|
||
if (strcmp(names[i], rname) == 0) {
|
||
add_field(out, ctx->arena, fn, types[i]);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
uint32_t cnt = ts_node_child_count(n);
|
||
for (uint32_t i = 0; i < cnt && top < 64; i++) {
|
||
TSNode c = ts_node_child(n, i);
|
||
if (!ts_node_is_null(c))
|
||
stack[top++] = c;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Parse a class-level docblock for @property / @method tags and register
|
||
* them as virtual fields and methods on the class. Used heavily by
|
||
* Eloquent and other dynamic-PHP frameworks.
|
||
*
|
||
* @property Foo $bar → field bar: Foo
|
||
* @property-read Foo $bar → same (treat the same as @property)
|
||
* @method Bar foo() → method foo() returning Bar
|
||
* @method static Bar foo() → method foo() returning Bar (static)
|
||
*
|
||
* The signature parsing for @method is lenient — we only care about the
|
||
* return type for downstream chain inference. Argument types in the
|
||
* tag are ignored. */
|
||
/* Parse `@template T` / `@template T of Foo` lines and return the
|
||
* NULL-terminated array of type-parameter names. */
|
||
static const char **parse_phpdoc_template_params(PHPLSPContext *ctx, const char *docstring) {
|
||
if (!docstring)
|
||
return NULL;
|
||
const char *names[16];
|
||
int n = 0;
|
||
const char *p = docstring;
|
||
while ((p = strstr(p, "@template")) != NULL) {
|
||
p += 9;
|
||
/* Skip variants that bind a type-param name with a kind suffix:
|
||
* @template-covariant T → record T
|
||
* @template-contravariant T → record T
|
||
* but NOT @template-extends / @template-implements (those declare
|
||
* a parent class, not a type param). */
|
||
bool is_param_variant = true;
|
||
if (*p == '-') {
|
||
const char *suffix = p + 1;
|
||
if (strncmp(suffix, "covariant", 9) == 0) {
|
||
p += 1 + 9;
|
||
} else if (strncmp(suffix, "contravariant", 13) == 0) {
|
||
p += 1 + 13;
|
||
} else {
|
||
/* @template-extends / -implements / -use / etc. — skip line. */
|
||
is_param_variant = false;
|
||
while (*p && *p != ' ' && *p != '\t' && *p != '\n')
|
||
p++;
|
||
}
|
||
}
|
||
if (!is_param_variant)
|
||
continue;
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
const char *name_start = p;
|
||
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
|
||
p++;
|
||
if (p == name_start)
|
||
continue;
|
||
if (n < 16) {
|
||
names[n++] = cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
|
||
}
|
||
}
|
||
if (n == 0)
|
||
return NULL;
|
||
const char **out = (const char **)cbm_arena_alloc(ctx->arena, (size_t)(n + 1) * sizeof(*out));
|
||
if (!out)
|
||
return NULL;
|
||
for (int i = 0; i < n; i++)
|
||
out[i] = names[i];
|
||
out[n] = NULL;
|
||
return out;
|
||
}
|
||
|
||
/* Substitute the type parameters in `t` according to a parallel
|
||
* (param_names, args) mapping, returning a new arena-allocated CBMType. */
|
||
static const CBMType *php_substitute_template(CBMArena *arena, const CBMType *t,
|
||
const char *const *param_names,
|
||
const CBMType *const *args) {
|
||
if (!t || !param_names || !args)
|
||
return t;
|
||
if (t->kind == CBM_TYPE_NAMED && t->data.named.qualified_name) {
|
||
const char *qn = t->data.named.qualified_name;
|
||
for (int i = 0; param_names[i]; i++) {
|
||
if (strcmp(qn, param_names[i]) == 0) {
|
||
return args[i] ? args[i] : t;
|
||
}
|
||
}
|
||
return t;
|
||
}
|
||
if (t->kind == CBM_TYPE_TEMPLATE) {
|
||
/* Recurse into args. */
|
||
int n = t->data.template_type.arg_count;
|
||
if (n <= 0 || !t->data.template_type.template_args)
|
||
return t;
|
||
const CBMType **new_args =
|
||
(const CBMType **)cbm_arena_alloc(arena, (size_t)(n + 1) * sizeof(*new_args));
|
||
if (!new_args)
|
||
return t;
|
||
for (int i = 0; i < n; i++) {
|
||
new_args[i] = php_substitute_template(arena, t->data.template_type.template_args[i],
|
||
param_names, args);
|
||
}
|
||
new_args[n] = NULL;
|
||
return cbm_type_template(arena, t->data.template_type.template_name, new_args, n);
|
||
}
|
||
return t;
|
||
}
|
||
|
||
static void apply_phpdoc_class_tags(PHPLSPContext *ctx, CBMTypeRegistry *reg, const char *class_qn,
|
||
const char *docstring, php_class_fields_t *out_fields) {
|
||
if (!docstring || !class_qn)
|
||
return;
|
||
|
||
/* @template T / @template T of Foo — register on the class. */
|
||
const char **tparams = parse_phpdoc_template_params(ctx, docstring);
|
||
if (tparams) {
|
||
for (int t = 0; t < reg->type_count; t++) {
|
||
if (strcmp(reg->types[t].qualified_name, class_qn) == 0) {
|
||
reg->types[t].type_param_names = tparams;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* @phpstan-type Alias TYPE_EXPR — register a per-file alias so
|
||
* subsequent @var/@param/@return references to `Alias` resolve to the
|
||
* aliased type. This is a phpstan/psalm convention used heavily for
|
||
* shape descriptions and value objects.
|
||
*
|
||
* Also accept the equivalent @psalm-type and @phan-type spellings. */
|
||
{
|
||
const char *prefixes[] = {"@phpstan-type", "@psalm-type", "@phan-type", NULL};
|
||
for (int pi = 0; prefixes[pi]; pi++) {
|
||
const char *p = docstring;
|
||
size_t plen = strlen(prefixes[pi]);
|
||
while ((p = strstr(p, prefixes[pi])) != NULL) {
|
||
p += plen;
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
/* alias name */
|
||
const char *name_start = p;
|
||
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
|
||
p++;
|
||
if (p == name_start)
|
||
continue;
|
||
char *alias_name =
|
||
cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
|
||
while (*p == ' ' || *p == '\t' || *p == '=')
|
||
p++;
|
||
/* type expression, span <...> brackets */
|
||
const char *type_start = p;
|
||
int depth = 0;
|
||
while (*p && *p != '\n') {
|
||
if (*p == '<')
|
||
depth++;
|
||
else if (*p == '>')
|
||
depth--;
|
||
else if (depth == 0 && (*p == ' ' || *p == '\t') && p > type_start) {
|
||
break;
|
||
}
|
||
p++;
|
||
}
|
||
if (p == type_start)
|
||
continue;
|
||
char *type_text =
|
||
cbm_arena_strndup(ctx->arena, type_start, (size_t)(p - type_start));
|
||
/* Strip trailing whitespace in case. */
|
||
size_t tl = strlen(type_text);
|
||
while (tl > 0 && (type_text[tl - 1] == ' ' || type_text[tl - 1] == '\t')) {
|
||
type_text[--tl] = '\0';
|
||
}
|
||
if (!*type_text)
|
||
continue;
|
||
const CBMType *aliased = resolve_phpdoc_type(ctx, type_text);
|
||
if (aliased && aliased->kind != CBM_TYPE_UNKNOWN) {
|
||
php_add_phpstan_alias(ctx, alias_name, aliased);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* @phpstan-import-type Alias from Other — best-effort. We don't have
|
||
* cross-file alias indexing yet, but accept the syntax without
|
||
* crashing. The alias becomes UNKNOWN until the source class's
|
||
* @phpstan-type ends up in our table. */
|
||
{
|
||
const char *p = docstring;
|
||
while ((p = strstr(p, "@phpstan-import-type")) != NULL) {
|
||
p += 20;
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
const char *name_start = p;
|
||
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
|
||
p++;
|
||
if (p == name_start)
|
||
continue;
|
||
(void)name_start; /* declared imported alias — silently accept */
|
||
}
|
||
}
|
||
|
||
/* @extends ParentClass<X> / @template-extends ParentClass<X>
|
||
* — when a class generically extends a parametric ancestor, we
|
||
* record that the ancestor's type-params are bound to the concrete
|
||
* args. We model this by replacing the embedded_types entry with
|
||
* a synthetic key class_qn + "@@" + parent_with_args, plus storing
|
||
* the type-arg mapping in a side table. For Phase 4s we keep it
|
||
* minimal: when method lookup walks the ancestor, eval_member_call
|
||
* on the receiver doesn't yet know about this binding. The full
|
||
* substitution happens in Phase 4s+ via a side table; for now we
|
||
* record the @extends parent in embedded_types as a NAMED type so
|
||
* inherited methods at least resolve (untyped). */
|
||
{
|
||
const char *p = docstring;
|
||
while ((p = strstr(p, "@extends")) != NULL) {
|
||
p += 8;
|
||
if (*p == '-') {
|
||
while (*p && *p != ' ' && *p != '\t' && *p != '\n')
|
||
p++;
|
||
continue;
|
||
}
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
const char *parent_start = p;
|
||
int depth = 0;
|
||
while (*p && *p != '\n') {
|
||
if (*p == '<')
|
||
depth++;
|
||
else if (*p == '>') {
|
||
depth--;
|
||
if (depth == 0) {
|
||
p++;
|
||
break;
|
||
}
|
||
} else if (depth == 0 && (*p == ' ' || *p == '\t'))
|
||
break;
|
||
p++;
|
||
}
|
||
if (p == parent_start)
|
||
continue;
|
||
char *parent_text =
|
||
cbm_arena_strndup(ctx->arena, parent_start, (size_t)(p - parent_start));
|
||
const CBMType *parent_type = resolve_phpdoc_type(ctx, parent_text);
|
||
const char *parent_qn = NULL;
|
||
if (parent_type && parent_type->kind == CBM_TYPE_NAMED) {
|
||
parent_qn = parent_type->data.named.qualified_name;
|
||
} else if (parent_type && parent_type->kind == CBM_TYPE_TEMPLATE) {
|
||
parent_qn = parent_type->data.template_type.template_name;
|
||
}
|
||
if (!parent_qn)
|
||
continue;
|
||
/* Append to embedded_types if not already present. */
|
||
for (int t = 0; t < reg->type_count; t++) {
|
||
if (strcmp(reg->types[t].qualified_name, class_qn) != 0)
|
||
continue;
|
||
int existing = 0;
|
||
if (reg->types[t].embedded_types) {
|
||
while (reg->types[t].embedded_types[existing])
|
||
existing++;
|
||
}
|
||
bool found = false;
|
||
for (int e = 0; e < existing; e++) {
|
||
if (strcmp(reg->types[t].embedded_types[e], parent_qn) == 0) {
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (found)
|
||
break;
|
||
const char **expanded = (const char **)cbm_arena_alloc(
|
||
ctx->arena, (size_t)(existing + 2) * sizeof(*expanded));
|
||
if (!expanded)
|
||
break;
|
||
for (int e = 0; e < existing; e++) {
|
||
expanded[e] = reg->types[t].embedded_types[e];
|
||
}
|
||
expanded[existing] = parent_qn;
|
||
expanded[existing + 1] = NULL;
|
||
reg->types[t].embedded_types = expanded;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* @property TYPE $name */
|
||
const char *p = docstring;
|
||
while ((p = strstr(p, "@property")) != NULL) {
|
||
p += 9;
|
||
/* Skip "-read" / "-write" suffix variants. */
|
||
if (*p == '-') {
|
||
while (*p && *p != ' ' && *p != '\t')
|
||
p++;
|
||
}
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
const char *type_start = p;
|
||
while (*p && *p != ' ' && *p != '\t' && *p != '\n')
|
||
p++;
|
||
if (p == type_start)
|
||
continue;
|
||
char *type_text = cbm_arena_strndup(ctx->arena, type_start, (size_t)(p - type_start));
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
if (*p != '$')
|
||
continue;
|
||
p++;
|
||
const char *name_start = p;
|
||
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
|
||
p++;
|
||
if (p == name_start)
|
||
continue;
|
||
char *fname = cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
|
||
const CBMType *t = resolve_phpdoc_type(ctx, type_text);
|
||
if (out_fields && fname && t && t->kind != CBM_TYPE_UNKNOWN) {
|
||
add_field(out_fields, ctx->arena, fname, t);
|
||
}
|
||
}
|
||
|
||
/* @method [static] TYPE name(...) */
|
||
p = docstring;
|
||
while ((p = strstr(p, "@method")) != NULL) {
|
||
p += 7;
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
/* optional `static` */
|
||
if (strncmp(p, "static", 6) == 0 && (p[6] == ' ' || p[6] == '\t')) {
|
||
p += 6;
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
}
|
||
const char *type_start = p;
|
||
while (*p && *p != ' ' && *p != '\t' && *p != '\n')
|
||
p++;
|
||
if (p == type_start)
|
||
continue;
|
||
char *type_text = cbm_arena_strndup(ctx->arena, type_start, (size_t)(p - type_start));
|
||
while (*p == ' ' || *p == '\t')
|
||
p++;
|
||
const char *name_start = p;
|
||
while (*p && (isalnum((unsigned char)*p) || *p == '_'))
|
||
p++;
|
||
if (p == name_start)
|
||
continue;
|
||
char *mname = cbm_arena_strndup(ctx->arena, name_start, (size_t)(p - name_start));
|
||
|
||
/* Register as a virtual function whose receiver_type is class_qn.
|
||
* Generates a synthetic QN class_qn + "." + mname. */
|
||
CBMRegisteredFunc rf;
|
||
memset(&rf, 0, sizeof(rf));
|
||
rf.qualified_name = cbm_arena_sprintf(ctx->arena, "%s.%s", class_qn, mname);
|
||
rf.short_name = mname;
|
||
rf.receiver_type = class_qn;
|
||
rf.min_params = -1;
|
||
const CBMType *ret = resolve_phpdoc_type(ctx, type_text);
|
||
const CBMType **rets = (const CBMType **)cbm_arena_alloc(ctx->arena, 2 * sizeof(*rets));
|
||
if (rets) {
|
||
rets[0] = ret;
|
||
rets[1] = NULL;
|
||
}
|
||
rf.signature = cbm_type_func(ctx->arena, NULL, NULL, rets);
|
||
cbm_registry_add_func(reg, rf);
|
||
}
|
||
}
|
||
|
||
/* Flatten the methods of a trait into the using class's method table.
|
||
*
|
||
* Tree-sitter-php emits `use Trait1, Trait2;` inside a class body as a
|
||
* `use_declaration` (distinct from the namespace-level
|
||
* namespace_use_declaration). The optional conflict resolution block
|
||
* uses `use_instead_of_clause` (for `Trait1::foo insteadof Trait2`) and
|
||
* `use_as_clause` (for `Trait2::foo as fooAlt`).
|
||
*
|
||
* Phase 4e basic strategy:
|
||
* - copy each method on the trait into the class as a synthetic
|
||
* CBMRegisteredFunc with receiver_type = class_qn
|
||
* - if the same method exists from multiple traits, last write wins
|
||
* (suboptimal but matches PHP's "use the last `use` line" default
|
||
* when no insteadof clause exists)
|
||
* - handle `as` aliasing: register method under the alias name too
|
||
* - skip insteadof (rare in practice; full support deferred)
|
||
*/
|
||
/* The registry indices of each receiver's methods, in registration order.
|
||
* Flattening a trait used to visit EVERY registered function to find the
|
||
* trait's own, so a file whose classes use traits paid O(uses x functions),
|
||
* and the functions grow with every class flattened before: quadratic in the
|
||
* trait-using classes of one file (#1265). The registry is append-only, so
|
||
* the index catches up incrementally (indexed_upto) and never misses a
|
||
* function registered between two flattens. */
|
||
typedef struct {
|
||
int *items;
|
||
int count;
|
||
int cap;
|
||
} php_func_ids_t;
|
||
|
||
struct php_trait_index {
|
||
CBMHashTable *by_receiver; /* receiver_type (borrowed) -> php_func_ids_t* */
|
||
int indexed_upto; /* reg->funcs[0, indexed_upto) are indexed */
|
||
bool failed; /* allocation failed: callers use the full scan */
|
||
};
|
||
|
||
static bool php_func_ids_push(CBMArena *arena, php_func_ids_t *ids, int func_index) {
|
||
if (ids->count == ids->cap) {
|
||
int cap = ids->cap ? ids->cap * 2 : 4;
|
||
int *items = (int *)cbm_arena_alloc(arena, (size_t)cap * sizeof(*items));
|
||
if (!items) {
|
||
return false;
|
||
}
|
||
if (ids->count > 0) {
|
||
memcpy(items, ids->items, (size_t)ids->count * sizeof(*items));
|
||
}
|
||
ids->items = items;
|
||
ids->cap = cap;
|
||
}
|
||
ids->items[ids->count++] = func_index;
|
||
return true;
|
||
}
|
||
|
||
/* Index every function registered since the last call. Returns false (and
|
||
* marks the index failed for good) on an allocation failure. */
|
||
static bool php_trait_index_sync(PHPLSPContext *ctx, struct php_trait_index *ix,
|
||
const CBMTypeRegistry *reg) {
|
||
if (ix->failed) {
|
||
return false;
|
||
}
|
||
if (!ix->by_receiver) {
|
||
ix->by_receiver = cbm_ht_create(0);
|
||
if (!ix->by_receiver) {
|
||
ix->failed = true;
|
||
return false;
|
||
}
|
||
}
|
||
for (; ix->indexed_upto < reg->func_count; ix->indexed_upto++) {
|
||
CBM_LSP_WORK(1);
|
||
const char *receiver = reg->funcs[ix->indexed_upto].receiver_type;
|
||
if (!receiver) {
|
||
continue;
|
||
}
|
||
php_func_ids_t *ids = (php_func_ids_t *)cbm_ht_get(ix->by_receiver, receiver);
|
||
if (!ids) {
|
||
ids = (php_func_ids_t *)cbm_arena_alloc(ctx->arena, sizeof(*ids));
|
||
if (!ids) {
|
||
ix->failed = true;
|
||
return false;
|
||
}
|
||
memset(ids, 0, sizeof(*ids));
|
||
cbm_ht_set(ix->by_receiver, receiver, ids);
|
||
}
|
||
if (!php_func_ids_push(ctx->arena, ids, ix->indexed_upto)) {
|
||
ix->failed = true;
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static void php_trait_index_free(struct php_trait_index *ix) {
|
||
if (ix->by_receiver) {
|
||
cbm_ht_free(ix->by_receiver);
|
||
ix->by_receiver = NULL;
|
||
}
|
||
}
|
||
|
||
/* Copy one method `src` of the trait into class_qn (see
|
||
* flatten_trait_into_class). Reads src before registering, because
|
||
* cbm_registry_add_func may move reg->funcs. */
|
||
static void flatten_trait_method(PHPLSPContext *ctx, CBMTypeRegistry *reg, const char *class_qn,
|
||
const char *canonical_trait_qn, const CBMRegisteredFunc *src,
|
||
const char *alias_for_method, const char *only_method_name) {
|
||
if (only_method_name && src->short_name && strcmp(src->short_name, only_method_name) != 0) {
|
||
return;
|
||
}
|
||
const char *new_short = alias_for_method ? alias_for_method : src->short_name;
|
||
if (!new_short)
|
||
return;
|
||
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_replace_returns(ctx->arena, src->signature, rets);
|
||
}
|
||
}
|
||
}
|
||
rf.signature = new_sig;
|
||
cbm_registry_add_func(reg, rf);
|
||
}
|
||
|
||
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;
|
||
struct php_trait_index *ix = ctx->trait_index;
|
||
if (ix && php_trait_index_sync(ctx, ix, reg)) {
|
||
/* Only the trait's own methods, in registration order; the ids are
|
||
* ascending, so the snapshot bound ends the walk. No sync runs inside
|
||
* the loop, so the list cannot change under it. */
|
||
const php_func_ids_t *ids =
|
||
(const php_func_ids_t *)cbm_ht_get(ix->by_receiver, canonical_trait_qn);
|
||
for (int j = 0; ids && j < ids->count && ids->items[j] < func_count_before; j++) {
|
||
CBM_LSP_WORK(1);
|
||
if (CBM_LSP_WORK_CEILING_HIT()) {
|
||
break; /* test seam only (#765, #1265): see lsp_work.h */
|
||
}
|
||
flatten_trait_method(ctx, reg, class_qn, canonical_trait_qn, ®->funcs[ids->items[j]],
|
||
alias_for_method, only_method_name);
|
||
}
|
||
return;
|
||
}
|
||
/* No index (no owning pass, or it could not allocate): the full scan,
|
||
* which yields the same methods in the same order. */
|
||
for (int i = 0; i < func_count_before; i++) {
|
||
CBM_LSP_WORK(1);
|
||
if (CBM_LSP_WORK_CEILING_HIT()) {
|
||
break; /* test seam only (#765, #1265): see lsp_work.h */
|
||
}
|
||
const CBMRegisteredFunc *src = ®->funcs[i];
|
||
if (!src->receiver_type || strcmp(src->receiver_type, canonical_trait_qn) != 0) {
|
||
continue;
|
||
}
|
||
flatten_trait_method(ctx, reg, class_qn, canonical_trait_qn, src, alias_for_method,
|
||
only_method_name);
|
||
}
|
||
}
|
||
|
||
static void process_trait_use(PHPLSPContext *ctx, CBMTypeRegistry *reg, const char *class_qn,
|
||
TSNode use_decl) {
|
||
/* Collect the named traits and any `as` alias clauses. */
|
||
const char *trait_qns[16];
|
||
int trait_count = 0;
|
||
|
||
uint32_t nc = ts_node_child_count(use_decl);
|
||
for (uint32_t i = 0; i < nc && trait_count < 16; i++) {
|
||
TSNode c = ts_node_child(use_decl, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "name") == 0 || strcmp(k, "qualified_name") == 0) {
|
||
char *tn = php_node_text(ctx, c);
|
||
if (tn) {
|
||
const char *resolved = php_resolve_class_name(ctx, tn);
|
||
if (resolved)
|
||
trait_qns[trait_count++] = resolved;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* First, do the default flatten for all traits. */
|
||
for (int i = 0; i < trait_count; i++) {
|
||
flatten_trait_into_class(ctx, reg, class_qn, trait_qns[i], NULL, NULL);
|
||
}
|
||
|
||
/* Process `use_list` block for `as` / `insteadof` clauses. The block is
|
||
* either named "body" or a sibling. */
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(use_decl, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "use_list") != 0 && strcmp(k, "use_clause") != 0) {
|
||
uint32_t bnc = ts_node_child_count(c);
|
||
for (uint32_t j = 0; j < bnc; j++) {
|
||
TSNode cc = ts_node_child(c, j);
|
||
if (ts_node_is_null(cc) || !ts_node_is_named(cc))
|
||
continue;
|
||
const char *kk = ts_node_type(cc);
|
||
if (strcmp(kk, "use_as_clause") == 0) {
|
||
/* Trait::method as alias OR method as alias */
|
||
char *trait_text = NULL;
|
||
char *method_text = NULL;
|
||
char *alias_text = NULL;
|
||
uint32_t anc = ts_node_child_count(cc);
|
||
for (uint32_t a = 0; a < anc; a++) {
|
||
TSNode ch = ts_node_child(cc, a);
|
||
if (ts_node_is_null(ch) || !ts_node_is_named(ch))
|
||
continue;
|
||
const char *ck = ts_node_type(ch);
|
||
if (strcmp(ck, "class_constant_access_expression") == 0) {
|
||
/* Trait::method */
|
||
uint32_t cc2 = ts_node_child_count(ch);
|
||
for (uint32_t a2 = 0; a2 < cc2; a2++) {
|
||
TSNode subch = ts_node_child(ch, a2);
|
||
if (ts_node_is_null(subch) || !ts_node_is_named(subch))
|
||
continue;
|
||
const char *sck = ts_node_type(subch);
|
||
if (strcmp(sck, "name") == 0 ||
|
||
strcmp(sck, "qualified_name") == 0) {
|
||
if (!trait_text)
|
||
trait_text = php_node_text(ctx, subch);
|
||
else
|
||
method_text = php_node_text(ctx, subch);
|
||
}
|
||
}
|
||
} else if (strcmp(ck, "name") == 0) {
|
||
char *t = php_node_text(ctx, ch);
|
||
if (!method_text)
|
||
method_text = t;
|
||
else
|
||
alias_text = t;
|
||
}
|
||
}
|
||
if (alias_text && method_text) {
|
||
const char *trait_qn_resolved = NULL;
|
||
if (trait_text) {
|
||
trait_qn_resolved = php_resolve_class_name(ctx, trait_text);
|
||
} else if (trait_count > 0) {
|
||
trait_qn_resolved = trait_qns[0];
|
||
}
|
||
if (trait_qn_resolved) {
|
||
flatten_trait_into_class(ctx, reg, class_qn, trait_qn_resolved,
|
||
alias_text, method_text);
|
||
}
|
||
}
|
||
}
|
||
/* `use_instead_of_clause` is recognised but not yet
|
||
* implemented — last-flatten-wins semantics already match
|
||
* the common case where the resolution clause picks a
|
||
* specific implementation. */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
static void process_class_for_fields(PHPLSPContext *ctx, CBMTypeRegistry *reg, TSNode class_node,
|
||
php_class_field_table_t *tab) {
|
||
const char *cqn = class_qn_for_node(ctx, class_node);
|
||
if (!cqn)
|
||
return;
|
||
php_class_fields_t *f = fields_for(tab, cqn, ctx->arena);
|
||
if (!f)
|
||
return;
|
||
|
||
/* PHPDoc on the class itself: @property + @method tags. */
|
||
char *class_doc = fetch_leading_phpdoc(ctx, class_node);
|
||
if (class_doc) {
|
||
apply_phpdoc_class_tags(ctx, reg, cqn, class_doc, f);
|
||
}
|
||
|
||
/* Belt-and-suspenders: walk the class declaration's `base_clause` and
|
||
* `class_interface_clause` to gather extends/implements QNs and append
|
||
* them to the registered type's embedded_types. The unified extractor
|
||
* is supposed to populate base_classes for class_declaration but
|
||
* may miss interface_declaration's `extends Base` chain or use a
|
||
* raw form (with backslashes) that doesn't round-trip cleanly.
|
||
* Doing this AST extraction here keeps the LSP self-sufficient. */
|
||
{
|
||
const char *parent_qns[16];
|
||
int parent_count = 0;
|
||
uint32_t cnc = ts_node_child_count(class_node);
|
||
for (uint32_t i = 0; i < cnc && parent_count < 16; i++) {
|
||
TSNode c = ts_node_child(class_node, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "base_clause") != 0 && strcmp(k, "class_interface_clause") != 0) {
|
||
continue;
|
||
}
|
||
uint32_t bnc = ts_node_child_count(c);
|
||
for (uint32_t j = 0; j < bnc && parent_count < 16; j++) {
|
||
TSNode bc = ts_node_child(c, j);
|
||
if (ts_node_is_null(bc) || !ts_node_is_named(bc))
|
||
continue;
|
||
const char *bk = ts_node_type(bc);
|
||
if (strcmp(bk, "name") != 0 && strcmp(bk, "qualified_name") != 0)
|
||
continue;
|
||
char *t = php_node_text(ctx, bc);
|
||
if (!t)
|
||
continue;
|
||
const char *resolved = php_resolve_class_name(ctx, t);
|
||
if (!resolved)
|
||
continue;
|
||
parent_qns[parent_count++] = resolved;
|
||
}
|
||
}
|
||
if (parent_count > 0) {
|
||
for (int t = 0; t < reg->type_count; t++) {
|
||
if (strcmp(reg->types[t].qualified_name, cqn) != 0)
|
||
continue;
|
||
/* Merge with existing embedded_types — dedup. */
|
||
int existing = 0;
|
||
if (reg->types[t].embedded_types) {
|
||
while (reg->types[t].embedded_types[existing])
|
||
existing++;
|
||
}
|
||
int total = existing;
|
||
for (int p = 0; p < parent_count; p++) {
|
||
bool seen = false;
|
||
for (int e = 0; e < existing; e++) {
|
||
if (strcmp(reg->types[t].embedded_types[e], parent_qns[p]) == 0) {
|
||
seen = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!seen)
|
||
total++;
|
||
}
|
||
if (total == existing)
|
||
break;
|
||
const char **expanded = (const char **)cbm_arena_alloc(
|
||
ctx->arena, (size_t)(total + 1) * sizeof(*expanded));
|
||
if (!expanded)
|
||
break;
|
||
int wi = 0;
|
||
for (int e = 0; e < existing; e++)
|
||
expanded[wi++] = reg->types[t].embedded_types[e];
|
||
for (int p = 0; p < parent_count; p++) {
|
||
bool seen = false;
|
||
for (int e = 0; e < existing; e++) {
|
||
if (strcmp(reg->types[t].embedded_types[e], parent_qns[p]) == 0) {
|
||
seen = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!seen)
|
||
expanded[wi++] = parent_qns[p];
|
||
}
|
||
expanded[wi] = NULL;
|
||
reg->types[t].embedded_types = expanded;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
TSNode body = ts_node_child_by_field_name(class_node, "body", 4);
|
||
if (ts_node_is_null(body))
|
||
body = child_named(class_node, "declaration_list");
|
||
if (ts_node_is_null(body))
|
||
body = child_named(class_node, "enum_declaration_list");
|
||
if (ts_node_is_null(body))
|
||
return;
|
||
|
||
uint32_t nc = ts_node_child_count(body);
|
||
for (uint32_t i = 0; i < nc; i++) {
|
||
TSNode c = ts_node_child(body, i);
|
||
if (ts_node_is_null(c) || !ts_node_is_named(c))
|
||
continue;
|
||
const char *k = ts_node_type(c);
|
||
if (strcmp(k, "property_declaration") == 0) {
|
||
extract_property_decl(ctx, c, f);
|
||
} else if (strcmp(k, "enum_case") == 0) {
|
||
/* Enum case: Suit::Hearts → typed as the enum itself. */
|
||
TSNode case_name;
|
||
memset(&case_name, 0, sizeof(case_name));
|
||
uint32_t enc = ts_node_child_count(c);
|
||
for (uint32_t j = 0; j < enc; j++) {
|
||
TSNode cc = ts_node_child(c, j);
|
||
if (!ts_node_is_null(cc) && ts_node_is_named(cc) &&
|
||
strcmp(ts_node_type(cc), "name") == 0) {
|
||
case_name = cc;
|
||
break;
|
||
}
|
||
}
|
||
if (!ts_node_is_null(case_name)) {
|
||
char *cn = php_node_text(ctx, case_name);
|
||
if (cn) {
|
||
/* Register as a static field of the enum type. The
|
||
* field-name model already handles $this->prop; for
|
||
* static access Suit::Hearts the eval of class_constant_access
|
||
* handles below. */
|
||
add_field(f, ctx->arena, cn, cbm_type_named(ctx->arena, cqn));
|
||
}
|
||
}
|
||
} else if (strcmp(k, "use_declaration") == 0) {
|
||
/* Trait `use Foo;` inside a class body. */
|
||
process_trait_use(ctx, reg, cqn, c);
|
||
} else if (strcmp(k, "method_declaration") == 0) {
|
||
/* Constructor: scan params for property_promotion_parameter and
|
||
* scan body for $this->X = $param assignments. */
|
||
TSNode mname = ts_node_child_by_field_name(c, "name", 4);
|
||
char *mn = !ts_node_is_null(mname) ? php_node_text(ctx, mname) : NULL;
|
||
bool is_ctor = mn && strcmp(mn, "__construct") == 0;
|
||
TSNode params = ts_node_child_by_field_name(c, "parameters", 10);
|
||
if (!ts_node_is_null(params)) {
|
||
uint32_t pnc = ts_node_child_count(params);
|
||
for (uint32_t j = 0; j < pnc; j++) {
|
||
TSNode p = ts_node_child(params, j);
|
||
if (ts_node_is_null(p) || !ts_node_is_named(p))
|
||
continue;
|
||
if (strcmp(ts_node_type(p), "property_promotion_parameter") == 0) {
|
||
extract_promoted_param(ctx, p, f);
|
||
}
|
||
}
|
||
}
|
||
if (is_ctor)
|
||
extract_ctor_assignments(ctx, c, f);
|
||
|
||
/* Re-parse the declared return type via php_parse_type_node so
|
||
* generic forms like `\Generator<int, User>` end up as
|
||
* TEMPLATE types, not broken NAMED("Generator<int, User>")
|
||
* that the simpler parse_return_type_text produced earlier.
|
||
*
|
||
* We temporarily set ctx->enclosing_class_qn so that
|
||
* `self`/`static` in the return-type position resolve to the
|
||
* current class. */
|
||
TSNode rt_node = ts_node_child_by_field_name(c, "return_type", 11);
|
||
if (!ts_node_is_null(rt_node) && mn) {
|
||
const char *saved_class = ctx->enclosing_class_qn;
|
||
const char *saved_parent = ctx->enclosing_parent_qn;
|
||
ctx->enclosing_class_qn = cqn;
|
||
/* ctx->enclosing_parent_qn left as-is (best-effort). */
|
||
const CBMType *parsed = php_parse_type_node(ctx, rt_node);
|
||
ctx->enclosing_class_qn = saved_class;
|
||
ctx->enclosing_parent_qn = saved_parent;
|
||
if (parsed && parsed->kind != CBM_TYPE_UNKNOWN) {
|
||
for (int fi = 0; fi < reg->func_count; fi++) {
|
||
CBMRegisteredFunc *rf = ®->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_replace_returns(ctx->arena, rf->signature, 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_replace_returns(
|
||
ctx->arena, rf->signature, 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).
|
||
*/
|
||
/* The index lives in the call's arena, not on this stack frame: ctx keeps
|
||
* a pointer to it for the duration of the pass. On an allocation failure
|
||
* it stays NULL and flatten_trait_into_class runs the full scan. */
|
||
struct php_trait_index *trait_index =
|
||
(struct php_trait_index *)cbm_arena_alloc(ctx->arena, sizeof(*trait_index));
|
||
if (trait_index) {
|
||
memset(trait_index, 0, sizeof(*trait_index));
|
||
}
|
||
struct php_trait_index *outer_index = ctx->trait_index;
|
||
ctx->trait_index = trait_index;
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if (trait_index) {
|
||
php_trait_index_free(trait_index);
|
||
}
|
||
ctx->trait_index = outer_index;
|
||
}
|
||
|
||
void cbm_php_refine_lsp_registry(PHPLSPContext *ctx, CBMTypeRegistry *reg, TSNode root) {
|
||
if (!ctx || !reg || ts_node_is_null(root))
|
||
return;
|
||
|
||
php_class_field_table_t tab = {0};
|
||
php_lsp_collect_class_fields(ctx, reg, root, &tab);
|
||
for (int i = 0; i < tab.count; i++) {
|
||
php_class_fields_t *fields = &tab.items[i];
|
||
if (fields->count == 0)
|
||
continue;
|
||
for (int t = 0; t < reg->type_count; t++) {
|
||
if (strcmp(reg->types[t].qualified_name, fields->class_qn) == 0) {
|
||
reg->types[t].field_names = fields->field_names;
|
||
reg->types[t].field_types = fields->field_types;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── 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;
|
||
}
|
||
PHPSignatureParseContext parse_ctx = {
|
||
.module_qn = module_qn,
|
||
.namespace_qn = NULL,
|
||
.use_local_names = NULL,
|
||
.use_target_qns = NULL,
|
||
.use_count = 0,
|
||
};
|
||
const CBMType **param_types = NULL;
|
||
if (d->signature_param_types || d->signature_param_count > 0) {
|
||
param_types = cbm_type_materialize_signature_params(
|
||
arena, d->signature_param_types, d->signature_param_count,
|
||
php_signature_parse_type, &parse_ctx);
|
||
} else if (!d->signature_param_types && d->signature_param_count == 0 &&
|
||
d->param_types) {
|
||
/* Legacy CBMDefinition callers have no counted carrier. Keep
|
||
* their historical projection as a local-only fallback. */
|
||
int legacy_count = 0;
|
||
while (d->param_types[legacy_count])
|
||
legacy_count++;
|
||
param_types = cbm_type_materialize_signature_params(
|
||
arena, d->param_types, legacy_count, php_signature_parse_type, &parse_ctx);
|
||
}
|
||
/* Build a signature carrying ordered params plus 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, param_types, rets);
|
||
cbm_registry_add_func(®, rf);
|
||
}
|
||
}
|
||
|
||
/* Index the registry (F12). Unindexed, every lookup below was a linear
|
||
* scan over the stdlib plus every definition of the file: O(lookups x
|
||
* definitions), quadratic in the size of one file. First-registered
|
||
* answers keep the result of that scan (the first registration of a
|
||
* duplicate QN wins), so the output does not change. Index memory goes to
|
||
* a scratch arena that dies with this call, as in cbm_run_php_lsp_cross. */
|
||
reg.index_first_registered = true;
|
||
CBMArena idx_arena;
|
||
cbm_arena_init(&idx_arena);
|
||
cbm_registry_finalize_into(®, &idx_arena);
|
||
|
||
/* 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. */
|
||
{
|
||
/* 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);
|
||
}
|
||
}
|
||
cbm_php_refine_lsp_registry(&ctx, ®, root);
|
||
/* Reset namespace/use state — process_file will re-collect. */
|
||
ctx.current_namespace_qn = "";
|
||
ctx.use_count = 0;
|
||
}
|
||
|
||
/* Trait flattening registered methods after the index was built; lookups
|
||
* would tail-scan them. Re-index once, first-registered as above, so the
|
||
* answers equal those of the indexed part followed by the tail scan. */
|
||
cbm_registry_finalize_into(®, &idx_arena);
|
||
|
||
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);
|
||
}
|
||
}
|
||
cbm_arena_destroy(&idx_arena);
|
||
}
|
||
|
||
/* ── 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. */
|
||
void cbm_php_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRegistry *reg,
|
||
const 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++) {
|
||
const 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++) {
|
||
const 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;
|
||
|
||
const char *def_mod = d->def_module_qn ? d->def_module_qn : "";
|
||
PHPSignatureParseContext parse_ctx = {
|
||
.module_qn = def_mod,
|
||
.namespace_qn = d->namespace_name,
|
||
.use_local_names = NULL,
|
||
.use_target_qns = NULL,
|
||
.use_count = 0,
|
||
};
|
||
const CBMType **param_types = cbm_type_materialize_signature_params(
|
||
arena, d->signature_param_types, d->signature_param_count, php_signature_parse_type,
|
||
&parse_ctx);
|
||
|
||
/* 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) {
|
||
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, param_types, 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
static int php_pipeline_use_kind(CBMImportKind kind) {
|
||
if (kind == CBM_IMPORT_KIND_FUNCTION)
|
||
return CBM_PHP_USE_FUNCTION;
|
||
if (kind == CBM_IMPORT_KIND_CONST)
|
||
return CBM_PHP_USE_CONST;
|
||
return CBM_PHP_USE_CLASS;
|
||
}
|
||
|
||
/* Import edges name files, while the use table names symbols. Bind only a
|
||
* unique definition in that proven file; never use an unrelated short-name
|
||
* match to manufacture the imported symbol. Namespace aliases retain their
|
||
* source namespace spelling when no such definition exists. */
|
||
static const char *php_pipeline_import_qn(PHPLSPContext *ctx, const CBMImport *imp,
|
||
const char *target, CBMLSPDef *defs, int def_count) {
|
||
const char *leaf = imp->module_path ? strrchr(imp->module_path, '\\') : NULL;
|
||
leaf = leaf ? leaf + 1 : imp->module_path;
|
||
if (!leaf || !leaf[0])
|
||
return target;
|
||
size_t target_len = strlen(target);
|
||
static const char file_suffix[] = ".__file__";
|
||
bool file = target_len >= sizeof(file_suffix) - 1 &&
|
||
strcmp(target + target_len - (sizeof(file_suffix) - 1), file_suffix) == 0;
|
||
size_t module_len = file ? target_len - (sizeof(file_suffix) - 1) : 0;
|
||
const char *found = NULL;
|
||
for (int i = 0; i < def_count; i++) {
|
||
const CBMLSPDef *def = &defs[i];
|
||
if (!def->qualified_name || !def->short_name || !def->label ||
|
||
strcmp(def->short_name, leaf) != 0)
|
||
continue;
|
||
bool in_file = file && def->def_module_qn && strlen(def->def_module_qn) == module_len &&
|
||
strncmp(def->def_module_qn, target, module_len) == 0;
|
||
if (!in_file && strcmp(def->qualified_name, target) != 0)
|
||
continue;
|
||
bool kind_matches =
|
||
imp->kind == CBM_IMPORT_KIND_FUNCTION ? strcmp(def->label, "Function") == 0
|
||
: imp->kind == CBM_IMPORT_KIND_CONST
|
||
? strcmp(def->label, "Constant") == 0 || strcmp(def->label, "Variable") == 0
|
||
: strcmp(def->label, "Class") == 0 || strcmp(def->label, "Interface") == 0 ||
|
||
strcmp(def->label, "Trait") == 0 || strcmp(def->label, "Enum") == 0;
|
||
if (!kind_matches)
|
||
continue;
|
||
if (found && strcmp(found, def->qualified_name) != 0) {
|
||
found = NULL;
|
||
break;
|
||
}
|
||
found = def->qualified_name;
|
||
}
|
||
if (found)
|
||
return found;
|
||
const char *name = imp->module_path[0] == '\\' ? imp->module_path + 1 : imp->module_path;
|
||
return php_ns_to_dot(ctx->arena, name);
|
||
}
|
||
|
||
static void php_collect_external_uses(PHPLSPContext *ctx, const CBMPHPImportBindings *bindings) {
|
||
if (!bindings || !bindings->targets || !bindings->imports)
|
||
return;
|
||
int count = bindings->imports->count;
|
||
ctx->external_use_qns =
|
||
cbm_arena_alloc(ctx->arena, (size_t)(count > 0 ? count : 1) * sizeof(char *));
|
||
if (!ctx->external_use_qns)
|
||
return;
|
||
for (int i = 0; i < count; i++) {
|
||
const CBMImport *imp = &bindings->imports->items[i];
|
||
if (imp->kind == CBM_IMPORT_KIND_DEFAULT && !bindings->targets[i] && imp->local_name &&
|
||
imp->local_name[0] && imp->module_path && imp->module_path[0]) {
|
||
const char *name =
|
||
imp->module_path[0] == '\\' ? imp->module_path + 1 : imp->module_path;
|
||
const char *qn = php_ns_to_dot(ctx->arena, name);
|
||
if (qn)
|
||
ctx->external_use_qns[ctx->external_use_count++] = qn;
|
||
}
|
||
}
|
||
}
|
||
|
||
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) {
|
||
cbm_run_php_lsp_cross_with_bindings(arena, source, source_len, module_qn, defs, def_count,
|
||
import_names, import_qns, import_count, cached_tree, out,
|
||
NULL);
|
||
}
|
||
|
||
void cbm_run_php_lsp_cross_with_bindings(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,
|
||
const CBMPHPImportBindings *bindings) {
|
||
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 = cbm_parse_source(parser, source, (uint32_t)source_len, (TSParseOptions){0});
|
||
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);
|
||
cbm_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. */
|
||
if (bindings && bindings->targets && bindings->imports) {
|
||
for (int i = 0; i < bindings->imports->count; i++) {
|
||
const CBMImport *imp = &bindings->imports->items[i];
|
||
if (imp->local_name && bindings->targets[i])
|
||
php_lsp_add_use(
|
||
&ctx, imp->local_name,
|
||
php_pipeline_import_qn(&ctx, imp, bindings->targets[i], defs, def_count),
|
||
php_pipeline_use_kind(imp->kind));
|
||
}
|
||
} else {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
int supplied_use_count = ctx.use_count;
|
||
|
||
/* 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. */
|
||
{
|
||
/* 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_collect_external_uses(&ctx, bindings);
|
||
cbm_php_refine_lsp_registry(&ctx, ®, root);
|
||
ctx.current_namespace_qn = "";
|
||
/* Reset to caller-supplied uses only — process_file re-adds AST uses. */
|
||
ctx.use_count = supplied_use_count;
|
||
}
|
||
|
||
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];
|
||
memset(dst, 0, sizeof(*dst));
|
||
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;
|
||
dst->kind = src->kind;
|
||
dst->site_start_byte = src->site_start_byte;
|
||
dst->site_end_byte = src->site_end_byte;
|
||
}
|
||
} else {
|
||
out[f].count = 0;
|
||
}
|
||
}
|
||
|
||
cbm_arena_destroy(&file_arena);
|
||
}
|
||
}
|