diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 18384d82e..6be9ad565 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -1229,6 +1229,138 @@ static const CBMRegisteredFunc *py_lookup_attribute(PyLSPContext *ctx, const cha return py_lookup_attribute_depth(ctx, type_qn, member_name, 0); } +/* ── super(): the C3 method resolution order ──────────────────────── */ + +/* Longest linearization followed. A longer one (or more bases than this) keeps + * the base-by-base walk of the caller. */ +#define PY_C3_MAX 64 + +/* The linearizations computed during one super() lookup: every class of a + * diamond is linearized once, however many paths reach it. */ +typedef struct { + const char *qn[PY_C3_MAX]; + const char **mro[PY_C3_MAX]; + int len[PY_C3_MAX]; + int count; +} py_c3_cache_t; + +static bool py_c3_in_tail(const char *qn, const char *const *seq, int head, int len) { + for (int k = head + 1; k < len; k++) { + if (strcmp(seq[k], qn) == 0) + return true; + } + return false; +} + +/* The C3 head to take next: the first sequence head that is in no sequence's + * tail. NULL when every head is blocked (Python rejects such a class). */ +static const char *py_c3_pick(const char **const *seqs, const int *lens, const int *heads, + int nseq) { + for (int s = 0; s < nseq; s++) { + if (heads[s] >= lens[s]) + continue; + const char *cand = seqs[s][heads[s]]; + bool blocked = false; + for (int t = 0; t < nseq && !blocked; t++) { + blocked = heads[t] < lens[t] && py_c3_in_tail(cand, seqs[t], heads[t], lens[t]); + } + if (!blocked) + return cand; + } + return NULL; +} + +/* Python's method resolution order of `type_qn`: the class, then the C3 merge + * of its bases' linearizations and of the base list. A common base therefore + * follows every class deriving from it. Sets *out to an arena array and returns + * its length, or -1 when there is no C3 order, the hierarchy is deeper than + * CBM_LSP_MAX_LOOKUP_DEPTH, or the order is longer than PY_C3_MAX. A base the + * registry does not know (external, unresolved) linearizes to itself. */ +static int py_c3_linearize(PyLSPContext *ctx, py_c3_cache_t *cache, const char *type_qn, + const char ***out, int depth) { + for (int c = 0; c < cache->count; c++) { + if (strcmp(cache->qn[c], type_qn) == 0) { + *out = cache->mro[c]; + return cache->len[c]; + } + } + if (depth > CBM_LSP_MAX_LOOKUP_DEPTH) + return -1; + const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, type_qn); + if (rt && rt->alias_of) + return py_c3_linearize(ctx, cache, rt->alias_of, out, depth + 1); + const char **bases = rt ? rt->embedded_types : NULL; + int nb = 0; + while (bases && bases[nb]) + nb++; + if (nb >= PY_C3_MAX) + return -1; + const char **seqs[PY_C3_MAX]; + int lens[PY_C3_MAX]; + int heads[PY_C3_MAX]; + for (int b = 0; b < nb; b++) { + lens[b] = py_c3_linearize(ctx, cache, bases[b], &seqs[b], depth + 1); + if (lens[b] < 0) + return -1; + heads[b] = 0; + } + seqs[nb] = bases; + lens[nb] = nb; + heads[nb] = 0; + const char **mro = (const char **)cbm_arena_alloc(ctx->arena, PY_C3_MAX * sizeof(char *)); + if (!mro) + return -1; + int n = 0; + mro[n++] = type_qn; + for (;;) { + bool pending = false; + for (int s = 0; s <= nb && !pending; s++) { + pending = heads[s] < lens[s]; + } + if (!pending) + break; + const char *pick = py_c3_pick((const char **const *)seqs, lens, heads, nb + 1); + if (!pick || n >= PY_C3_MAX) + return -1; + mro[n++] = pick; + for (int s = 0; s <= nb; s++) { + if (heads[s] < lens[s] && strcmp(seqs[s][heads[s]], pick) == 0) + heads[s]++; + } + } + if (cache->count < PY_C3_MAX) { + cache->qn[cache->count] = type_qn; + cache->mro[cache->count] = mro; + cache->len[cache->count] = n; + cache->count++; + } + *out = mro; + return n; +} + +/* The method super() reaches from inside `class_qn`: the first class AFTER it in + * its C3 order that declares `member` itself. *have_mro is false when the + * hierarchy has no C3 order; the caller then keeps its base-by-base walk. */ +static const CBMRegisteredFunc *py_lookup_super_attribute(PyLSPContext *ctx, const char *class_qn, + const char *member, bool *have_mro) { + *have_mro = false; + if (!ctx || !class_qn || !member) + return NULL; + py_c3_cache_t cache; + cache.count = 0; + const char **mro = NULL; + int n = py_c3_linearize(ctx, &cache, class_qn, &mro, 0); + if (n < 0) + return NULL; + *have_mro = true; + for (int i = 1; i < n; i++) { + const CBMRegisteredFunc *f = cbm_registry_lookup_method(ctx->registry, mro[i], member); + if (f) + return f; + } + return NULL; +} + /* Per-file field overlay: look up a (class_qn, field_name) recorded during resolve * against the sealed shared registry. Linear scan — the overlay only holds fields * discovered in THIS file's own classes, so it is small. */ @@ -2964,6 +3096,22 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { const CBMRegisteredType *enclosing = cbm_registry_lookup_type(ctx->registry, ctx->enclosing_class_qn); if (enclosing && enclosing->embedded_types) { + // The proxy walks the enclosing class's C3 order, in which a + // diamond's shared base follows every class deriving from it: + // RadarrCalendarEntity(RadarrEntity, CalendarEntity) reaches + // CalendarEntity before Entity, which the first base reaches + // through its own ancestry. The base-by-base walk below stays for + // a hierarchy without a C3 order and for an undeclared __init__. + bool have_mro = false; + const CBMRegisteredFunc *sf = py_lookup_super_attribute( + ctx, ctx->enclosing_class_qn, attr_name, &have_mro); + if (sf) { + bool is_init = strcmp(attr_name, "__init__") == 0; + py_emit_resolved_call(ctx, sf->qualified_name, + is_init ? "lsp_super_init" : "lsp_super", + is_init ? 0.90f : 0.88f, call_node); + return; + } for (int i = 0; enclosing->embedded_types[i]; i++) { // super().__init__() is a constructor delegation: // lsp_super_init is the MORE SPECIFIC, more accurate diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 9a756f688..398c3af57 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15211,6 +15211,114 @@ TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548) { return pyn_run_shadowing(52); } +/* ── super() follows the C3 method resolution order ──────────────── */ + +/* The home-assistant radarr shape: RadarrCalendarEntity(RadarrEntity, + * CalendarEntity), where RadarrEntity reaches the common base Entity through + * CoordinatorEntity and CalendarEntity overrides Entity's method. Python's C3 + * order is [RadarrCalendarEntity, RadarrEntity, CoordinatorEntity, + * CalendarEntity, Entity]: a common base comes after every class that + * derives from it, so super() reaches CalendarEntity, not Entity through the + * first base's ancestry. */ +static const char pym_base_py[] = "class Entity:\n" + " def __init__(self):\n" + " self.ready = False\n" + "\n" + " def write_state(self):\n" + " return 0\n" + "\n" + "class CoordinatorEntity(Entity):\n" + " pass\n" + "\n" + "class CalendarEntity(Entity):\n" + " def __init__(self):\n" + " self.events = []\n" + "\n" + " def write_state(self):\n" + " return 1\n"; + +static const char pym_radarr_py[] = "from base import CoordinatorEntity, CalendarEntity\n" + "\n" + "class RadarrEntity(CoordinatorEntity):\n" + " pass\n" + "\n" + "class RadarrCalendarEntity(RadarrEntity, CalendarEntity):\n" + " def __init__(self):\n" + " super().__init__()\n" + "\n" + " def write_state(self):\n" + " return super().write_state()\n" + "\n" + "class PlainCalendar(CalendarEntity):\n" + " def write_state(self):\n" + " return super().write_state()\n"; + +static int pym_run_super_c3(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_superc3_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "base.py", pym_base_py); + write_temp_file(tmp, "radarr.py", pym_radarr_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/superc3.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc == 0 ? cbm_store_open_path(db_path) : NULL; + const char *pr = p ? cbm_pipeline_project_name(p) : NULL; + const char *rce = "radarr.RadarrCalendarEntity"; + char src[256]; + snprintf(src, sizeof(src), "%s.write_state", rce); + bool m_c3 = s && pyn_edge(s, pr, src, "base.CalendarEntity.write_state", "CALLS"); + bool m_dfs = s && pyn_edge(s, pr, src, "base.Entity.write_state", "CALLS"); + snprintf(src, sizeof(src), "%s.__init__", rce); + bool i_c3 = s && pyn_edge(s, pr, src, "base.CalendarEntity.__init__", "CALLS"); + bool i_dfs = s && pyn_edge(s, pr, src, "base.Entity.__init__", "CALLS"); + bool plain = s && pyn_edge(s, pr, "radarr.PlainCalendar.write_state", + "base.CalendarEntity.write_state", "CALLS"); + if (s) { + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(m_c3); /* super().write_state(): CalendarEntity comes before Entity */ + ASSERT_FALSE(m_dfs); /* ... never Entity through RadarrEntity's ancestry */ + ASSERT_TRUE(i_c3); /* super().__init__(): the same order */ + ASSERT_FALSE(i_dfs); + ASSERT_TRUE(plain); /* control: single inheritance */ + PASS(); +} + +TEST(pipeline_python_super_follows_c3_mro) { + return pym_run_super_c3(0); +} + +TEST(pipeline_python_super_follows_c3_mro_parallel) { + return pym_run_super_c3(52); +} + /* L7 review of #1548: a package __init__.py function can share a submodule's QN * (poetry: layouts/__init__.py `def layout` is the QN prefix of every symbol in * layouts/layout.py). The function-local detection is bounded at the symbol's @@ -24914,6 +25022,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_nested_def_local_after_incremental_issue1548); RUN_TEST(pipeline_python_local_def_shadows_module_def_issue1548); RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); + RUN_TEST(pipeline_python_super_follows_c3_mro); + RUN_TEST(pipeline_python_super_follows_c3_mro_parallel); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_issue1548); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548); RUN_TEST(pipeline_python_nested_helper_keeps_tests_issue1548);