Merge pull request #2706 from DeusData/fix/php-trait-flatten-index

fix(php): index trait methods by receiver while flattening (#1265)
This commit is contained in:
Martin Vogel
2026-10-19 22:26:45 +02:00
committed by GitHub
3 changed files with 287 additions and 32 deletions
+161 -32
View File
@@ -19,7 +19,9 @@
#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>
@@ -3745,6 +3747,126 @@ static void apply_phpdoc_class_tags(PHPLSPContext *ctx, CBMTypeRegistry *reg, co
* - 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) {
@@ -3780,43 +3902,36 @@ static void flatten_trait_into_class(PHPLSPContext *ctx, CBMTypeRegistry *reg, c
* 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, &reg->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 = &reg->funcs[i];
if (!src->receiver_type || strcmp(src->receiver_type, canonical_trait_qn) != 0) {
continue;
}
if (only_method_name && src->short_name && strcmp(src->short_name, only_method_name) != 0) {
continue;
}
const char *new_short = alias_for_method ? alias_for_method : src->short_name;
if (!new_short)
continue;
CBMRegisteredFunc rf;
memset(&rf, 0, sizeof(rf));
rf.qualified_name = cbm_arena_sprintf(ctx->arena, "%s.%s", class_qn, new_short);
rf.short_name = cbm_arena_strdup(ctx->arena, new_short);
rf.receiver_type = class_qn;
rf.min_params = src->min_params;
/* Self-substitute the return type: any NAMED(trait_qn) in the
* signature's return becomes NAMED(class_qn). */
const CBMType *new_sig = src->signature;
if (src->signature && src->signature->kind == CBM_TYPE_FUNC &&
src->signature->data.func.return_types) {
const CBMType *ret0 = src->signature->data.func.return_types[0];
if (ret0 && ret0->kind == CBM_TYPE_NAMED && ret0->data.named.qualified_name &&
strcmp(ret0->data.named.qualified_name, canonical_trait_qn) == 0) {
const CBMType **rets =
(const CBMType **)cbm_arena_alloc(ctx->arena, 2 * sizeof(*rets));
if (rets) {
rets[0] = cbm_type_named(ctx->arena, class_qn);
rets[1] = NULL;
new_sig = cbm_type_func_replace_returns(ctx->arena, src->signature, rets);
}
}
}
rf.signature = new_sig;
cbm_registry_add_func(reg, rf);
flatten_trait_method(ctx, reg, class_qn, canonical_trait_qn, src, alias_for_method,
only_method_name);
}
}
@@ -4217,6 +4332,16 @@ static void php_lsp_collect_class_fields(PHPLSPContext *ctx, CBMTypeRegistry *re
* 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;
@@ -4241,6 +4366,10 @@ static void php_lsp_collect_class_fields(PHPLSPContext *ctx, CBMTypeRegistry *re
}
}
}
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) {
+5
View File
@@ -71,6 +71,11 @@ typedef struct {
* Zero via memset. */
int walk_depth;
/* Receiver -> registered-method index used while traits are flattened
* (php_lsp.c, php_trait_index_t). Owned by the class-field pass, NULL
* outside it. Zero via memset. */
struct php_trait_index *trait_index;
/* Debug mode (CBM_LSP_DEBUG env). */
bool debug;
} PHPLSPContext;
+121
View File
@@ -653,6 +653,125 @@ static uint64_t phplsp_counted_work(const char *src, int *ceiling_hit, CBMFileRe
return work;
}
/* Every reported collision shape, in one file: #765's aliased import that
* shares the trait's name, the v0.9.0 variant where the alias TARGET's short
* name equals the local trait (owen-it/laravel-auditing wrapper), #951's
* class named like its aliased trait, a self-using trait and a mutual pair. A
* model using the wrapper trait still sees the flattened method. */
TEST(phplsp_trait_collision_shapes_bounded_work_issue765) {
const char *src = "<?php\n"
"namespace App\\Models\\Traits;\n"
"\n"
"use OwenIt\\Auditing\\Auditable as AuditableBase;\n"
"use App\\Functions\\Delivery as Delivery_Trait;\n"
"\n"
"trait Auditable {\n"
" use AuditableBase;\n"
" public function auditTag(): string { $x = 'a'; return $x; }\n"
"}\n"
"trait Selfish {\n"
" use Selfish;\n"
" public function me(): int { return 1; }\n"
"}\n"
"trait Ping {\n"
" use Pong;\n"
" public function ping(): int { return 1; }\n"
"}\n"
"trait Pong {\n"
" use Ping;\n"
" public function pong(): int { return 2; }\n"
"}\n"
"class Delivery {\n"
" use Delivery_Trait;\n"
" public function run(): void { }\n"
"}\n"
"class User {\n"
" use Auditable, Ping;\n"
" public function label(): string { return $this->auditTag(); }\n"
" public function both(): int { return $this->ping() + $this->pong(); }\n"
"}\n";
int hit = 0;
CBMFileResult *r = NULL;
uint64_t work = phplsp_counted_work(src, &hit, &r);
printf(" collision shapes: work=%llu\n", (unsigned long long)work);
ASSERT_NOT_NULL(r);
ASSERT_FALSE(hit);
/* The flattened trait method is still found through the using class. */
ASSERT(find_resolved(r, "label", "auditTag") >= 0);
cbm_free_result(r);
PASS();
}
/* n classes in one file, each composing the same two traits, and one probe
* class calling a flattened method through $this. */
static char *phplsp_trait_users_source(int n) {
size_t sz = (size_t)n * 256 + 1024;
char *src = malloc(sz);
if (!src) {
return NULL;
}
size_t pos = (size_t)snprintf(src, sz,
"<?php\nnamespace App\\Bulk;\n\n"
"trait Stamps {\n"
" public function stamp(): int { return 1; }\n"
" public function touch(): int { return 2; }\n"
" public function fresh(): int { return 3; }\n"
"}\n"
"trait Flags {\n"
" use Stamps;\n"
" public function flag(): int { return 4; }\n"
"}\n");
for (int i = 0; i < n; i++) {
pos += (size_t)snprintf(src + pos, sz - pos,
"class M%d {\n"
" use Flags;\n"
" public function own%d(): int { return %d; }\n"
"}\n",
i, i, i);
}
/* One call through a flattened method, so the lookup cost stays constant
* and the gate measures flattening (F12: the single-file registry is not
* finalized, so every lookup is a linear scan; that is its own defect). */
snprintf(src + pos, sz - pos,
"class Probe {\n"
" use Flags;\n"
" public function probe(): int { return $this->stamp(); }\n"
"}\n");
return src;
}
/* Deterministic complexity gate: twice the trait-using classes must cost about
* twice the counted work. Each flatten visits the registry; if that visit grows
* with the classes already flattened, the ratio drifts toward 4 (main before the
* per-trait method index: 3.22 at 150 -> 300). Both sizes stay below the 256
* top-level declarations the class-field walk visits (#2388), so every class
* is flattened. */
TEST(phplsp_trait_flatten_work_is_linear_issue1265) {
enum { N1 = 100, N2 = 200 };
char *s1 = phplsp_trait_users_source(N1);
char *s2 = phplsp_trait_users_source(N2);
ASSERT_NOT_NULL(s1);
ASSERT_NOT_NULL(s2);
int hit1 = 0;
int hit2 = 0;
CBMFileResult *r2 = NULL;
uint64_t w1 = phplsp_counted_work(s1, &hit1, NULL);
uint64_t w2 = phplsp_counted_work(s2, &hit2, &r2);
free(s1);
free(s2);
printf(" trait users %d -> %d: work %llu -> %llu (ratio %.2f)\n", N1, N2,
(unsigned long long)w1, (unsigned long long)w2, w1 ? (double)w2 / (double)w1 : 0.0);
ASSERT_NOT_NULL(r2);
ASSERT_FALSE(hit1);
ASSERT_FALSE(hit2);
ASSERT(w1 > 0);
/* The flattened method still resolves through the probe class. */
ASSERT(find_resolved(r2, "Probe.probe", "stamp") >= 0);
cbm_free_result(r2);
ASSERT(w2 * 10 <= w1 * 25); /* ratio <= 2.5 */
PASS();
}
/* F12: the single-file PHP LSP (cbm_run_php_lsp) never indexed its registry,
* so every type and method lookup was a linear scan over the stdlib plus every
* definition of the file. Each class here looks up the previous class and its
@@ -5747,6 +5866,8 @@ SUITE(php_lsp) {
RUN_TEST(phplsp_trait_self_use_terminates);
RUN_TEST(phplsp_trait_aliased_import_name_collision_terminates);
RUN_TEST(phplsp_class_aliased_trait_name_collision_terminates);
RUN_TEST(phplsp_trait_collision_shapes_bounded_work_issue765);
RUN_TEST(phplsp_trait_flatten_work_is_linear_issue1265);
RUN_TEST(phplsp_single_file_lookup_work_is_linear_f12);
RUN_TEST(phplsp_match_result_type);
RUN_TEST(phplsp_ternary_result_type);