Skip to content
201 changes: 185 additions & 16 deletions internal/cbm/extract_defs.c

Large diffs are not rendered by default.

35 changes: 32 additions & 3 deletions internal/cbm/extract_unified.c
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan
return NULL;
}

char *name = cbm_func_name_node_text(ctx->arena, name_node, ctx->source);
char *name = cbm_func_name_node_text(ctx->arena, name_node, ctx->source, ctx->language);
if (!name || !name[0]) {
return NULL;
}
Expand All @@ -644,12 +644,24 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan
}
}

/* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def
* extractor bakes it into the def QN. Compose it identically here — otherwise
* an in-body call sources to a QN one or more segments short of the def, and
* the edge is dropped at write. */
const char *qn_name = name;
if (ctx->language == CBM_LANG_NIX) {
qn_name = cbm_nix_qn_name(ctx->arena, node, ctx->source, name);
if (!qn_name || !qn_name[0]) {
return NULL;
}
}

if (state->enclosing_class_qn) {
return cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, name);
return cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, qn_name);
}
/* Java/Go: directory-based module so this enclosing-func QN matches the def
* QN and the LSP caller_qn (the lsp_resolve join keys on exact equality). */
return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, name,
return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, qn_name,
ctx->language);
}

Expand All @@ -658,6 +670,13 @@ static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const WalkS
if (ctx->language == CBM_LANG_OBJECTSCRIPT_UDL) {
return objectscript_get_class_name(ctx, node);
}
/* Nix: an attrset-valued `binding` is a named scope. Same shared helper the def
* extractor's compute_class_qn uses — these are two separate functions, and a
* one-segment disagreement between them drops every CALLS edge sourced from a
* nested binding. */
if (ctx->language == CBM_LANG_NIX) {
return cbm_nix_binding_scope_qn(ctx, node, state ? state->enclosing_class_qn : NULL);
}
TSNode name_node = ts_node_child_by_field_name(node, TS_FIELD("name"));
/* Newer tree-sitter-kotlin: class/object name is a type_identifier child. */
if (ts_node_is_null(name_node) && ctx->language == CBM_LANG_KOTLIN) {
Expand Down Expand Up @@ -1418,6 +1437,16 @@ static void push_boundary_scopes(CBMExtractCtx *ctx, TSNode node, const CBMLangS
state->os_type_map.class_base_count = 0;
}
}
} else if (ctx->language == CBM_LANG_NIX && strcmp(ts_node_type(node), "binding") == 0 &&
cbm_nix_binding_is_attrset_scope(node)) {
/* Nix: a binding whose value is an attribute set is a named scope, exactly
* as is_namespace_scope_kind treats it on the def side. Pushing it here is
* what makes an in-body call source to `proj.file.setA.fn` rather than the
* bare `proj.file.fn` that no node carries once defs are attrpath-qualified. */
const char *cqn = compute_class_qn(ctx, node, state);
if (cqn) {
push_scope(state, SCOPE_CLASS, depth, cqn);
}
} else if (ctx->language == CBM_LANG_RUST && strcmp(ts_node_type(node), "impl_item") == 0) {
TSNode type_node = ts_node_child_by_field_name(node, TS_FIELD("type"));
if (!ts_node_is_null(type_node)) {
Expand Down
172 changes: 170 additions & 2 deletions internal/cbm/helpers.c
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@ TSNode cbm_resolve_c_declarator_name_node(TSNode func_node) {
// space. Without this the conversion operator is indexed as "operator bool()
// const", and a member lookup for "operator bool" (the implicit call in
// `if (obj)`) misses.
char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source) {
char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source, CBMLanguage lang) {
char *text = cbm_node_text(a, name_node, source);
if (text && strcmp(ts_node_type(name_node), "operator_cast") == 0) {
char *paren = strchr(text, '(');
Expand All @@ -843,9 +843,177 @@ char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source)
*paren = '\0';
}
}
/* Nix quoted attrpath segment: `"kebab-case" = a: a;` and `services."my.svc" = …`
* are ordinary names that merely need quoting in source. The node text carries
* the delimiters, so without this the def is named `"kebab-case"` — quotes and
* all — and every consumer keying on the name (search, CALLS resolution, the
* LSP join) would have to know to re-quote. Stripped here rather than in the
* resolver so the def name and the call-scope QN, which both route through this
* function, cannot disagree. */
if (text && lang == CBM_LANG_NIX) {
cbm_nix_strip_attr_quotes(text);
}
return text;
}

/* ── Nix attrpath helpers ───────────────────────────────────
* A Nix binding's name is a PATH (`a.b.c = …`), whose segments may be quoted or
* interpolated. These render it the way the rest of the extractor expects: leaf
* segment as the name, leading segments as scope. Shared by the defs and unified
* (call-scope) extractors so both compute the same QN — if they disagree, a CALLS
* edge names a source node that does not exist and is dropped at write, which is
* precisely how the function-header bug manifested. */

/* Bound on the interpolation scan below. An attrpath segment is an identifier or a
* string, so its subtree is shallow; this only has to stop a pathological input. */
enum { NIX_ATTR_SCAN_MAX = 32 };

