diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 7cc4177ef..4572a2156 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -31,6 +31,10 @@ enum { EXPORT_ANCESTOR_DEPTH = 4, FUNC_PARENT_CLIMB_LIMIT = 4, /* fun_expr -> term -> uni_term -> let_binding (Nickel) */ + /* Nix header lambdas to descend before the file's body: `{ pkgs, ... }:` is one, + * the nixpkgs overlay `final: prev:` is two. Bounded so a pathological chain + * cannot spin. */ + NIX_HEADER_HOP_MAX = 8, DECORATOR_SCAN_LIMIT = 3, C_RETURN_WALK_DEPTH = 5, VAR_RECURSION_LIMIT = 8, @@ -188,6 +192,7 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec); static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, int depth_unused); static void extract_variables(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec); +static void extract_var_names(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec); static void extract_class_variables(CBMExtractCtx *ctx, TSNode class_node, const CBMLangSpec *spec); static void extract_rust_impl(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec); static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const char *class_qn, @@ -821,15 +826,22 @@ TSNode cbm_resolve_func_name(TSNode node, CBMLanguage lang) { /* Nix: a named function is a `function_expression` (lambda `x: body`) with * no name of its own — the binding name lives on the enclosing `binding`'s * `attrpath` field (`name = x: ...`). Resolve through the parent binding to - * the attrpath's `attr` identifier so `addOne = x: ...` mints a Function - * def. A lambda whose parent is not a binding (e.g. an inline `map (x: x)` - * argument) resolves null and stays out of func_types. */ + * the attrpath's LAST `attr` so `addOne = x: ...` mints a Function def. A + * lambda whose parent is not a binding (e.g. an inline `map (x: x)` + * argument) resolves null and stays out of func_types. + * + * The last segment, not the first: an attrpath is a PATH, and `a.b.fn = …` + * is sugar for `a = { b = { fn = …; }; };`. Both spellings must mint the + * same name (`fn`) and the same QN (`proj.mod.a.b.fn`) — the leading + * segments are scope, supplied by cbm_nix_attrpath_scope. Taking the first + * segment named `a.b.fn` "a", which collided with every other binding + * whose path began `a`. */ if (lang == CBM_LANG_NIX && strcmp(kind, "function_expression") == 0) { TSNode parent = ts_node_parent(node); if (!ts_node_is_null(parent) && strcmp(ts_node_type(parent), "binding") == 0) { TSNode attrpath = ts_node_child_by_field_name(parent, TS_FIELD("attrpath")); if (!ts_node_is_null(attrpath)) { - TSNode attr = ts_node_child_by_field_name(attrpath, TS_FIELD("attr")); + TSNode attr = cbm_nix_attrpath_last_attr(attrpath); return ts_node_is_null(attr) ? attrpath : attr; } } @@ -3204,7 +3216,7 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec return; } - char *name = cbm_func_name_node_text(a, name_node, ctx->source); + char *name = cbm_func_name_node_text(a, name_node, ctx->source, ctx->language); if (!name || !name[0] || strcmp(name, "function") == 0) { return; } @@ -3217,17 +3229,33 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec return; } + /* Nix `"${foo}" = x: …`: an interpolated attrpath segment has no statically + * knowable name. Minting it would produce a def literally named `"${foo}"` + * that nothing can ever look up or resolve a call against. An absent node is + * the honest answer — same reasoning as the Makefile guard above. */ + if (ctx->language == CBM_LANG_NIX && cbm_nix_attr_is_interpolated(name_node)) { + return; + } + TSNode func_node = unwrap_template_inner(node, ctx->language); CBMDefinition def; memset(&def, 0, sizeof(def)); def.name = name; + /* Nix: a binding's name is a path. The leaf is the name; the leading segments + * are scope, so `a.b.fn = …` gets the same QN as `a = { b = { fn = …; }; }`. + * Without this every binding whose path shares a leaf name collapsed onto one + * node, silently discarding the later definition and its CALLS edges. */ + const char *qn_name = name; + if (ctx->language == CBM_LANG_NIX) { + qn_name = cbm_nix_qn_name(a, node, ctx->source, name); + } /* Java/Go derive the module from the containing directory (package), so the * filename stem is NOT baked into the QN (Go func in myapp/db/conn.go -> * proj.myapp.db.Func, not proj.myapp.db.conn.Func). Other langs unchanged. */ def.qualified_name = - cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, name, ctx->language); + cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, qn_name, ctx->language); /* A free function declared inside a namespace (C++/C#/PHP) is qualified by * the namespace scope the def walk carries (enclosing_class_qn was extended * by is_namespace_scope_kind), so `ns::serialize` is `proj.file.ns.serialize` @@ -3235,10 +3263,19 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * resolution (ADL, namespace-function lookup) can never see it. Class methods * never reach here (they go through extract_class_methods), so a set * enclosing scope here is always a namespace. The out-of-line method path - * below overrides this for `Ns::Cls::method` definitions. */ + * below overrides this for `Ns::Cls::method` definitions. + * + * Nix joins this list for the same reason: a binding inside an attrset is + * scoped by it, so `setA = { fn = ...; }` is proj.file.setA.fn. Uses qn_name, + * not name, so an attrpath's own leading segments compose with the enclosing + * scope. Note the call-scope side (compute_func_qn in extract_unified.c) is + * NOT language-gated — it qualifies whenever a scope is pushed — so this gate + * and the scope-push rule must move together, or a call QN names a def QN that + * was never minted and the edge is dropped at write. */ if (ctx->enclosing_class_qn && - (ctx->language == CBM_LANG_CPP || ctx->language == CBM_LANG_CUDA)) { - def.qualified_name = cbm_arena_sprintf(a, "%s.%s", ctx->enclosing_class_qn, name); + (ctx->language == CBM_LANG_CPP || ctx->language == CBM_LANG_CUDA || + ctx->language == CBM_LANG_NIX)) { + def.qualified_name = cbm_arena_sprintf(a, "%s.%s", ctx->enclosing_class_qn, qn_name); } def.label = "Function"; def.file_path = ctx->rel_path; @@ -4229,7 +4266,7 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node, const char *class_qn, const CBMLangSpec *spec, TSNode name_node) { CBMArena *a = ctx->arena; - char *name = cbm_func_name_node_text(a, name_node, ctx->source); + char *name = cbm_func_name_node_text(a, name_node, ctx->source, ctx->language); if (!name || !name[0]) { return; } @@ -4639,7 +4676,12 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp // --- Variable extraction --- // Helper to push a Variable definition -static void push_var_def(CBMExtractCtx *ctx, const char *name, TSNode node) { +/* `qn_name` is the name as it should appear in the qualified name, which differs + * from `name` only where a language scopes a variable below the module — Nix, + * whose binding names are attrpaths (`a.b.c = …` is name `c`, QN suffix `a.b.c`). + * Pass NULL to use `name` for both. */ +static void push_var_def_qn(CBMExtractCtx *ctx, const char *name, const char *qn_name, + TSNode node) { if (!name || !name[0] || strcmp(name, "_") == 0) { return; } @@ -4649,8 +4691,8 @@ static void push_var_def(CBMExtractCtx *ctx, const char *name, TSNode node) { def.name = name; /* Java/Go: directory-based module (package), so a Go package-level var in * myapp/db/conn.go is proj.myapp.db.Var, matching its siblings. */ - def.qualified_name = - cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, name, ctx->language); + def.qualified_name = cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, + qn_name ? qn_name : name, ctx->language); def.label = "Variable"; def.file_path = ctx->rel_path; def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; @@ -4659,6 +4701,10 @@ static void push_var_def(CBMExtractCtx *ctx, const char *name, TSNode node) { cbm_defs_push(&ctx->result->defs, a, def); } +static void push_var_def(CBMExtractCtx *ctx, const char *name, TSNode node) { + push_var_def_qn(ctx, name, NULL, node); +} + // Helper: extract name from a declarator chain (C/C++/ObjC) // declaration > init_declarator > declarator (may be pointer_declarator > identifier) static const char *extract_c_declarator_name(CBMArena *a, TSNode decl, const char *source) { @@ -5335,10 +5381,94 @@ static void extract_vars_config(CBMExtractCtx *ctx, TSNode node, CBMArena *a, co /* ── Variable name extraction dispatcher ────────────────────────── */ +/* Nix: a module-level `binding` whose value is neither a lambda nor an attribute + * set. Both of those are already represented — a lambda-valued binding is minted + * as a Function by the def walk, and an attrset-valued one is a scope + * (is_namespace_scope_kind) — so minting either again here would double-count it. + * + * The name is the attrpath's leaf and the QN carries the whole path, matching how + * the def walk names functions; `services.nginx.enable = true` is name `enable`, + * QN proj.file.services.nginx.enable. */ +static void extract_vars_nix(CBMExtractCtx *ctx, TSNode node, CBMArena *a) { + if (strcmp(ts_node_type(node), "binding") != 0) { + return; + } + TSNode value = ts_node_child_by_field_name(node, TS_FIELD("expression")); + if (ts_node_is_null(value)) { + return; + } + if (strcmp(ts_node_type(value), "function_expression") == 0) { + return; /* already a Function */ + } + if (cbm_nix_binding_is_attrset_scope(node)) { + return; /* a scope, not a value */ + } + 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; + } + char *name = cbm_node_text(a, leaf, ctx->source); + if (!name || !name[0]) { + return; + } + cbm_nix_strip_attr_quotes(name); + const char *scope = cbm_nix_attrpath_scope(a, attrpath, ctx->source); + const char *qn_name = scope ? cbm_arena_sprintf(a, "%s.%s", scope, name) : name; + push_var_def_qn(ctx, name, qn_name, node); +} + +/* Mint every direct binding of one Nix binding container. */ +static void extract_nix_binding_set(CBMExtractCtx *ctx, TSNode set, const CBMLangSpec *spec) { + if (ts_node_is_null(set)) { + return; + } + uint32_t n = ts_node_named_child_count(set); + for (uint32_t i = 0; i < n; i++) { + TSNode child = ts_node_named_child(set, i); + if (strcmp(ts_node_type(child), "binding") == 0) { + extract_var_names(ctx, child, spec); + } + } +} + +/* Walk past a Nix file's header lambda(s) to the container(s) holding its + * file-scope bindings, minting each. Handles the curried header (`final: prev:`) + * and both containers of a `let … in { … }` file: the let's own bindings are file + * scope in the same sense a C++ file-static is, and the returned attrset's are the + * exported surface. Anything deeper is nested and deliberately skipped. */ +static void extract_nix_module_vars(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) { + TSNode cur = ts_node_named_child_count(root) > 0 ? ts_node_named_child(root, 0) : root; + /* Descend header lambdas: `{ pkgs, ... }: `, `final: prev: `. */ + for (int hop = 0; hop < NIX_HEADER_HOP_MAX && !ts_node_is_null(cur) && + strcmp(ts_node_type(cur), "function_expression") == 0; + hop++) { + cur = ts_node_child_by_field_name(cur, TS_FIELD("body")); + } + if (ts_node_is_null(cur)) { + return; + } + if (strcmp(ts_node_type(cur), "let_expression") == 0) { + extract_nix_binding_set(ctx, cbm_find_child_by_kind(cur, "binding_set"), spec); + cur = ts_node_child_by_field_name(cur, TS_FIELD("body")); + if (ts_node_is_null(cur)) { + return; + } + } + const char *k = ts_node_type(cur); + if (strcmp(k, "attrset_expression") == 0 || strcmp(k, "rec_attrset_expression") == 0) { + extract_nix_binding_set(ctx, cbm_find_child_by_kind(cur, "binding_set"), spec); + } +} + static void extract_var_names(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) { (void)spec; CBMArena *a = ctx->arena; const char *kind = ts_node_type(node); + if (ctx->language == CBM_LANG_NIX) { + extract_vars_nix(ctx, node, a); + return; + } switch (ctx->language) { /* Mainstream + C-family + Rust */ @@ -5572,6 +5702,22 @@ static void extract_variables(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec return; } + /* Nix: the file's top level sits behind its header lambda(s), so the root's + * only child is a function_expression and the generic loop below would see + * nothing. Resolve past the header to the binding container(s) that actually + * constitute file scope, and mint only THEIR direct bindings. + * + * That bound is the point. Every Nix binding's parent is a binding_set at any + * depth, so admitting them all would mint a node per `enable = true` in a + * NixOS module's settings tree — the per-leaf flood the Helm values.yaml case + * above exists to avoid. C++ mints file-scope declarations and never locals; + * this is the same rule applied to a language whose file scope is behind a + * lambda. */ + if (ctx->language == CBM_LANG_NIX) { + extract_nix_module_vars(ctx, root, spec); + return; + } + // `root` is the file's root node (the sole caller passes ctx->root), so it // is the parent of every top-level child and the module-level check is // invariant across the loop — hoist it out (and it's now O(1) via _p). @@ -6226,14 +6372,31 @@ static bool is_template_class_node(TSNode node, CBMLanguage lang) { * namespace emits no def of its own — it only extends the enclosing scope for * its members. C#/PHP need the same treatment paired with their LSP resolvers * (a def-only change breaks their existing namespace handling), done separately. */ -static bool is_namespace_scope_kind(CBMLanguage lang, const char *kind) { +static bool is_namespace_scope_kind(CBMLanguage lang, const char *kind, TSNode node) { if (lang == CBM_LANG_CPP || lang == CBM_LANG_CUDA) { return strcmp(kind, "namespace_definition") == 0; } + /* Nix: a binding whose value is an attribute set is a named scope that is not + * itself a definition — the same shape as a C++ namespace. `setA = { fn = …; }` + * makes `fn` proj.file.setA.fn, so two attrsets can each hold a `fn` without + * the second silently overwriting the first. + * + * Deliberately NOT `let` bindings: those are lexical, and C++ does not qualify + * by block scope either. Takes the node because the decision depends on the + * binding's VALUE, which the kind string alone cannot express. */ + if (lang == CBM_LANG_NIX && strcmp(kind, "binding") == 0) { + return cbm_nix_binding_is_attrset_scope(node); + } return false; } static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const char *saved_enclosing) { + /* Nix scopes are `binding` nodes, which carry an `attrpath` rather than a + * `name` field. Shared with the unified extractor's own compute_class_qn so + * the def QN and the call-scope QN cannot drift. */ + if (ctx->language == CBM_LANG_NIX) { + return cbm_nix_binding_scope_qn(ctx, node, saved_enclosing); + } TSNode name_node = ts_node_child_by_field_name(node, TS_FIELD("name")); if (ts_node_is_null(name_node) && ctx->language == CBM_LANG_OBJC) { name_node = cbm_find_child_by_kind(node, "identifier"); @@ -6758,10 +6921,16 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, // Ada subprograms nest (a procedure body's declarative part can // contain inner subprogram bodies); descend so the nested defs // are captured and same-file calls to them resolve to a CALLS edge. + // Nix: a library/module file's ROOT expression is normally itself a + // function_expression (`{ pkgs, lib, ... }: `), so stopping here + // abandons the entire file — every binding in it is lost. Descend so + // the body's `name = args: ...` bindings are reached. Inner curried + // lambdas (`f = a: b: ...`) resolve no name and mint nothing, so the + // extra descent adds defs without adding noise. bool descend_into_func = (ctx->language == CBM_LANG_WOLFRAM || ctx->language == CBM_LANG_TYPESCRIPT || ctx->language == CBM_LANG_JAVASCRIPT || ctx->language == CBM_LANG_TSX || - ctx->language == CBM_LANG_ADA); + ctx->language == CBM_LANG_ADA || ctx->language == CBM_LANG_NIX); if (!descend_into_func) { continue; } @@ -6779,7 +6948,7 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, * is walked normally — functions AND classes, unlike a class body which * routes methods through extract_class_methods. Do NOT emit a def or run * the class/func paths on the namespace node itself. */ - if (is_namespace_scope_kind(ctx->language, kind)) { + if (is_namespace_scope_kind(ctx->language, kind, node)) { const char *new_enclosing = compute_class_qn(ctx, node, frame.enclosing_class_qn); wd_push_children_reverse(&s, node, new_enclosing); continue; diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index d6f2380e2..5821a2eef 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -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; } @@ -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); } @@ -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) { @@ -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)) { diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 3cea43ab3..c1e1613a8 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -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, '('); @@ -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"), ...) @@ -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; diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 6fb136294..4ab8b1e54 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -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 diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 93a329984..00d99110b 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -53,6 +53,18 @@ static int __attribute__((unused)) has_import(CBMFileResult *r, const char *path } /* Count definitions with a given label. */ +/* Check for a definition with the given qualified name. Distinct from + * find_def_by_name, which returns the first match by NAME and so cannot tell two + * same-named definitions in different scopes apart — exactly the case that + * attrpath qualification exists to separate. */ +static int has_def_qn(CBMFileResult *r, const char *qn) { + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].qualified_name && strcmp(r->defs.items[i].qualified_name, qn) == 0) + return 1; + } + return 0; +} + static int count_defs_with_label(CBMFileResult *r, const char *label) { int count = 0; for (int i = 0; i < r->defs.count; i++) { @@ -1145,6 +1157,237 @@ TEST(nix_function) { PASS(); } +/* A Nix file's root expression is normally itself a function (`{ pkgs, lib, ... }:`) + * — the near-universal library/module header. Definitions must survive that header. + * Each shape below is asserted separately so a regression names the shape it broke. + * `nix_function` above deliberately stays as-is: its binding is an application, not + * a function, so it pins the "parses, no def" case and cannot cover this. */ +TEST(nix_defs_in_let_rooted_file) { + CBMFileResult *r = extract("let\n alpha = x: x + 1;\n beta = { a, b }: a + b;\nin\n" + "{ inherit alpha beta; }\n", + CBM_LANG_NIX, "t", "bare.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "alpha")); + ASSERT(has_def(r, "Function", "beta")); + cbm_free_result(r); + PASS(); +} + +TEST(nix_defs_in_attrset_rooted_file) { + CBMFileResult *r = extract("{\n epsilon = x: x + 1;\n zeta = { a, b }: a + b;\n}\n", + CBM_LANG_NIX, "t", "attrset.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "epsilon")); + ASSERT(has_def(r, "Function", "zeta")); + cbm_free_result(r); + PASS(); +} + +TEST(nix_defs_in_nested_let) { + CBMFileResult *r = extract("let\n outer =\n let theta = x: x + 1;\n in theta;\n" + "in\n{ inherit outer; }\n", + CBM_LANG_NIX, "t", "nested.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "theta")); + cbm_free_result(r); + PASS(); +} + +/* The regression this suite previously could not see: the root `{ prelude }:` matches + * nix_func_types, resolves no name of its own, and — before the fix — terminated the + * walk, so nothing below the header was ever visited. */ +TEST(nix_defs_survive_function_header_let) { + CBMFileResult *r = extract("{ prelude }:\nlet\n gamma = x: x + 1;\n" + " delta = { a, b }: a + b;\nin\n{ inherit gamma delta; }\n", + CBM_LANG_NIX, "t", "wrapped.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "gamma")); + ASSERT(has_def(r, "Function", "delta")); + cbm_free_result(r); + PASS(); +} + +TEST(nix_defs_survive_function_header_attrset) { + CBMFileResult *r = extract("{ prelude }:\n{\n eta = x: x + 1;\n}\n", CBM_LANG_NIX, "t", + "wrapped_attrset.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "eta")); + cbm_free_result(r); + PASS(); +} + +/* A curried header — `final: prev: { … }` is the standard nixpkgs overlay signature, and + * the most common multi-arm header in the ecosystem. Two nested function_expressions sit + * between the file root and the body. */ +TEST(nix_defs_survive_curried_header) { + CBMFileResult *r = + extract("final: prev: {\n kappa = x: x + 1;\n}\n", CBM_LANG_NIX, "t", "overlay.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "kappa")); + cbm_free_result(r); + PASS(); +} + +/* A Nix binding's name is a PATH, and the extractor previously took only its first + * segment. Convention here matches C++ namespaces — `ns::serialize` is name + * `serialize`, QN `proj.file.ns.serialize` — so a Nix binding is name = leaf + * segment, QN = enclosing scope + leaf. */ +TEST(nix_attrset_scope_disambiguates_leaf_names) { + CBMFileResult *r = + extract("{\n setA = { dup = x: x + 1; };\n setB = { dup = y: y + 2; };\n}\n", + CBM_LANG_NIX, "t", "collide.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* Both survive. Unqualified, these shared one QN, so the second definition — + * and every CALLS edge sourced from it — was silently discarded at write. */ + ASSERT(count_defs_with_label(r, "Function") == 2); + ASSERT(has_def_qn(r, "t.collide.setA.dup")); + ASSERT(has_def_qn(r, "t.collide.setB.dup")); + cbm_free_result(r); + PASS(); +} + +/* `a.b.fn = …` is sugar for `a = { b = { fn = …; }; }`. Both spellings must yield + * the same name and the same QN; the leading segments are scope, not name. */ +TEST(nix_dotted_attrpath_qualifies_like_nested) { + CBMFileResult *r = extract("{\n wrap.deep.fn = z: z + 1;\n}\n", CBM_LANG_NIX, "t", "d.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "fn")); + ASSERT(has_def_qn(r, "t.d.wrap.deep.fn")); + + CBMFileResult *n = + extract("{\n wrap = { deep = { fn = z: z + 1; }; };\n}\n", CBM_LANG_NIX, "t", "d.nix"); + ASSERT_NOT_NULL(n); + ASSERT_FALSE(n->has_error); + /* The equality that makes this a correctness fix rather than a preference. */ + ASSERT(has_def_qn(n, "t.d.wrap.deep.fn")); + cbm_free_result(n); + cbm_free_result(r); + PASS(); +} + +/* A quoted segment is an ordinary name that merely needs quoting in source. The + * delimiters are not part of it, and leaving them in means every consumer keying + * on the name has to know to re-quote. */ +TEST(nix_quoted_attr_name_strips_quotes) { + CBMFileResult *r = extract("{\n \"kebab-case\" = a: a;\n svc.\"my.name\" = b: b;\n}\n", + CBM_LANG_NIX, "t", "q.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "kebab-case")); + ASSERT_FALSE(has_def_any(r, "\"kebab-case\"")); + ASSERT(has_def_qn(r, "t.q.kebab-case")); + /* A quoted segment may itself contain dots; they are part of the name, not + * path separators, but the QN is a dotted string either way. */ + ASSERT(has_def(r, "Function", "my.name")); + cbm_free_result(r); + PASS(); +} + +/* `"${x}" = …` has no statically knowable name. Minting it produces a def named + * `"${x}"` that nothing can look up or resolve a call against, so mint nothing — + * the same call the Makefile dot-prefix guard makes. */ +TEST(nix_interpolated_attr_mints_no_def) { + CBMFileResult *r = + extract("{\n \"${dynamic}\" = a: a;\n fixed = b: b;\n}\n", CBM_LANG_NIX, "t", "i.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "fixed")); + ASSERT(count_defs_with_label(r, "Function") == 1); + cbm_free_result(r); + PASS(); +} + +/* Nix Variables. `nix_var_types` has always declared `binding`, but no Nix name + * resolver existed, so the count was unconditionally zero. + * + * Scope follows the rule every other language uses: extract_variables mints FILE + * scope and never locals — a C++ declaration inside a function body is not a + * Variable. For Nix, file scope is the `let` bindings and the returned attrset; + * anything in a deeper attrset is not. Without that bound a NixOS module's + * settings tree would mint a node per `enable = true`. */ +TEST(nix_module_level_bindings_mint_variables) { + CBMFileResult *r = extract("{ pkgs, lib, ... }:\n" + "let\n" + " privateConst = 42;\n" + "in\n" + "{\n" + " exported = \"value\";\n" + " services.nginx.enable = true;\n" + "}\n", + CBM_LANG_NIX, "t", "mod.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* A let binding is file scope in the same sense a C++ file-static is. */ + ASSERT(has_def(r, "Variable", "privateConst")); + ASSERT(has_def(r, "Variable", "exported")); + /* The QN carries the attrpath, exactly as it does for functions. */ + ASSERT(has_def(r, "Variable", "enable")); + ASSERT(has_def_qn(r, "t.mod.services.nginx.enable")); + cbm_free_result(r); + PASS(); +} + +/* The flood guard. Each absence assertion is paired with a positive one on the + * same predicate in the same result, so none can pass by extracting nothing. */ +TEST(nix_nested_bindings_are_not_module_level) { + CBMFileResult *r = extract("{ pkgs }:\n" + "{\n" + " topLevel = 1;\n" + " deep = {\n" + " nested = {\n" + " shouldNotAppear = 2;\n" + " };\n" + " };\n" + "}\n", + CBM_LANG_NIX, "t", "deep.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Variable", "topLevel")); /* positive control */ + ASSERT_FALSE(has_def_any(r, "shouldNotAppear")); + /* `deep` is an attrset — a scope, not a value — so not a Variable either. */ + ASSERT_FALSE(has_def(r, "Variable", "deep")); + ASSERT_FALSE(has_def(r, "Variable", "nested")); + cbm_free_result(r); + PASS(); +} + +/* A lambda-valued binding is already minted as a Function by the def walk. Minting + * it again here would double-count every helper in the ecosystem. */ +TEST(nix_lambda_binding_is_function_not_variable) { + CBMFileResult *r = + extract("{\n fn = x: x + 1;\n val = 7;\n}\n", CBM_LANG_NIX, "t", "mix.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "fn")); + ASSERT_FALSE(has_def(r, "Variable", "fn")); + ASSERT(has_def(r, "Variable", "val")); /* positive control */ + ASSERT_FALSE(has_def(r, "Function", "val")); + cbm_free_result(r); + PASS(); +} + +/* Descending past the header must not mint a second def from a curried lambda's inner + * arm: `iota = a: b: ...` is one named function, not two. The inner `b:` has a + * function_expression parent, resolves no name, and must stay out. */ +TEST(nix_curried_lambda_mints_one_def) { + CBMFileResult *r = extract("{ prelude }:\nlet\n iota = a: b: a + b;\nin\n{ inherit iota; }\n", + CBM_LANG_NIX, "t", "curried.nix"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "iota")); + ASSERT(count_defs_with_label(r, "Function") == 1); + cbm_free_result(r); + PASS(); +} + /* --- Fortran --- */ TEST(fortran_function) { /* Fortran subroutine name extraction is incomplete — just verify no crash */ @@ -4802,6 +5045,20 @@ SUITE(extraction) { RUN_TEST(julia_function); RUN_TEST(elm_function); RUN_TEST(nix_function); + RUN_TEST(nix_defs_in_let_rooted_file); + RUN_TEST(nix_defs_in_attrset_rooted_file); + RUN_TEST(nix_defs_in_nested_let); + RUN_TEST(nix_defs_survive_function_header_let); + RUN_TEST(nix_defs_survive_function_header_attrset); + RUN_TEST(nix_defs_survive_curried_header); + RUN_TEST(nix_attrset_scope_disambiguates_leaf_names); + RUN_TEST(nix_dotted_attrpath_qualifies_like_nested); + RUN_TEST(nix_quoted_attr_name_strips_quotes); + RUN_TEST(nix_interpolated_attr_mints_no_def); + RUN_TEST(nix_module_level_bindings_mint_variables); + RUN_TEST(nix_nested_bindings_are_not_module_level); + RUN_TEST(nix_lambda_binding_is_function_not_variable); + RUN_TEST(nix_curried_lambda_mints_one_def); RUN_TEST(fortran_function); /* OOP/Systems variants */ diff --git a/tests/test_grammar_labels.c b/tests/test_grammar_labels.c index b592e3285..bae856fab 100644 --- a/tests/test_grammar_labels.c +++ b/tests/test_grammar_labels.c @@ -200,7 +200,7 @@ static const LabelGolden LABEL_GOLDENS[] = { {"gn", "Module:1"}, {"just", "Function:1,Module:1"}, {"hcl", "Class:1,Module:1"}, - {"nix", "Module:1"}, + {"nix", "Module:1,Variable:2"}, {"gomod", "Module:1"}, {"gotemplate", "Module:1"}, {"graphql", "Class:1,Field:1,Module:1"}, diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 2685b425d..ce198e779 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -851,6 +851,61 @@ static bool cross_file_call_exists(cbm_store_t *s, const char *project, const ch return found; } +/* Nix attrpath qualification, end to end. A call inside a scoped binding must + * source to the QUALIFIED definition. + * + * This has to be a pipeline test rather than an extraction one. Definition QNs + * (extract_defs.c) and call-scope QNs (extract_unified.c) are computed by two + * separate functions. If they disagree by even one segment, the CALLS edge names + * a source node that was never minted and is dropped at write — with no error, + * and with every extraction-level assertion still passing. Only the store sees it. + * + * Both callers below are scoped, one by an enclosing attrset and one by a dotted + * attrpath, so this covers both routes into the qualified name. */ +TEST(pipeline_nix_scoped_binding_calls_resolve) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + + char nix_path[512]; + snprintf(nix_path, sizeof(nix_path), "%s/lib.nix", g_tmpdir); + FILE *nf = fopen(nix_path, "w"); + if (!nf) { + teardown_test_repo(); + FAIL("failed to write nix fixture"); + } + fprintf(nf, "{ prelude }:\n" + "let\n" + " nixTarget = t: t + 1;\n" + "in\n" + "{\n" + " setA = { nixCaller = x: nixTarget x; };\n" + " outer.inner.nixDeepCaller = y: nixTarget y;\n" + "}\n"); + fclose(nf); + + char nix_db[512]; + snprintf(nix_db, sizeof(nix_db), "%s/test_nix_calls.db", g_tmpdir); + + cbm_pipeline_t *np = cbm_pipeline_new(g_tmpdir, nix_db, CBM_MODE_FULL); + ASSERT_NOT_NULL(np); + ASSERT_EQ(cbm_pipeline_run(np), 0); + + cbm_store_t *ns = cbm_store_open_path(nix_db); + ASSERT_NOT_NULL(ns); + const char *nix_project = cbm_pipeline_project_name(np); + + /* Scoped by an enclosing attrset: the def QN is proj.lib.setA.nixCaller. */ + ASSERT(cross_file_call_exists(ns, nix_project, "nixCaller", "nixTarget")); + /* Scoped by a dotted attrpath: proj.lib.outer.inner.nixDeepCaller. */ + ASSERT(cross_file_call_exists(ns, nix_project, "nixDeepCaller", "nixTarget")); + + cbm_store_close(ns); + cbm_pipeline_free(np); + teardown_test_repo(); + PASS(); +} + /* Regression: incremental re-index of an edited file must NOT drop inbound * cross-file CALLS edges whose source lives in an UNCHANGED file. * @@ -7363,6 +7418,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_complexity_transitive_loop_depth); /* Calls pass */ RUN_TEST(pipeline_calls_resolution); + RUN_TEST(pipeline_nix_scoped_binding_calls_resolve); RUN_TEST(pipeline_incremental_preserves_cross_file_calls); RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges);