From 11867dfa74df3c0b47ca431e3ff5698b9a103433 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 25 Sep 2026 18:49:37 +0200 Subject: [PATCH 1/2] feat(identity): callable identity plumbing, no language enabled (#2061) Overloads that share a base QN (Java/C#/C++/Kotlin/Swift/Scala/ObjC) merge into one graph node today, and that node collects the CALLS of every overload. The agreed cure is one signature-qualified callable identity for all overloading languages: callable_qn := base_qn [] (params) [cvref] This change lands the plumbing with every language still at CBM_CALLABLE_ID_NONE, so the graph is byte-identical; each language is switched on later in its own change (with its index-format bump). - internal/cbm/callable_sig.{c,h}: the per-language mode side table (NONE|TYPED|LABELED_TYPED|LABELED|ARITY; kept out of CBMLangSpec like cbm_string_dispatch_suffixes, since ~160 positional rows would otherwise all need the member under -Wmissing-field-initializers), the shared builder cbm_callable_sig[_mode] for the def and the call-scope QN, and the inverse cbm_qn_callable_base_len[_named]. Suffix contract: no '.', '::' or '->'; whitespace-normalized; comments/annotations/attributes dropped; qualified types keep their last segment; '->' spelled '=>'; variadic '[]' (Java) / '~'; C++ top-level cv dropped, arrays decay, cvref kept; <= 200 bytes, longer ones keep whole leading entries and end in '#)'. The builder is iterative (cursor walk, bounded declarator loop) and self-checks against the inverse. - The inverse is anchored to the node's bare name wherever the name is known: real QNs already end in ')' without being callables (a Java field whose name is its declarator `X = f()`, make `$(VAR)` targets), and only the anchor makes the inverse exact on them. - Leaf splitters strip a suffix first (no-op today): registry by-name key (indexed by the bare name for a suffixed callable), cbm_lsp_bare_segment, cbm_pipeline_qn_class_method_tail, pxc_last_component, pxc_qn_leaf, go_index_visit_node, collect_prod_defs, sg_qn_prefix_len. - New unused fields: CBMDefinition.qn_sig_off, CBMResolvedCall.callee_sig (relocated by result compaction), CBMRegisteredFunc.sig, CBMLSPDef.sig. The lsp_surface codec is unchanged (no format bump in this change). - get_code_snippet and trace_path gain base-QN tiers (cbm_store_find_nodes_by_qn_base) that find nothing until suffixes exist. - store: cbm_store_find_nodes_by_qn_suffix built its LIKE pattern in a 512-byte buffer (a long suffix was truncated into a pattern that matched nothing) and did not escape '_'/'%' (`my_func` also matched `myXfunc`). The pattern is now heap-built, escaped, and matched with ESCAPE '\'. Proof: full sorted node+edge+lsp_surface dumps are byte-identical before and after on 7 corpora (this repo, elasticsearch libs, Exposed, dotnet System.Linq, System.Collections, coreclr/gc, a C corpus). Refs #2061 Signed-off-by: Martin Vogel --- Makefile.cbm | 2 + internal/cbm/callable_sig.c | 1192 ++++++++++++++++++++++++++ internal/cbm/callable_sig.h | 83 ++ internal/cbm/cbm.h | 8 + internal/cbm/lsp/go_lsp.h | 3 + internal/cbm/lsp/type_registry.h | 3 + internal/cbm/result_compact.c | 1 + scripts/memory-core-baseline.txt | 2 +- src/mcp/mcp.c | 126 ++- src/pipeline/lsp_resolve.h | 16 +- src/pipeline/pass_ensemble_routing.c | 10 +- src/pipeline/pass_lsp_cross.c | 17 +- src/pipeline/pass_semantic.c | 10 +- src/pipeline/registry.c | 18 +- src/store/store.c | 218 ++++- src/store/store.h | 7 + tests/test_callable_sig.c | 348 ++++++++ tests/test_main.c | 2 + tests/test_store_nodes.c | 98 +++ 19 files changed, 2079 insertions(+), 85 deletions(-) create mode 100644 internal/cbm/callable_sig.c create mode 100644 internal/cbm/callable_sig.h create mode 100644 tests/test_callable_sig.c diff --git a/Makefile.cbm b/Makefile.cbm index ab5c89db73..210c79437b 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -338,6 +338,7 @@ FOUNDATION_SRCS = \ EXTRACTION_SRCS = \ $(CBM_DIR)/cbm.c \ $(CBM_DIR)/extract_defs.c \ + $(CBM_DIR)/callable_sig.c \ $(CBM_DIR)/extract_calls.c \ $(CBM_DIR)/extract_imports.c \ $(CBM_DIR)/extract_usages.c \ @@ -633,6 +634,7 @@ TEST_FOUNDATION_SRCS = \ TEST_EXTRACTION_SRCS = \ tests/test_extraction.c \ + tests/test_callable_sig.c \ tests/test_extraction_inheritance.c \ tests/test_extraction_imports.c \ tests/test_parse_coverage.c \ diff --git a/internal/cbm/callable_sig.c b/internal/cbm/callable_sig.c new file mode 100644 index 0000000000..9d32f391a7 --- /dev/null +++ b/internal/cbm/callable_sig.c @@ -0,0 +1,1192 @@ +/* + * callable_sig.c — see callable_sig.h for the identity contract (#2061). + * + * The builder renders the suffix into a growable arena buffer, then applies + * the spelling rules in a single character pass, then the length cap. The result is re-checked + * against cbm_qn_callable_base_len so a malformed parse can never mint a suffix the leaf splitters + * would misread: such a suffix degrades to its hashed form. + */ +#include "callable_sig.h" +#include "helpers.h" +#include +#include +#include +#include +#include + +enum { + SIG_DEPTH_LIMIT = 64, /* type subtree recursion bound */ + SIG_DECL_DEPTH = 8, /* C-family declarator chain bound */ + SIG_MAX_ENTRIES = 256, /* entries tracked for the cap; more => capped */ + SIG_INITIAL_CAP = 256, /* first render buffer; grows by doubling */ + SIG_HASH_HEX = 16, /* FNV-1a 64 rendered as hex */ + SIG_OPERATOR_LEN = 8, /* strlen("operator") */ + SIG_CONST_LEN = 5, /* strlen("const") */ + SIG_VOLATILE_LEN = 8, /* strlen("volatile") */ + SIG_ARITY_DIGITS = 16, /* "(%d)" scratch */ + SIG_CAPPED_TAIL = 1 + SIG_HASH_HEX + 1, /* "#" hex ")" */ +}; + +static const uint64_t SIG_FNV_OFFSET = 0xcbf29ce484222325ULL; /* FNV-1a 64 basis */ +static const uint64_t SIG_FNV_PRIME = 0x100000001b3ULL; +static const char SIG_OPERATOR_CHARS[] = "+-*/%^&|~!=<>?[]"; + +CBMCallableIdentity cbm_callable_identity(CBMLanguage lang) { + /* Every language keeps its historical QN until its enable change lands + * (with the index-format bump that change carries). The planned modes: + * Java/Kotlin/C#/C++/CUDA/Scala TYPED, Swift LABELED_TYPED, ObjC LABELED, + * dynamic tier-2 languages ARITY. */ + (void)lang; + return CBM_CALLABLE_ID_NONE; +} + +/* ── Output buffer ─────────────────────────────────────────────── */ + +typedef struct { + CBMArena *arena; + const char *src; + CBMLanguage lang; + CBMCallableIdentity mode; + char *buf; + size_t len; + size_t cap; + bool overflow; + int entries; + bool too_many; + size_t entry_end[SIG_MAX_ENTRIES]; + size_t open_off; /* offset of the parameter list's "(" */ + size_t close_off; /* offset just past its ")" */ + bool skip_names; /* nested C parameter lists: leave out parameter names */ +} sig_ctx_t; + +static bool sig_ident_char(unsigned char ch) { + return isalnum(ch) || ch == '_' || ch == '$' || ch >= 0x80; +} + +/* Grow by doubling inside the arena; the abandoned copies are bounded by the + * final size (geometric), and the arena is the extraction's scratch anyway. */ +static void sig_raw(sig_ctx_t *c, const char *s, size_t n) { + if (c->overflow) { + return; + } + if (c->len + n >= c->cap) { + size_t cap = c->cap ? c->cap : SIG_INITIAL_CAP; + while (c->len + n >= cap) { + cap *= 2; + } + char *grown = cbm_arena_alloc(c->arena, cap); + if (!grown) { + c->overflow = true; + return; + } + memcpy(grown, c->buf, c->len); + c->buf = grown; + c->cap = cap; + } + memcpy(c->buf + c->len, s, n); + c->len += n; + c->buf[c->len] = '\0'; +} + +static void sig_raw_str(sig_ctx_t *c, const char *s) { + sig_raw(c, s, strlen(s)); +} + +/* Append one source token: internal whitespace is dropped except as a single + * space between two identifier characters, which is also the rule at the + * boundary with what is already in the buffer. */ +static void sig_token(sig_ctx_t *c, const char *s, size_t n) { + bool pending_space = false; + for (size_t i = 0; i < n; i++) { + unsigned char ch = (unsigned char)s[i]; + if (isspace(ch)) { + pending_space = true; + continue; + } + bool first = c->len == 0 || c->buf[c->len - 1] == ' '; + if (!first && sig_ident_char((unsigned char)c->buf[c->len - 1]) && sig_ident_char(ch) && + (pending_space || i == 0)) { + sig_raw(c, " ", 1); + } + pending_space = false; + sig_raw(c, (const char *)&s[i], 1); + } +} + +static const char *sig_variadic_marker(CBMLanguage lang) { + return lang == CBM_LANG_JAVA ? "[]" : "~"; +} + +/* ── Type spelling ─────────────────────────────────────────────── */ + +static bool sig_skipped_kind(const char *kind) { + return strstr(kind, "comment") != NULL || strcmp(kind, "annotation") == 0 || + strcmp(kind, "marker_annotation") == 0 || strcmp(kind, "attribute_list") == 0 || + strcmp(kind, "attribute") == 0 || strcmp(kind, "attribute_declaration") == 0 || + strcmp(kind, "attribute_specifier") == 0; +} + +/* Whether a leaf is emitted: C-family nested parameter lists spell their + * declarators without the parameter names. */ +static void sig_leaf(sig_ctx_t *c, TSNode n) { + const char *kind = ts_node_type(n); + if (c->skip_names && + (strcmp(kind, "identifier") == 0 || strcmp(kind, "field_identifier") == 0)) { + return; + } + uint32_t s = ts_node_start_byte(n); + uint32_t e = ts_node_end_byte(n); + if (e - s == 3 && memcmp(c->src + s, "...", 3) == 0) { + sig_raw_str(c, sig_variadic_marker(c->lang)); + return; + } + sig_token(c, c->src + s, e - s); +} + +/* Render every leaf token of a type subtree, in order, with a cursor walk + * bounded at SIG_DEPTH_LIMIT. C# tuple element names are not part of the + * tuple's type identity and are skipped; Swift tuple labels are, and stay. */ +static void sig_type_tokens(sig_ctx_t *c, TSNode root) { + if (ts_node_is_null(root)) { + return; + } + bool in_tuple_element[SIG_DEPTH_LIMIT + 1] = {false}; + TSTreeCursor cur = ts_tree_cursor_new(root); + int depth = 0; + for (;;) { + TSNode n = ts_tree_cursor_current_node(&cur); + const char *kind = ts_node_type(n); + const char *field = depth > 0 ? ts_tree_cursor_current_field_name(&cur) : NULL; + bool skip = sig_skipped_kind(kind) || + (in_tuple_element[depth] && field && strcmp(field, "name") == 0); + if (!skip && ts_node_child_count(n) == 0) { + sig_leaf(c, n); + } else if (!skip && depth < SIG_DEPTH_LIMIT && ts_tree_cursor_goto_first_child(&cur)) { + depth++; + in_tuple_element[depth] = strcmp(kind, "tuple_element") == 0; + continue; + } + while (depth > 0 && !ts_tree_cursor_goto_next_sibling(&cur)) { + ts_tree_cursor_goto_parent(&cur); + depth--; + } + if (depth == 0) { + break; + } + } + ts_tree_cursor_delete(&cur); +} + +/* ── Entries ───────────────────────────────────────────────────── */ + +static bool sig_writes_types(const sig_ctx_t *c) { + return c->mode == CBM_CALLABLE_ID_TYPED || c->mode == CBM_CALLABLE_ID_LABELED_TYPED; +} + +static bool sig_writes_labels(const sig_ctx_t *c) { + return c->mode == CBM_CALLABLE_ID_LABELED_TYPED || c->mode == CBM_CALLABLE_ID_LABELED; +} + +static void sig_entry_open(sig_ctx_t *c) { + if (c->entries > 0 && sig_writes_types(c)) { + sig_raw(c, ",", 1); + } +} + +static void sig_entry_close(sig_ctx_t *c) { + if (c->entries < SIG_MAX_ENTRIES) { + c->entry_end[c->entries] = c->len; + } else { + c->too_many = true; + } + c->entries++; +} + +/* label (NULL = none -> "_") followed by ':' in the labeled modes. */ +static void sig_label(sig_ctx_t *c, const char *label, size_t n) { + if (!sig_writes_labels(c)) { + return; + } + if (label && n > 0) { + sig_raw(c, label, n); + } else { + sig_raw(c, "_", 1); + } + sig_raw(c, ":", 1); +} + +static void sig_label_node(sig_ctx_t *c, TSNode n) { + if (ts_node_is_null(n)) { + sig_label(c, NULL, 0); + return; + } + uint32_t s = ts_node_start_byte(n); + sig_label(c, c->src + s, ts_node_end_byte(n) - s); +} + +/* An entry holding one plain type node (NULL node -> "?"). */ +static void sig_entry_type(sig_ctx_t *c, TSNode type, const char *suffix) { + sig_entry_open(c); + sig_label(c, NULL, 0); + if (sig_writes_types(c)) { + size_t before = c->len; + sig_type_tokens(c, type); + if (c->len == before) { + sig_raw(c, "?", 1); + } + if (suffix) { + sig_raw_str(c, suffix); + } + } + sig_entry_close(c); +} + +static TSNode sig_field(TSNode n, const char *field) { + return ts_node_child_by_field_name(n, field, (uint32_t)strlen(field)); +} + +static bool sig_node_text_is(const sig_ctx_t *c, TSNode n, const char *text) { + uint32_t s = ts_node_start_byte(n); + uint32_t e = ts_node_end_byte(n); + size_t len = strlen(text); + return e - s == len && memcmp(c->src + s, text, len) == 0; +} + +/* ── Java ──────────────────────────────────────────────────────── */ + +static void sig_java_params(sig_ctx_t *c, TSNode node) { + TSNode params = sig_field(node, "parameters"); + uint32_t count = ts_node_is_null(params) ? 0 : ts_node_named_child_count(params); + for (uint32_t i = 0; i < count; i++) { + TSNode p = ts_node_named_child(params, i); + const char *kind = ts_node_type(p); + if (strcmp(kind, "formal_parameter") == 0) { + sig_entry_open(c); + sig_label(c, NULL, 0); + if (sig_writes_types(c)) { + size_t before = c->len; + sig_type_tokens(c, sig_field(p, "type")); + if (c->len == before) { + sig_raw(c, "?", 1); + } + sig_type_tokens(c, sig_field(p, "dimensions")); /* C-style int a[] */ + } + sig_entry_close(c); + } else if (strcmp(kind, "spread_parameter") == 0) { + TSNode type = {0}; + uint32_t pc = ts_node_named_child_count(p); + for (uint32_t j = 0; j < pc; j++) { + TSNode ch = ts_node_named_child(p, j); + const char *ck = ts_node_type(ch); + if (strcmp(ck, "modifiers") != 0 && strcmp(ck, "variable_declarator") != 0 && + !sig_skipped_kind(ck)) { + type = ch; + break; + } + } + sig_entry_type(c, type, sig_variadic_marker(c->lang)); + } + /* receiver_parameter (`Foo this`) is not part of the signature. */ + } +} + +/* ── C# ────────────────────────────────────────────────────────── */ + +static void sig_cs_param(sig_ctx_t *c, TSNode p) { + sig_entry_open(c); + sig_label(c, NULL, 0); + if (sig_writes_types(c)) { + uint32_t pc = ts_node_named_child_count(p); + for (uint32_t j = 0; j < pc; j++) { + TSNode m = ts_node_named_child(p, j); + if (strcmp(ts_node_type(m), "modifier") == 0 && + (sig_node_text_is(c, m, "ref") || sig_node_text_is(c, m, "out") || + sig_node_text_is(c, m, "in") || sig_node_text_is(c, m, "readonly"))) { + sig_type_tokens(c, m); + } + } + size_t before = c->len; + sig_type_tokens(c, sig_field(p, "type")); + if (c->len == before) { + sig_raw(c, "?", 1); + } + } + sig_entry_close(c); +} + +static void sig_cs_params(sig_ctx_t *c, TSNode node) { + TSNode params = sig_field(node, "parameters"); + uint32_t count = ts_node_is_null(params) ? 0 : ts_node_child_count(params); + for (uint32_t i = 0; i < count; i++) { + TSNode p = ts_node_child(params, i); + const char *field = ts_node_field_name_for_child(params, i); + if (strcmp(ts_node_type(p), "parameter") == 0) { + sig_cs_param(c, p); + } else if (field && strcmp(field, "type") == 0) { + /* `params T[] rest`: the grammar inlines the parameter's type and + * name into the list; `params` itself does not change identity. */ + sig_entry_type(c, p, NULL); + } + } +} + +static void sig_cs_tparams(sig_ctx_t *c, TSNode node) { + TSNode tp = sig_field(node, "type_parameters"); + uint32_t count = ts_node_is_null(tp) ? 0 : ts_node_named_child_count(tp); + int written = 0; + for (uint32_t i = 0; i < count; i++) { + TSNode p = ts_node_named_child(tp, i); + if (strcmp(ts_node_type(p), "type_parameter") != 0) { + continue; + } + sig_raw(c, written ? "," : "<", 1); + sig_type_tokens(c, sig_field(p, "name")); + written++; + } + if (written) { + sig_raw(c, ">", 1); + } +} + +/* ── C / C++ ───────────────────────────────────────────────────── */ + +static TSNode sig_c_inner_declarator(TSNode d) { + TSNode next = sig_field(d, "declarator"); + if (!ts_node_is_null(next)) { + return next; + } + uint32_t count = ts_node_named_child_count(d); + for (uint32_t i = 0; i < count; i++) { + TSNode ch = ts_node_named_child(d, i); + if (strstr(ts_node_type(ch), "declarator") != NULL) { + return ch; + } + } + return (TSNode){0}; +} + +static bool sig_is_c_decl_kind(const char *k) { + return strcmp(k, "function_definition") == 0 || strcmp(k, "declaration") == 0 || + strcmp(k, "field_declaration") == 0 || strcmp(k, "template_declaration") == 0; +} + +/* The function_declarator of a C-family definition/declaration, looking + * through (nested) template_declarations and pointer/reference return + * declarators. */ +static TSNode sig_c_function_declarator(TSNode node) { + TSNode cur = node; + for (int depth = 0; depth < SIG_DECL_DEPTH && !ts_node_is_null(cur) && + strcmp(ts_node_type(cur), "template_declaration") == 0; + depth++) { + TSNode inner = {0}; + uint32_t count = ts_node_named_child_count(cur); + for (uint32_t i = 0; i < count && ts_node_is_null(inner); i++) { + TSNode ch = ts_node_named_child(cur, i); + if (sig_is_c_decl_kind(ts_node_type(ch))) { + inner = ch; + } + } + cur = inner; + } + for (int depth = 0; depth < SIG_DECL_DEPTH && !ts_node_is_null(cur); depth++) { + if (strcmp(ts_node_type(cur), "function_declarator") == 0) { + return cur; + } + cur = sig_c_inner_declarator(cur); + } + return (TSNode){0}; +} + +static void sig_c_qualifiers(sig_ctx_t *c, TSNode n) { + uint32_t count = ts_node_named_child_count(n); + for (uint32_t i = 0; i < count; i++) { + TSNode q = ts_node_named_child(n, i); + if (strcmp(ts_node_type(q), "type_qualifier") == 0) { + sig_type_tokens(c, q); + } + } +} + +static bool sig_c_has_qualifier(TSNode n) { + uint32_t count = ts_node_named_child_count(n); + for (uint32_t i = 0; i < count; i++) { + if (strcmp(ts_node_type(ts_node_named_child(n, i)), "type_qualifier") == 0) { + return true; + } + } + return false; +} + +static bool sig_c_is_name(TSNode d) { + if (ts_node_is_null(d)) { + return true; + } + const char *k = ts_node_type(d); + return strstr(k, "declarator") == NULL; +} + +static bool sig_c_is_param_kind(const char *k) { + return strcmp(k, "parameter_declaration") == 0 || + strcmp(k, "optional_parameter_declaration") == 0 || + strcmp(k, "variadic_parameter_declaration") == 0; +} + +static bool sig_c_is_void_list(const sig_ctx_t *c, TSNode params) { + if (ts_node_named_child_count(params) != 1) { + return false; + } + TSNode p = ts_node_named_child(params, 0); + TSNode type = sig_field(p, "type"); + return strcmp(ts_node_type(p), "parameter_declaration") == 0 && !ts_node_is_null(type) && + sig_node_text_is(c, type, "void") && ts_node_is_null(sig_field(p, "declarator")) && + !sig_c_has_qualifier(p); +} + +/* The parameter list of a function-typed parameter (`int (*cb)(int x)`), + * spelled from its tokens without parameter names or default values. */ +static void sig_c_nested_params(sig_ctx_t *c, TSNode fdecl) { + TSNode params = sig_field(fdecl, "parameters"); + sig_raw(c, "(", 1); + uint32_t count = ts_node_is_null(params) ? 0 : ts_node_child_count(params); + bool void_list = !ts_node_is_null(params) && sig_c_is_void_list(c, params); + int written = 0; + bool saved = c->skip_names; + c->skip_names = true; + for (uint32_t i = 0; i < count && !void_list; i++) { + TSNode p = ts_node_child(params, i); + bool is_ellipsis = !ts_node_is_named(p) && sig_node_text_is(c, p, "..."); + if (!is_ellipsis && !sig_c_is_param_kind(ts_node_type(p))) { + continue; + } + if (written++) { + sig_raw(c, ",", 1); + } + if (is_ellipsis) { + sig_raw_str(c, sig_variadic_marker(c->lang)); + continue; + } + sig_c_qualifiers(c, p); + sig_type_tokens(c, sig_field(p, "type")); + sig_type_tokens(c, sig_field(p, "declarator")); + } + c->skip_names = saved; + sig_raw(c, ")", 1); +} + +/* Declarator markers, outermost first: `*` (with its qualifiers unless it is + * the top-level one, which the language drops), `&`/`&&`, an array that + * decays to `*` at the top level, a pack/variadic as the variadic marker. + * A function declarator opens "(" and is closed — with its parameter list — + * after everything inside it, innermost first; one with nothing inside is a + * function parameter decaying to a pointer. Returns whether any marker was + * written, so the caller knows whether base cv-qualifiers are top-level. */ +static bool sig_c_markers(sig_ctx_t *c, TSNode d) { + TSNode pending[SIG_DECL_DEPTH]; + size_t pending_at[SIG_DECL_DEPTH]; + int npending = 0; + bool wrote = false; + for (int depth = 0; !sig_c_is_name(d) && depth < SIG_DECL_DEPTH; depth++) { + const char *k = ts_node_type(d); + TSNode inner = sig_c_inner_declarator(d); + if (strstr(k, "pointer_declarator") != NULL) { + sig_raw(c, "*", 1); + if (!sig_c_is_name(inner)) { + sig_c_qualifiers(c, d); /* not top-level: qualifiers are identity */ + } + } else if (strstr(k, "reference_declarator") != NULL) { + TSNode op = ts_node_child(d, 0); + bool rvalue = + !ts_node_is_null(op) && !ts_node_is_named(op) && sig_node_text_is(c, op, "&&"); + sig_raw_str(c, rvalue ? "&&" : "&"); + } else if (strstr(k, "array_declarator") != NULL) { + sig_raw_str(c, sig_c_is_name(inner) ? "*" : "[]"); + } else if (strstr(k, "function_declarator") != NULL) { + sig_raw(c, "(", 1); + pending[npending] = d; + pending_at[npending] = c->len; + npending++; + } else if (strcmp(k, "variadic_declarator") == 0) { + sig_raw_str(c, sig_variadic_marker(c->lang)); + } else { + d = inner; /* parenthesized_declarator and friends add nothing */ + continue; + } + wrote = true; + d = inner; + } + while (npending > 0) { + npending--; + if (c->len == pending_at[npending]) { + sig_raw(c, "*", 1); /* a function parameter decays to a pointer */ + } + sig_raw(c, ")", 1); + sig_c_nested_params(c, pending[npending]); + } + return wrote; +} + +/* One C-family parameter's type: [non-top-level cv] base-type markers. */ +static void sig_c_param_type(sig_ctx_t *c, TSNode p) { + TSNode declarator = sig_field(p, "declarator"); + /* Probe whether the declarator adds markers, then render in order: + * top-level base cv (no markers) is dropped like the language does. */ + size_t mark = c->len; + bool has_markers = sig_c_markers(c, declarator); + c->len = mark; + c->buf[c->len] = '\0'; + if (has_markers) { + sig_c_qualifiers(c, p); + } + TSNode type = sig_field(p, "type"); + const char *tk = ts_node_is_null(type) ? "" : ts_node_type(type); + TSNode tag_name = sig_field(type, "name"); + bool elaborated = (strcmp(tk, "struct_specifier") == 0 || strcmp(tk, "class_specifier") == 0 || + strcmp(tk, "union_specifier") == 0 || strcmp(tk, "enum_specifier") == 0) && + !ts_node_is_null(tag_name) && ts_node_is_null(sig_field(type, "body")); + size_t before = c->len; + sig_type_tokens(c, elaborated ? tag_name : type); + if (c->len == before) { + sig_raw(c, "?", 1); + } + (void)sig_c_markers(c, declarator); +} + +/* "(" entries ")" of the callable's own function_declarator. */ +static void sig_c_param_list(sig_ctx_t *c, TSNode fdecl) { + TSNode params = sig_field(fdecl, "parameters"); + c->open_off = c->len; + sig_raw(c, "(", 1); + uint32_t count = ts_node_is_null(params) ? 0 : ts_node_child_count(params); + bool void_list = !ts_node_is_null(params) && sig_c_is_void_list(c, params); + for (uint32_t i = 0; i < count && !void_list; i++) { + TSNode p = ts_node_child(params, i); + bool is_ellipsis = !ts_node_is_named(p) && sig_node_text_is(c, p, "..."); + if (!is_ellipsis && !sig_c_is_param_kind(ts_node_type(p))) { + continue; + } + sig_entry_open(c); + sig_label(c, NULL, 0); + if (sig_writes_types(c)) { + if (is_ellipsis) { + sig_raw_str(c, sig_variadic_marker(c->lang)); + } else { + sig_c_param_type(c, p); + } + } + sig_entry_close(c); + } + sig_raw(c, ")", 1); + c->close_off = c->len; +} + +static void sig_cpp_tparams(sig_ctx_t *c, TSNode node) { + TSNode tmpl = node; + if (strcmp(ts_node_type(tmpl), "template_declaration") != 0) { + tmpl = ts_node_parent(node); + if (ts_node_is_null(tmpl) || strcmp(ts_node_type(tmpl), "template_declaration") != 0) { + return; + } + } + TSNode list = sig_field(tmpl, "parameters"); + uint32_t count = ts_node_is_null(list) ? 0 : ts_node_named_child_count(list); + sig_raw(c, "<", 1); + int written = 0; + for (uint32_t i = 0; i < count; i++) { + TSNode p = ts_node_named_child(list, i); + const char *k = ts_node_type(p); + if (sig_skipped_kind(k)) { + continue; + } + if (written++) { + sig_raw(c, ",", 1); + } + /* Template identity depends on parameter kinds, not their names. */ + if (strcmp(k, "type_parameter_declaration") == 0 || + strcmp(k, "optional_type_parameter_declaration") == 0) { + sig_raw_str(c, "typename"); + } else if (strcmp(k, "variadic_type_parameter_declaration") == 0) { + sig_raw_str(c, "typename"); + sig_raw_str(c, sig_variadic_marker(c->lang)); + } else if (strcmp(k, "template_template_parameter_declaration") == 0) { + sig_raw_str(c, "template"); + } else { + sig_c_param_type(c, p); + } + } + sig_raw(c, ">", 1); +} + +/* `template void S::f()`: the template parameters belong to the + * class, and the in-class declaration `void f();` carries none — both must + * spell the same identity. */ +static bool sig_cpp_member_of_class_template(TSNode fdecl) { + TSNode name = ts_node_is_null(fdecl) ? fdecl : sig_field(fdecl, "declarator"); + for (int depth = 0; depth < SIG_DECL_DEPTH && !ts_node_is_null(name) && + strcmp(ts_node_type(name), "qualified_identifier") == 0; + depth++) { + TSNode scope = sig_field(name, "scope"); + if (!ts_node_is_null(scope) && strcmp(ts_node_type(scope), "template_type") == 0) { + return true; + } + name = sig_field(name, "name"); + } + return false; +} + +static void sig_cpp_cvref(sig_ctx_t *c, TSNode fdecl) { + uint32_t count = ts_node_named_child_count(fdecl); + for (uint32_t i = 0; i < count; i++) { + TSNode ch = ts_node_named_child(fdecl, i); + const char *k = ts_node_type(ch); + if (strcmp(k, "type_qualifier") == 0 || strcmp(k, "ref_qualifier") == 0) { + sig_type_tokens(c, ch); + } + } +} + +/* ── Kotlin ────────────────────────────────────────────────────── */ + +static bool sig_kotlin_is_vararg(const sig_ctx_t *c, TSNode mods) { + uint32_t count = ts_node_named_child_count(mods); + for (uint32_t i = 0; i < count; i++) { + TSNode m = ts_node_named_child(mods, i); + if (strcmp(ts_node_type(m), "parameter_modifier") == 0 && + sig_node_text_is(c, m, "vararg")) { + return true; + } + } + return false; +} + +static void sig_kotlin_param(sig_ctx_t *c, TSNode p, bool vararg) { + sig_entry_open(c); + sig_label(c, NULL, 0); + if (sig_writes_types(c)) { + size_t before = c->len; + uint32_t count = ts_node_named_child_count(p); + for (uint32_t i = 0; i < count; i++) { + TSNode ch = ts_node_named_child(p, i); + if (strcmp(ts_node_type(ch), "simple_identifier") != 0) { + sig_type_tokens(c, ch); /* type_modifiers + the type */ + } + } + if (c->len == before) { + sig_raw(c, "?", 1); + } + if (vararg) { + sig_raw_str(c, sig_variadic_marker(c->lang)); + } + } + sig_entry_close(c); +} + +static void sig_kotlin_params(sig_ctx_t *c, TSNode node) { + TSNode receiver = sig_field(node, "receiver"); + if (!ts_node_is_null(receiver)) { + /* An extension's receiver is part of its identity: String.f(Int) and + * Int.f(Int) share a base QN. */ + sig_entry_open(c); + sig_label(c, NULL, 0); + if (sig_writes_types(c)) { + sig_raw_str(c, "this:"); + sig_type_tokens(c, receiver); + } + sig_entry_close(c); + } + TSNode params = cbm_find_child_by_kind(node, "function_value_parameters"); + uint32_t count = ts_node_is_null(params) ? 0 : ts_node_named_child_count(params); + bool vararg = false; + for (uint32_t i = 0; i < count; i++) { + TSNode ch = ts_node_named_child(params, i); + const char *k = ts_node_type(ch); + if (strcmp(k, "parameter_modifiers") == 0) { + vararg = vararg || sig_kotlin_is_vararg(c, ch); + } else if (strcmp(k, "parameter") == 0) { + sig_kotlin_param(c, ch, vararg); + vararg = false; + } + } +} + +/* ── Swift ─────────────────────────────────────────────────────── */ + +static void sig_swift_param(sig_ctx_t *c, TSNode p) { + TSNode external = sig_field(p, "external_name"); + TSNode internal = {0}; + uint32_t count = ts_node_child_count(p); + for (uint32_t i = 0; i < count; i++) { + TSNode ch = ts_node_child(p, i); + const char *field = ts_node_field_name_for_child(p, i); + if (field && strcmp(field, "name") == 0 && + strcmp(ts_node_type(ch), "simple_identifier") == 0) { + internal = ch; + break; + } + } + sig_entry_open(c); + /* One name is both label and internal name; `_` means unlabeled. */ + sig_label_node(c, ts_node_is_null(external) ? internal : external); + if (sig_writes_types(c)) { + size_t before = c->len; + for (uint32_t i = 0; i < count; i++) { + TSNode ch = ts_node_child(p, i); + const char *field = ts_node_field_name_for_child(p, i); + if (field && strcmp(field, "external_name") == 0) { + continue; + } + if (ts_node_eq(ch, internal)) { + continue; + } + const char *k = ts_node_type(ch); + if (strcmp(k, "parameter_modifiers") == 0) { + uint32_t mc = ts_node_named_child_count(ch); + for (uint32_t j = 0; j < mc; j++) { + TSNode m = ts_node_named_child(ch, j); + uint32_t ms = ts_node_start_byte(m); + if (ts_node_end_byte(m) > ms && c->src[ms] != '@') { + sig_type_tokens(c, m); /* inout; @escaping etc. dropped */ + } + } + } else if (ts_node_is_named(ch)) { + sig_type_tokens(c, ch); + } else if (sig_node_text_is(c, ch, "...")) { + sig_raw_str(c, sig_variadic_marker(c->lang)); + } + } + if (c->len == before) { + sig_raw(c, "?", 1); + } + } + sig_entry_close(c); +} + +static void sig_swift_params(sig_ctx_t *c, TSNode node) { + uint32_t count = ts_node_named_child_count(node); + for (uint32_t i = 0; i < count; i++) { + TSNode ch = ts_node_named_child(node, i); + if (strcmp(ts_node_type(ch), "parameter") == 0) { + sig_swift_param(c, ch); + } + } +} + +/* ── Scala ─────────────────────────────────────────────────────── */ + +static void sig_scala_params(sig_ctx_t *c, TSNode node) { + /* Every parameter clause, flattened: all of them are erased into the + * method's JVM signature. */ + uint32_t count = ts_node_named_child_count(node); + for (uint32_t i = 0; i < count; i++) { + TSNode clause = ts_node_named_child(node, i); + if (strcmp(ts_node_type(clause), "parameters") != 0) { + continue; + } + uint32_t pc = ts_node_named_child_count(clause); + for (uint32_t j = 0; j < pc; j++) { + TSNode p = ts_node_named_child(clause, j); + if (strcmp(ts_node_type(p), "parameter") == 0) { + sig_entry_type(c, sig_field(p, "type"), NULL); + } + } + } +} + +/* ── Objective-C ───────────────────────────────────────────────── */ + +static void sig_objc_params(sig_ctx_t *c, TSNode node) { + /* Selector keywords are the method's direct `identifier` children: the + * first is the name, each later one labels the parameter after it. */ + uint32_t count = ts_node_child_count(node); + bool saw_name = false; + TSNode pending = {0}; + bool have_pending = false; + for (uint32_t i = 0; i < count; i++) { + TSNode ch = ts_node_child(node, i); + const char *k = ts_node_type(ch); + if (strcmp(k, "identifier") == 0) { + if (saw_name) { + pending = ch; + have_pending = true; + } + saw_name = true; + } else if (strcmp(k, "method_parameter") == 0) { + sig_entry_open(c); + if (have_pending) { + sig_label_node(c, pending); + } else { + sig_label(c, NULL, 0); + } + have_pending = false; + if (sig_writes_types(c)) { + TSNode mt = cbm_find_child_by_kind(ch, "method_type"); + TSNode tn = ts_node_is_null(mt) ? mt : cbm_find_child_by_kind(mt, "type_name"); + size_t before = c->len; + sig_type_tokens(c, ts_node_is_null(tn) ? mt : tn); + if (c->len == before) { + sig_raw(c, "?", 1); + } + } + sig_entry_close(c); + } else if (!ts_node_is_named(ch) && sig_node_text_is(c, ch, "...")) { + sig_entry_open(c); + sig_raw_str(c, sig_variadic_marker(c->lang)); + sig_entry_close(c); + } + } +} + +/* ── Generic ───────────────────────────────────────────────────── */ + +static void sig_generic_params(sig_ctx_t *c, TSNode node) { + TSNode params = sig_field(node, "parameters"); + uint32_t count = ts_node_is_null(params) ? 0 : ts_node_named_child_count(params); + for (uint32_t i = 0; i < count; i++) { + TSNode p = ts_node_named_child(params, i); + if (sig_skipped_kind(ts_node_type(p))) { + continue; + } + TSNode type = sig_field(p, "type"); + sig_entry_type(c, type, NULL); + } +} + +/* ── Spelling pass ─────────────────────────────────────────────── */ + +/* Qualified type paths keep their last segment; "->" becomes "=>"; any other + * '.' or "::" (a receiver function type, a leading global scope) is dropped, + * so the suffix contract holds whatever the grammar produced. */ +static size_t sig_spell(char *s, size_t n) { + size_t out = 0; + size_t i = 0; + while (i < n) { + size_t sep = 0; + if (s[i] == '.') { + sep = 1; + } else if (s[i] == ':' && i + 1 < n && s[i + 1] == ':') { + sep = 2; + } + if (sep) { + bool ident_before = out > 0 && sig_ident_char((unsigned char)s[out - 1]); + bool ident_after = i + sep < n && sig_ident_char((unsigned char)s[i + sep]); + if (ident_before && ident_after) { + while (out > 0 && sig_ident_char((unsigned char)s[out - 1])) { + out--; + } + } + i += sep; + continue; + } + if (s[i] == '-' && i + 1 < n && s[i + 1] == '>') { + s[out++] = '='; + s[out++] = '>'; + i += 2; + continue; + } + s[out++] = s[i++]; + } + s[out] = '\0'; + return out; +} + +static uint64_t sig_fnv64(const char *s, size_t n) { + uint64_t h = SIG_FNV_OFFSET; + for (size_t i = 0; i < n; i++) { + h ^= (unsigned char)s[i]; + h *= SIG_FNV_PRIME; + } + return h; +} + +/* "(" leading whole entries ")" ... "#)" within CBM_CALLABLE_SIG_MAX. */ +static const char *sig_capped(CBMArena *a, const sig_ctx_t *c, const char *full, size_t n) { + char tail[SIG_CAPPED_TAIL + 1]; + snprintf(tail, sizeof(tail), "#%016llx)", (unsigned long long)sig_fnv64(full, n)); + size_t keep = 0; /* bytes of entry text after the "(" */ + bool comma = sig_writes_types(c); + int limit = c->too_many ? SIG_MAX_ENTRIES : c->entries; + for (int i = 0; i < limit; i++) { + size_t end = c->entry_end[i]; + if (end < c->open_off + 1 || end > c->close_off) { + break; + } + size_t candidate = end - (c->open_off + 1); + if (1 + candidate + (comma ? 1 : 0) + SIG_CAPPED_TAIL > CBM_CALLABLE_SIG_MAX) { + break; + } + keep = candidate; + } + char *out = cbm_arena_alloc(a, CBM_CALLABLE_SIG_MAX + 1); + if (!out) { + return NULL; + } + size_t len = 0; + out[len++] = '('; + memcpy(out + len, c->buf + c->open_off + 1, keep); + len += keep; + if (keep && comma) { + out[len++] = ','; + } + memcpy(out + len, tail, SIG_CAPPED_TAIL); + len += SIG_CAPPED_TAIL; + out[len] = '\0'; + return out; +} + +/* ── Builder ───────────────────────────────────────────────────── */ + +static void sig_render(sig_ctx_t *c, TSNode node) { + switch (c->lang) { + case CBM_LANG_CPP: + case CBM_LANG_CUDA: + case CBM_LANG_C: { + TSNode fdecl = sig_c_function_declarator(node); + if (c->lang != CBM_LANG_C && !sig_cpp_member_of_class_template(fdecl)) { + sig_cpp_tparams(c, node); + } + if (ts_node_is_null(fdecl)) { + c->open_off = c->len; + sig_raw(c, "()", 2); + c->close_off = c->len; + return; + } + sig_c_param_list(c, fdecl); + sig_cpp_cvref(c, fdecl); + return; + } + default: + break; + } + if (c->lang == CBM_LANG_CSHARP) { + sig_cs_tparams(c, node); + } + c->open_off = c->len; + sig_raw(c, "(", 1); + switch (c->lang) { + case CBM_LANG_JAVA: + sig_java_params(c, node); + break; + case CBM_LANG_CSHARP: + sig_cs_params(c, node); + break; + case CBM_LANG_KOTLIN: + sig_kotlin_params(c, node); + break; + case CBM_LANG_SWIFT: + sig_swift_params(c, node); + break; + case CBM_LANG_SCALA: + sig_scala_params(c, node); + break; + case CBM_LANG_OBJC: + sig_objc_params(c, node); + break; + default: + sig_generic_params(c, node); + break; + } + sig_raw(c, ")", 1); + c->close_off = c->len; +} + +static bool sig_roundtrips(const char *suffix, size_t n) { + /* "f" + suffix must invert to the base "f". */ + char probe[CBM_CALLABLE_SIG_MAX + 2]; + if (n > CBM_CALLABLE_SIG_MAX) { + return false; + } + probe[0] = 'f'; + memcpy(probe + 1, suffix, n); + probe[n + 1] = '\0'; + return cbm_qn_callable_base_len(probe) == 1; +} + +/* "(#)": the identity of a signature that cannot be spelled safely. */ +static const char *sig_hashed(CBMArena *a, const char *full, size_t n) { + return cbm_arena_sprintf(a, "(#%016llx)", (unsigned long long)sig_fnv64(full, n)); +} + +const char *cbm_callable_sig_mode(CBMArena *a, TSNode func_node, const char *source, + CBMLanguage lang, CBMCallableIdentity mode) { + if (!a || !source || ts_node_is_null(func_node) || mode == CBM_CALLABLE_ID_NONE) { + return NULL; + } + sig_ctx_t c; + memset(&c, 0, sizeof(c)); + c.arena = a; + c.src = source; + c.lang = lang; + c.mode = mode; + sig_raw(&c, "", 0); + if (c.overflow || !c.buf) { + return NULL; + } + c.buf[0] = '\0'; + sig_render(&c, func_node); + if (c.overflow) { + return NULL; + } + if (mode == CBM_CALLABLE_ID_ARITY) { + char digits[SIG_ARITY_DIGITS]; + snprintf(digits, sizeof(digits), "(%d)", c.entries); + return cbm_arena_strdup(a, digits); + } + size_t full = c.len; + char *spelled = cbm_arena_strndup(a, c.buf, full); + if (!spelled) { + return NULL; + } + size_t n = sig_spell(spelled, full); + if (n <= CBM_CALLABLE_SIG_MAX) { + return sig_roundtrips(spelled, n) ? spelled : sig_hashed(a, spelled, n); + } + /* Over the cap: re-derive the entry offsets in spelled coordinates. The + * spelling pass only shortens and only looks one byte ahead, and every + * recorded offset sits before a ',' or ')', so spelling a prefix gives + * exactly that prefix's spelled length. */ + int limit = c.too_many ? SIG_MAX_ENTRIES : c.entries; + for (int i = 0; i < limit; i++) { + char *prefix = cbm_arena_strndup(a, c.buf, c.entry_end[i]); + c.entry_end[i] = prefix ? sig_spell(prefix, c.entry_end[i]) : 0; + } + char *open_prefix = cbm_arena_strndup(a, c.buf, c.open_off); + char *close_prefix = cbm_arena_strndup(a, c.buf, c.close_off); + c.open_off = open_prefix ? sig_spell(open_prefix, c.open_off) : 0; + c.close_off = close_prefix ? sig_spell(close_prefix, c.close_off) : 0; + c.buf = spelled; + c.len = n; + const char *capped = sig_capped(a, &c, spelled, n); + if (capped && sig_roundtrips(capped, strlen(capped))) { + return capped; + } + return sig_hashed(a, spelled, n); +} + +const char *cbm_callable_sig(CBMArena *a, TSNode func_node, const char *source, CBMLanguage lang) { + return cbm_callable_sig_mode(a, func_node, source, lang, cbm_callable_identity(lang)); +} + +/* ── Inverse ───────────────────────────────────────────────────── */ + +static bool sig_ends_with_operator(const char *qn, size_t end) { + if (end < SIG_OPERATOR_LEN || + memcmp(qn + end - SIG_OPERATOR_LEN, "operator", SIG_OPERATOR_LEN) != 0) { + return false; + } + size_t at = end - SIG_OPERATOR_LEN; + return at == 0 || !sig_ident_char((unsigned char)qn[at - 1]); +} + +/* Whether qn[0..end) ends in a leaf a suffix may follow: an identifier (but + * not a make `$(VAR)` reference), the '>' closing template parameters, a C++ + * `operator...` leaf, or operator symbols — alone (Swift/Scala `+`, `==`) or + * after a Scala `_` (`unary_!`). A path such as `app/(auth)`, an anonymous + * `(anon)` leaf or an empty leaf is never a callable followed by a suffix. */ +static bool sig_callable_leaf_before(const char *qn, size_t end) { + size_t start = end; + while (start > 0 && qn[start - 1] != '.') { + start--; + } + if (start == end) { + return false; + } + unsigned char last = (unsigned char)qn[end - 1]; + if (last == '>' || (sig_ident_char(last) && last != '$')) { + return true; + } + if (end - start >= SIG_OPERATOR_LEN && memcmp(qn + start, "operator", SIG_OPERATOR_LEN) == 0) { + return true; + } + size_t op = end; + while (op > start && strchr(SIG_OPERATOR_CHARS, qn[op - 1]) != NULL) { + op--; + } + return op < end && (op == start || qn[op - 1] == '_'); +} + +size_t cbm_qn_callable_base_len_named(const char *qn, const char *name) { + size_t base = cbm_qn_callable_base_len(qn); + if (!qn || !name) { + return base; + } + size_t len = strlen(qn); + size_t n = strlen(name); + if (base == len || n == 0 || base < n || memcmp(qn + base - n, name, n) != 0) { + return len; + } + size_t at = base - n; + bool anchored = + at == 0 || qn[at - 1] == '.' || (at >= 2 && qn[at - 1] == ':' && qn[at - 2] == ':'); + return anchored ? base : len; +} + +size_t cbm_qn_callable_base_len(const char *qn) { + if (!qn) { + return 0; + } + size_t len = strlen(qn); + /* Trailing cvref (C++): const / volatile / & / && after the ')'. */ + size_t t = len; + for (;;) { + if (t > 0 && qn[t - 1] == '&') { + t--; + } else if (t >= SIG_CONST_LEN && + memcmp(qn + t - SIG_CONST_LEN, "const", SIG_CONST_LEN) == 0) { + t -= SIG_CONST_LEN; + } else if (t >= SIG_VOLATILE_LEN && + memcmp(qn + t - SIG_VOLATILE_LEN, "volatile", SIG_VOLATILE_LEN) == 0) { + t -= SIG_VOLATILE_LEN; + } else { + break; + } + } + if (t == 0 || qn[t - 1] != ')') { + return len; + } + /* The balanced "(...)" that ends at t. */ + int depth = 0; + size_t p = t; + bool found = false; + while (p > 0) { + p--; + if (qn[p] == ')') { + depth++; + } else if (qn[p] == '(') { + depth--; + if (depth == 0) { + found = true; + break; + } + } + } + if (!found || !sig_callable_leaf_before(qn, p)) { + return len; + } + /* A bare C++ call-operator leaf `...operator()` is a name, not a suffix. */ + if (t - p == 2 && sig_ends_with_operator(qn, p)) { + return len; + } + /* Optional "" right before the "(". */ + if (qn[p - 1] == '>') { + int adepth = 0; + size_t q = p; + while (q > 0) { + q--; + if (qn[q] == '>') { + adepth++; + } else if (qn[q] == '<') { + adepth--; + if (adepth == 0) { + break; + } + } else if (qn[q] == '.') { + q = 0; + adepth = 1; + break; + } + } + if (adepth == 0 && q > 0 && qn[q - 1] != '.' && !sig_ends_with_operator(qn, q)) { + return q; + } + } + return p; +} diff --git a/internal/cbm/callable_sig.h b/internal/cbm/callable_sig.h new file mode 100644 index 0000000000..c7b4d64fc9 --- /dev/null +++ b/internal/cbm/callable_sig.h @@ -0,0 +1,83 @@ +/* + * callable_sig.h — signature-qualified callable identity (#2061). + * + * A callable's graph identity is + * + * callable_qn := base_qn [] (params) [cvref] + * + * so overloads that share a base QN (Java/C#/C++/Kotlin/Swift/Scala/ObjC) + * become distinct nodes instead of merging into one node that collects every + * overload's CALLS edges. The `name` column stays the bare name. + * + * Every language selects a mode (cbm_callable_identity). A language whose + * mode is CBM_CALLABLE_ID_NONE keeps its historical QN byte for byte; the + * suffix is applied only where a mode is enabled, and the SAME builder + * (cbm_callable_sig) serves the definition QN and the call-scope QN so the two + * can never disagree. + * + * Suffix contract (what cbm_qn_callable_base_len inverts): + * - it never contains '.', "::" or "->" — the separators every QN leaf + * splitter uses — so a splitter that first strips the suffix finds the + * same leaf as before; + * - it is whitespace-normalized: one space only between two identifier + * characters ("unsigned long", "const T&"), none elsewhere; + * - type spelling: comments, annotations and attributes are dropped; + * qualified type paths keep their last segment (java.util.List -> List, + * std::vector -> vector); "->" in function types is spelled "=>"; a + * variadic/rest marker is "[]" for Java (the JVM array it is) and "~" + * everywhere else; C++ top-level cv-qualifiers are dropped and array + * parameters decay to pointers, as the language itself does; + * - it is at most CBM_CALLABLE_SIG_MAX bytes. A longer signature keeps the + * leading whole parameter entries that fit and ends in "#<16 hex>)", the + * FNV-1a 64 of the full uncapped suffix, so identity is preserved. + */ +#ifndef CBM_CALLABLE_SIG_H +#define CBM_CALLABLE_SIG_H + +#include "cbm.h" +#include "arena.h" +#include "tree_sitter/api.h" +#include + +typedef enum { + CBM_CALLABLE_ID_NONE = 0, /* historical base QN, no suffix */ + CBM_CALLABLE_ID_TYPED, /* (int,String) */ + CBM_CALLABLE_ID_LABELED_TYPED, /* (_:Data,to:URL) Swift */ + CBM_CALLABLE_ID_LABELED, /* (_:style:) ObjC selector */ + CBM_CALLABLE_ID_ARITY, /* (2) dynamic langs */ +} CBMCallableIdentity; + +enum { CBM_CALLABLE_SIG_MAX = 200 }; + +/* The identity mode a language uses for callable QNs. Every language is + * CBM_CALLABLE_ID_NONE until its enable change lands (with its index-format + * bump). Kept as a side table, like cbm_string_dispatch_suffixes, rather than + * a CBMLangSpec field: the ~160 positional lang_specs rows would all have to + * spell the new member under -Wmissing-field-initializers. */ +CBMCallableIdentity cbm_callable_identity(CBMLanguage lang); + +/* The identity suffix for the callable at `func_node` in the language's own + * mode, or NULL when that mode is NONE (or the node carries no parameter + * information the mode can use). Arena-owned. */ +const char *cbm_callable_sig(CBMArena *a, TSNode func_node, const char *source, CBMLanguage lang); + +/* Same, with an explicit mode — the builder the enable changes and the golden + * tables exercise before a language is switched on. */ +const char *cbm_callable_sig_mode(CBMArena *a, TSNode func_node, const char *source, + CBMLanguage lang, CBMCallableIdentity mode); + +/* Length of the base QN, i.e. `qn` without its callable identity suffix; equal + * to strlen(qn) when there is none. Purely syntactic (the inverse of the + * builder's contract above); a bare C++ `operator()` leaf is recognised as a + * name, not a suffix. NULL -> 0. */ +size_t cbm_qn_callable_base_len(const char *qn); + +/* The inverse anchored to the callable's bare `name` (the node's name + * column): the suffix is stripped only when the base QN is `name` or ends in + * "." / "::" + `name`. Other QN text can end in ')' as well — a Java field + * whose name is its declarator `X = f()`, a make `$(VAR)` target — and only + * the anchor makes the inverse exact on it. Use this form wherever the name + * is known; NULL name -> the unanchored form. */ +size_t cbm_qn_callable_base_len_named(const char *qn, const char *name); + +#endif /* CBM_CALLABLE_SIG_H */ diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index fb5e446a5d..a33349f1ae 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -246,6 +246,10 @@ typedef struct { * HTTP_CALLS edge to base + path. Tail fields: zero-init stays valid. */ const char *http_client; const char *http_base_url; + /* Callable identity (#2061): offset of the signature suffix inside + * qualified_name (base QN = the first qn_sig_off bytes); 0 = no suffix. + * Always 0 until a language enables its callable_identity mode. */ + uint32_t qn_sig_off; } CBMDefinition; /* Argument captured from a call expression */ @@ -431,6 +435,10 @@ typedef struct { uint32_t site_start_byte; // exact source occurrence; end > start when present uint32_t site_end_byte; // exclusive byte offset in the source file CBMSourceOrigin source_origin; // raw source or C-family preprocessed buffer + /* Callable identity (#2061): the resolved overload's signature suffix, + * appended to callee_qn when the edge is written; NULL = none (always, + * until a language enables its callable_identity mode). */ + const char *callee_sig; } CBMResolvedCall; typedef struct { diff --git a/internal/cbm/lsp/go_lsp.h b/internal/cbm/lsp/go_lsp.h index 65e97675c7..5874cbeefa 100644 --- a/internal/cbm/lsp/go_lsp.h +++ b/internal/cbm/lsp/go_lsp.h @@ -99,6 +99,9 @@ typedef struct { * a decorator rebinds the function name, so the undecorated definition is * not automatically an exact callable-value target. */ const char **decorators; + /* Callable identity (#2061): signature suffix of this def's graph QN; + * qualified_name stays the base QN. NULL = none (always, until enabled). */ + const char *sig; } CBMLSPDef; // Parse source, build registry from defs + stdlib, run LSP. diff --git a/internal/cbm/lsp/type_registry.h b/internal/cbm/lsp/type_registry.h index c752350b17..065ae25852 100644 --- a/internal/cbm/lsp/type_registry.h +++ b/internal/cbm/lsp/type_registry.h @@ -40,6 +40,9 @@ typedef struct { * remain NULL when raw cross-file provenance is ambiguous; the Rust trait * flag still prevents that method from being mistaken for inherent. */ const char *impl_trait_qn; + /* Callable identity (#2061): this overload's signature suffix; its graph + * QN is qualified_name + sig. NULL = none (always, until enabled). */ + const char *sig; } CBMRegisteredFunc; // Registered type with fields and method names. diff --git a/internal/cbm/result_compact.c b/internal/cbm/result_compact.c index 90c8c6d820..8fedf8269a 100644 --- a/internal/cbm/result_compact.c +++ b/internal/cbm/result_compact.c @@ -357,6 +357,7 @@ static void cr_walk(cr_ctx_t *c, CBMFileResult *r) { cr_str(c, &r->resolved_calls.items[i].callee_qn); cr_str(c, &r->resolved_calls.items[i].strategy); cr_str(c, &r->resolved_calls.items[i].reason); + cr_str(c, &r->resolved_calls.items[i].callee_sig); } cr_array(c, (void **)&r->string_refs.items, r->string_refs.count, sizeof(CBMStringRef)); for (int i = 0; i < r->string_refs.count && r->string_refs.items; i++) { diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index 138ef0f77d..1453388034 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -52,7 +52,7 @@ src/git/git_context.c 18 src/main.c 32 src/mcp/compact_out.c 39 src/mcp/index_supervisor.c 8 -src/mcp/mcp.c 784 +src/mcp/mcp.c 778 src/pipeline/artifact.c 38 src/pipeline/fqn.c 23 src/pipeline/lsp_resolve.h 4 diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 2ed29799b8..a4f110974a 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -58,6 +58,7 @@ enum { #include "cypher/cypher.h" #include "discover/discover.h" #include "pipeline/pipeline.h" +#include "callable_sig.h" /* cbm_qn_callable_base_len */ #include "pipeline/pass_cross_repo.h" #include "git/git_context.h" #include "cli/cli.h" @@ -4552,10 +4553,18 @@ static void sg_lines_str(char *out, size_t sz, int start, int end) { * ablation (tree > flat/DOT for LLM comprehension), Lost-in-Distance * (related rows adjacent — grouping by module does exactly that). */ -/* qn-prefix = qualified_name minus its last '.'-segment. Returns length. */ -static size_t sg_qn_prefix_len(const char *qn) { - const char *last = qn ? strrchr(qn, '.') : NULL; - return last ? (size_t)(last - qn) : 0; +/* qn-prefix = qualified_name minus its last '.'-segment. Returns length. + * The segment is found in the base QN: a callable identity suffix (#2061) + * stays on the row's short name, so prefix + "." + name still rebuilds it. */ +static size_t sg_qn_prefix_len(const char *qn, const char *name) { + size_t last = 0; + size_t base_len = cbm_qn_callable_base_len_named(qn, name); + for (size_t i = 0; i < base_len; i++) { + if (qn[i] == '.') { + last = i; + } + } + return last; } typedef struct { @@ -4822,7 +4831,7 @@ static void emit_search_results_grouped_tree(cbm_sb_t *sb, const cbm_search_outp const cbm_search_result_t *sr = &out->results[i]; const char *qn = sr->node.qualified_name ? sr->node.qualified_name : ""; const char *file = sr->node.file_path ? sr->node.file_path : ""; - size_t plen = sg_qn_prefix_len(qn); + size_t plen = sg_qn_prefix_len(qn, sr->node.name); bool same_group = previous_prefix && strlen(previous_prefix) == plen && memcmp(previous_prefix, qn, plen) == 0 && previous_file && strcmp(previous_file, file) == 0; @@ -4978,7 +4987,7 @@ static void emit_search_results_tree_json(yyjson_mut_doc *doc, yyjson_mut_val *r const cbm_search_result_t *sr = &out->results[i]; const char *qn = sr->node.qualified_name ? sr->node.qualified_name : ""; const char *file = sr->node.file_path ? sr->node.file_path : ""; - size_t plen = sg_qn_prefix_len(qn); + size_t plen = sg_qn_prefix_len(qn, sr->node.name); bool same_group = previous_prefix && strlen(previous_prefix) == plen && memcmp(previous_prefix, qn, plen) == 0 && previous_file && strcmp(previous_file, file) == 0; @@ -8888,7 +8897,7 @@ static yyjson_mut_val *bfs_to_tree_json(yyjson_mut_doc *doc, cbm_traverse_result } const char *qn = tr->visited[i].node.qualified_name ? tr->visited[i].node.qualified_name : ""; - size_t plen = sg_qn_prefix_len(qn); + size_t plen = sg_qn_prefix_len(qn, tr->visited[i].node.name); if (!have_group || strlen(cur_group) != plen || memcmp(cur_group, qn, plen) != 0) { char *next_group = cbm_strndup(qn, plen); if (!next_group) { @@ -9012,7 +9021,7 @@ static void bfs_to_tree_table(cbm_sb_t *sb, const char *key, cbm_traverse_result char *cur_group = NULL; for (int i = 0; i < ordered_count; i++) { const char *qn = ordered[i].node.qualified_name ? ordered[i].node.qualified_name : ""; - size_t plen = sg_qn_prefix_len(qn); + size_t plen = sg_qn_prefix_len(qn, ordered[i].node.name); if (!cur_group || strlen(cur_group) != plen || memcmp(cur_group, qn, plen) != 0) { char *next_group = cbm_strndup(qn, plen); if (!next_group) { @@ -9293,6 +9302,13 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { } } } + if (node_count == 0) { + /* A bare base QN names every signature-qualified overload (#2061); + * several overloads report ambiguity below like same-named nodes. */ + cbm_store_free_nodes(nodes, 0); + nodes = NULL; + cbm_store_find_nodes_by_qn_base(store, project, func_name, false, &nodes, &node_count); + } if (node_count == 0) { yyjson_mut_doc *error_doc = yyjson_mut_doc_new(NULL); @@ -12667,6 +12683,34 @@ static char *handle_get_file_outline(cbm_mcp_server_t *srv, const char *args) { return result; } +/* Respond from one lookup tier's candidates (always consumed), or NULL when + * the tier found nothing. One match answers directly; several prefer the real + * definition (a .c body over a .h declaration, a Function over a Module) so + * an unambiguous-by-preference match resolves without a disambiguation round + * trip; only a genuine tie returns suggestions. */ +static char *snippet_from_tier(cbm_mcp_server_t *srv, cbm_node_t *nodes, int count, + const char *input, const char *match_method, bool include_neighbors, + const char *args) { + if (count <= 0) { + cbm_store_free_nodes(nodes, count); + return NULL; + } + bool ambiguous = false; + int sel = count == SKIP_ONE ? 0 : pick_resolved_node(nodes, count, &ambiguous); + if (ambiguous) { + char *result = snippet_suggestions(input, nodes, count); + cbm_store_free_nodes(nodes, count); + return result; + } + cbm_node_t node = {0}; + copy_node(&nodes[sel], &node); + cbm_store_free_nodes(nodes, count); + char *result = + build_snippet_response(srv, &node, match_method, include_neighbors, NULL, 0, args); + free_node_contents(&node); + return result; +} + static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) { char *qn = cbm_mcp_get_string_arg(args, "qualified_name"); char *project = get_project_arg(args); @@ -12708,50 +12752,38 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) { return result; } + /* Tier 1b: a bare base QN names every signature-qualified overload of it + * (#2061). Finds nothing for QNs without a callable identity suffix. */ + cbm_node_t *tier_nodes = NULL; + int tier_count = 0; + cbm_store_find_nodes_by_qn_base(store, effective_project, qn, false, &tier_nodes, &tier_count); + char *result = + snippet_from_tier(srv, tier_nodes, tier_count, qn, "base", include_neighbors, args); + /* Tier 2: Suffix match — handles partial QNs ("main.HandleRequest") * and short names ("ProcessOrder") via LIKE '%.X'. */ - cbm_node_t *suffix_nodes = NULL; - int suffix_count = 0; - cbm_store_find_nodes_by_qn_suffix(store, effective_project, qn, &suffix_nodes, &suffix_count); - - if (suffix_count == SKIP_ONE) { - copy_node(&suffix_nodes[0], &node); - cbm_store_free_nodes(suffix_nodes, suffix_count); - char *result = - build_snippet_response(srv, &node, "suffix", include_neighbors, NULL, 0, args); - free_node_contents(&node); - free(qn); - free(project); - return result; - } - - if (suffix_count > SKIP_ONE) { - /* Prefer the real definition (a .c body over a .h declaration, a Function - * over a Module) so an unambiguous-by-preference match resolves directly - * instead of forcing a disambiguation round trip; only a genuine tie still - * returns suggestions. */ - bool snip_ambiguous = false; - int ssel = pick_resolved_node(suffix_nodes, suffix_count, &snip_ambiguous); - if (!snip_ambiguous) { - copy_node(&suffix_nodes[ssel], &node); - cbm_store_free_nodes(suffix_nodes, suffix_count); - char *result = - build_snippet_response(srv, &node, "suffix", include_neighbors, NULL, 0, args); - free_node_contents(&node); - free(qn); - free(project); - return result; - } - char *result = snippet_suggestions(qn, suffix_nodes, suffix_count); - cbm_store_free_nodes(suffix_nodes, suffix_count); - free(qn); - free(project); - return result; + if (!result) { + tier_nodes = NULL; + tier_count = 0; + cbm_store_find_nodes_by_qn_suffix(store, effective_project, qn, &tier_nodes, &tier_count); + result = + snippet_from_tier(srv, tier_nodes, tier_count, qn, "suffix", include_neighbors, args); + } + + /* Tier 2b: the partial/short form of Tier 1b ("Session.upload"). */ + if (!result) { + tier_nodes = NULL; + tier_count = 0; + cbm_store_find_nodes_by_qn_base(store, effective_project, qn, true, &tier_nodes, + &tier_count); + result = + snippet_from_tier(srv, tier_nodes, tier_count, qn, "suffix", include_neighbors, args); } - - cbm_store_free_nodes(suffix_nodes, suffix_count); free(qn); free(project); + if (result) { + return result; + } /* Nothing found — guide the caller toward search_graph */ return cbm_mcp_text_result( diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 666f9fd552..0e92150661 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -22,6 +22,7 @@ #define CBM_PIPELINE_LSP_RESOLVE_H #include "cbm.h" +#include "callable_sig.h" /* cbm_qn_callable_base_len — leaf splitters skip the suffix */ #include "graph_buffer/graph_buffer.h" #include "foundation/constants.h" @@ -64,7 +65,10 @@ static inline const char *cbm_lsp_bare_segment(const char *name) { return name; } const char *seg = name; - for (const char *p = name; *p; p++) { + /* Scan the base only: a callable identity suffix (#2061) belongs to the + * leaf and may itself contain ':' (Swift labels) or '>' (generics). */ + const char *end = name + cbm_qn_callable_base_len(name); + for (const char *p = name; p < end; p++) { /* '.' (dotted QN / Java-style member) and ':' (C++ `::`, last colon * wins) are member/scope separators. '>' is only a separator when it * closes the `->` arrow (preceded by '-'); a bare '>' closes a template @@ -86,7 +90,15 @@ static inline const char *cbm_pipeline_qn_class_method_tail(const char *qn) { if (!qn) { return NULL; } - const char *last = strrchr(qn, '.'); + /* The last '.' of the BASE QN: a callable identity suffix (#2061) never + * contains '.', so this is the historical strrchr for every QN. */ + const char *last = NULL; + size_t base_len = cbm_qn_callable_base_len(qn); + for (size_t i = 0; i < base_len; i++) { + if (qn[i] == '.') { + last = qn + i; + } + } if (!last || last == qn) { return NULL; } diff --git a/src/pipeline/pass_ensemble_routing.c b/src/pipeline/pass_ensemble_routing.c index 0aa574839d..1a6ca09809 100644 --- a/src/pipeline/pass_ensemble_routing.c +++ b/src/pipeline/pass_ensemble_routing.c @@ -6,6 +6,7 @@ #include "foundation/compat_fs.h" #include "foundation/constants.h" #include "foundation/str_util.h" +#include "callable_sig.h" #include #include @@ -425,7 +426,14 @@ static void collect_prod_defs(cbm_pipeline_ctx_t *ctx, ens_prod_def_t ***defs_ou char class_qn[CBM_SZ_256]; class_qn[0] = '\0'; if (xd->qualified_name) { - const char *dot = strrchr(xd->qualified_name, '.'); + /* Owner = base QN minus its leaf (#2061 suffixes hold no '.'). */ + const char *dot = NULL; + size_t base_len = cbm_qn_callable_base_len_named(xd->qualified_name, xd->name); + for (size_t i = 0; i < base_len; i++) { + if (xd->qualified_name[i] == '.') { + dot = xd->qualified_name + i; + } + } if (dot) { int len = (int)(dot - xd->qualified_name); if (len > 0 && len < CBM_SZ_256) { diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 3b74cc1ebb..d484d18c25 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -19,6 +19,7 @@ #include "result_spill.h" #include "pipeline/pipeline_internal.h" #include "pipeline/lsp_resolve.h" +#include "callable_sig.h" #include "lsp/go_lsp.h" #include "lsp/c_lsp.h" #include "lsp/py_lsp.h" @@ -273,12 +274,20 @@ static const char *pxc_join_base_qns(CBMArena *arena, const char *const *bases, static bool pxc_is_jvm_lang(CBMLanguage lang); +/* Leaf of the BASE QN: a callable identity suffix (#2061) never contains + * '.', so this equals the historical strrchr split for every QN. */ static const char *pxc_last_component(const char *qn) { if (!qn) { return NULL; } - const char *dot = strrchr(qn, '.'); - return dot ? dot + 1 : qn; + const char *leaf = qn; + size_t base_len = cbm_qn_callable_base_len(qn); + for (size_t i = 0; i < base_len; i++) { + if (qn[i] == '.') { + leaf = qn + i + 1; + } + } + return leaf; } /* Every return is arena-owned: the fallback spelling is a copy, never the @@ -377,7 +386,9 @@ static const char *pxc_qn_leaf(const char *name) { return NULL; } const char *leaf = name; - for (const char *p = name; *p; p++) { + /* Base only: a callable identity suffix (#2061) may carry ':' labels. */ + const char *end = name + cbm_qn_callable_base_len(name); + for (const char *p = name; p < end; p++) { if (*p == '.' || *p == ':' || *p == '/' || *p == '\\') { leaf = p + 1; } diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c index 48dab00831..597d629ba4 100644 --- a/src/pipeline/pass_semantic.c +++ b/src/pipeline/pass_semantic.c @@ -23,6 +23,7 @@ #include "foundation/compat_fs.h" #include "foundation/limits.h" #include "cbm.h" +#include "callable_sig.h" #include #include @@ -356,7 +357,14 @@ static void go_index_add(go_method_index_t *ix, const char *name, int type_index static void go_index_visit_node(const cbm_gbuf_node_t *node, void *userdata) { go_method_index_t *ix = userdata; const char *qn = node->qualified_name; - const char *dot = qn ? strrchr(qn, '.') : NULL; + /* Last '.' of the base QN (a callable identity suffix never holds one). */ + const char *dot = NULL; + size_t base_len = cbm_qn_callable_base_len_named(qn, node->name); + for (size_t i = 0; qn && i < base_len; i++) { + if (qn[i] == '.') { + dot = qn + i; + } + } if (!dot || dot == qn || !dot[SKIP_ONE]) { return; } diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index bdf6f5944d..19754dcbf7 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -30,6 +30,7 @@ enum { REG_MAX_CANDIDATES = 256 }; #include "foundation/hash_table.h" #include "foundation/dyn_array.h" #include "foundation/platform.h" +#include "callable_sig.h" /* cbm_qn_callable_base_len_named: overloads share the name key */ #include #include @@ -943,15 +944,24 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified * their dotted form would only widen the bucket the scorer walks. * * `name` is NULL or empty only for callers that have no symbol name to - * give; those have the derived key and nothing else. */ + * give; those have the derived key and nothing else. + * + * A signature-qualified callable (#2061) is indexed by its bare `name` in + * place of the derived key, so every overload shares one bucket; any other + * QN keeps its historical last-segment key. */ const char *derived = simple_name(qualified_name); - index_under_name(r, derived, owned_qn); + const char *primary = + name && cbm_qn_callable_base_len_named(owned_qn, name) < strlen(owned_qn) ? name : derived; + index_under_name(r, primary, owned_qn); /* '#' is a QN fence, and extract_defs.c's rust_cfg_qualified_name is the * only thing in the tree that mints one today. A grammar that starts * minting a '#' opts into this second key by doing so, whatever it means by * the fence: its symbols become reachable under the passed name as well, - * and they share that name's bucket with everything else filed under it. */ - if (name && name[0] && strchr(derived, '#') && strcmp(name, derived) != 0) { + * and they share that name's bucket with everything else filed under it. + * The passed name is compared with the key already written: a + * signature-qualified callable is filed under `name` already, and its + * capped suffix may carry a '#' of its own. */ + if (name && name[0] && strchr(derived, '#') && strcmp(name, primary) != 0) { index_under_name(r, name, owned_qn); } } diff --git a/src/store/store.c b/src/store/store.c index d66ef0ffbb..b1749fc153 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -78,6 +78,8 @@ enum { #include "foundation/compat.h" #include "foundation/log.h" #include "foundation/compat_regex.h" +#include "callable_sig.h" /* cbm_qn_callable_base_len: base-match tier */ +#include "foundation/mem_core.h" /* cbm_alloc: pattern buffers */ #include "foundation/str_util.h" #define XXH_INLINE_ALL @@ -4389,29 +4391,95 @@ int cbm_store_find_nodes_by_file_overlap(cbm_store_t *s, const char *project, co /* ── FindNodesByQNSuffix ───────────────────────────────────────── */ +/* Row filter on the raw qualified_name + name columns (NULL keeps every row). */ +typedef bool (*store_qn_keep_fn)(const char *qn, const char *name, const void *arg); + +/* Step `stmt` (columns as scan_node reads them) into a node array, keeping the + * rows `keep` accepts. Finalizes `stmt`. */ +static int store_collect_nodes(cbm_store_t *s, sqlite3_stmt *stmt, store_qn_keep_fn keep, + const void *keep_arg, cbm_node_t **out, int *count) { + int cap = ST_INIT_CAP_8; + int n = 0; + cbm_node_t *nodes = malloc(cap * sizeof(cbm_node_t)); + int scan_rc8; + while ((scan_rc8 = sqlite3_step(stmt)) == SQLITE_ROW) { + if (keep && !keep((const char *)sqlite3_column_text(stmt, CBM_SZ_4), + (const char *)sqlite3_column_text(stmt, CBM_SZ_3), keep_arg)) { + continue; + } + if (n >= cap) { + cap *= ST_GROWTH; + nodes = safe_realloc(nodes, cap * sizeof(cbm_node_t)); + } + memset(&nodes[n], 0, sizeof(cbm_node_t)); + scan_node(stmt, &nodes[n]); + n++; + } + if (scan_rc8 != SQLITE_DONE) { /* SCANCHK:8:stmt */ + store_set_error_sqlite(s, "row scan aborted"); + sqlite3_finalize(stmt); + cbm_store_free_nodes(nodes, n); + *out = NULL; + *count = 0; + return CBM_STORE_ERR; + } + sqlite3_finalize(stmt); + *out = nodes; + *count = n; + return CBM_STORE_OK; +} + +/* "%." + text + tail with LIKE's wildcards ('%', '_') and the escape + * character escaped, for `LIKE ? ESCAPE '\'`. Heap-owned. The caller's text + * is matched literally at any length: a fixed 512-byte buffer used to + * truncate a long suffix into a pattern that matched nothing, and an + * unescaped '_' in `my_func` also matched `myXfunc`. */ +static char *store_like_dot_suffix(const char *text, const char *tail) { + size_t n = strlen(text); + size_t tail_len = strlen(tail); + char *pat = cbm_alloc(CBM_MEM_CLASS_STORE, (2 * n) + tail_len + ST_COL_3); /* "%." + NUL */ + if (!pat) { + return NULL; + } + size_t k = 0; + pat[k++] = '%'; + pat[k++] = '.'; + for (size_t i = 0; i < n; i++) { + if (text[i] == '%' || text[i] == '_' || text[i] == '\\') { + pat[k++] = '\\'; + } + pat[k++] = text[i]; + } + memcpy(pat + k, tail, tail_len + SKIP_ONE); + return pat; +} + int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const char *suffix, cbm_node_t **out, int *count) { *out = NULL; *count = 0; - if (!s || !s->db) { + if (!s || !s->db || !suffix) { return CBM_STORE_ERR; } /* Match QNs ending with ".suffix" or exactly equal to suffix */ - char like_pattern[CBM_SZ_512]; - snprintf(like_pattern, sizeof(like_pattern), "%%.%s", suffix); + char *like_pattern = store_like_dot_suffix(suffix, ""); + if (!like_pattern) { + return CBM_STORE_ERR; + } const char *sql_with_project = "SELECT id, project, label, name, qualified_name, file_path, " "start_line, end_line, properties FROM nodes " - "WHERE project = ?1 AND (qualified_name LIKE ?2 OR qualified_name = ?3)"; + "WHERE project = ?1 AND (qualified_name LIKE ?2 ESCAPE '\\' OR qualified_name = ?3)"; const char *sql_any = "SELECT id, project, label, name, qualified_name, file_path, " "start_line, end_line, properties FROM nodes " - "WHERE (qualified_name LIKE ?1 OR qualified_name = ?2)"; + "WHERE (qualified_name LIKE ?1 ESCAPE '\\' OR qualified_name = ?2)"; sqlite3_stmt *stmt = NULL; int rc = sqlite3_prepare_v2(s->db, project ? sql_with_project : sql_any, CBM_NOT_FOUND, &stmt, NULL); if (rc != SQLITE_OK) { + cbm_free(CBM_MEM_CLASS_STORE, like_pattern); store_set_error_sqlite(s, "qn_suffix prepare"); return CBM_STORE_ERR; } @@ -4424,32 +4492,130 @@ int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const bind_text(stmt, SKIP_ONE, like_pattern); bind_text(stmt, ST_COL_2, suffix); } + rc = store_collect_nodes(s, stmt, NULL, NULL, out, count); + cbm_free(CBM_MEM_CLASS_STORE, like_pattern); + return rc; +} - int cap = ST_INIT_CAP_8; - int n = 0; - cbm_node_t *nodes = malloc(cap * sizeof(cbm_node_t)); - int scan_rc8; - while ((scan_rc8 = sqlite3_step(stmt)) == SQLITE_ROW) { - if (n >= cap) { - cap *= ST_GROWTH; - nodes = safe_realloc(nodes, cap * sizeof(cbm_node_t)); +/* ── FindNodesByQNBase ─────────────────────────────────────────── */ + +typedef struct { + const char *base; + size_t len; + bool suffix; +} store_qn_base_arg_t; + +/* ASCII case-insensitive equality: SQLite's LIKE folds exactly ASCII. */ +static bool store_ascii_ieq(const char *a, const char *b, size_t n) { + for (size_t i = 0; i < n; i++) { + unsigned char x = (unsigned char)a[i]; + unsigned char y = (unsigned char)b[i]; + if (x >= 'A' && x <= 'Z') { + x = (unsigned char)(x - 'A' + 'a'); + } + if (y >= 'A' && y <= 'Z') { + y = (unsigned char)(y - 'A' + 'a'); + } + if (x != y) { + return false; } - memset(&nodes[n], 0, sizeof(cbm_node_t)); - scan_node(stmt, &nodes[n]); - n++; } - if (scan_rc8 != SQLITE_DONE) { /* SCANCHK:8:stmt */ - store_set_error_sqlite(s, "row scan aborted"); - sqlite3_finalize(stmt); - cbm_store_free_nodes(nodes, n); - *out = NULL; - *count = 0; + return true; +} + +/* Keep a row whose QN carries a callable identity suffix (#2061) over `base`: + * its base QN equals `base` (exact) or ends with "." + `base` (suffix mode, + * ASCII case-insensitive like the LIKE suffix tier). An unsuffixed QN never + * qualifies, so this tier finds nothing until a language mints suffixes. */ +static bool store_qn_base_keep(const char *qn, const char *name, const void *argp) { + const store_qn_base_arg_t *arg = argp; + if (!qn || !name) { + return false; + } + size_t base_len = cbm_qn_callable_base_len_named(qn, name); + if (qn[base_len] == '\0') { + return false; + } + if (!arg->suffix) { + return base_len == arg->len && memcmp(qn, arg->base, arg->len) == 0; + } + return base_len > arg->len && qn[base_len - arg->len - SKIP_ONE] == '.' && + store_ascii_ieq(qn + base_len - arg->len, arg->base, arg->len); +} + +/* Exact mode reads the qualified_name index range [base "(", base "="): a + * suffix starts with '(' (0x28) or '<' (0x3C). Suffix mode needs the leading + * wildcard, like the suffix tier it follows. Both bounds are heap-owned. */ +static bool store_qn_base_bounds(const char *base, bool suffix_match, char **lo, char **hi) { + if (suffix_match) { + *lo = store_like_dot_suffix(base, "(%"); + *hi = store_like_dot_suffix(base, "<%"); + } else { + size_t n = strlen(base); + *lo = cbm_alloc(CBM_MEM_CLASS_STORE, n + ST_COL_2); + *hi = cbm_alloc(CBM_MEM_CLASS_STORE, n + ST_COL_2); + if (*lo && *hi) { + memcpy(*lo, base, n); + memcpy(*hi, base, n); + (*lo)[n] = '('; + (*hi)[n] = '='; + (*lo)[n + SKIP_ONE] = '\0'; + (*hi)[n + SKIP_ONE] = '\0'; + } + } + if (*lo && *hi) { + return true; + } + cbm_free(CBM_MEM_CLASS_STORE, *lo); + cbm_free(CBM_MEM_CLASS_STORE, *hi); + return false; +} + +int cbm_store_find_nodes_by_qn_base(cbm_store_t *s, const char *project, const char *base, + bool suffix_match, cbm_node_t **out, int *count) { + *out = NULL; + *count = 0; + if (!s || !s->db || !base || !base[0]) { + return CBM_STORE_ERR; + } + static const char *const sql[2][2] = { + /* [suffix_match][has project] */ + {"SELECT id, project, label, name, qualified_name, file_path, start_line, end_line, " + "properties FROM nodes WHERE qualified_name >= ?1 AND qualified_name < ?2", + "SELECT id, project, label, name, qualified_name, file_path, start_line, end_line, " + "properties FROM nodes WHERE project = ?1 AND qualified_name >= ?2 AND " + "qualified_name < ?3"}, + {"SELECT id, project, label, name, qualified_name, file_path, start_line, end_line, " + "properties FROM nodes WHERE (qualified_name LIKE ?1 ESCAPE '\\' OR " + "qualified_name LIKE ?2 ESCAPE '\\')", + "SELECT id, project, label, name, qualified_name, file_path, start_line, end_line, " + "properties FROM nodes WHERE project = ?1 AND (qualified_name LIKE ?2 ESCAPE '\\' OR " + "qualified_name LIKE ?3 ESCAPE '\\')"}, + }; + char *lo = NULL; + char *hi = NULL; + if (!store_qn_base_bounds(base, suffix_match, &lo, &hi)) { return CBM_STORE_ERR; } - sqlite3_finalize(stmt); - *out = nodes; - *count = n; - return CBM_STORE_OK; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, sql[suffix_match ? 1 : 0][project ? 1 : 0], CBM_NOT_FOUND, &stmt, + NULL) != SQLITE_OK) { + cbm_free(CBM_MEM_CLASS_STORE, lo); + cbm_free(CBM_MEM_CLASS_STORE, hi); + store_set_error_sqlite(s, "qn_base prepare"); + return CBM_STORE_ERR; + } + int col = SKIP_ONE; + if (project) { + bind_text(stmt, col++, project); + } + bind_text(stmt, col++, lo); + bind_text(stmt, col, hi); + store_qn_base_arg_t arg = {base, strlen(base), suffix_match}; + int rc = store_collect_nodes(s, stmt, store_qn_base_keep, &arg, out, count); + cbm_free(CBM_MEM_CLASS_STORE, lo); + cbm_free(CBM_MEM_CLASS_STORE, hi); + return rc; } /* ── NodeDegree ────────────────────────────────────────────────── */ diff --git a/src/store/store.h b/src/store/store.h index 06ea1fbb7e..d8d25c70fb 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -105,6 +105,13 @@ int cbm_store_find_nodes_by_file_overlap(cbm_store_t *s, const char *project, co int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const char *suffix, cbm_node_t **out, int *count); +/* Find callables whose signature-qualified QN (#2061) has `base` as its base + * QN (exact), or a base ending with "." + `base` (suffix_match). QNs without + * a callable identity suffix never match: this is the tier that lets a bare + * QN name every overload. */ +int cbm_store_find_nodes_by_qn_base(cbm_store_t *s, const char *project, const char *base, + bool suffix_match, cbm_node_t **out, int *count); + /* Get CALLS degree of a node (inbound and outbound). */ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *out_deg); diff --git a/tests/test_callable_sig.c b/tests/test_callable_sig.c new file mode 100644 index 0000000000..ad0315032a --- /dev/null +++ b/tests/test_callable_sig.c @@ -0,0 +1,348 @@ +/* + * test_callable_sig.c — golden tables for the signature-qualified callable + * identity builder and its inverse (#2061, plumbing: no language enabled). + */ +#include "test_framework.h" +#include "callable_sig.h" +#include "lang_specs.h" +#include "foundation/arena.h" +#include "pipeline/pipeline.h" +#include "pipeline/lsp_resolve.h" +#include "tree_sitter/api.h" +#include + +typedef struct { + CBMLanguage lang; + CBMCallableIdentity mode; + const char *source; + const char *kind; /* node kind of the callable */ + int nth; /* 0-based occurrence of `kind` in preorder */ + const char *expected; +} sig_case_t; + +static bool find_nth(TSNode n, const char *kind, int *left, TSNode *out) { + if (strcmp(ts_node_type(n), kind) == 0) { + if (*left == 0) { + *out = n; + return true; + } + (*left)--; + } + uint32_t count = ts_node_child_count(n); + for (uint32_t i = 0; i < count; i++) { + if (find_nth(ts_node_child(n, i), kind, left, out)) { + return true; + } + } + return false; +} + +/* Parse `source`, build the nth `kind` node's suffix in `mode`; the returned + * string is arena-owned (NULL when absent). */ +static const char *sig_of(CBMArena *a, CBMLanguage lang, CBMCallableIdentity mode, + const char *source, const char *kind, int nth) { + TSParser *parser = ts_parser_new(); + ts_parser_set_language(parser, cbm_ts_language(lang)); + TSTree *tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)strlen(source)); + TSNode node = {0}; + int left = nth; + const char *sig = NULL; + if (tree && find_nth(ts_tree_root_node(tree), kind, &left, &node)) { + sig = cbm_callable_sig_mode(a, node, source, lang, mode); + } + ts_tree_delete(tree); + ts_parser_delete(parser); + return sig; +} + +static const sig_case_t k_cases[] = { + /* Java: generics kept, qualifiers and annotations dropped, varargs = []. */ + {CBM_LANG_JAVA, CBM_CALLABLE_ID_TYPED, + "class A { > void work(final java.util.List xs, int[] a, " + "String... rest) {} }", + "method_declaration", 0, "(List,int[],String[])"}, + {CBM_LANG_JAVA, CBM_CALLABLE_ID_TYPED, "class A { void f(@Ann Map m, int a[]){} }", + "method_declaration", 0, "(Map,int[])"}, + {CBM_LANG_JAVA, CBM_CALLABLE_ID_TYPED, + "class A { void f(\n @Ann Map< K ,\n V > m, /* c */ int a [ ]\n) {} }", "method_declaration", + 0, "(Map,int[])"}, + {CBM_LANG_JAVA, CBM_CALLABLE_ID_TYPED, "class A { A(int x){} void g(){} }", + "constructor_declaration", 0, "(int)"}, + {CBM_LANG_JAVA, CBM_CALLABLE_ID_TYPED, "class A { A(int x){} void g(){} }", + "method_declaration", 0, "()"}, + /* C#: generic arity, ref/out, params inlined by the grammar, tuple names + * dropped, operators and indexers. */ + {CBM_LANG_CSHARP, CBM_CALLABLE_ID_TYPED, + "class A { public void Work(ref int x, out string y, params object[] rest, int? n = 3, " + "List> m, (int a, string b) t, this Foo z) {} }", + "method_declaration", 0, + "(ref int,out string,object[],int?,List>,(int,string),Foo)"}, + {CBM_LANG_CSHARP, CBM_CALLABLE_ID_TYPED, + "class A { public static A operator +(A a, A b) => a; }", "operator_declaration", 0, "(A,A)"}, + {CBM_LANG_CSHARP, CBM_CALLABLE_ID_TYPED, "class A { int this[int i] { get => 0; } }", + "indexer_declaration", 0, "(int)"}, + /* C++: template kinds, declarator markers, top-level cv dropped, arrays + * decay, function pointers, C variadic, packs, cvref, operators. */ + {CBM_LANG_CPP, CBM_CALLABLE_ID_TYPED, + "struct S { template void work(const std::vector & v, " + "int (*cb)(int), unsigned long x, ...) const && noexcept; };", + "template_declaration", 0, + "(const vector&,int(*)(int),unsigned long,~)const&&"}, + {CBM_LANG_CPP, CBM_CALLABLE_ID_TYPED, "struct S { S& operator=(S&&) &; };", "field_declaration", + 0, "(S&&)&"}, + {CBM_LANG_CPP, CBM_CALLABLE_ID_TYPED, "struct S { bool operator()(int) volatile; };", + "field_declaration", 0, "(int)volatile"}, + {CBM_LANG_CPP, CBM_CALLABLE_ID_TYPED, + "struct S { template void emplace(Args&&... args); };", "template_declaration", + 0, "(Args&&~)"}, + {CBM_LANG_CPP, CBM_CALLABLE_ID_TYPED, "void n::S::arr(int a[10], char *const p, int q) {}", + "function_definition", 0, "(int*,char*,int)"}, + {CBM_LANG_CPP, CBM_CALLABLE_ID_TYPED, + "void w(\n const int a , // trailing\n char const * /* c */ const p\n);", + "declaration", 0, "(int,const char*)"}, + {CBM_LANG_CPP, CBM_CALLABLE_ID_TYPED, "void v(void);", "declaration", 0, "()"}, + {CBM_LANG_CPP, CBM_CALLABLE_ID_TYPED, "void f(int (*(*g)(int x))(double), void h(char *s));", + "declaration", 0, "(int(*(*)(int))(double),void(*)(char*))"}, + {CBM_LANG_CPP, CBM_CALLABLE_ID_TYPED, + "template struct S { void f(int); };\ntemplate void S::f(int x) {}", + "function_definition", 0, "(int)"}, + /* Kotlin: extension receiver, vararg, function types, nullable, + * qualified types, suspend. */ + {CBM_LANG_KOTLIN, CBM_CALLABLE_ID_TYPED, + "class A {\n fun > String.work(vararg xs: Int, f: (Int) -> Unit, " + "m: Map>? = null, @Ann q: kotlin.collections.List<*>) {}\n}", + "function_declaration", 0, "(this:String,Int~,(Int)=>Unit,Map>?,List<*>)"}, + {CBM_LANG_KOTLIN, CBM_CALLABLE_ID_TYPED, "fun top(x: Int) = x", "function_declaration", 0, + "(Int)"}, + {CBM_LANG_KOTLIN, CBM_CALLABLE_ID_TYPED, "class A { constructor(x: Int) {} }", + "secondary_constructor", 0, "(Int)"}, + /* Swift: labels + types (the #2061 Alamofire shape), variadic, inout, + * attributes dropped, qualified types, init/subscript/operators. */ + {CBM_LANG_SWIFT, CBM_CALLABLE_ID_LABELED_TYPED, + "class Session {\n func upload(_ data: Data, to url: URLConvertible, method: " + "HTTPMethod = .post, headers: [String: String]? = nil, handler: @escaping (Int) -> Void, " + "xs: Int..., io: inout Swift.Int, t: (a: Int, String)) -> UploadRequest { }\n}", + "function_declaration", 0, + "(_:Data,to:URLConvertible,method:HTTPMethod,headers:[String:String]?,handler:(Int)=>Void," + "xs:Int~,io:inout Int,t:(a:Int,String))"}, + {CBM_LANG_SWIFT, CBM_CALLABLE_ID_LABELED_TYPED, + "class Session {\n func upload(_ data: Data, with request: URLRequest) {}\n}", + "function_declaration", 0, "(_:Data,with:URLRequest)"}, + {CBM_LANG_SWIFT, CBM_CALLABLE_ID_LABELED_TYPED, "class C { init(frame: CGRect) {} }", + "init_declaration", 0, "(frame:CGRect)"}, + {CBM_LANG_SWIFT, CBM_CALLABLE_ID_LABELED_TYPED, + "class C { subscript(index i: Int) -> Int { return 0 } }", "subscript_declaration", 0, + "(index:Int)"}, + {CBM_LANG_SWIFT, CBM_CALLABLE_ID_LABELED_TYPED, + "class C { static func + (l: C, r: C) -> C { l } }", "function_declaration", 0, "(l:C,r:C)"}, + {CBM_LANG_SWIFT, CBM_CALLABLE_ID_LABELED_TYPED, "func extra() {}", "function_declaration", 0, + "()"}, + /* Scala: every clause flattened, by-name and repeated parameters. */ + {CBM_LANG_SCALA, CBM_CALLABLE_ID_TYPED, + "class A {\n def work[T <: AnyRef](xs: List[T], n: => Int, rest: String*)(implicit ord: " + "Ordering[T]): Unit = {}\n}", + "function_definition", 0, "(List[T],=>Int,String*,Ordering[T])"}, + {CBM_LANG_SCALA, CBM_CALLABLE_ID_TYPED, + "class A {\n def f(g: (Int, String) => scala.collection.immutable.Map[String, Int]): Unit\n}", + "function_declaration", 0, "((Int,String)=>Map[String,Int])"}, + /* Objective-C: the selector's keyword labels. */ + {CBM_LANG_OBJC, CBM_CALLABLE_ID_LABELED, + "@implementation Foo\n- (instancetype)initWithFrame:(CGRect)frame style:(NSInteger)style " + "{ return self; }\n@end", + "method_definition", 0, "(_:style:)"}, + {CBM_LANG_OBJC, CBM_CALLABLE_ID_LABELED, "@implementation Foo\n- (void)run { }\n@end", + "method_definition", 0, "()"}, + {CBM_LANG_OBJC, CBM_CALLABLE_ID_LABELED, + "@implementation Foo\n- (void)setX:(int)x :(int)y {}\n@end", "method_definition", 0, "(_:_:)"}, + {CBM_LANG_OBJC, CBM_CALLABLE_ID_LABELED, + "@implementation Foo\n+ (id)make:(int)a, ... { return nil; }\n@end", "method_definition", 0, + "(_:~)"}, + /* ARITY (dynamic tier-2 languages), shown on a Swift declaration. */ + {CBM_LANG_SWIFT, CBM_CALLABLE_ID_ARITY, "class C { func upload(_ data: Data, to url: URL) {} }", + "function_declaration", 0, "(2)"}, +}; + +TEST(callable_sig_golden_table) { + for (size_t i = 0; i < sizeof(k_cases) / sizeof(k_cases[0]); i++) { + const sig_case_t *tc = &k_cases[i]; + CBMArena a; + cbm_arena_init(&a); + const char *sig = sig_of(&a, tc->lang, tc->mode, tc->source, tc->kind, tc->nth); + if (!sig || strcmp(sig, tc->expected) != 0) { + printf(" case %zu: got %s, want %s\n", i, sig ? sig : "(null)", tc->expected); + } + ASSERT_NOT_NULL(sig); + ASSERT_STR_EQ(sig, tc->expected); + ASSERT_TRUE(strlen(sig) <= CBM_CALLABLE_SIG_MAX); + /* Contract: no separator a leaf splitter uses, and the inverse + * recovers the base from a real-shaped QN. */ + ASSERT_NULL(strchr(sig, '.')); + ASSERT_NULL(strstr(sig, "::")); + ASSERT_NULL(strstr(sig, "->")); + char *qn = cbm_arena_sprintf(&a, "proj.pkg.Cls.name%s", sig); + ASSERT_EQ(cbm_qn_callable_base_len(qn), strlen("proj.pkg.Cls.name")); + cbm_arena_destroy(&a); + } + PASS(); +} + +/* Long signatures cap at CBM_CALLABLE_SIG_MAX with a hash of the FULL + * suffix: deterministic, still distinct, still invertible. */ +TEST(callable_sig_cap_keeps_identity) { + char src_a[4096]; + char src_b[4096]; + size_t n = (size_t)snprintf(src_a, sizeof(src_a), "class A { void f("); + for (int i = 0; i < 30; i++) { + n += (size_t)snprintf(src_a + n, sizeof(src_a) - n, "%sVeryLongParameterTypeName%d p%d", + i ? ", " : "", i, i); + } + memcpy(src_b, src_a, n); + snprintf(src_a + n, sizeof(src_a) - n, ", int last) {} }"); + snprintf(src_b + n, sizeof(src_b) - n, ", long last) {} }"); + CBMArena a; + cbm_arena_init(&a); + const char *sa = + sig_of(&a, CBM_LANG_JAVA, CBM_CALLABLE_ID_TYPED, src_a, "method_declaration", 0); + const char *sa2 = + sig_of(&a, CBM_LANG_JAVA, CBM_CALLABLE_ID_TYPED, src_a, "method_declaration", 0); + const char *sb = + sig_of(&a, CBM_LANG_JAVA, CBM_CALLABLE_ID_TYPED, src_b, "method_declaration", 0); + ASSERT_NOT_NULL(sa); + ASSERT_NOT_NULL(sb); + ASSERT_TRUE(strlen(sa) <= CBM_CALLABLE_SIG_MAX); + ASSERT_TRUE(strlen(sb) <= CBM_CALLABLE_SIG_MAX); + ASSERT_STR_EQ(sa, sa2); + ASSERT_STR_NEQ(sa, sb); /* they differ only past the cap: the hash tells */ + ASSERT_TRUE(strncmp(sa, "(VeryLongParameterTypeName0,", 28) == 0); + const char *hash = strchr(sa, '#'); + ASSERT_NOT_NULL(hash); + ASSERT_EQ(strlen(hash), (size_t)18); /* "#" + 16 hex + ")" */ + char *qn = cbm_arena_sprintf(&a, "p.A.f%s", sa); + ASSERT_EQ(cbm_qn_callable_base_len(qn), strlen("p.A.f")); + cbm_arena_destroy(&a); + PASS(); +} + +/* PR1 is plumbing: no language mints a suffix yet. */ +TEST(callable_sig_every_language_is_none) { + CBMArena a; + cbm_arena_init(&a); + const char *src = "class A { void f(int x) {} }"; + for (int lang = 0; lang < CBM_LANG_COUNT; lang++) { + ASSERT_EQ(cbm_callable_identity((CBMLanguage)lang), CBM_CALLABLE_ID_NONE); + } + TSParser *parser = ts_parser_new(); + ts_parser_set_language(parser, cbm_ts_language(CBM_LANG_JAVA)); + TSTree *tree = ts_parser_parse_string(parser, NULL, src, (uint32_t)strlen(src)); + TSNode m = {0}; + int left = 0; + ASSERT_TRUE(find_nth(ts_tree_root_node(tree), "method_declaration", &left, &m)); + ASSERT_NULL(cbm_callable_sig(&a, m, src, CBM_LANG_JAVA)); + ts_tree_delete(tree); + ts_parser_delete(parser); + cbm_arena_destroy(&a); + PASS(); +} + +/* The inverse is the identity on every historical QN shape. */ +TEST(callable_sig_base_len_unsuffixed_is_full) { + static const char *const qns[] = { + "proj.pkg.Cls.method", + "proj.S.operator()", + "proj.S.operator<", + "proj.S.operator<=>", + "proj.S.operator->", + "proj.S.operator>>", + "proj.S.operator&&", + "proj.app/(auth)", + "proj.Makefile.$(OBJ)", + "proj.f.(anon)", + "", + "proj.x.list", + "proj.x.make_const", + "a::b::c", + "proj.~Dtor", + "(x)", + "proj.Makefile.foo$(EXT)", + "proj.lib/(group)/page", + "proj.C.+", + "proj.A.unary_!", + }; + for (size_t i = 0; i < sizeof(qns) / sizeof(qns[0]); i++) { + ASSERT_EQ(cbm_qn_callable_base_len(qns[i]), strlen(qns[i])); + } + ASSERT_EQ(cbm_qn_callable_base_len(NULL), (size_t)0); + PASS(); +} + +TEST(callable_sig_base_len_suffixed) { + static const struct { + const char *qn; + const char *base; + } rows[] = { + {"p.S.work(int,String)", "p.S.work"}, + {"p.S.work()", "p.S.work"}, + {"p.S.operator()()", "p.S.operator()"}, + {"p.S.operator()(int)const", "p.S.operator()"}, + {"p.S.operator<(T)", "p.S.operator<"}, + {"p.S.operator<=>(S)", "p.S.operator<=>"}, + {"p.S.operator->()", "p.S.operator->"}, + {"p.S.f(const vector&,int(*)(int))const&&", "p.S.f"}, + {"p.C.Work(ref int)", "p.C.Work"}, + {"p.Session.upload(_:Data,to:URL)", "p.Session.upload"}, + {"p.K.work(this:String,(Int)=>Unit)", "p.K.work"}, + {"p.O.initWithFrame(_:style:)", "p.O.initWithFrame"}, + {"p.C.+(l:C,r:C)", "p.C.+"}, + {"p.A.unary_!()", "p.A.unary_!"}, + {"p.S.operator/(S)", "p.S.operator/"}, + {"p.S.~S()", "p.S.~S"}, + {"p.J.f(VeryLong0,#0123456789abcdef)", "p.J.f"}, + }; + for (size_t i = 0; i < sizeof(rows) / sizeof(rows[0]); i++) { + ASSERT_EQ(cbm_qn_callable_base_len(rows[i].qn), strlen(rows[i].base)); + } + PASS(); +} + +/* The leaf splitters look only at the base: Swift labels carry ':' and a + * suffix may carry '>' — neither is a separator. */ +TEST(callable_sig_leaf_splitters_skip_suffix) { + const char *swift = "p.Session.upload(_:Data,to:URL)"; + ASSERT_STR_EQ(cbm_lsp_bare_segment(swift), "upload(_:Data,to:URL)"); + ASSERT_STR_EQ(cbm_pipeline_qn_class_method_tail(swift), "Session.upload(_:Data,to:URL)"); + /* Unsuffixed spellings are unchanged. */ + ASSERT_STR_EQ(cbm_lsp_bare_segment("Math::square"), "square"); + ASSERT_STR_EQ(cbm_lsp_bare_segment("p->run"), "run"); + ASSERT_STR_EQ(cbm_lsp_bare_segment("identity"), "identity"); + ASSERT_STR_EQ(cbm_pipeline_qn_class_method_tail("p.q.Cls.m"), "Cls.m"); + PASS(); +} + +/* The registry's by-name bucket is the base leaf, so every overload of a + * signature-qualified callable is found by its bare name; an unsuffixed QN + * keeps its historical key (last '.' or "::" segment). */ +TEST(callable_sig_registry_by_name_uses_base_leaf) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "upload", "p.Session.upload(_:Data,to:URL)", "Method"); + cbm_registry_add(r, "upload", "p.Session.upload(_:Data,with:URLRequest)", "Method"); + cbm_registry_add(r, "square", "p.Math::square", "Function"); + const char **out = NULL; + int count = 0; + cbm_registry_find_by_name(r, "upload", &out, &count); + ASSERT_EQ(count, 2); + count = 0; + cbm_registry_find_by_name(r, "square", &out, &count); + ASSERT_EQ(count, 1); + cbm_registry_free(r); + PASS(); +} + +SUITE(callable_sig) { + RUN_TEST(callable_sig_golden_table); + RUN_TEST(callable_sig_cap_keeps_identity); + RUN_TEST(callable_sig_every_language_is_none); + RUN_TEST(callable_sig_base_len_unsuffixed_is_full); + RUN_TEST(callable_sig_base_len_suffixed); + RUN_TEST(callable_sig_leaf_splitters_skip_suffix); + RUN_TEST(callable_sig_registry_by_name_uses_base_leaf); +} diff --git a/tests/test_main.c b/tests/test_main.c index 5c3dcf067b..1cc6697d65 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -892,6 +892,7 @@ extern void suite_subprocess(void); extern void suite_private_file_lock(void); extern void suite_lock_registry(void); extern void suite_extraction(void); +extern void suite_callable_sig(void); extern void suite_extraction_inheritance(void); extern void suite_extraction_imports(void); extern void suite_parse_coverage(void); @@ -1207,6 +1208,7 @@ int main(int argc, char **argv) { /* Existing C code regression tests */ RUN_SELECTED_SUITE(ac); RUN_SELECTED_SUITE(extraction); + RUN_SELECTED_SUITE(callable_sig); RUN_SELECTED_SUITE(extraction_inheritance); RUN_SELECTED_SUITE(extraction_imports); RUN_SELECTED_SUITE(parse_coverage); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index c08beadada..4f1d6dad2b 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -193,6 +193,101 @@ TEST(store_project_delete) { PASS(); } +/* ── QN suffix / callable-base lookups (#2061) ─────────────────── */ + +static void qn_lookup_node(cbm_store_t *s, const char *name, const char *qn) { + cbm_node_t n = {.project = "test", + .label = "Method", + .name = name, + .qualified_name = qn, + .file_path = "a.java", + .start_line = 1, + .end_line = 2}; + cbm_store_upsert_node(s, &n); +} + +/* The suffix tier matches its text literally: '_' is not a LIKE wildcard, + * so `my_func` must not also find `myXfunc`. */ +TEST(store_qn_suffix_is_literal) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + qn_lookup_node(s, "my_func", "test.a.my_func"); + qn_lookup_node(s, "myXfunc", "test.a.myXfunc"); + cbm_node_t *nodes = NULL; + int count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_qn_suffix(s, "test", "my_func", &nodes, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(nodes[0].qualified_name, "test.a.my_func"); + cbm_store_free_nodes(nodes, count); + cbm_store_close(s); + PASS(); +} + +/* A suffix longer than the old 512-byte pattern buffer is not truncated into + * a pattern that matches nothing. */ +TEST(store_qn_suffix_is_unbounded) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + char leaf[601]; + memset(leaf, 'a', sizeof(leaf) - 1); + leaf[sizeof(leaf) - 1] = '\0'; + char long_qn[640]; + char long_suffix[640]; + snprintf(long_qn, sizeof(long_qn), "test.m.%s", leaf); + snprintf(long_suffix, sizeof(long_suffix), "m.%s", leaf); + qn_lookup_node(s, leaf, long_qn); + + cbm_node_t *nodes = NULL; + int count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_qn_suffix(s, "test", long_suffix, &nodes, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(nodes[0].qualified_name, long_qn); + cbm_store_free_nodes(nodes, count); + cbm_store_close(s); + PASS(); +} + +/* A bare base QN (exact, or as a dotted suffix) finds every signature- + * qualified overload of it, and never an unsuffixed QN — so the tier is inert + * until a language mints suffixes. */ +TEST(store_qn_base_finds_overloads_only) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + qn_lookup_node(s, "work", "test.S.work(int)"); + qn_lookup_node(s, "work", "test.S.work(String)"); + qn_lookup_node(s, "work", "test.S.work"); + qn_lookup_node(s, "workx", "test.S.workx(int)"); + qn_lookup_node(s, "operator()", "test.S.operator()"); + qn_lookup_node(s, "Work", "test.C.Work(T)"); + + cbm_node_t *nodes = NULL; + int count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_qn_base(s, "test", "test.S.work", false, &nodes, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 2); + cbm_store_free_nodes(nodes, count); + + ASSERT_EQ(cbm_store_find_nodes_by_qn_base(s, "test", "S.work", true, &nodes, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 2); + cbm_store_free_nodes(nodes, count); + + ASSERT_EQ(cbm_store_find_nodes_by_qn_base(s, "test", "test.C.Work", false, &nodes, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + cbm_store_free_nodes(nodes, count); + + /* `operator()` is a bare C++ leaf, not "operator" + "()". */ + ASSERT_EQ(cbm_store_find_nodes_by_qn_base(s, "test", "test.S.operator", false, &nodes, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + cbm_store_free_nodes(nodes, count); + cbm_store_close(s); + PASS(); +} + /* ── Node CRUD ──────────────────────────────────────────────────── */ TEST(store_node_crud) { @@ -2393,6 +2488,9 @@ TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails) { } SUITE(store_nodes) { + RUN_TEST(store_qn_suffix_is_literal); + RUN_TEST(store_qn_suffix_is_unbounded); + RUN_TEST(store_qn_base_finds_overloads_only); RUN_TEST(store_coverage_roundtrip_prune_shadow); RUN_TEST(store_coverage_targeted_path_and_scope_lookup); RUN_TEST(store_coverage_meta_zero_row_truncation_and_delete); From 5538355530bb126c3041f7fcf8ef7a82b4bb3fec Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 25 Sep 2026 22:38:35 +0200 Subject: [PATCH 2/2] chore(lint): clang-format the callable_sig.h include comment in store.c Signed-off-by: Martin Vogel --- src/store/store.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/store.c b/src/store/store.c index b1749fc153..14760c0b79 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -78,7 +78,7 @@ enum { #include "foundation/compat.h" #include "foundation/log.h" #include "foundation/compat_regex.h" -#include "callable_sig.h" /* cbm_qn_callable_base_len: base-match tier */ +#include "callable_sig.h" /* cbm_qn_callable_base_len: base-match tier */ #include "foundation/mem_core.h" /* cbm_alloc: pattern buffers */ #include "foundation/str_util.h"