/* Strip one matching pair of surrounding double quotes, in place. */
void cbm_nix_strip_attr_quotes(char *text) {
if (!text) {
return;
}
size_t len = strlen(text);
if (len >= CBM_QUOTE_PAIR && text[0] == '"' && text[len - SKIP_ONE] == '"') {
memmove(text, text + SKIP_ONE, len - PAIR_LEN);
text[len - PAIR_LEN] = '\0';
}
}

/* True when a segment contains a `${...}` interpolation, and therefore has no
* statically knowable name. Iterative and bounded — the lint forbids unlisted
* recursion, and an attrpath segment is shallow by construction. */
bool cbm_nix_attr_is_interpolated(TSNode attr) {
if (ts_node_is_null(attr)) {
return false;
}
TSNode stack[NIX_ATTR_SCAN_MAX];
int top = 0;
stack[top++] = attr;
while (top > 0) {
TSNode cur = stack[--top];
if (strcmp(ts_node_type(cur), "interpolation") == 0) {
return true;
}
uint32_t n = ts_node_named_child_count(cur);
for (uint32_t i = 0; i < n && top < NIX_ATTR_SCAN_MAX; i++) {
stack[top++] = ts_node_named_child(cur, i);
}
}
return false;
}

/* The leaf segment of an attrpath — the name. `attr` is a FIELD in this grammar,
* not a node type (segments are identifier / string_expression / interpolation),
* so iterate named children rather than matching on a type string. */
TSNode cbm_nix_attrpath_last_attr(TSNode attrpath) {
TSNode last = {0};
if (ts_node_is_null(attrpath)) {
return last;
}
uint32_t n = ts_node_named_child_count(attrpath);
if (n == 0) {
return last;
}
return ts_node_named_child(attrpath, n - SKIP_ONE);
}

/* The scope prefix of an attrpath: every segment except the leaf, quote-stripped
* and dot-joined. `a.b.fn = …` yields "a.b" so it qualifies identically to the
* nested spelling `a = { b = { fn = …; }; }`. Returns NULL for a single-segment
* path (no scope) or when a leading segment is interpolated (not nameable). */
const char *cbm_nix_attrpath_scope(CBMArena *a, TSNode attrpath, const char *source) {
if (ts_node_is_null(attrpath)) {
return NULL;
}
uint32_t n = ts_node_named_child_count(attrpath);
if (n <= SKIP_ONE) {
return NULL;
}
const char *scope = NULL;
for (uint32_t i = 0; i + SKIP_ONE < n; i++) {
TSNode seg = ts_node_named_child(attrpath, i);
if (cbm_nix_attr_is_interpolated(seg)) {
return NULL;
}
char *seg_text = cbm_node_text(a, seg, source);
if (!seg_text || !seg_text[0]) {
return NULL;
}
cbm_nix_strip_attr_quotes(seg_text);
scope = scope ? cbm_arena_sprintf(a, "%s.%s", scope, seg_text) : seg_text;
}
return scope;
}

/* True when a Nix `binding`'s value is an attribute set, i.e. the binding names a
* scope rather than defining a value. Deliberately excludes `let` bindings and
* lambda-valued bindings: the former are lexical (C++ does not qualify by block
* scope either), the latter are definitions in their own right. */
bool cbm_nix_binding_is_attrset_scope(TSNode node) {
if (ts_node_is_null(node) || strcmp(ts_node_type(node), "binding") != 0) {
return false;
}
TSNode value = ts_node_child_by_field_name(node, TS_FIELD("expression"));
if (ts_node_is_null(value)) {
return false;
}
const char *vk = ts_node_type(value);
return strcmp(vk, "attrset_expression") == 0 || strcmp(vk, "rec_attrset_expression") == 0;
}

/* The scope QN contributed by a Nix `binding` whose value is an attribute set —
* `setA = { … }` contributes "…file.setA", and a dotted `a.b = { … }` contributes
* "…file.a.b". Returns saved_enclosing unchanged when the binding cannot name a
* scope (empty or interpolated attrpath).
*
* Lives here, called by BOTH extract_defs.c and extract_unified.c, because those
* two files carry separate compute_class_qn implementations. A def QN and a
* call-scope QN that disagree by even one segment make the CALLS edge name a
* source node that was never minted, and it is silently dropped at write. Sharing
* the computation makes that class of drift impossible rather than merely
* unlikely. */
const char *cbm_nix_binding_scope_qn(CBMExtractCtx *ctx, TSNode node, const char *saved_enclosing) {
if (!ctx || ts_node_is_null(node) || strcmp(ts_node_type(node), "binding") != 0) {
return saved_enclosing;
}
TSNode attrpath = ts_node_child_by_field_name(node, TS_FIELD("attrpath"));
TSNode leaf = cbm_nix_attrpath_last_attr(attrpath);
if (ts_node_is_null(leaf) || cbm_nix_attr_is_interpolated(leaf)) {
return saved_enclosing;
}
char *leaf_text = cbm_node_text(ctx->arena, leaf, ctx->source);
if (!leaf_text || !leaf_text[0]) {
return saved_enclosing;
}
cbm_nix_strip_attr_quotes(leaf_text);
const char *scope = cbm_nix_attrpath_scope(ctx->arena, attrpath, ctx->source);
const char *rel = scope ? cbm_arena_sprintf(ctx->arena, "%s.%s", scope, leaf_text) : leaf_text;
if (saved_enclosing) {
return cbm_arena_sprintf(ctx->arena, "%s.%s", saved_enclosing, rel);
}
return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, rel, ctx->language);
}

/* The QN-relative name of a Nix binding: its attrpath scope joined to its leaf
* name. `a.b.fn = …` yields "a.b.fn"; a bare `fn = …` yields "fn". Callers prepend
* either the enclosing attrset scope or the module QN, so the two scope sources —
* a dotted attrpath and an enclosing attrset — compose. Takes the already-resolved
* leaf name rather than re-deriving it, so it cannot disagree with the name the
* def was minted under. */
const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, const char *name) {
if (!name) {
return NULL;
}
TSNode parent = ts_node_parent(func_node);
if (ts_node_is_null(parent) || strcmp(ts_node_type(parent), "binding") != 0) {
return name;
}
TSNode attrpath = ts_node_child_by_field_name(parent, TS_FIELD("attrpath"));
const char *scope = cbm_nix_attrpath_scope(a, attrpath, source);
return scope ? cbm_arena_sprintf(a, "%s.%s", scope, name) : name;
}

static const char *func_node_name(CBMArena *a, TSNode func_node, const char *source,
CBMLanguage lang) {
// Wolfram: set_delayed_top/set_top/set_delayed/set — LHS is apply(user_symbol("f"), ...)
Expand Down Expand Up @@ -884,7 +1052,7 @@ static const char *func_node_name(CBMArena *a, TSNode func_node, const char *sou
if (strcmp(ts_node_type(func_node), "function_definition") == 0) {
TSNode dn = cbm_resolve_c_declarator_name_node(func_node);
if (!ts_node_is_null(dn)) {
return cbm_func_name_node_text(a, dn, source);
return cbm_func_name_node_text(a, dn, source, lang);
}
}
return NULL;
Expand Down
41 changes: 40 additions & 1 deletion internal/cbm/helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,46 @@ TSNode cbm_resolve_c_declarator_name_node(TSNode func_node);
// C++ conversion-operator's `operator_cast` node (which spans the full
// "operator bool() const") down to "operator bool". Shared by the defs and
// unified extractors so the def name and call-scope QN agree.
char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source);
// Also strips the surrounding quotes from a Nix quoted attrpath segment, so
// `"kebab-case" = a: a;` is named kebab-case rather than "kebab-case". Takes the
// language for that reason; every caller must pass ctx->language.
char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source, CBMLanguage lang);

// ── Nix attrpath helpers ──
// A Nix binding's name is a PATH (`a.b.c = …`) whose segments may be quoted or
// interpolated. Shared by the defs and unified (call-scope) extractors so both
// derive the same name and the same scope prefix — divergence makes a CALLS edge
// name a source node that does not exist, and it is dropped at write.

// Strip one matching pair of surrounding double quotes, in place.
void cbm_nix_strip_attr_quotes(char *text);

// True when an attrpath segment contains a `${...}` interpolation and therefore
// has no statically knowable name.
bool cbm_nix_attr_is_interpolated(TSNode attr);

// The leaf segment of an attrpath — the name. Null node for an empty attrpath.
TSNode cbm_nix_attrpath_last_attr(TSNode attrpath);

// The scope prefix of an attrpath: all segments but the leaf, quote-stripped and
// dot-joined, so `a.b.fn = …` qualifies identically to `a = { b = { fn = …; }; }`.
// NULL for a single-segment path, or when a leading segment is interpolated.
const char *cbm_nix_attrpath_scope(CBMArena *a, TSNode attrpath, const char *source);

// True when a Nix `binding`'s value is an attribute set — the binding names a
// scope rather than defining a value. Excludes let-bindings and lambda values.
bool cbm_nix_binding_is_attrset_scope(TSNode node);

// The scope QN contributed by a Nix `binding` whose value is an attribute set.
// Called by BOTH extract_defs.c and extract_unified.c, which carry separate
// compute_class_qn implementations — sharing this makes a def/call-scope QN
// mismatch (which silently drops the CALLS edge) structurally impossible.
const char *cbm_nix_binding_scope_qn(CBMExtractCtx *ctx, TSNode node, const char *saved_enclosing);

// The QN-relative name of a Nix binding — its attrpath scope joined to `name`.
// Callers prepend the enclosing attrset scope (or the module QN), so a dotted
// attrpath and an enclosing attrset compose into one qualified name.
const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, const char *name);

// Resolve a function/method definition node's NAME node across all ~130 grammars
// (generic `name` field, arrow→declarator, C/C++ declarator chain, plus the many
Expand Down
Loading
Loading