diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index dc89a6b2d6..360bd22aa5 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -694,6 +694,24 @@ typedef struct { int count; } CBMReturnTypeTable; +// --- Scala companion cache --- +// The class/trait/enum names of every scope an object_definition has asked +// about, keyed by (scope node, name bytes). A scope is scanned once, on its +// first query; names are byte ranges into the source, never copied. One extra +// key per scanned scope (len == 0) records the scan itself, so a scope without +// any class is not rescanned either. Open addressing, power-of-two capacity. +typedef struct { + const void *scope; // ts_node id of the scope (parent) node; NULL = empty slot + uint32_t off; + uint32_t len; +} CBMScalaScopeName; + +typedef struct { + CBMScalaScopeName *slots; + uint32_t cap; // 0 until the first query + uint32_t count; +} CBMScalaCompanionCache; + typedef struct { CBMArena *arena; /* Scratch for AST traversal, owned by the cbm_extract_file_ex call that @@ -738,6 +756,9 @@ typedef struct { * POD section index. */ void *doc_memo; void *doc_pod_index; + /* Scala only; lives in `scratch` (or `arena` without one). Nothing in a + * CBMFileResult points at it. */ + CBMScalaCompanionCache scala_companions; } CBMExtractCtx; // --- Public API --- @@ -945,6 +966,11 @@ void cbm_impltrait_push(CBMImplTraitArray *arr, CBMArena *a, CBMImplTrait it); void cbm_resolvedcall_push(CBMResolvedCallArray *arr, CBMArena *a, CBMResolvedCall rc); void cbm_channels_push(CBMChannelArray *arr, CBMArena *a, CBMChannel ch); +/* True for a Scala object_definition with a same-named class/trait/enum + * sibling. O(1) per query after the first one in a scope, which scans the + * scope's named children once into ctx->scala_companions. */ +bool cbm_scala_is_companion_object(CBMExtractCtx *ctx, TSNode node); + // --- Sub-extractor entry points --- void cbm_extract_definitions(CBMExtractCtx *ctx); diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index e352217998..550c3954b6 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -6395,6 +6395,12 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec } else { class_qn = cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, name, ctx->language); } + /* A Scala class and its companion object are distinct owners despite sharing + * the source-level name. Use Scala's conventional singleton-class suffix so + * neither container nor their methods overwrite each other in the graph. */ + if (ctx->language == CBM_LANG_SCALA && cbm_scala_is_companion_object(ctx, node)) { + class_qn = cbm_arena_sprintf(a, "%s$", class_qn); + } const char *label = class_label_for_kind(kind); // Sway/WGSL: label struct defs as "Struct" and Sway `abi` blocks as @@ -9250,12 +9256,18 @@ static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const char char *cname = cbm_node_text(ctx->arena, name_node, ctx->source); if (cname && cname[0]) { if (saved_enclosing) { - return cbm_arena_sprintf(ctx->arena, "%s.%s", saved_enclosing, cname); + const char *qn = cbm_arena_sprintf(ctx->arena, "%s.%s", saved_enclosing, cname); + return ctx->language == CBM_LANG_SCALA && cbm_scala_is_companion_object(ctx, node) + ? cbm_arena_sprintf(ctx->arena, "%s$", qn) + : qn; } /* Top-level: language-aware module so Java/Go don't double the * filename stem (matches extract_class_def above). */ - return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, cname, - ctx->language); + const char *qn = cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, + cname, ctx->language); + return ctx->language == CBM_LANG_SCALA && cbm_scala_is_companion_object(ctx, node) + ? cbm_arena_sprintf(ctx->arena, "%s$", qn) + : qn; } } return saved_enclosing; diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index 4aa0baa759..a9432b1180 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -24,6 +24,7 @@ static void parse_go_imports(CBMExtractCtx *ctx); static void parse_python_imports(CBMExtractCtx *ctx); static void parse_es_imports(CBMExtractCtx *ctx); static void parse_java_imports(CBMExtractCtx *ctx); +static void parse_scala_imports(CBMExtractCtx *ctx); static void parse_rust_imports(CBMExtractCtx *ctx); static void parse_c_imports(CBMExtractCtx *ctx); static void parse_ruby_imports(CBMExtractCtx *ctx); @@ -984,6 +985,146 @@ static void parse_generic_imports(CBMExtractCtx *ctx, const char *node_type) { ts_tree_cursor_delete(&cursor); } +// --- Scala imports --- + +static char *scala_trim_span(CBMArena *a, const char *start, const char *end) { + while (start < end && isspace((unsigned char)*start)) { + start++; + } + while (end > start && isspace((unsigned char)end[-1])) { + end--; + } + return cbm_arena_strndup(a, start, (size_t)(end - start)); +} + +static void scala_emit_import(CBMExtractCtx *ctx, const char *path, const char *local) { + if (!path || !path[0] || !local || !local[0]) { + return; + } + CBMImport imp = {.local_name = cbm_arena_strdup(ctx->arena, local), + .module_path = cbm_arena_strdup(ctx->arena, path)}; + cbm_imports_push(&ctx->result->imports, ctx->arena, imp); +} + +static void scala_parse_selector(CBMExtractCtx *ctx, const char *prefix, const char *start, + const char *end) { + char *selector = scala_trim_span(ctx->arena, start, end); + if (!selector || !selector[0] || + (strncmp(selector, "given", 5) == 0 && + (selector[5] == '\0' || isspace((unsigned char)selector[5])))) { + return; + } + if (strcmp(selector, "*") == 0 || strcmp(selector, "_") == 0) { + scala_emit_import(ctx, prefix, "*"); + return; + } + + char *rename = strstr(selector, "=>"); + size_t op_len = 2; + if (!rename) { + rename = strstr(selector, " as "); + op_len = 4; + } + char *name = selector; + char *local = selector; + if (rename) { + local = scala_trim_span(ctx->arena, rename + op_len, selector + strlen(selector)); + name = scala_trim_span(ctx->arena, selector, rename); + if (strcmp(local, "_") == 0 || strcmp(local, "*") == 0) { + return; // Scala 2 hidden selector: `Name => _` + } + } + if (!name[0] || !local[0]) { + return; + } + const char *path = + prefix && prefix[0] ? cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, name) : name; + scala_emit_import(ctx, path, local); +} + +static void scala_parse_import_expr(CBMExtractCtx *ctx, const char *start, const char *end) { + char *expr = scala_trim_span(ctx->arena, start, end); + if (!expr || !expr[0]) { + return; + } + char *open = strchr(expr, '{'); + char *close = strrchr(expr, '}'); + if (open && close && close > open) { + char *prefix = scala_trim_span(ctx->arena, expr, open); + size_t plen = strlen(prefix); + while (plen > 0 && (prefix[plen - 1] == '.' || isspace((unsigned char)prefix[plen - 1]))) { + prefix[--plen] = '\0'; + } + const char *part = open + 1; + for (const char *p = part;; p++) { + if (p == close || *p == ',') { + scala_parse_selector(ctx, prefix, part, p); + if (p == close) { + break; + } + part = p + 1; + } + } + return; + } + + char *rename = strstr(expr, " as "); + if (rename) { + char *path = scala_trim_span(ctx->arena, expr, rename); + char *local = scala_trim_span(ctx->arena, rename + 4, expr + strlen(expr)); + scala_emit_import(ctx, path, local); + return; + } + size_t len = strlen(expr); + if ((len > 2 && strcmp(expr + len - 2, ".*") == 0) || + (len > 2 && strcmp(expr + len - 2, "._") == 0)) { + expr[len - 2] = '\0'; + scala_emit_import(ctx, expr, "*"); + return; + } + if (len > 6 && strcmp(expr + len - 6, ".given") == 0) { + expr[len - 6] = '\0'; + scala_emit_import(ctx, expr, "*"); + return; + } + scala_emit_import(ctx, expr, path_last(ctx->arena, expr)); +} + +static void parse_scala_imports(CBMExtractCtx *ctx) { + TSTreeCursor cursor = ts_tree_cursor_new(ctx->root); + if (!ts_tree_cursor_goto_first_child(&cursor)) { + ts_tree_cursor_delete(&cursor); + return; + } + do { + TSNode node = ts_tree_cursor_current_node(&cursor); + if (strcmp(ts_node_type(node), "import_declaration") != 0) { + continue; + } + char *text = cbm_node_text(ctx->arena, node, ctx->source); + if (!text || strncmp(text, "import", 6) != 0) { + continue; + } + const char *part = text + 6; + int brace_depth = 0; + for (const char *p = part;; p++) { + if (*p == '{') { + brace_depth++; + } else if (*p == '}') { + brace_depth--; + } + if (*p == '\0' || (*p == ',' && brace_depth == 0)) { + scala_parse_import_expr(ctx, part, p); + if (*p == '\0') { + break; + } + part = p + 1; + } + } + } while (ts_tree_cursor_goto_next_sibling(&cursor)); + ts_tree_cursor_delete(&cursor); +} + // --- Kotlin imports --- // tree-sitter-kotlin nests imports: source_file -> import_list -> import_header*. // parse_generic_imports only scans the DIRECT children of root, and "import" is @@ -1659,6 +1800,37 @@ static void parse_embedded_imports(CBMExtractCtx *ctx) { } } +// Scala chains braceless clauses: `package a` followed by `package b` puts the +// file's symbols in a.b. Append every directly following braceless +// package_clause to the namespace already recorded from the first one. A +// braced clause (`package b { ... }`) scopes only its body and is left alone. +static void join_chained_scala_packages(CBMExtractCtx *ctx, TSTreeCursor *cursor) { + while (ts_tree_cursor_goto_next_sibling(cursor)) { + TSNode next = ts_tree_cursor_current_node(cursor); + const char *next_kind = ts_node_type(next); + if (strcmp(next_kind, "comment") == 0 || strcmp(next_kind, "block_comment") == 0) { + continue; + } + if (strcmp(next_kind, "package_clause") != 0) { + return; + } + TSNode body = next; + if (find_first_descendant_of(next, "template_body", &body)) { + return; + } + TSNode name_node = next; + if (!find_first_descendant_of(next, "package_identifier", &name_node)) { + return; + } + char *segment = cbm_node_text(ctx->arena, name_node, ctx->source); + if (!segment || !segment[0]) { + return; + } + ctx->result->namespace_name = + cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->result->namespace_name, segment); + } +} + // --- Namespace / package declaration capture --- // Java/Kotlin/C#/PHP put the file's symbols inside a namespace/package whose // name is NOT reflected in the path-based QN scheme. Capturing it lets the @@ -1670,6 +1842,7 @@ static void capture_namespace_decl(CBMExtractCtx *ctx) { "file_scoped_namespace_declaration", // C# 10 "package_declaration", // Java / Kotlin "package_header", // Kotlin + "package_clause", // Scala "namespace_definition", // PHP NULL}; TSTreeCursor cursor = ts_tree_cursor_new(ctx->root); @@ -1686,6 +1859,7 @@ static void capture_namespace_decl(CBMExtractCtx *ctx) { // The namespace name is the first qualified_name / scoped_identifier / // namespace_name / identifier descendant. static const char *name_kinds[] = {"qualified_name", + "package_identifier", "scoped_identifier", "namespace_name", "identifier", @@ -1703,6 +1877,9 @@ static void capture_namespace_decl(CBMExtractCtx *ctx) { } } if (ctx->result->namespace_name) { + if (strcmp(kind, "package_clause") == 0) { + join_chained_scala_packages(ctx, &cursor); + } break; } } while (ts_tree_cursor_goto_next_sibling(&cursor)); @@ -2969,6 +3146,7 @@ void cbm_extract_imports(CBMExtractCtx *ctx) { switch (ctx->language) { case CBM_LANG_JAVA: case CBM_LANG_KOTLIN: + case CBM_LANG_SCALA: case CBM_LANG_CSHARP: case CBM_LANG_PHP: capture_namespace_decl(ctx); @@ -2996,7 +3174,7 @@ void cbm_extract_imports(CBMExtractCtx *ctx) { parse_kotlin_imports(ctx); break; case CBM_LANG_SCALA: - parse_generic_imports(ctx, "import_declaration"); + parse_scala_imports(ctx); break; case CBM_LANG_CSHARP: parse_csharp_imports(ctx); diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 086f501a0c..7e14726603 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1156,12 +1156,18 @@ static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const WalkS * scope QN matches the def-side class QN (extract_defs.c compute_class_qn / * extract_class_def), which the lsp_resolve join requires for nested types. */ if (state && state->enclosing_class_qn) { - return cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, name); + const char *qn = cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, name); + return ctx->language == CBM_LANG_SCALA && cbm_scala_is_companion_object(ctx, node) + ? cbm_arena_sprintf(ctx->arena, "%s$", qn) + : qn; } /* Java/Go: directory-based module (see compute_func_qn). */ - return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, name, - ctx->language); + const char *qn = + cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, name, ctx->language); + return ctx->language == CBM_LANG_SCALA && cbm_scala_is_companion_object(ctx, node) + ? cbm_arena_sprintf(ctx->arena, "%s$", qn) + : qn; } /* Forward declaration */ diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 4c4d340f0b..25b8e3eaea 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -173,6 +173,120 @@ bool cbm_label_is_type_like(const char *label) { strcmp(label, "Type") == 0 || strcmp(label, "Trait") == 0; } +/* --- Scala companion cache (CBMScalaCompanionCache) --- */ + +static uint64_t scc_hash(const void *scope, const char *bytes, uint32_t len) { + uint64_t h = 1469598103934665603ULL; + const unsigned char *p = (const unsigned char *)&scope; + for (size_t i = 0; i < sizeof(scope); i++) { + h = (h ^ p[i]) * 1099511628211ULL; + } + for (uint32_t i = 0; i < len; i++) { + h = (h ^ (unsigned char)bytes[i]) * 1099511628211ULL; + } + return h; +} + +/* Slot holding (scope, name), or the empty slot where it would go. */ +static CBMScalaScopeName *scc_find(const CBMScalaCompanionCache *c, const char *source, + const void *scope, uint32_t off, uint32_t len) { + uint32_t mask = c->cap - 1; + uint32_t i = (uint32_t)scc_hash(scope, source + off, len) & mask; + for (;;) { + CBMScalaScopeName *s = &c->slots[i]; + if (!s->scope) { + return s; + } + if (s->scope == scope && s->len == len && + (len == 0 || memcmp(source + s->off, source + off, len) == 0)) { + return s; + } + i = (i + 1) & mask; + } +} + +static bool scc_insert(CBMScalaCompanionCache *c, CBMArena *a, const char *source, + const void *scope, uint32_t off, uint32_t len) { + if ((c->count + 1) * 2 > c->cap) { + uint32_t new_cap = c->cap ? c->cap * 2 : 64; + CBMScalaScopeName *old = c->slots; + uint32_t old_cap = c->cap; + c->slots = cbm_arena_calloc(a, (size_t)new_cap * sizeof(*c->slots)); + if (!c->slots) { + c->slots = old; + return false; + } + c->cap = new_cap; + for (uint32_t i = 0; i < old_cap; i++) { + if (old[i].scope) { + *scc_find(c, source, old[i].scope, old[i].off, old[i].len) = old[i]; + } + } + } + CBMScalaScopeName *s = scc_find(c, source, scope, off, len); + if (!s->scope) { + s->scope = scope; + s->off = off; + s->len = len; + c->count++; + } + return true; +} + +/* First query in a scope: record its class/trait/enum names, plus the + * (scope, "") marker that says the scope has been scanned. */ +static bool scc_scan_scope(CBMScalaCompanionCache *c, CBMArena *a, const char *source, + TSNode scope) { + const void *id = scope.id; + if (c->cap && scc_find(c, source, id, 0, 0)->scope) { + return true; + } + if (!scc_insert(c, a, source, id, 0, 0)) { + return false; + } + uint32_t count = ts_node_named_child_count(scope); + for (uint32_t i = 0; i < count; i++) { + TSNode sibling = ts_node_named_child(scope, i); + const char *kind = ts_node_type(sibling); + if (strcmp(kind, "class_definition") != 0 && strcmp(kind, "trait_definition") != 0 && + strcmp(kind, "enum_definition") != 0) { + continue; + } + TSNode name = ts_node_child_by_field_name(sibling, TS_FIELD("name")); + if (ts_node_is_null(name)) { + continue; + } + uint32_t off = ts_node_start_byte(name); + uint32_t len = ts_node_end_byte(name) - off; + if (len && !scc_insert(c, a, source, id, off, len)) { + return false; + } + } + return true; +} + +bool cbm_scala_is_companion_object(CBMExtractCtx *ctx, TSNode node) { + if (!ctx || !ctx->source || strcmp(ts_node_type(node), "object_definition") != 0) { + return false; + } + TSNode name_node = ts_node_child_by_field_name(node, TS_FIELD("name")); + TSNode parent = ts_node_parent(node); + if (ts_node_is_null(name_node) || ts_node_is_null(parent)) { + return false; + } + uint32_t off = ts_node_start_byte(name_node); + uint32_t len = ts_node_end_byte(name_node) - off; + if (len == 0 || ts_node_end_byte(name_node) > (uint32_t)ctx->source_len) { + return false; + } + CBMArena *a = ctx->scratch ? ctx->scratch : ctx->arena; + CBMScalaCompanionCache *c = &ctx->scala_companions; + if (!a || !scc_scan_scope(c, a, ctx->source, parent)) { + return false; + } + return scc_find(c, ctx->source, parent.id, off, len)->scope != NULL; +} + // True when `label` names a data relation: SQL CREATE TABLE / CREATE VIEW, and // a dbt Model (a Jinja-templated .sql file, which materializes as a warehouse // table or view). Relations live in the registry so FROM/JOIN and dbt ref() diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index 1453388034..a213ebc5c4 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -62,7 +62,7 @@ src/pipeline/pass_compile_commands.c 17 src/pipeline/pass_complexity.c 15 src/pipeline/pass_configlink.c 3 src/pipeline/pass_cross_repo.c 8 -src/pipeline/pass_definitions.c 14 +src/pipeline/pass_definitions.c 12 src/pipeline/pass_enrichment.c 15 src/pipeline/pass_ensemble_routing.c 10 src/pipeline/pass_envscan.c 2 @@ -71,15 +71,15 @@ src/pipeline/pass_importance.c 4 src/pipeline/pass_infrascan.c 2 src/pipeline/pass_k8s.c 6 src/pipeline/pass_lsp_cross.c 50 -src/pipeline/pass_parallel.c 31 -src/pipeline/pass_pkgmap.c 82 +src/pipeline/pass_parallel.c 29 +src/pipeline/pass_pkgmap.c 80 src/pipeline/pass_semantic.c 3 src/pipeline/pass_tests.c 3 src/pipeline/pass_usages.c 4 src/pipeline/path_alias.c 42 src/pipeline/pipeline.c 134 src/pipeline/pipeline_delta.c 19 -src/pipeline/pipeline_incremental.c 144 +src/pipeline/pipeline_incremental.c 142 src/pipeline/registry.c 17 src/pipeline/worker_pool.c 2 src/store/store.c 358 diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 6689196820..827fb447aa 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -118,7 +118,7 @@ static const cbm_gbuf_node_t *pc_find_by_computed_qn(const cbm_gbuf_t *gbuf, con return n; } -static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, +static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, CBMLanguage language, const CBMFileResult *result, const char ***out_keys, const char ***out_vals, int *out_count) { *out_keys = NULL; @@ -141,7 +141,7 @@ static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, continue; } const cbm_gbuf_node_t *target = - cbm_pipeline_resolve_import_node(ctx, rel_path, file_qn, imp, NULL); + cbm_pipeline_resolve_import_node(ctx, rel_path, file_qn, language, imp, NULL, NULL); if (!target) { continue; } @@ -1053,7 +1053,7 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file const char **imp_keys = NULL; const char **imp_vals = NULL; int imp_count = 0; - build_import_map(ctx, rel, result, &imp_keys, &imp_vals, &imp_count); + build_import_map(ctx, rel, files[i].language, result, &imp_keys, &imp_vals, &imp_count); /* Compute module QN for same-module resolution (directory-based for * Java/Go so it matches their def-node QNs in the registry). */ @@ -1216,7 +1216,8 @@ void cbm_pipeline_pass_fastapi_depends(cbm_pipeline_ctx_t *ctx, const cbm_file_i const char **imp_keys = NULL; const char **imp_vals = NULL; int imp_count = 0; - build_import_map(ctx, files[i].rel_path, result, &imp_keys, &imp_vals, &imp_count); + build_import_map(ctx, files[i].rel_path, files[i].language, result, &imp_keys, &imp_vals, + &imp_count); for (int d = 0; d < result->defs.count; d++) { CBMDefinition *def = &result->defs.items[d]; diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index f3a2cd948c..ea4ab16ac2 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -396,7 +396,8 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const * (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed * through the same predicate, so the three registries cannot diverge. */ if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label); + cbm_registry_add_def(ctx->registry, def->name, def->qualified_name, def->label, + def->file_path ? def->file_path : rel); } char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); @@ -512,7 +513,9 @@ int cbm_pipeline_create_env_configures_for_file(cbm_pipeline_ctx_t *ctx, /* Create IMPORTS edges for one file's imports. Mirrors the resolution * logic in pass_parallel.c register_and_link_def — keep the two in sync. */ static int create_import_edges_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, - const char *rel, CBMHashTable *namespace_map) { + const char *rel, CBMLanguage language, + CBMHashTable *namespace_map, + const cbm_scala_index_t *scala_index) { int count = 0; char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *source_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); @@ -525,8 +528,8 @@ static int create_import_edges_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileRe if (!imp->module_path) { continue; } - const cbm_gbuf_node_t *target = - cbm_pipeline_resolve_import_node(ctx, rel, file_qn, imp, namespace_map); + const cbm_gbuf_node_t *target = cbm_pipeline_resolve_import_node( + ctx, rel, file_qn, language, imp, namespace_map, scala_index); if (target && target->id != source_node->id) { char esc_ln[CBM_SZ_128]; cbm_json_escape(esc_ln, sizeof(esc_ln), imp->local_name ? imp->local_name : ""); @@ -938,7 +941,8 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * resolve to defs already in the graph, but the file's * own defs are now persisted before the lookup. No namespace * map is available without the cache (single-file scope). */ - total_imports += create_import_edges_for_file(ctx, result, rel, NULL); + total_imports += + create_import_edges_for_file(ctx, result, rel, files[i].language, NULL, NULL); create_channel_edges_for_file(ctx, result, rel); cbm_pipeline_create_env_configures_for_file(ctx, result, rel); cbm_free_result(result); @@ -952,16 +956,19 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t if (local_cache) { /* Build a namespace/package → File-QN map so that namespace imports * (C# `using`, Java/Kotlin `import`, PHP `use`) resolve to the file - * that declares the namespace. */ - const char **rels = (const char **)calloc((size_t)file_count, sizeof(char *)); - if (rels) { + * that declares the namespace, and the Scala top-level index. */ + CBMHashTable *namespace_map = NULL; + cbm_scala_index_t *scala_index = NULL; + const char **namespaces = + cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)file_count * sizeof(char *)); + if (namespaces) { for (int i = 0; i < file_count; i++) { - rels[i] = files[i].rel_path; + namespaces[i] = local_cache[i] ? local_cache[i]->namespace_name : NULL; } + cbm_pipeline_import_maps_build(ctx, files, file_count, namespaces, &namespace_map, + &scala_index); + cbm_free(CBM_MEM_CLASS_OTHER, namespaces); } - CBMHashTable *namespace_map = - cbm_pipeline_namespace_map_build(ctx->project_name, local_cache, rels, file_count); - free(rels); for (int i = 0; i < file_count; i++) { if (cbm_pipeline_check_cancel(ctx)) { break; @@ -970,12 +977,12 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t if (!result) { continue; } - total_imports += - create_import_edges_for_file(ctx, result, files[i].rel_path, namespace_map); + total_imports += create_import_edges_for_file( + ctx, result, files[i].rel_path, files[i].language, namespace_map, scala_index); create_channel_edges_for_file(ctx, result, files[i].rel_path); cbm_pipeline_create_env_configures_for_file(ctx, result, files[i].rel_path); } - cbm_pipeline_namespace_map_free(namespace_map); + cbm_pipeline_import_maps_free(namespace_map, scala_index); if (owns_local_cache) { for (int i = 0; i < file_count; i++) { if (local_cache[i]) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index a8a9f36933..9d28be1f45 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1691,7 +1691,7 @@ int cbm_parallel_extract(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, /* `file_node_id` is the defining file's node id (0 when it has none), looked up * once per file by the caller: computing the file QN and finding its node for * every definition was 700 k allocations and lookups on the Go corpus. */ -static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, +static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const char *rel, int64_t file_node_id, int *reg_entries) { int edges = 0; if (!def->name || !def->qualified_name || !def->label) { @@ -1700,7 +1700,8 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d /* Registry membership is defined ONCE by cbm_label_is_registry_symbol * (helpers.c) — see pass_definitions.c for the per-label rationale. */ if (cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label); + cbm_registry_add_def(ctx->registry, def->name, def->qualified_name, def->label, + def->file_path ? def->file_path : rel); (*reg_entries)++; } const cbm_gbuf_node_t *def_node = cbm_gbuf_find_by_qn(ctx->gbuf, def->qualified_name); @@ -1750,7 +1751,8 @@ static void pp_add_file_doc(const cbm_gbuf_node_t *file_node, const char *doc) { /* Create IMPORTS edges for one file's imports (parallel path). */ static int create_imports_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, - const char *rel, CBMHashTable *namespace_map) { + const char *rel, CBMLanguage language, CBMHashTable *namespace_map, + const cbm_scala_index_t *scala_index) { int count = 0; char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *source_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); @@ -1763,8 +1765,8 @@ static int create_imports_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *re if (!imp->module_path) { continue; } - const cbm_gbuf_node_t *target = - cbm_pipeline_resolve_import_node(ctx, rel, file_qn, imp, namespace_map); + const cbm_gbuf_node_t *target = cbm_pipeline_resolve_import_node( + ctx, rel, file_qn, language, imp, namespace_map, scala_index); if (target && target->id != source_node->id) { char esc_ln[CBM_SZ_128]; cbm_json_escape(esc_ln, sizeof(esc_ln), imp->local_name ? imp->local_name : ""); @@ -1831,36 +1833,30 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t int imports_edges = 0; /* Namespace/package → File-QN map for namespace imports (C# `using`, - * Java/Kotlin `import`, PHP `use`). Built from the full result cache so - * every declaring file is visible regardless of loop order. */ - const char **rels = (const char **)calloc((size_t)file_count, sizeof(char *)); - if (rels) { - for (int i = 0; i < file_count; i++) { - rels[i] = files[i].rel_path; - } - } - /* Built from every file, including the ones already parked on disk: their - * results are NULL in result_cache, and a file absent from this map does - * not fail to resolve, it resolves through the looser fallback. Spilling - * therefore used to CHANGE the graph rather than merely delay it -- php - * measured 57,182 edges in memory against 59,379 while spilling, the same - * binary and corpus (2026-09-18), differing in both directions. */ + * Java/Kotlin `import`, PHP `use`) and the Scala top-level index. Built + * from the full result cache so every declaring file is visible + * regardless of loop order, including the files already parked on disk: + * their results are NULL in result_cache, and a file absent from this map + * does not fail to resolve, it resolves through the looser fallback. + * Spilling therefore used to CHANGE the graph rather than merely delay it + * -- php measured 57,182 edges in memory against 59,379 while spilling, + * the same binary and corpus (2026-09-18), differing in both directions. */ const char **namespaces = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)file_count * sizeof(char *)); CBMHashTable *namespace_map = NULL; + cbm_scala_index_t *scala_index = NULL; if (namespaces) { for (int i = 0; i < file_count; i++) { namespaces[i] = result_cache[i] ? result_cache[i]->namespace_name : cbm_result_spill_namespace(ctx->spill, i); } - namespace_map = - cbm_pipeline_namespace_map_build_names(ctx->project_name, namespaces, rels, file_count); + cbm_pipeline_import_maps_build(ctx, files, file_count, namespaces, &namespace_map, + &scala_index); cbm_free(CBM_MEM_CLASS_OTHER, namespaces); } - free(rels); for (int i = 0; i < file_count; i++) { if (cbm_pipeline_check_cancel(ctx)) { - cbm_pipeline_namespace_map_free(namespace_map); + cbm_pipeline_import_maps_free(namespace_map, scala_index); return CBM_NOT_FOUND; } @@ -1884,12 +1880,13 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t free(file_qn); pp_add_file_doc(file_node, result->module_doc); for (int d = 0; d < result->defs.count; d++) { - defines_edges += - register_and_link_def(ctx, &result->defs.items[d], file_node_id, ®_entries); + defines_edges += register_and_link_def(ctx, &result->defs.items[d], rel, + file_node_id, ®_entries); } } - imports_edges += create_imports_edges(ctx, result, rel, namespace_map); + imports_edges += + create_imports_edges(ctx, result, rel, files[i].language, namespace_map, scala_index); create_channel_edges(ctx, result, rel); cbm_pipeline_create_env_configures_for_file(ctx, result, rel); if (loaded) { @@ -1897,7 +1894,7 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t } } - cbm_pipeline_namespace_map_free(namespace_map); + cbm_pipeline_import_maps_free(namespace_map, scala_index); cbm_log_info("parallel.registry.done", "entries", itoa_log(reg_entries), "defines", itoa_log(defines_edges), "imports", itoa_log(imports_edges)); diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index 9c4ace14b0..9ec34ddf2a 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -2134,11 +2134,163 @@ static psr4_outcome_t resolve_php_psr4_class(const cbm_pipeline_ctx_t *ctx, return covered ? PSR4_UNRESOLVED : PSR4_NOT_APPLICABLE; } +/* ── Scala top-level index ───────────────────────────────────────── */ + +/* The two File nodes with the lexicographically smallest QNs declaring a + * package, borrowed from the graph buffer. The smallest is the `import pkg._` + * target on every platform regardless of directory-walk order; the runner-up + * serves when the smallest is the importing file itself. */ +typedef struct { + const cbm_gbuf_node_t *first; + const cbm_gbuf_node_t *second; /* NULL for a single-file package */ +} scala_package_files_t; + +struct cbm_scala_index { + CBMHashTable *symbols; /* "pkg.Name" → node, or &scala_toplevel_ambiguous */ + CBMHashTable *packages; /* "pkg" → scala_package_files_t */ +}; + +/* Marker value in the Scala top-level index: two files of a split package + * declare different top-level symbols under the same name, so an import of + * that name is ambiguous and must not bind. */ +static const cbm_gbuf_node_t scala_toplevel_ambiguous; + +static const cbm_gbuf_node_t *scala_toplevel_lookup(const cbm_scala_index_t *scala_index, + const char *package, const char *name) { + if (!scala_index || !scala_index->symbols || !package || !name || !name[0]) { + return NULL; + } + char key[PKGMAP_PATH_BUF]; + if (snprintf(key, sizeof(key), "%s.%s", package, name) >= (int)sizeof(key)) { + return NULL; + } + const cbm_gbuf_node_t *node = (const cbm_gbuf_node_t *)cbm_ht_get(scala_index->symbols, key); + return node == &scala_toplevel_ambiguous ? NULL : node; +} + +/* `import pkg._` binds the package itself: its smallest declaring file, or + * the runner-up when that is the importer. */ +static const cbm_gbuf_node_t *scala_package_file(const cbm_scala_index_t *scala_index, + const char *package, const char *source_file_qn) { + if (!scala_index || !scala_index->packages) { + return NULL; + } + const scala_package_files_t *pf = cbm_ht_get(scala_index->packages, package); + if (!pf) { + return NULL; + } + const cbm_gbuf_node_t *pick = pf->first; + if (source_file_qn && pick && strcmp(pick->qualified_name, source_file_qn) == 0) { + pick = pf->second; + } + return pick; +} + +/* Scala source packages are independent of the repository layout. Resolve the + * longest declared package prefix first, then the imported top-level symbol + * through the (package, name) index, then an optional member under that + * owner's QN. Every step fails closed: generic suffix fallback is unsafe for + * JVM FQNs because common names such as api/get/app occur throughout a + * monorepo, and a member import that binds the owner instead would turn + * `member()` into a CALLS edge to the owner class via resolve_import_map. */ +static const cbm_gbuf_node_t *resolve_scala_namespace_import(const cbm_pipeline_ctx_t *ctx, + const char *source_file_qn, + const CBMImport *imp, + const CBMHashTable *namespace_map, + const cbm_scala_index_t *scala_index) { + if (!namespace_map || !imp || !imp->module_path || !imp->module_path[0]) { + return NULL; + } + char full[PKGMAP_PATH_BUF]; + snprintf(full, sizeof(full), "%s", imp->module_path); + for (char *p = full; *p; p++) { + if (*p == '\\' || *p == ':' || *p == '/') { + *p = '.'; + } + } + size_t full_len = strlen(full); + while (full_len > 0 && (full[full_len - 1] == '*' || full[full_len - 1] == '.')) { + full[--full_len] = '\0'; + } + + bool wildcard = imp->local_name && strcmp(imp->local_name, "*") == 0; + if (wildcard && cbm_ht_has(namespace_map, full)) { + return scala_package_file(scala_index, full, source_file_qn); + } + + char package[PKGMAP_PATH_BUF]; + snprintf(package, sizeof(package), "%s", full); + for (;;) { + char *dot = strrchr(package, '.'); + if (!dot) { + break; + } + *dot = '\0'; + if (!cbm_ht_has(namespace_map, package)) { + continue; + } + const char *remainder = full + strlen(package) + 1; + char top_level[CBM_SZ_256]; + size_t top_len = strcspn(remainder, "."); + if (top_len == 0 || top_len >= sizeof(top_level)) { + return NULL; + } + memcpy(top_level, remainder, top_len); + top_level[top_len] = '\0'; + /* Only a top-level symbol of a file declaring this package qualifies; + * a nested class or method of the same name is not importable by + * `pkg.Name` and an ambiguous split-package name yields no edge. */ + const cbm_gbuf_node_t *owner = scala_toplevel_lookup(scala_index, package, top_level); + if (!owner || !owner->qualified_name) { + return NULL; + } + if (wildcard || !strchr(remainder, '.')) { + return owner; + } + /* `import pkg.Owner.member`: the member must exist under the owner's + * QN. Binding the owner instead would mislabel the edge. Only object + * members are importable, so when Owner has a companion the `Owner$` + * scope is tried before the class. */ + static const char *const scopes[] = {"$", ""}; + for (size_t si = 0; si < sizeof(scopes) / sizeof(scopes[0]); si++) { + char member_qn[CBM_SZ_512]; + if (snprintf(member_qn, sizeof(member_qn), "%s%s.%s", owner->qualified_name, scopes[si], + remainder + top_len + 1) >= (int)sizeof(member_qn)) { + return NULL; + } + const cbm_gbuf_node_t *scoped = cbm_gbuf_find_by_qn(ctx->gbuf, member_qn); + if (scoped && scoped->label && import_targetable_label(scoped->label)) { + return scoped; + } + } + return NULL; + } + return NULL; +} + +/* Scala `package` clauses are resolved only by the fail-closed resolver above. + * Letting them feed the generic first-declaring-file walk would bind Java or + * Kotlin imports of a shared package prefix to an arbitrary Scala file. */ +static bool path_is_scala(const char *path) { + const char *base = path; + for (const char *p = base; *p; p++) { + if (*p == '/' || *p == '\\') { + base = p + SKIP_ONE; + } + } + return cbm_language_for_filename(base) == CBM_LANG_SCALA; +} + +static bool declaring_file_is_scala(const cbm_gbuf_node_t *node) { + return node && node->file_path && path_is_scala(node->file_path); +} + const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t *ctx, const char *source_rel, const char *source_file_qn, - const CBMImport *imp, - CBMHashTable *namespace_map) { + CBMLanguage language, const CBMImport *imp, + CBMHashTable *namespace_map, + const cbm_scala_index_t *scala_index) { if (!ctx || !imp || !imp->module_path) { return NULL; } @@ -2226,6 +2378,10 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t } } + if (language == CBM_LANG_SCALA) { + return resolve_scala_namespace_import(ctx, source_file_qn, imp, namespace_map, scala_index); + } + /* Strategy 2: namespace map. `using App.Utils`, `import com.example.Foo`, * `use App\Utils\Helper` name a NAMESPACE (or a member of it) that the * path-based QN cannot express. Try the full module path and progressively @@ -2279,7 +2435,8 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t memcpy(qbuf, seg, len); qbuf[len] = '\0'; const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(ctx->gbuf, qbuf); - if (n && (!source_file_qn || strcmp(n->qualified_name, source_file_qn) != 0)) { + if (n && (!source_file_qn || strcmp(n->qualified_name, source_file_qn) != 0) && + !declaring_file_is_scala(n)) { return n; } } @@ -2471,17 +2628,45 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t return NULL; } +/* ── File node package property ──────────────────────────────────── */ + +/* Persist a file's package clause on its File node so an incremental run + * can rebuild the namespace map and the Scala index for the files it does + * not re-extract (see incr_load_stored_namespaces). */ +static void file_node_set_package(const cbm_pipeline_ctx_t *ctx, const char *file_qn, + const char *namespace_name) { + cbm_gbuf_node_t *node = (cbm_gbuf_node_t *)cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); + if (!node || !node->label || strcmp(node->label, "File") != 0) { + return; + } + /* Append to the existing object ({"extension":".scala"}); the previous + * generation's value is gone with the purge of a changed file, so a + * `package` key already present means this pass set it, or the file is + * one the pass does not re-extract. */ + const char *props = node->properties_json ? node->properties_json : "{}"; + if (strstr(props, "\"package\"")) { + return; + } + size_t props_len = strlen(props); + if (props_len < 2 || props[props_len - 1] != '}') { + return; + } + char escaped[PKGMAP_PATH_BUF]; + cbm_json_escape(escaped, sizeof(escaped), namespace_name); + char json[PKGMAP_PATH_BUF * 2]; + int n = snprintf(json, sizeof(json), "%.*s%s\"package\":\"%s\"}", (int)props_len - 1, props, + props_len > 2 ? "," : "", escaped); + if (n < 0 || n >= (int)sizeof(json)) { + return; + } + cbm_gbuf_node_set_properties_json(node, json); +} + /* ── Namespace map ───────────────────────────────────────────────── */ -/* The namespace names themselves, so a caller that has parked some results on - * disk can still contribute their namespaces (see - * cbm_result_spill_namespace). A file missing from this map does not fail to - * resolve -- it resolves DIFFERENTLY, through the looser fallback, which is why - * an incomplete map changed edge counts in both directions rather than only - * losing edges. */ -CBMHashTable *cbm_pipeline_namespace_map_build_names(const char *project_name, - const char *const *namespaces, - const char *const *rels, int count) { +static CBMHashTable *namespace_map_build(const cbm_pipeline_ctx_t *ctx, + const char *const *namespaces, const char *const *rels, + int count) { CBMHashTable *map = NULL; for (int i = 0; i < count; i++) { const char *namespace_name = namespaces[i]; @@ -2494,14 +2679,15 @@ CBMHashTable *cbm_pipeline_namespace_map_build_names(const char *project_name, return NULL; } } - char *file_qn = cbm_pipeline_fqn_compute(project_name, rels[i], "__file__"); + char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rels[i], "__file__"); if (!file_qn) { continue; } + file_node_set_package(ctx, file_qn, namespace_name); /* Normalize the namespace key to dot-separated form so it matches the * dot-normalized lookups in cbm_pipeline_resolve_import_node (PHP uses * '\\', some grammars '::' or '/'). */ - char *key = strdup(namespace_name); + char *key = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, namespace_name); if (!key) { free(file_qn); continue; @@ -2534,42 +2720,267 @@ CBMHashTable *cbm_pipeline_namespace_map_build_names(const char *project_name, free(prev); /* old value string */ } } - free(key); /* our fresh strdup — never stored */ - free(file_qn); /* content copied into combined */ + cbm_free(CBM_MEM_CLASS_OTHER, key); /* our fresh copy — never stored */ + free(file_qn); /* content copied into combined */ } } return map; } -/* Convenience for callers whose results are all in memory (the sequential - * definitions pass). A caller that can SPILL must use the _names variant and - * fill the parked slots from cbm_result_spill_namespace, or its map silently - * loses those files. */ -CBMHashTable *cbm_pipeline_namespace_map_build(const char *project_name, - CBMFileResult *const *results, - const char *const *rels, int count) { - const char **names = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)count * sizeof(char *)); - if (!names) { - return NULL; - } - for (int i = 0; i < count; i++) { - names[i] = results[i] ? results[i]->namespace_name : NULL; - } - CBMHashTable *map = cbm_pipeline_namespace_map_build_names(project_name, names, rels, count); - cbm_free(CBM_MEM_CLASS_OTHER, names); - return map; -} - static void ns_map_free_entry(const char *key, void *value, void *ud) { (void)ud; - free((void *)key); /* strdup'd in cbm_pipeline_namespace_map_build */ + cbm_free(CBM_MEM_CLASS_OTHER, (void *)key); /* cbm_mem_strdup'd in namespace_map_build */ free(value); } -void cbm_pipeline_namespace_map_free(CBMHashTable *map) { +static void namespace_map_free(CBMHashTable *map) { if (!map) { return; } cbm_ht_foreach(map, ns_map_free_entry, NULL); cbm_ht_free(map); } + +/* ── Scala top-level index ───────────────────────────────────────── */ + +typedef struct { + char *namespace_name; /* dot-normalized package clause */ + char *module_qn; /* language-aware module QN of the declaring file */ +} scala_file_entry_t; + +typedef struct { + CBMHashTable *files; /* rel path -> scala_file_entry_t (build-time only) */ + cbm_scala_index_t *index; +} scala_index_build_t; + +/* The module QN a file's top-level symbols hang off. Directory-module + * languages (Java, Go) put `class Outer` in Outer.java at .Outer, so + * is the module and Outer's methods and nested types sit two segments + * below it; stem-module languages (Scala `object Foo` in Foo.scala is + * Foo.Foo) use the file stem. MUST match cbm_lang_module_is_dir() + * (internal/cbm/helpers.c). */ +static bool pkgmap_module_is_dir(const char *rel) { + const char *base = rel; + for (const char *p = rel; *p; p++) { + if (*p == '/' || *p == '\\') { + base = p + SKIP_ONE; + } + } + CBMLanguage lang = cbm_language_for_filename(base); + return lang == CBM_LANG_JAVA || lang == CBM_LANG_GO; +} + +/* Keep the two smallest File nodes of a package (see scala_package_files_t). */ +static void scala_index_note_package_file(cbm_scala_index_t *index, const char *package, + const cbm_gbuf_node_t *file_node) { + if (!index->packages) { + index->packages = cbm_ht_create(CBM_SZ_64); + if (!index->packages) { + return; + } + } + scala_package_files_t *pf = cbm_ht_get(index->packages, package); + if (!pf) { + pf = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*pf)); + char *key = pf ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, package) : NULL; + if (!key) { + cbm_free(CBM_MEM_CLASS_OTHER, pf); + return; + } + pf->first = file_node; + cbm_ht_set(index->packages, key, pf); /* index owns key + pf */ + return; + } + int c = strcmp(file_node->qualified_name, pf->first->qualified_name); + if (c < 0) { + pf->second = pf->first; + pf->first = file_node; + } else if (c > 0 && + (!pf->second || strcmp(file_node->qualified_name, pf->second->qualified_name) < 0)) { + pf->second = file_node; + } +} + +/* A node is top-level when its QN is exactly ., one segment + * below the declaring file's module. Nested classes and methods carry deeper + * QNs. Module and File nodes share those QN shapes but declare nothing + * importable; a File node of a declaring file is instead a candidate for the + * package's `import pkg._` target. */ +static void scala_index_visit_node(const cbm_gbuf_node_t *node, void *ud) { + scala_index_build_t *b = ud; + if (!node || !node->qualified_name || !node->file_path || !node->name || + !import_targetable_label(node->label) || strcmp(node->label, "Module") == 0) { + return; + } + const scala_file_entry_t *fe = + (const scala_file_entry_t *)cbm_ht_get(b->files, node->file_path); + if (!fe) { + return; + } + if (strcmp(node->label, "File") == 0) { + scala_index_note_package_file(b->index, fe->namespace_name, node); + return; + } + size_t mod_len = strlen(fe->module_qn); + const char *qn = node->qualified_name; + if (strncmp(qn, fe->module_qn, mod_len) != 0 || qn[mod_len] != '.' || !qn[mod_len + 1] || + strchr(qn + mod_len + 1, '.')) { + return; + } + const char *tail = qn + mod_len + 1; + char key[PKGMAP_PATH_BUF]; + if (snprintf(key, sizeof(key), "%s.%s", fe->namespace_name, tail) >= (int)sizeof(key)) { + return; + } + if (!b->index->symbols) { + b->index->symbols = cbm_ht_create(CBM_SZ_256); + if (!b->index->symbols) { + return; + } + } + const char *stored_key = cbm_ht_get_key(b->index->symbols, key); + if (!stored_key) { + char *owned = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, key); + if (owned) { + cbm_ht_set(b->index->symbols, owned, (void *)node); /* index owns key */ + } + return; + } + if (cbm_ht_get(b->index->symbols, stored_key) != node) { + cbm_ht_set(b->index->symbols, stored_key, (void *)&scala_toplevel_ambiguous); + } +} + +static void scala_index_free_file_entry(const char *key, void *value, void *ud) { + (void)key; /* borrowed from the caller's rels array */ + (void)ud; + scala_file_entry_t *fe = value; + cbm_free(CBM_MEM_CLASS_OTHER, fe->namespace_name); + free(fe->module_qn); /* malloc'd by cbm_pipeline_fqn_module_dir */ + cbm_free(CBM_MEM_CLASS_OTHER, fe); +} + +static void scala_index_free_symbol(const char *key, void *value, void *ud) { + (void)value; /* borrowed gbuf node or the ambiguity marker */ + (void)ud; + cbm_free(CBM_MEM_CLASS_OTHER, (void *)key); /* cbm_mem_strdup'd in scala_index_visit_node */ +} + +static void scala_index_free_package(const char *key, void *value, void *ud) { + (void)ud; + cbm_free(CBM_MEM_CLASS_OTHER, value); /* File nodes are borrowed */ + cbm_free(CBM_MEM_CLASS_OTHER, (void *)key); +} + +static void scala_index_free(cbm_scala_index_t *index) { + if (!index) { + return; + } + if (index->symbols) { + cbm_ht_foreach(index->symbols, scala_index_free_symbol, NULL); + cbm_ht_free(index->symbols); + } + if (index->packages) { + cbm_ht_foreach(index->packages, scala_index_free_package, NULL); + cbm_ht_free(index->packages); + } + cbm_free(CBM_MEM_CLASS_OTHER, index); +} + +/* Every file declaring a namespace contributes, not only Scala ones: a Scala + * import routinely names a Java class of the same (mixed) package. */ +static cbm_scala_index_t *scala_index_build(const cbm_pipeline_ctx_t *ctx, + const char *const *namespaces, const char *const *rels, + int count) { + scala_index_build_t b = {0}; + b.index = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*b.index)); + if (!b.index) { + return NULL; + } + for (int i = 0; i < count; i++) { + if (!namespaces[i] || !namespaces[i][0] || !rels[i]) { + continue; + } + if (!b.files) { + b.files = cbm_ht_create(CBM_SZ_64); + if (!b.files) { + break; + } + } + scala_file_entry_t *fe = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*fe)); + if (!fe) { + continue; + } + fe->namespace_name = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, namespaces[i]); + fe->module_qn = + cbm_pipeline_fqn_module_dir(ctx->project_name, rels[i], pkgmap_module_is_dir(rels[i])); + if (!fe->namespace_name || !fe->module_qn) { + scala_index_free_file_entry(rels[i], fe, NULL); + continue; + } + for (char *p = fe->namespace_name; *p; p++) { + if (*p == '\\' || *p == ':' || *p == '/') { + *p = '.'; + } + } + void *prev = cbm_ht_set(b.files, rels[i], fe); + if (prev) { + scala_index_free_file_entry(rels[i], prev, NULL); + } + } + if (!b.files) { + scala_index_free(b.index); + return NULL; + } + cbm_gbuf_foreach_node(ctx->gbuf, scala_index_visit_node, &b); + cbm_ht_foreach(b.files, scala_index_free_file_entry, NULL); + cbm_ht_free(b.files); + return b.index; +} + +/* ── Import maps ─────────────────────────────────────────────────── */ + +void cbm_pipeline_import_maps_build(const cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, + int file_count, const char *const *namespaces, + CBMHashTable **namespace_map, cbm_scala_index_t **scala_index) { + *namespace_map = NULL; + *scala_index = NULL; + if (!ctx || !ctx->gbuf || !files || !namespaces) { + return; + } + /* The fresh files first, then the previous generation's clauses for the + * files this pass does not re-extract (incremental routes only; the + * caller guarantees the two sets are disjoint). */ + int stored = ctx->stored_namespaces ? ctx->stored_namespace_count : 0; + size_t total = (size_t)file_count + (size_t)stored; + const char **all_ns = cbm_calloc(CBM_MEM_CLASS_OTHER, (total ? total : 1) * sizeof(char *)); + const char **all_rels = cbm_calloc(CBM_MEM_CLASS_OTHER, (total ? total : 1) * sizeof(char *)); + if (!all_ns || !all_rels) { + cbm_free(CBM_MEM_CLASS_OTHER, all_ns); + cbm_free(CBM_MEM_CLASS_OTHER, all_rels); + return; + } + bool scala_importer = false; + for (int i = 0; i < file_count; i++) { + all_ns[i] = namespaces[i]; + all_rels[i] = files[i].rel_path; + scala_importer = scala_importer || files[i].language == CBM_LANG_SCALA; + } + for (int i = 0; i < stored; i++) { + all_ns[file_count + i] = ctx->stored_namespaces[i].namespace_name; + all_rels[file_count + i] = ctx->stored_namespaces[i].rel_path; + } + *namespace_map = namespace_map_build(ctx, all_ns, all_rels, (int)total); + /* Only the Scala resolver reads the index; a corpus without a Scala + * importer pays nothing for it. */ + if (scala_importer) { + *scala_index = scala_index_build(ctx, all_ns, all_rels, (int)total); + } + cbm_free(CBM_MEM_CLASS_OTHER, all_ns); + cbm_free(CBM_MEM_CLASS_OTHER, all_rels); +} + +void cbm_pipeline_import_maps_free(CBMHashTable *namespace_map, cbm_scala_index_t *scala_index) { + scala_index_free(scala_index); + namespace_map_free(namespace_map); +} diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index c1d571ecdc..80379e1934 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -239,6 +239,14 @@ void cbm_registry_free(cbm_registry_t *r); void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label); +/* cbm_registry_add for a definition with a known declaring file. The one + * entry point the pipeline registers through: besides the plain add, a + * type-like `Foo$` declared in a Scala file is recorded as a companion owner, + * which is the only thing that lets the resolver read `Foo.m` as `Foo$.m`. + * `Foo$` from any other language is an ordinary name. */ +void cbm_registry_add_def(cbm_registry_t *r, const char *name, const char *qualified_name, + const char *label, const char *file_path); + /* Resolve a callee name using prioritized strategies. * import_map: NULL-terminated array of {local_name, resolved_qn} pairs, or NULL. * Returns result with qualified_name="" if unresolved. diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 99195cc2c0..2f5bcdafa0 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1060,7 +1060,7 @@ static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { if (!incr_label_is_registry_symbol(node->label)) { return; } - cbm_registry_add(r, node->name, node->qualified_name, node->label); + cbm_registry_add_def(r, node->name, node->qualified_name, node->label, node->file_path); } static void free_incremental_result_cache(CBMFileResult **cache, int count) { @@ -1959,6 +1959,88 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p return 1; } +/* ── Stored package clauses ────────────────────────────────────── + * + * Both incremental routes hand the passes only the changed files, and the + * passes build the import maps (namespace map, Scala top-level index) from + * the files they are handed. The maps therefore knew only the changed files' + * packages: an import into an unchanged package resolved through the looser + * symbol fallback, or with the fail-closed Scala resolver not at all, until + * the next full index. The definitions/registry pass persists each file's + * clause on its File node (`package` property); read those back for every + * file that stays, so the passes see the whole project as a full build does. + * `skip` holds the rel paths whose rows are still present but about to be + * replaced or removed (legacy route); NULL once the purge already ran. */ +static void incr_free_stored_namespaces(cbm_file_namespace_t *ns, int count) { + for (int i = 0; i < count; i++) { + cbm_free(CBM_MEM_CLASS_OTHER, ns[i].rel_path); + cbm_free(CBM_MEM_CLASS_OTHER, ns[i].namespace_name); + } + cbm_free(CBM_MEM_CLASS_OTHER, ns); +} + +static int incr_load_stored_namespaces(cbm_store_t *store, const char *project, + const CBMHashTable *skip, cbm_file_namespace_t **out, + int *out_count) { + *out = NULL; + *out_count = 0; + sqlite3 *db = cbm_store_get_db(store); + if (!db) { + return -1; + } + /* LIKE keeps the JSON parse off the File nodes without a clause; + * json_valid() guards it because json_extract() raises on malformed + * properties (same shape as the store's FTS body expression). */ + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(db, + "SELECT file_path, json_extract(properties, '$.package') FROM nodes" + " WHERE project = ?1 AND label = 'File'" + " AND properties LIKE '%\"package\"%' AND json_valid(properties)", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + return -1; + } + sqlite3_bind_text(stmt, 1, project, CBM_NOT_FOUND, SQLITE_TRANSIENT); + cbm_file_namespace_t *rows = NULL; + int count = 0; + int cap = 0; + int step_rc; + while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) { + const char *rel = (const char *)sqlite3_column_text(stmt, 0); + const char *ns = (const char *)sqlite3_column_text(stmt, 1); + if (!rel || !rel[0] || !ns || !ns[0] || (skip && cbm_ht_has(skip, rel))) { + continue; + } + if (count == cap) { + int new_cap = cap ? cap * 2 : CBM_SZ_64; + cbm_file_namespace_t *grown = cbm_realloc( + CBM_MEM_CLASS_OTHER, rows, (size_t)new_cap * sizeof(cbm_file_namespace_t)); + if (!grown) { + step_rc = SQLITE_NOMEM; + break; + } + rows = grown; + cap = new_cap; + } + rows[count].rel_path = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, rel); + rows[count].namespace_name = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, ns); + if (!rows[count].rel_path || !rows[count].namespace_name) { + cbm_free(CBM_MEM_CLASS_OTHER, rows[count].rel_path); + cbm_free(CBM_MEM_CLASS_OTHER, rows[count].namespace_name); + step_rc = SQLITE_NOMEM; + break; + } + count++; + } + sqlite3_finalize(stmt); + if (step_rc != SQLITE_DONE) { + incr_free_stored_namespaces(rows, count); + return -1; + } + *out = rows; + *out_count = count; + return 0; +} + /* ── Delta-repair orchestration (closure route) ────────────────── * * The closure route's executor: clone the live generation, repair the @@ -2167,6 +2249,16 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char cbm_pipeline_get_excluded(p, &excluded_dirs, &excluded_count); path_aliases = cbm_load_path_aliases_excluded(cbm_pipeline_repo_path(p), excluded_dirs, excluded_count); + /* The purge already removed the closure and deleted files, so every File + * row left in the stage is a file the passes will not see. A failed read + * is a delta-route failure: the maps would silently diverge from a full + * build. */ + cbm_file_namespace_t *stored_ns = NULL; + int stored_ns_count = 0; + if (incr_load_stored_namespaces(staging, project, NULL, &stored_ns, &stored_ns_count) != 0) { + cbm_log_error("delta.err", "phase", "stored_namespaces"); + goto out; + } cbm_pipeline_ctx_t ctx = { .project_name = project, .repo_path = cbm_pipeline_repo_path(p), @@ -2178,6 +2270,8 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .path_aliases = path_aliases, .excluded_dirs = excluded_dirs, .excluded_count = excluded_count, + .stored_namespaces = stored_ns, + .stored_namespace_count = stored_ns_count, }; for (int i = 0; i < ci; i++) { char *file_qn = cbm_pipeline_fqn_compute(project, changed_files[i].rel_path, "__file__"); @@ -2213,6 +2307,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char if (ctx.macro_table) { free((void *)ctx.macro_table); } + incr_free_stored_namespaces(stored_ns, stored_ns_count); cbm_pkgmap_free(cbm_pipeline_get_pkgmap()); cbm_pipeline_set_pkgmap(NULL); if (phase_rc != 0) { @@ -2650,27 +2745,41 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil char *saved_adr = NULL; cbm_adr_t existing_adr = {0}; int adr_rc = cbm_store_adr_get(store, project, &existing_adr); + bool abort_preserve = false; if (adr_rc == CBM_STORE_OK) { bool had_adr_content = existing_adr.content != NULL; if (had_adr_content) { saved_adr = strdup(existing_adr.content); } cbm_store_adr_free(&existing_adr); - if (had_adr_content && !saved_adr) { - cbm_gbuf_free(existing); - free(changed_files); - for (int i = 0; i < deleted_count; i++) { - free(deleted[i]); - } - free(deleted); - free_mode_skipped(mode_skipped, mode_skipped_count); - cbm_store_free_coverage(old_cov, old_cov_count); - cbm_store_close(store); - closure_plan_free(&closure_plan); - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } + abort_preserve = had_adr_content && !saved_adr; } else if (adr_rc != CBM_STORE_NOT_FOUND) { cbm_store_adr_free(&existing_adr); + abort_preserve = true; + } + + /* Package clauses of the files that stay: the changed and deleted files + * are still in the database here, so they are skipped explicitly. */ + cbm_file_namespace_t *stored_ns = NULL; + int stored_ns_count = 0; + if (!abort_preserve) { + CBMHashTable *skip = cbm_ht_create((size_t)(ci + deleted_count) * PAIR_LEN + CBM_SZ_64); + for (int i = 0; skip && i < ci; i++) { + cbm_ht_set(skip, changed_files[i].rel_path, &changed_files[i]); + } + for (int i = 0; skip && i < deleted_count; i++) { + cbm_ht_set(skip, deleted[i], deleted[i]); + } + int ns_rc = + skip ? incr_load_stored_namespaces(store, project, skip, &stored_ns, &stored_ns_count) + : -1; + cbm_ht_free(skip); /* keys borrowed */ + if (ns_rc != 0) { + cbm_log_error("incremental.err", "msg", "stored_namespaces_failed"); + abort_preserve = true; + } + } + if (abort_preserve) { cbm_gbuf_free(existing); free(changed_files); for (int i = 0; i < deleted_count; i++) { @@ -2681,6 +2790,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil cbm_store_free_coverage(old_cov, old_cov_count); cbm_store_close(store); closure_plan_free(&closure_plan); + free(saved_adr); return CBM_PIPELINE_ABORT_PRESERVE_DB; } @@ -2751,6 +2861,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .path_aliases = path_aliases, .excluded_dirs = excluded_dirs, .excluded_count = excluded_count, + .stored_namespaces = stored_ns, + .stored_namespace_count = stored_ns_count, }; for (int i = 0; i < ci; i++) { @@ -2794,6 +2906,9 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil free((void *)ctx.macro_table); ctx.macro_table = NULL; } + incr_free_stored_namespaces(stored_ns, stored_ns_count); + ctx.stored_namespaces = NULL; + ctx.stored_namespace_count = 0; /* Parallel extraction builds the process-global package map. Match the * full pipeline's ownership boundary on both success and failure. */ diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 7f266eae98..231067fb0d 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -84,6 +84,16 @@ typedef struct { void cbm_pkg_entries_init(cbm_pkg_entries_t *e); void cbm_pkg_entries_free(cbm_pkg_entries_t *e); +/* A file's declared namespace/package clause, as persisted on its File node + * (`package` property) by the previous index generation. */ +typedef struct { + char *rel_path; /* heap */ + char *namespace_name; /* heap, raw (un-normalized) clause */ +} cbm_file_namespace_t; + +/* Scala top-level index (pass_pkgmap.c); see cbm_pipeline_import_maps_build. */ +typedef struct cbm_scala_index cbm_scala_index_t; + /* Shared context passed to each pass function. * Derived from cbm_pipeline_t fields during run. */ typedef struct { @@ -113,6 +123,16 @@ typedef struct { char **excluded_dirs; int excluded_count; + /* Package clauses of the files this run does NOT re-extract, read back + * from their File nodes by the incremental routes. The import maps + * (namespace map, Scala index) are built from the files handed to a pass, + * and an incremental pass receives only the changed files; without these + * the maps would know only the changed files' packages and an import into + * an unchanged package would resolve differently than in a full build. + * NULL/0 on a full build. Owned by pipeline_incremental.c. */ + const cbm_file_namespace_t *stored_namespaces; + int stored_namespace_count; + /* Sequential cross-LSP registry arena. The lsp_cross pass builds its * shared per-language registries here; resolved_calls entries may BORROW * strings owned by these registries, and the later calls pass still @@ -235,35 +255,48 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje * 1. Module-path resolution (relative / pkgmap / fqn_module) → existing node. * This preserves the behavior for Python/TS/Go whose module path maps * directly to a sibling Module/File QN. - * 2. namespace_map[module_path-prefix] → File node QN (Java/Kotlin/C#/PHP + * 2. namespace_map[module_path-prefix] → File/symbol node QN (Java/Kotlin/Scala/C#/PHP * `using`/`import` of a NAMESPACE that the path-based QN cannot express). * 3. Symbol-name fallback: the import's last path segment matched against an * in-graph definition node of the same simple name in a different file * (Rust `use crate::util::helper`, Java `import com.example.Util`, ...). * - * `namespace_map` may be NULL (skips step 2). `source_file_qn` is the importing - * file's __file__ QN, used to avoid self-imports in step 3. */ + * `namespace_map` may be NULL (skips step 2). `scala_index` is the Scala + * top-level index from cbm_pipeline_import_maps_build (NULL when no Scala + * file is among the importers). `source_file_qn` is the importing file's + * __file__ QN, used to avoid self-imports in step 3. */ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t *ctx, const char *source_rel, const char *source_file_qn, - const CBMImport *imp, - CBMHashTable *namespace_map); - -/* Build a namespace → File-node-QN map from a set of extraction results. - * Each result that declared a namespace/package contributes one entry keyed by - * the namespace string (e.g. "App.Utils", "com.example"). Returns NULL when no - * results declared a namespace. Caller frees via cbm_pipeline_namespace_map_free. */ -CBMHashTable *cbm_pipeline_namespace_map_build(const char *project_name, - CBMFileResult *const *results, - const char *const *rels, int count); -/* The same map built from the namespace names directly. The parallel pass needs - * this: results it has spilled are NULL in its cache, and a file missing from - * the map does not fail to resolve -- it resolves through the looser fallback, - * so an incomplete map CHANGES the graph instead of shrinking it. */ -CBMHashTable *cbm_pipeline_namespace_map_build_names(const char *project_name, - const char *const *namespaces, - const char *const *rels, int count); -void cbm_pipeline_namespace_map_free(CBMHashTable *map); + CBMLanguage language, const CBMImport *imp, + CBMHashTable *namespace_map, + const cbm_scala_index_t *scala_index); + +/* The import-resolution maps of one definitions/registry pass, built after + * all definition nodes are in the graph buffer: + * + * namespace_map: namespace → '\n'-separated File-node QNs of every file + * declaring it (e.g. "App.Utils", "com.example"), for C# `using`, Java/ + * Kotlin `import`, PHP `use`. NULL when no file declares a namespace. + * scala_index: "." → the node declaring a top-level `Name` + * in a file whose package clause is ``, plus the package's + * wildcard target, so the Scala resolver does every step in O(1). A name + * declared at top level by two files of a split package is recorded as + * ambiguous and never binds. NULL unless a Scala file is among `files` + * (only the Scala resolver reads it). + * + * `namespaces[i]` is the clause of files[i] or NULL — a caller that spilled + * results must still supply it (cbm_result_spill_namespace): a file missing + * from the map does not fail to resolve, it resolves through the looser + * fallback, so an incomplete map CHANGES the graph instead of shrinking it. + * ctx->stored_namespaces complete the maps on an incremental run. Each + * declaring file's clause is also recorded as the `package` property of its + * File node, which is where a later incremental run reads it from. Frees via + * cbm_pipeline_import_maps_free. */ +void cbm_pipeline_import_maps_build(const cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, + int file_count, const char *const *namespaces, + CBMHashTable **namespace_map, cbm_scala_index_t **scala_index); +void cbm_pipeline_import_maps_free(CBMHashTable *namespace_map, cbm_scala_index_t *scala_index); /* Parse a manifest file and collect pkg entries. Returns true if basename matched. */ bool cbm_pkgmap_try_parse(const char *basename, const char *rel_path, const char *source, diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index ff56a41ac4..0186da7924 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -26,6 +26,7 @@ enum { REG_MAX_CANDIDATES = 256 }; #define DEFAULT_CONFIDENCE 0.5 #include "pipeline/pipeline.h" #include "cbm.h" /* cbm_label_is_relation — the resolve-time relation veto */ +#include "discover/discover.h" /* cbm_language_for_filename — Scala companion gate */ #include "foundation/compat.h" /* CBM_TLS */ #include "foundation/hash_table.h" #include "foundation/dyn_array.h" @@ -97,6 +98,12 @@ struct cbm_registry { /* byName: simpleName → qn_array_t* (heap-owned) */ CBMHashTable *by_name; + + /* Scala companion owners (`pkg.Foo$`), recorded by cbm_registry_add_def + * for type-like definitions in Scala files only. Keys borrow `exact`'s + * owned QN. NULL until the first companion, so every other language's + * registry never allocates it and every `$` probe below is one NULL test. */ + CBMHashTable *companions; }; /* ── Helpers ─────────────────────────────────────────────────────── */ @@ -865,6 +872,7 @@ void cbm_registry_free(cbm_registry_t *r) { /* by_name first: its items borrow exact's keys. */ cbm_ht_foreach(r->by_name, free_qn_array, NULL); cbm_ht_free(r->by_name); + cbm_ht_free(r->companions); /* keys borrow exact's */ cbm_ht_foreach(r->exact, free_label, NULL); cbm_ht_free(r->exact); for (int i = 0; i < r->label_pool_n; i++) { @@ -982,6 +990,54 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified } } +static bool path_is_scala(const char *path) { + const char *base = path; + for (const char *p = path; *p; p++) { + if (*p == '/' || *p == '\\') { + base = p + SKIP_ONE; + } + } + return cbm_language_for_filename(base) == CBM_LANG_SCALA; +} + +void cbm_registry_add_def(cbm_registry_t *r, const char *name, const char *qualified_name, + const char *label, const char *file_path) { + cbm_registry_add(r, name, qualified_name, label); + if (!r || !qualified_name || !label || !file_path) { + return; + } + size_t len = strlen(qualified_name); + if (len < 2 || qualified_name[len - 1] != '$' || !cbm_label_is_type_like(label) || + !path_is_scala(file_path)) { + return; + } + const char *owned_qn = cbm_ht_get_key(r->exact, qualified_name); + if (!owned_qn) { + return; + } + if (!r->companions) { + r->companions = cbm_ht_create(CBM_SZ_256); + if (!r->companions) { + return; + } + } + cbm_ht_set(r->companions, owned_qn, (void *)owned_qn); +} + +/* True when the first `owner_len` bytes of `owner` name a class whose Scala + * companion (`$`) is registered. The only gate on every `$` probe in + * the resolver: a Java or JS `Foo$` next to `Foo` is never a companion. */ +static bool companion_known(const cbm_registry_t *r, const char *owner, size_t owner_len) { + if (!r->companions || owner_len == 0 || owner_len + 2 > CBM_SZ_512) { + return false; + } + char key[CBM_SZ_512]; + memcpy(key, owner, owner_len); + key[owner_len] = '$'; + key[owner_len + 1] = '\0'; + return cbm_ht_has(r->companions, key); +} + /* ── Lookup ──────────────────────────────────────────────────────── */ bool cbm_registry_exists(const cbm_registry_t *r, const char *qn) { @@ -1079,26 +1135,46 @@ static cbm_resolution_t resolve_import_map(const cbm_registry_t *r, const char * if (stored_key) { return (cbm_resolution_t){stored_key, "import_map", CONF_IMPORT_MAP, REG_RESOLVED}; } - - /* import_map_suffix fallback: find a QN starting with resolved+"." and - * ending with "."+suffix. Any such QN's last segment equals the last - * segment of suffix, so probe the by_name index and tail-check the (few) - * candidates instead of cbm_ht_foreach over the WHOLE exact table — that - * scan ran per unresolved call and dominated elasticsearch's resolve - * phase (94% of samples: 700k-entry foreach + strlen per entry). */ if (suffix && suffix[0]) { + /* Scala: an import binds the class, but `Foo.method()` on a class name + * is a call into the companion object, whose QN carries a trailing `$`. + * Probed only when `$` is a registered Scala companion, so a + * Java or JS class that happens to be named `Foo$` never takes part. */ + bool companion = companion_known(r, resolved, strlen(resolved)); + if (companion) { + snprintf(candidate, sizeof(candidate), "%s$.%s", resolved, suffix); + stored_key = cbm_ht_get_key(r->exact, candidate); + if (stored_key) { + return (cbm_resolution_t){stored_key, "import_map", CONF_IMPORT_MAP, REG_RESOLVED}; + } + } + + /* import_map_suffix fallback: find a QN starting with resolved+"." and + * ending with "."+suffix. Any such QN's last segment equals the last + * segment of suffix, so probe the by_name index and tail-check the + * (few) candidates instead of cbm_ht_foreach over the WHOLE exact + * table — that scan ran per unresolved call and dominated + * elasticsearch's resolve phase (94% of samples: 700k-entry foreach + + * strlen per entry). */ char resolved_dot[CBM_SZ_512]; + char companion_dot[CBM_SZ_512]; char dot_suffix[CBM_SZ_256]; snprintf(resolved_dot, sizeof(resolved_dot), "%s.", resolved); + snprintf(companion_dot, sizeof(companion_dot), "%s$.", resolved); snprintf(dot_suffix, sizeof(dot_suffix), ".%s", suffix); qn_array_t *arr = cbm_ht_get(r->by_name, simple_name(suffix)); if (arr) { size_t rd_len = strlen(resolved_dot); + size_t cd_len = strlen(companion_dot); size_t ds_len = strlen(dot_suffix); for (int i = 0; i < arr->count; i++) { const char *qn = arr->items[i]; size_t klen = strlen(qn); - if (klen >= rd_len + ds_len && strncmp(qn, resolved_dot, rd_len) == 0 && + bool under_owner = + klen >= rd_len + ds_len && strncmp(qn, resolved_dot, rd_len) == 0; + bool under_companion = + companion && klen >= cd_len + ds_len && strncmp(qn, companion_dot, cd_len) == 0; + if ((under_owner || under_companion) && strcmp(qn + klen - ds_len, dot_suffix) == 0) { return (cbm_resolution_t){qn, "import_map_suffix", CONF_IMPORT_MAP_SUFFIX, REG_RESOLVED}; @@ -1118,6 +1194,23 @@ static cbm_resolution_t resolve_same_module(const cbm_registry_t *r, const char if (stored_key) { return (cbm_resolution_t){stored_key, "same_module", CONF_SAME_MODULE, REG_RESOLVED}; } + /* Scala `Owner.member` in the declaring file: the member may live under + * the companion `Owner$`. Each owner segment is tried once, and only when + * `.$` is a registered Scala companion. */ + for (const char *dot = r->companions ? strchr(callee_name, '.') : NULL; dot; + dot = strchr(dot + 1, '.')) { + int owner_len = snprintf(candidate, sizeof(candidate), "%s.%.*s", module_qn, + (int)(dot - callee_name), callee_name); + if (owner_len < 0 || owner_len >= (int)sizeof(candidate) || + !companion_known(r, candidate, (size_t)owner_len)) { + continue; + } + snprintf(candidate + owner_len, sizeof(candidate) - (size_t)owner_len, "$%s", dot); + stored_key = cbm_ht_get_key(r->exact, candidate); + if (stored_key) { + return (cbm_resolution_t){stored_key, "same_module", CONF_SAME_MODULE, REG_RESOLVED}; + } + } if (suffix && suffix[0]) { snprintf(candidate, sizeof(candidate), "%s.%s", module_qn, suffix); stored_key = cbm_ht_get_key(r->exact, candidate); @@ -1171,6 +1264,49 @@ static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const * as trustworthy as a same-module hit. */ #define CONF_QUALIFIED_SUFFIX 0.90 +/* Segment equality that lets a Scala companion object stand in for its class: + * a call written `Foo.make` reaches the method declared under `Foo$`. `cand` + * points into `cand_qn`; the `$` form is accepted only when the candidate QN + * up to and including that segment is a registered Scala companion, so a + * Java or JS segment `Foo$` is plain text that does not equal `Foo`. */ +static bool qn_seg_matches(const cbm_registry_t *r, const char *cand_qn, const char *seg, + size_t seg_len, const char *cand, size_t cand_len) { + if (cand_len == seg_len) { + return strncmp(seg, cand, seg_len) == 0; + } + return cand_len == seg_len + 1 && cand[seg_len] == '$' && strncmp(seg, cand, seg_len) == 0 && + companion_known(r, cand_qn, (size_t)(cand - cand_qn) + seg_len); +} + +/* True when `qn` equals `dotted` or ends with ".", compared segment by + * segment from the right so companion segments (`Foo$`) match `Foo`. */ +static bool qn_tail_matches(const cbm_registry_t *r, const char *qn, const char *dotted) { + const char *q_end = qn + strlen(qn); + const char *d_end = dotted + strlen(dotted); + for (;;) { + const char *q_dot = q_end; + while (q_dot > qn && q_dot[-1] != '.') { + q_dot--; + } + const char *d_dot = d_end; + while (d_dot > dotted && d_dot[-1] != '.') { + d_dot--; + } + if (!qn_seg_matches(r, qn, d_dot, (size_t)(d_end - d_dot), q_dot, + (size_t)(q_end - q_dot))) { + return false; + } + if (d_dot == dotted) { + return true; /* every callee segment matched at a segment boundary */ + } + if (q_dot == qn) { + return false; /* callee has more segments than the candidate */ + } + q_end = q_dot - 1; + d_end = d_dot - 1; + } +} + /* When a callee is package/namespace-qualified (Foo::Bar::sub or Foo.Bar.sub), * disambiguate among same-simple-name candidates by matching the FULL qualified * tail against each candidate QN at a segment boundary. Returns the sole @@ -1182,7 +1318,8 @@ static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const * both reduce to "run", so the bare-name scorer would route every caller to a * single winner. Language agnostic: callees with no separator return NULL and * leave behavior unchanged. */ -static const char *qualified_suffix_match(const qn_array_t *arr, const char *callee_name) { +static const char *qualified_suffix_match(const cbm_registry_t *r, const qn_array_t *arr, + const char *callee_name) { /* Normalize "::" → "." so the tail composes with dotted candidate QNs. */ char dotted[CBM_SZ_512]; size_t w = 0; @@ -1203,16 +1340,7 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal const char *match = NULL; for (int i = 0; i < arr->count; i++) { const char *qn = arr->items[i]; - size_t qlen = strlen(qn); - if (qlen < w) { - continue; - } - const char *tail = qn + (qlen - w); - if (strcmp(tail, dotted) != 0) { - continue; - } - /* Segment boundary: tail is the whole QN or is preceded by '.'. */ - if (tail != qn && tail[-1] != '.') { + if (!qn_tail_matches(r, qn, dotted)) { continue; } if (match) { @@ -1238,7 +1366,8 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal * * Language agnostic by design: the registry holds no language, and every * language that writes receiver chains gains the same protection. */ -static bool receiver_chain_admits(const char *callee_name, const char *candidate_qn) { +static bool receiver_chain_admits(const cbm_registry_t *r, const char *callee_name, + const char *candidate_qn) { /* Normalize "::" -> "." so the chain composes with dotted candidate QNs, * the same way qualified_suffix_match does. */ char dotted[CBM_SZ_512]; @@ -1304,7 +1433,7 @@ static bool receiver_chain_admits(const char *callee_name, const char *candidate for (const char *anc = candidate_qn; anc < cand_last;) { const char *anc_end = strchr(anc, '.'); size_t anc_len = (size_t)(anc_end - anc); - if (anc_len == len && len > 0 && strncmp(seg, anc, len) == 0) { + if (len > 0 && qn_seg_matches(r, candidate_qn, seg, len, anc, anc_len)) { return true; } anc = anc_end + SKIP_ONE; @@ -1331,7 +1460,7 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char * candidates by full qualified tail, before bare-name scoring collapses * them onto a single winner. */ if (arr->count > 1) { - const char *q = qualified_suffix_match(arr, callee_name); + const char *q = qualified_suffix_match(r, arr, callee_name); if (q) { return (cbm_resolution_t){q, "qualified_suffix", CONF_QUALIFIED_SUFFIX, REG_RESOLVED}; } @@ -1339,7 +1468,7 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char /* Strategy 3: unique name */ if (arr->count == SKIP_ONE) { - if (!receiver_chain_admits(callee_name, arr->items[0])) { + if (!receiver_chain_admits(r, callee_name, arr->items[0])) { return empty_result(); } double conf = CONF_UNIQUE_NAME; @@ -1357,7 +1486,7 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char const char *best = best_by_import_distance((const char **)arr->items, qn_test_flags(arr), arr->count, module_qn); if (best) { - if (!receiver_chain_admits(callee_name, best)) { + if (!receiver_chain_admits(r, callee_name, best)) { return empty_result(); } double conf = candidate_count_penalty(CONF_SUFFIX_MATCH, arr->count); diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 01d4419c1b..e93fff5b0e 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -50,6 +50,7 @@ #include #include #include +#include #include #include @@ -138,6 +139,28 @@ static cbm_store_t *ei_index_files(EILangProj *lp, const EILangFile *files, int return cbm_store_open_path(lp->dbpath); } +/* Rewrite one already-indexed fixture file and index the same repository + * again, closing `store` first. One changed file and nothing new keeps the + * second run on an incremental route. Returns the reopened store. */ +static cbm_store_t *ei_rewrite_and_reindex(EILangProj *lp, cbm_store_t *store, + const EILangFile *file) { + cbm_store_close(store); + char path[700]; + snprintf(path, sizeof(path), "%s/%s", lp->tmpdir, file->name); + FILE *f = fopen(path, "wb"); + if (!f) + return NULL; + fputs(file->content, f); + fclose(f); + + char args[700]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", lp->tmpdir); + char *resp = cbm_mcp_handle_tool(lp->srv, "index_repository", args); + if (resp) + free(resp); + return cbm_store_open_path(lp->dbpath); +} + static char *ei_slurp_file(const char *path) { FILE *f = fopen(path, "rb"); if (!f) { @@ -813,6 +836,433 @@ TEST(ei_cpp_header_include_targets_header_file) { PASS(); } +/* Scala packages do not have to mirror the repository path. Concrete imports + * must resolve to the symbol declared in the source package, while package + * wildcards may only resolve inside that exact package. External wildcards + * must fail closed instead of selecting an unrelated same-name node. */ +TEST(ei_scala_package_imports_ignore_physical_layout) { + static const EILangFile f[] = { + {"modules/model/src/odd/layout/Target.scala", + "package com.example.model\nobject Target { def build(): Int = 1 }\n"}, + {"modules/model/src/another/layout/Aux.scala", + "package com.example.model\nobject Aux { def value: Int = 2 }\n"}, + {"modules/decoy/src/App.scala", "package unrelated\nobject App\n"}, + {"modules/client/src/Consumer.scala", + "package com.example.client\n" + "import com.example.model.Target\n" + "import com.example.model._\n" + "import outside.library._\n" + "object Consumer { def run(): Int = Target.build() }\n"}}; + + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, f, 4); + ASSERT_NOT_NULL(store); + int64_t source_id = + ei_node_id_for_file_label(store, lp.project, "modules/client/src/Consumer.scala", "File"); + ASSERT_GT(source_id, 0); + + cbm_edge_t *edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_store_find_edges_by_source_type(store, source_id, "IMPORTS", &edges, &edge_count), + CBM_STORE_OK); + ASSERT_EQ(edge_count, 2); + + bool saw_target_symbol = false; + for (int i = 0; i < edge_count; i++) { + cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + ASSERT_NOT_NULL(target); + ASSERT_EQ(cbm_store_find_node_by_id(store, edges[i].target_id, target), CBM_STORE_OK); + ASSERT_NOT_NULL(target->file_path); + ASSERT_TRUE(strstr(target->file_path, "modules/model/src/") == target->file_path); + if (target->name && strcmp(target->name, "Target") == 0 && + strstr(target->file_path, "Target.scala") != NULL) { + saw_target_symbol = true; + } + cbm_store_free_nodes(target, 1); + } + cbm_store_free_edges(edges, edge_count); + ASSERT_TRUE(saw_target_symbol); + ei_cleanup(&lp, store); + PASS(); +} + +/* Scala package clauses feed only the Scala resolver. A Java import whose + * package is external must not bind to a Scala file that merely declares a + * shared prefix of that package. */ +TEST(ei_java_import_ignores_scala_package_prefix) { + static const EILangFile f[] = { + {"core/src/main/scala/com/acme/package.scala", + "package com.acme\npackage object core { val marker: Int = 1 }\n"}, + {"client/src/main/java/com/acme/client/Consumer.java", + "package com.acme.client;\n" + "import com.acme.util.Future;\n" + "public class Consumer { Future pending; }\n"}}; + + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, f, 2); + ASSERT_NOT_NULL(store); + int64_t source_id = ei_node_id_for_file_label( + store, lp.project, "client/src/main/java/com/acme/client/Consumer.java", "File"); + ASSERT_GT(source_id, 0); + + cbm_edge_t *edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_store_find_edges_by_source_type(store, source_id, "IMPORTS", &edges, &edge_count), + CBM_STORE_OK); + ASSERT_EQ(edge_count, 0); + cbm_store_free_edges(edges, edge_count); + ei_cleanup(&lp, store); + PASS(); +} + +/* Index `files`, return the IMPORTS edges leaving `source_file` and, when + * exactly one is expected, the name and file of its target. */ +static int ei_scala_single_import_target(const EILangFile *files, int nfiles, + const char *source_file, char *target_name, size_t name_sz, + char *target_file, size_t file_sz) { + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, files, nfiles); + if (!store) { + return -1; + } + int64_t source_id = ei_node_id_for_file_label(store, lp.project, source_file, "File"); + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (source_id <= 0 || cbm_store_find_edges_by_source_type(store, source_id, "IMPORTS", &edges, + &edge_count) != CBM_STORE_OK) { + ei_cleanup(&lp, store); + return -1; + } + if (edge_count == 1) { + cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + if (target && + cbm_store_find_node_by_id(store, edges[0].target_id, target) == CBM_STORE_OK) { + snprintf(target_name, name_sz, "%s", target->name ? target->name : ""); + snprintf(target_file, file_sz, "%s", target->file_path ? target->file_path : ""); + } + cbm_store_free_nodes(target, 1); + } + cbm_store_free_edges(edges, edge_count); + ei_cleanup(&lp, store); + return edge_count; +} + +/* The most common mixed-package case: a Scala file imports a Java class that + * lives in the same source package but a different source root. */ +TEST(ei_scala_imports_java_class_from_mixed_package) { + static const EILangFile f[] = { + {"core/src/main/java/com/acme/core/Codec.java", + "package com.acme.core;\npublic class Codec { public static int size() { return 1; } }\n"}, + {"core/src/main/scala/com/acme/core/Util.scala", "package com.acme.core\nobject Util\n"}, + {"client/src/main/scala/com/acme/client/Consumer.scala", + "package com.acme.client\n" + "import com.acme.core.Codec\n" + "object Consumer { def run(): Int = Codec.size() }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 3, + "client/src/main/scala/com/acme/client/Consumer.scala", + name, sizeof(name), file, sizeof(file)), + 1); + ASSERT_STR_EQ(name, "Codec"); + ASSERT_TRUE(strstr(file, "Codec.java") != NULL); + PASS(); +} + +/* A package declared by both Java and Scala files: a Java import still binds + * the Java declaring file exactly as on main, never a Scala sibling. */ +TEST(ei_java_import_of_mixed_package_binds_java_file) { + static const EILangFile f[] = {{"a/src/main/scala/com/acme/shared/Helper.scala", + "package com.acme.shared\nobject Helper\n"}, + {"b/src/main/java/com/acme/shared/Shared.java", + "package com.acme.shared;\npublic class Shared {}\n"}, + {"c/src/main/java/com/acme/app/App.java", + "package com.acme.app;\nimport com.acme.shared.Shared;\npublic " + "class App { Shared s; }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 3, "c/src/main/java/com/acme/app/App.java", name, + sizeof(name), file, sizeof(file)), + 1); + ASSERT_TRUE(strstr(file, "Shared.java") != NULL); + PASS(); +} + +/* Two files of a split package both declare a top-level `Codec`: the import is + * ambiguous and must produce no edge rather than pick one. */ +TEST(ei_scala_split_package_same_name_top_level_is_ambiguous) { + static const EILangFile f[] = {{"main/src/com/acme/model/Codec.scala", + "package com.acme.model\nobject Codec { def a(): Int = 1 }\n"}, + {"bench/src/com/acme/model/Codec.scala", + "package com.acme.model\nobject Codec { def b(): Int = 2 }\n"}, + {"client/src/Consumer.scala", + "package com.acme.client\n" + "import com.acme.model.Codec\n" + "object Consumer { def run(): Int = Codec.a() }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 3, "client/src/Consumer.scala", name, sizeof(name), + file, sizeof(file)), + 0); + PASS(); +} + +/* `import pkg.Name` matches only a top-level Name. A nested object of the same + * name neither becomes the target nor makes the real top-level one ambiguous. */ +TEST(ei_scala_import_ignores_nested_same_name) { + static const EILangFile f[] = { + {"model/src/Codec.scala", "package com.acme.model\nobject Codec { def a(): Int = 1 }\n"}, + {"model/src/Outer.scala", + "package com.acme.model\nobject Outer { object Codec { def b(): Int = 2 } }\n"}, + {"client/src/Consumer.scala", "package com.acme.client\n" + "import com.acme.model.Codec\n" + "object Consumer { def run(): Int = Codec.a() }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 3, "client/src/Consumer.scala", name, sizeof(name), + file, sizeof(file)), + 1); + ASSERT_STR_EQ(name, "Codec"); + ASSERT_TRUE(strstr(file, "model/src/Codec.scala") != NULL); + PASS(); +} + +/* `import pkg.Owner.member` binds the member under the owner's QN. When the + * member does not exist the import fails closed: no IMPORTS edge, and the bare + * `missing()` call must not become a CALLS edge to the `Config` class. */ +TEST(ei_scala_unresolvable_member_import_yields_no_owner_edge) { + static const EILangFile f[] = { + {"model/src/Config.scala", + "package com.acme.model\nobject Config { def load(): Int = 1 }\n"}, + {"client/src/Loader.scala", "package com.acme.client\n" + "import com.acme.model.Config.load\n" + "object Loader { def run(): Int = load() }\n"}, + {"client/src/Consumer.scala", "package com.acme.client\n" + "import com.acme.model.Config.missing\n" + "object Consumer { def run(): Int = missing() }\n"}}; + + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, f, 3); + ASSERT_NOT_NULL(store); + + int64_t loader_id = + ei_node_id_for_file_label(store, lp.project, "client/src/Loader.scala", "File"); + ASSERT_GT(loader_id, 0); + cbm_edge_t *edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_store_find_edges_by_source_type(store, loader_id, "IMPORTS", &edges, &edge_count), + CBM_STORE_OK); + ASSERT_EQ(edge_count, 1); + cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + ASSERT_NOT_NULL(target); + ASSERT_EQ(cbm_store_find_node_by_id(store, edges[0].target_id, target), CBM_STORE_OK); + ASSERT_STR_EQ(target->name, "load"); + cbm_store_free_nodes(target, 1); + cbm_store_free_edges(edges, edge_count); + + int64_t consumer_id = + ei_node_id_for_file_label(store, lp.project, "client/src/Consumer.scala", "File"); + ASSERT_GT(consumer_id, 0); + edges = NULL; + edge_count = 0; + ASSERT_EQ( + cbm_store_find_edges_by_source_type(store, consumer_id, "IMPORTS", &edges, &edge_count), + CBM_STORE_OK); + ASSERT_EQ(edge_count, 0); + cbm_store_free_edges(edges, edge_count); + + cbm_node_t *configs = NULL; + int config_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_name(store, lp.project, "Config", &configs, &config_count), + CBM_STORE_OK); + ASSERT_GT(config_count, 0); + for (int i = 0; i < config_count; i++) { + cbm_edge_t *calls = NULL; + int call_count = 0; + ASSERT_EQ( + cbm_store_find_edges_by_target_type(store, configs[i].id, "CALLS", &calls, &call_count), + CBM_STORE_OK); + ASSERT_EQ(call_count, 0); + cbm_store_free_edges(calls, call_count); + } + cbm_store_free_nodes(configs, config_count); + ei_cleanup(&lp, store); + PASS(); +} + +/* Consecutive `package a` / `package b` clauses declare a.b. */ +TEST(ei_scala_chained_package_clauses_resolve) { + static const EILangFile f[] = { + {"model/src/Target.scala", + "package com.acme\npackage model\nobject Target { def build(): Int = 1 }\n"}, + {"client/src/Consumer.scala", "package com.acme.client\n" + "import com.acme.model.Target\n" + "object Consumer { def run(): Int = Target.build() }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 2, "client/src/Consumer.scala", name, sizeof(name), + file, sizeof(file)), + 1); + ASSERT_STR_EQ(name, "Target"); + ASSERT_TRUE(strstr(file, "Target.scala") != NULL); + PASS(); +} + +/* Java class QNs are directory-based (`Request` in Request.java is + * .Request), so its nested `Builder` sits one segment below the stem + * module QN. It is not a top-level symbol of the package and must neither + * bind `import pkg.Builder` nor make the real top-level Scala `Builder` + * ambiguous. */ +TEST(ei_scala_import_ignores_java_nested_type_of_same_name) { + static const EILangFile f[] = { + {"core/src/main/java/com/acme/core/Request.java", + "package com.acme.core;\n" + "public class Request {\n" + " public static class Builder { public Request build() { return null; } }\n" + "}\n"}, + {"core/src/main/scala/com/acme/core/Builder.scala", + "package com.acme.core\nobject Builder { def make(): Int = 1 }\n"}, + {"client/src/Consumer.scala", "package com.acme.client\n" + "import com.acme.core.Builder\n" + "object Consumer { def run(): Int = Builder.make() }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 3, "client/src/Consumer.scala", name, sizeof(name), + file, sizeof(file)), + 1); + ASSERT_STR_EQ(name, "Builder"); + ASSERT_TRUE(strstr(file, "Builder.scala") != NULL); + PASS(); +} + +/* The Scala IMPORTS of client/src/Consumer.scala in the incremental fixture + * below: exactly the symbol import of `Target` and the wildcard bound to the + * package's smallest declaring file, plus the `run() -> build()` CALLS edge + * the import map yields. */ +static int ei_scala_incr_consumer_edges_ok(cbm_store_t *store, const char *project) { + int64_t source_id = + ei_node_id_for_file_label(store, project, "client/src/Consumer.scala", "File"); + if (source_id <= 0) { + fprintf(stderr, " no File node for Consumer.scala\n"); + return 0; + } + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_source_type(store, source_id, "IMPORTS", &edges, &edge_count) != + CBM_STORE_OK) { + return 0; + } + bool saw_target = false; + bool saw_wildcard = false; + for (int i = 0; i < edge_count; i++) { + cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + if (!target || + cbm_store_find_node_by_id(store, edges[i].target_id, target) != CBM_STORE_OK) { + free(target); + continue; + } + if (target->name && strcmp(target->name, "Target") == 0 && target->file_path && + strstr(target->file_path, "model/src/Target.scala")) { + saw_target = true; + } else if (target->label && strcmp(target->label, "File") == 0 && target->file_path && + strcmp(target->file_path, "model/src/Aux.scala") == 0) { + saw_wildcard = true; + } + cbm_store_free_nodes(target, 1); + } + cbm_store_free_edges(edges, edge_count); + if (edge_count != 2 || !saw_target || !saw_wildcard) { + fprintf(stderr, " Consumer IMPORTS: count=%d target=%d wildcard=%d\n", edge_count, + saw_target, saw_wildcard); + return 0; + } + + cbm_node_t *runs = NULL; + int run_count = 0; + if (cbm_store_find_nodes_by_name(store, project, "run", &runs, &run_count) != CBM_STORE_OK) { + return 0; + } + bool saw_call = false; + for (int i = 0; i < run_count && !saw_call; i++) { + cbm_edge_t *calls = NULL; + int call_count = 0; + if (cbm_store_find_edges_by_source_type(store, runs[i].id, "CALLS", &calls, &call_count) != + CBM_STORE_OK) { + continue; + } + for (int j = 0; j < call_count && !saw_call; j++) { + cbm_node_t *callee = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + if (callee && + cbm_store_find_node_by_id(store, calls[j].target_id, callee) == CBM_STORE_OK && + callee->name && strcmp(callee->name, "build") == 0) { + saw_call = true; + } + if (callee) { + cbm_store_free_nodes(callee, 1); + } + } + cbm_store_free_edges(calls, call_count); + } + cbm_store_free_nodes(runs, run_count); + if (!saw_call) { + fprintf(stderr, " no CALLS run -> build\n"); + } + return saw_call; +} + +/* Graph content must not depend on whether the last index was full or + * incremental. An incremental run hands the passes only the changed file, so + * the namespace map and the Scala index must be completed from the package + * clauses persisted for the unchanged files; otherwise the fail-closed Scala + * resolver drops every import into an unchanged package, and the CALLS built + * on them, until the next full index. Exercised on both incremental routes + * (closure repair, then the legacy partial fallback), each proven by the + * route probe so a silent fall-back to a full reindex cannot pass this. */ +TEST(ei_scala_incremental_reindex_keeps_imports_into_unchanged_packages) { + static const EILangFile f[] = { + {"model/src/Target.scala", + "package com.acme.model\nobject Target { def build(): Int = 1 }\n"}, + {"model/src/Aux.scala", "package com.acme.model\nobject Aux { def value: Int = 2 }\n"}, + {"client/src/Consumer.scala", "package com.acme.client\n" + "import com.acme.model.Target\n" + "import com.acme.model._\n" + "object Consumer { def run(): Int = Target.build() }\n"}}; + /* Body-only edits: a new definition name makes the closure route decline + * (another file might reference it), which would fall back to a full + * reindex and void the route proof below. */ + static const EILangFile edit1 = {"client/src/Consumer.scala", + "package com.acme.client\n" + "import com.acme.model.Target\n" + "import com.acme.model._\n" + "object Consumer { def run(): Int = Target.build() + 1 }\n"}; + static const EILangFile edit2 = {"client/src/Consumer.scala", + "package com.acme.client\n" + "import com.acme.model.Target\n" + "import com.acme.model._\n" + "object Consumer { def run(): Int = Target.build() + 2 }\n"}; + + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, f, 3); + ASSERT_NOT_NULL(store); + ASSERT_TRUE(ei_scala_incr_consumer_edges_ok(store, lp.project)); + + store = ei_rewrite_and_reindex(&lp, store, &edit1); + ASSERT_NOT_NULL(store); + ASSERT_EQ((int)cbm_pipeline_incremental_test_last_route(), + (int)CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + ASSERT_TRUE(ei_scala_incr_consumer_edges_ok(store, lp.project)); + + cbm_pipeline_incremental_test_force_legacy_partial_once(); + store = ei_rewrite_and_reindex(&lp, store, &edit2); + ASSERT_NOT_NULL(store); + ASSERT_EQ((int)cbm_pipeline_incremental_test_last_route(), + (int)CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL); + ASSERT_TRUE(ei_scala_incr_consumer_edges_ok(store, lp.project)); + + ei_cleanup(&lp, store); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════════════ * RED REPRODUCTION — Rust * @@ -1469,6 +1919,18 @@ SUITE(edge_imports) { RUN_TEST(ei_py_external_import_never_binds_project_symbol); RUN_TEST(ei_cpp_header_include_targets_header_file); + /* ── GREEN GUARDS — Scala package-aware resolution ── */ + RUN_TEST(ei_scala_package_imports_ignore_physical_layout); + RUN_TEST(ei_java_import_ignores_scala_package_prefix); + RUN_TEST(ei_scala_imports_java_class_from_mixed_package); + RUN_TEST(ei_java_import_of_mixed_package_binds_java_file); + RUN_TEST(ei_scala_split_package_same_name_top_level_is_ambiguous); + RUN_TEST(ei_scala_import_ignores_nested_same_name); + RUN_TEST(ei_scala_unresolvable_member_import_yields_no_owner_edge); + RUN_TEST(ei_scala_chained_package_clauses_resolve); + RUN_TEST(ei_scala_import_ignores_java_nested_type_of_same_name); + RUN_TEST(ei_scala_incremental_reindex_keeps_imports_into_unchanged_packages); + /* ── RED REPRODUCTIONS — Rust (expected to FAIL until pipeline fixed) ── */ RUN_TEST(ei_rust_mod_plus_use); RUN_TEST(ei_rust_use_crate_path); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index cde8aee452..fe155e5662 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -58,6 +58,17 @@ static int __attribute__((unused)) has_import(CBMFileResult *r, const char *path return 0; } +static const CBMImport *find_import(CBMFileResult *r, const char *local, const char *path) { + for (int i = 0; i < r->imports.count; i++) { + const CBMImport *imp = &r->imports.items[i]; + if (imp->local_name && imp->module_path && strcmp(imp->local_name, local) == 0 && + strcmp(imp->module_path, path) == 0) { + return imp; + } + } + return NULL; +} + /* 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 @@ -5010,10 +5021,11 @@ TEST(swift_non_url_constructor_untouched_issue1892) { * the per-file constant map and resolved at the call site, for both return * statements and arrow expression bodies. */ TEST(extract_ts_await_generic_call_issue2210) { - CBMFileResult *r = extract("function parseJsonBody() { return {} as T; }\n" - "async function plain() { return await parseJsonBody(); }\n" - "async function generic() { return await parseJsonBody(); }\n", - CBM_LANG_TYPESCRIPT, "t", "await.ts"); + CBMFileResult *r = + extract("function parseJsonBody() { return {} as T; }\n" + "async function plain() { return await parseJsonBody(); }\n" + "async function generic() { return await parseJsonBody(); }\n", + CBM_LANG_TYPESCRIPT, "t", "await.ts"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); ASSERT_EQ(count_calls_named(r, "parseJsonBody"), 2); @@ -6342,6 +6354,182 @@ TEST(extract_ts_member_call_flags_is_method) { PASS(); } +TEST(extract_scala_companion_owners_are_distinct) { + CBMFileResult *r = extract("case class Rational private (n: Int, d: Int) {\n" + " lazy val isWhole: Boolean = d == 1\n" + " def reciprocal: Rational = Rational(d, n)\n" + "}\n" + "case object Rational {\n" + " val zero: Rational = Rational(0, 1)\n" + " def apply(n: Int, d: Int): Rational = new Rational(n, d)\n" + "}\n", + CBM_LANG_SCALA, "t", "Rational.scala"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + ASSERT_TRUE(has_def_qn(r, "t.Rational.Rational")); + ASSERT_TRUE(has_def_qn(r, "t.Rational.Rational$")); + ASSERT_TRUE(has_def_qn(r, "t.Rational.Rational.reciprocal")); + ASSERT_TRUE(has_def_qn(r, "t.Rational.Rational$.apply")); + /* Class-body vals keep their module-level QN like every other language; + * parent_class tells the two owners apart. */ + ASSERT_TRUE(has_def_qn(r, "t.Rational.isWhole")); + ASSERT_TRUE(has_def_qn(r, "t.Rational.zero")); + + cbm_free_result(r); + PASS(); +} + +TEST(extract_scala_trait_companion_and_standalone_object) { + CBMFileResult *r = extract("trait Codec {\n" + " def encode(s: String): Array[Byte]\n" + "}\n" + "object Codec {\n" + " def utf8: Codec = ???\n" + "}\n" + "object Registry {\n" + " def lookup(name: String): Option[Codec] = None\n" + "}\n", + CBM_LANG_SCALA, "t", "Codec.scala"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* Trait companions collide just like class companions. */ + ASSERT_TRUE(has_def_qn(r, "t.Codec.Codec")); + ASSERT_TRUE(has_def_qn(r, "t.Codec.Codec$")); + ASSERT_TRUE(has_def_qn(r, "t.Codec.Codec.encode")); + ASSERT_TRUE(has_def_qn(r, "t.Codec.Codec$.utf8")); + /* A standalone object has nothing to collide with and keeps its plain QN. */ + ASSERT_TRUE(has_def_qn(r, "t.Codec.Registry")); + ASSERT_FALSE(has_def_qn(r, "t.Codec.Registry$")); + ASSERT_TRUE(has_def_qn(r, "t.Codec.Registry.lookup")); + + cbm_free_result(r); + PASS(); +} + +/* Companion detection is answered per scope from a cache built on the first + * query in that scope, so the same name must be judged against its own + * scope's siblings only: `object Node` is a companion at the top level and + * standalone inside `Tree`, and a scope with no class at all (`Ops`) must not + * turn any of its objects into companions. A flat sealed hierarchy of many + * case classes and case objects in one scope is the shape that used to be + * quadratic; every object there must still be classified correctly. */ +TEST(extract_scala_companion_detection_is_scoped) { + CBMFileResult *r = extract("class Node(v: Int)\n" + "object Node { def leaf: Node = new Node(0) }\n" + "object Tree {\n" + " object Node { def root: Int = 0 }\n" + " class Leaf\n" + " object Leaf { def make: Leaf = new Leaf }\n" + "}\n" + "object Ops {\n" + " object Node { def count: Int = 1 }\n" + " object Leaf { def count: Int = 2 }\n" + "}\n" + "sealed trait Op\n" + "case class Add(n: Int) extends Op\n" + "case object Add extends Op\n" + "case class Sub(n: Int) extends Op\n" + "case object Neg extends Op\n" + "case object Op { def all: Seq[Op] = Nil }\n", + CBM_LANG_SCALA, "t", "Ast.scala"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + ASSERT_TRUE(has_def_qn(r, "t.Ast.Node")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Node$")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Node$.leaf")); + /* Inside `Tree` there is no class Node, so its `object Node` is standalone, + * while `Leaf` has a class sibling there. */ + ASSERT_TRUE(has_def_qn(r, "t.Ast.Tree.Node")); + ASSERT_FALSE(has_def_qn(r, "t.Ast.Tree.Node$")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Tree.Node.root")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Tree.Leaf")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Tree.Leaf$")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Tree.Leaf$.make")); + /* `Ops` holds objects only; nothing there is a companion, whatever the + * neighbouring scopes declare. */ + ASSERT_TRUE(has_def_qn(r, "t.Ast.Ops.Node")); + ASSERT_FALSE(has_def_qn(r, "t.Ast.Ops.Node$")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Ops.Leaf")); + ASSERT_FALSE(has_def_qn(r, "t.Ast.Ops.Leaf$")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Ops.Node.count")); + /* Sealed hierarchy in one scope: companions of a case class and of the + * sealed trait get `$`, a case object without a class sibling does not. */ + ASSERT_TRUE(has_def_qn(r, "t.Ast.Add")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Add$")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Sub")); + ASSERT_FALSE(has_def_qn(r, "t.Ast.Sub$")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Neg")); + ASSERT_FALSE(has_def_qn(r, "t.Ast.Neg$")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Op")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Op$")); + ASSERT_TRUE(has_def_qn(r, "t.Ast.Op$.all")); + + cbm_free_result(r); + PASS(); +} + +TEST(extract_scala_import_selectors_and_aliases) { + CBMFileResult *r = extract("import foo.Direct\n" + "import foo.{Selected, Original => Alias, Hidden => _, _}\n" + "import modern.{Source as ModernAlias, *}\n" + "import alpha.One, beta.Two\n", + CBM_LANG_SCALA, "t", "Imports.scala"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_NOT_NULL(find_import(r, "Direct", "foo.Direct")); + ASSERT_NOT_NULL(find_import(r, "Selected", "foo.Selected")); + ASSERT_NOT_NULL(find_import(r, "Alias", "foo.Original")); + ASSERT_NULL(find_import(r, "Hidden", "foo.Hidden")); + ASSERT_NOT_NULL(find_import(r, "*", "foo")); + ASSERT_NOT_NULL(find_import(r, "ModernAlias", "modern.Source")); + ASSERT_NOT_NULL(find_import(r, "*", "modern")); + ASSERT_NOT_NULL(find_import(r, "One", "alpha.One")); + ASSERT_NOT_NULL(find_import(r, "Two", "beta.Two")); + cbm_free_result(r); + PASS(); +} + +TEST(extract_scala_package_namespace) { + CBMFileResult *r = extract("package com.example.app.account\n" + "import com.example.app.model.Target\n" + "object Consumer\n", + CBM_LANG_SCALA, "t", "modules/client/Consumer.scala"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_NOT_NULL(r->namespace_name); + ASSERT_STR_EQ(r->namespace_name, "com.example.app.account"); + cbm_free_result(r); + PASS(); +} + +TEST(extract_scala_chained_package_clauses) { + CBMFileResult *r = extract("package com.example\n" + "// chained clause below\n" + "package app.account\n" + "import com.example.app.model.Target\n" + "object Consumer\n", + CBM_LANG_SCALA, "t", "modules/client/Consumer.scala"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_NOT_NULL(r->namespace_name); + ASSERT_STR_EQ(r->namespace_name, "com.example.app.account"); + cbm_free_result(r); + + /* A braced clause scopes only its body; the file namespace stays the outer one. */ + r = extract("package com.example\n" + "package braced { object Inner }\n" + "object Outer\n", + CBM_LANG_SCALA, "t", "Outer.scala"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(r->namespace_name); + ASSERT_STR_EQ(r->namespace_name, "com.example"); + cbm_free_result(r); + PASS(); +} + /* this/super receivers keep the enclosing-class target, where a weak * namespace-proximity match is usually correct — so they are NOT flagged. A * new_expression has no member receiver and is never flagged either. */ @@ -9238,6 +9426,12 @@ SUITE(extraction) { RUN_TEST(extract_python_bare_call_flags_locally_bound_callee); RUN_TEST(extract_python_bare_call_flag_is_depth_independent); RUN_TEST(extract_ts_member_call_flags_is_method); + RUN_TEST(extract_scala_companion_owners_are_distinct); + RUN_TEST(extract_scala_trait_companion_and_standalone_object); + RUN_TEST(extract_scala_companion_detection_is_scoped); + RUN_TEST(extract_scala_import_selectors_and_aliases); + RUN_TEST(extract_scala_package_namespace); + RUN_TEST(extract_scala_chained_package_clauses); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index ed9b0dd3cb..d8606efe3d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1277,6 +1277,44 @@ static bool cross_file_call_exists(cbm_store_t *s, const char *project, const ch return cross_file_edge_exists(s, project, src_name, tgt_name, "CALLS"); } +static bool cross_file_call_to_owner_has_strategy(cbm_store_t *s, const char *project, + const char *src_name, const char *tgt_name, + const char *owner_fragment, + const char *strategy_fragment) { + cbm_node_t *srcs = NULL; + cbm_node_t *tgts = NULL; + int sc = 0; + int tc = 0; + cbm_store_find_nodes_by_name(s, project, src_name, &srcs, &sc); + cbm_store_find_nodes_by_name(s, project, tgt_name, &tgts, &tc); + bool found = false; + for (int i = 0; i < sc && !found; i++) { + cbm_edge_t *edges = NULL; + int ec = 0; + cbm_store_find_edges_by_source_type(s, srcs[i].id, "CALLS", &edges, &ec); + for (int j = 0; j < ec && !found; j++) { + for (int k = 0; k < tc; k++) { + if (edges[j].target_id == tgts[k].id && tgts[k].qualified_name && + strstr(tgts[k].qualified_name, owner_fragment) && edges[j].properties_json && + strstr(edges[j].properties_json, strategy_fragment)) { + found = true; + break; + } + } + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + } + if (srcs) { + cbm_store_free_nodes(srcs, sc); + } + if (tgts) { + cbm_store_free_nodes(tgts, tc); + } + return found; +} + /* True iff the exact named CALLS edge exists and its serialized strategy * contains `strategy_fragment`. Parallel synthetic-carrier regressions use * this on a separate ordinary-call control: it proves the cross-file LSP ran @@ -7218,6 +7256,274 @@ TEST(pipeline_go_bare_ref_never_binds_field_parallel) { PASS(); } +static void write_scala_import_alias_fixture(const char *tmp) { + write_temp_file(tmp, "alias_targets.scala", + "package alias_targets\n" + "object CorrectObject { def execute(): Int = 1 }\n" + "object WrongObject { def execute(): Int = 2 }\n" + "object ExternalStateRef { def make(): Int = 3 }\n"); + write_temp_file(tmp, "alias_caller.scala", + "package alias_caller\n" + "import alias_targets.{CorrectObject => ImportedAlias}\n" + "import outside.library.ExternalStateRef\n" + "object AliasCalls {\n" + " def invokeAlias(): Int = ImportedAlias.execute()\n" + " def invokeExternal(): Int = ExternalStateRef.make()\n" + "}\n"); +} + +static bool scala_import_alias_edge_is_exact(cbm_store_t *s, const char *project) { + cbm_edge_t *imports = NULL; + int import_count = 0; + bool exact_call = cross_file_call_to_owner_has_strategy(s, project, "invokeAlias", "execute", + "CorrectObject.execute", "import_map"); + cbm_store_find_edges_by_type(s, project, "IMPORTS", &imports, &import_count); + if (imports) { + cbm_store_free_edges(imports, import_count); + } + return exact_call && import_count == 1; +} + +TEST(pipeline_scala_import_alias_resolves_exact_method) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_scala_alias_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_scala_import_alias_fixture(tmp); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/scala_alias.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(scala_import_alias_edge_is_exact(s, cbm_pipeline_project_name(p))); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* `import pkg.Owner.member` when the package is split across modules and a + * second file in the same package declares a same-named member: the import + * must bind to Owner.member, not degrade to the owner (which would drop the + * unqualified `member()` call). */ +TEST(pipeline_scala_member_import_in_split_package_binds_owner_member) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_scala_member_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "main_parser.scala", + "package split_pkg\n" + "object Parser { def check(s: String): Boolean = true }\n"); + write_temp_file(tmp, "bench_parser.scala", + "package split_pkg\n" + "class ParserBench { def check(s: String): Boolean = false }\n"); + write_temp_file(tmp, "member_caller.scala", + "package member_caller\n" + "import split_pkg.Parser.check\n" + "object Decoder { def validate(s: String): Boolean = check(s) }\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/scala_member.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(cross_file_call_to_owner_has_strategy(s, cbm_pipeline_project_name(p), "validate", + "check", "Parser.check", "import_map")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Only the Scala resolver reads the top-level index, so a pass whose files + * hold no Scala importer must not pay the node walk and per-symbol keys for + * it: Java/Kotlin/C#/PHP corpora get exactly the namespace map, as before. + * One Scala file among the importers builds it, whether or not that file + * declares a package (a default-package Scala file still imports). */ +TEST(pipeline_scala_index_built_only_for_scala_importers) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + ASSERT_NOT_NULL(gb); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "File", "Codec.java", "proj.core.Codec.__file__", + "core/Codec.java", 0, 0, "{}"), + 0); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Class", "Codec", "proj.core.Codec", "core/Codec.java", 1, 3, + "{}"), + 0); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "File", "App.scala", "proj.app.App.__file__", + "app/App.scala", 0, 0, "{}"), + 0); + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "proj", + .repo_path = "/tmp/proj", + .gbuf = gb, + .cancelled = &cancelled, + }; + + cbm_file_info_t java_only[] = {{.rel_path = "core/Codec.java", .language = CBM_LANG_JAVA}}; + const char *java_ns[] = {"com.acme.core"}; + CBMHashTable *namespace_map = NULL; + cbm_scala_index_t *scala_index = NULL; + cbm_pipeline_import_maps_build(&ctx, java_only, 1, java_ns, &namespace_map, &scala_index); + ASSERT_NOT_NULL(namespace_map); + ASSERT_NULL(scala_index); + cbm_pipeline_import_maps_free(namespace_map, scala_index); + + cbm_file_info_t mixed[] = {{.rel_path = "core/Codec.java", .language = CBM_LANG_JAVA}, + {.rel_path = "app/App.scala", .language = CBM_LANG_SCALA}}; + const char *mixed_ns[] = {"com.acme.core", NULL}; + cbm_pipeline_import_maps_build(&ctx, mixed, 2, mixed_ns, &namespace_map, &scala_index); + ASSERT_NOT_NULL(namespace_map); + ASSERT_NOT_NULL(scala_index); + cbm_pipeline_import_maps_free(namespace_map, scala_index); + + cbm_gbuf_free(gb); + PASS(); +} + +static void write_scala_companion_fixture(const char *tmp) { + write_temp_file( + tmp, "rational.scala", + "package numeric\n" + "class Rational(n: Int, d: Int) { def reciprocal: Rational = Rational.make(d, n) }\n" + "object Rational {\n" + " def make(n: Int, d: Int): Rational = new Rational(n, d)\n" + " def fromInt(n: Int): Rational = make(n, 1)\n" + "}\n"); + write_temp_file(tmp, "factory_user.scala", + "package app\n" + "import numeric.Rational\n" + "object FactoryUser { def half: Rational = Rational.make(1, 2) }\n"); + write_temp_file(tmp, "member_user.scala", + "package app\n" + "import numeric.Rational.fromInt\n" + "object MemberUser { def two: Rational = fromInt(2) }\n"); +} + +static bool scala_companion_edges_are_exact(cbm_store_t *s, const char *project) { + return cross_file_call_to_owner_has_strategy(s, project, "half", "make", "Rational$.make", + "import_map") && + cross_file_call_to_owner_has_strategy(s, project, "two", "fromInt", "Rational$.fromInt", + "import_map"); +} + +/* A class with a companion object is one importable name. `import pkg.Foo` + * must not fail closed as ambiguous now that class and companion have + * distinct QNs, `Foo.factory()` must bind into the companion (`Foo$`), and + * `import pkg.Foo.member` must bind the companion member. */ +TEST(pipeline_scala_companion_import_and_factory_call) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_scala_companion_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_scala_companion_fixture(tmp); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/scala_companion.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(scala_companion_edges_are_exact(s, cbm_pipeline_project_name(p))); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* The parallel route registers definitions through its own serial registry + * build; the companion owner must be recorded there too, or `Rational.make` + * falls back to the class on a 4-worker index. */ +TEST(pipeline_scala_companion_parallel_import_and_factory_call) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_scala_companion_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_scala_companion_fixture(tmp); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "companion_filler%d.scala", i); + snprintf(body, sizeof(body), "object CompanionFiller%d { def value: Int = %d }\n", i, i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/scala_companion_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(scala_companion_edges_are_exact(s, cbm_pipeline_project_name(p))); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_scala_import_alias_parallel_resolves_exact_method) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_scala_alias_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_scala_import_alias_fixture(tmp); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "alias_filler%d.scala", i); + snprintf(body, sizeof(body), "object AliasFiller%d { def value: Int = %d }\n", i, i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/scala_alias_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(scala_import_alias_edge_is_exact(s, cbm_pipeline_project_name(p))); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + /* Count nodes with the given exact name in the project (e.g. a Route path). */ static int count_nodes_named(cbm_store_t *s, const char *project, const char *name) { cbm_node_t *ns = NULL; @@ -9437,8 +9743,8 @@ static const cbm_gbuf_node_t *resolve_device_h_from(cbm_gbuf_t *gb) { CBMImport imp = {0}; imp.module_path = "linux/device.h"; imp.local_name = "h"; - return cbm_pipeline_resolve_import_node(&ctx, "arch/x/bugs.c", "p.arch.x.bugs.c.__file__", &imp, - NULL); + return cbm_pipeline_resolve_import_node(&ctx, "arch/x/bugs.c", "p.arch.x.bugs.c.__file__", + CBM_LANG_C, &imp, NULL, NULL); } TEST(pipeline_header_include_target_is_independent_of_registration_order) { @@ -17665,6 +17971,12 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges); RUN_TEST(pipeline_python_bare_local_binding_suppresses_weak_edge); RUN_TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge); + RUN_TEST(pipeline_scala_import_alias_resolves_exact_method); + RUN_TEST(pipeline_scala_member_import_in_split_package_binds_owner_member); + RUN_TEST(pipeline_scala_index_built_only_for_scala_importers); + RUN_TEST(pipeline_scala_companion_import_and_factory_call); + RUN_TEST(pipeline_scala_companion_parallel_import_and_factory_call); + RUN_TEST(pipeline_scala_import_alias_parallel_resolves_exact_method); RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier); RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments); diff --git a/tests/test_registry.c b/tests/test_registry.c index 278e97d9b0..590f8023de 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1288,6 +1288,113 @@ TEST(resolve_import_map_alias_with_suffix_hits_method) { PASS(); } +/* Scala companion objects carry a `$` suffix on their QN. A call written on + * the class name (`Rational.make`) must reach the companion member through + * every strategy that compares owner segments: import_map, same_module and + * the qualified-tail disambiguator. Non-`$` QNs are matched exactly as before. */ +TEST(resolve_scala_companion_owner_matches_class_name) { + cbm_registry_t *r = cbm_registry_new(); + const char *f = "num/Rational.scala"; + cbm_registry_add_def(r, "Rational", "proj.num.Rational.Rational", "Class", f); + cbm_registry_add_def(r, "Rational", "proj.num.Rational.Rational$", "Class", f); + cbm_registry_add_def(r, "make", "proj.num.Rational.Rational$.make", "Method", f); + cbm_registry_add_def(r, "make", "proj.other.Builder.Builder.make", "Method", + "other/Builder.scala"); + + /* import_map: the import binds the class; the method lives in Rational$ */ + const char *keys[] = {"Rational"}; + const char *vals[] = {"proj.num.Rational.Rational"}; + cbm_resolution_t res = cbm_registry_resolve(r, "Rational.make", "proj.app.Main", keys, vals, 1); + ASSERT_STR_EQ(res.qualified_name, "proj.num.Rational.Rational$.make"); + ASSERT_STR_EQ(res.strategy, "import_map"); + + /* same_module: called from the declaring file */ + res = cbm_registry_resolve(r, "Rational.make", "proj.num.Rational", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.num.Rational.Rational$.make"); + ASSERT_STR_EQ(res.strategy, "same_module"); + + /* qualified_suffix: two `make` candidates, the owner segment picks Rational$ */ + res = cbm_registry_resolve(r, "Rational.make", "proj.elsewhere.X", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.num.Rational.Rational$.make"); + ASSERT_STR_EQ(res.strategy, "qualified_suffix"); + + /* A plain owner does not match a `$`-less candidate any more loosely. */ + res = cbm_registry_resolve(r, "Builder.make", "proj.elsewhere.X", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.other.Builder.Builder.make"); + ASSERT_STR_EQ(res.strategy, "qualified_suffix"); + res = cbm_registry_resolve(r, "Other.make", "proj.elsewhere.X", NULL, NULL, 0); + ASSERT(res.strategy == NULL || strcmp(res.strategy, "qualified_suffix") != 0); + cbm_registry_free(r); + PASS(); +} + +/* The companion rules are keyed on the registry's own record of Scala + * companions, not on the `$` character: the plain cbm_registry_add records + * nothing, and neither does cbm_registry_add_def for a non-Scala file. */ +TEST(resolve_dollar_owner_is_companion_only_when_registered_from_scala) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "Rational", "proj.num.Rational.Rational", "Class"); + cbm_registry_add(r, "Rational", "proj.num.Rational.Rational$", "Class"); + cbm_registry_add(r, "make", "proj.num.Rational.Rational$.make", "Method"); + cbm_registry_add(r, "make", "proj.other.Builder.Builder.make", "Method"); + const char *keys[] = {"Rational"}; + const char *vals[] = {"proj.num.Rational.Rational"}; + cbm_resolution_t res = cbm_registry_resolve(r, "Rational.make", "proj.app.Main", keys, vals, 1); + ASSERT(res.strategy == NULL || strcmp(res.strategy, "import_map") != 0); + res = cbm_registry_resolve(r, "Rational.make", "proj.num.Rational", NULL, NULL, 0); + ASSERT(res.strategy == NULL || strcmp(res.strategy, "same_module") != 0); + res = cbm_registry_resolve(r, "Rational.make", "proj.elsewhere.X", NULL, NULL, 0); + ASSERT(res.strategy == NULL || strcmp(res.strategy, "qualified_suffix") != 0); + cbm_registry_free(r); + PASS(); +} + +/* `$` is a legal identifier character in Java and JS/TS (`user$` streams, + * `Foo$` helper classes), so a class `Foo$` next to `Foo` is an ordinary + * sibling there. With both registered from a Java or a TypeScript file, + * `Foo.m` resolves exactly as it does on a registry without any companion + * rule (the expectations below were taken from that registry): import_map, + * same_module and the qualified tail never reach the `$` owner's member, and + * the receiver chain does not admit it as a unique-name hit. */ +TEST(resolve_java_ts_dollar_class_next_to_class_is_unchanged) { + const char *files[] = {"a/Foo.java", "a/Foo.ts"}; + for (size_t fi = 0; fi < sizeof(files) / sizeof(files[0]); fi++) { + const char *f = files[fi]; + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add_def(r, "Foo", "proj.a.Foo", "Class", f); + cbm_registry_add_def(r, "Foo$", "proj.a.Foo$", "Class", f); + cbm_registry_add_def(r, "m", "proj.a.Foo$.m", "Method", f); + cbm_registry_add_def(r, "m", "proj.b.Bar.m", "Method", "b/Bar.java"); + + const char *keys[] = {"Foo"}; + const char *vals[] = {"proj.a.Foo"}; + /* Without companion rules the import path gives up and the bare-name + * scorer picks the import-nearest candidate at suffix_match confidence; + * that verdict must stay exactly as it is. */ + cbm_resolution_t res = cbm_registry_resolve(r, "Foo.m", "proj.app.Main", keys, vals, 1); + ASSERT_STR_EQ(res.strategy, "suffix_match"); + ASSERT_STR_EQ(res.qualified_name, "proj.a.Foo$.m"); + + res = cbm_registry_resolve(r, "Foo.m", "proj.a", NULL, NULL, 0); + ASSERT_NULL(res.strategy); + + res = cbm_registry_resolve(r, "Foo.m", "proj.elsewhere.X", NULL, NULL, 0); + ASSERT_NULL(res.strategy); + cbm_registry_free(r); + + /* Unique candidate: the receiver chain `Foo` must not be read as the + * ancestor `Foo$`. */ + r = cbm_registry_new(); + cbm_registry_add_def(r, "Foo", "proj.a.Foo", "Class", f); + cbm_registry_add_def(r, "Foo$", "proj.a.Foo$", "Class", f); + cbm_registry_add_def(r, "m", "proj.a.Foo$.m", "Method", f); + res = cbm_registry_resolve(r, "Foo.m", "proj.elsewhere.X", NULL, NULL, 0); + ASSERT_NULL(res.strategy); + cbm_registry_free(r); + } + PASS(); +} + SUITE(registry) { /* FQN */ RUN_TEST(fqn_simple); @@ -1325,6 +1432,9 @@ SUITE(registry) { RUN_TEST(resolve_import_map_bare_alias); RUN_TEST(resolve_import_map_aliased_from_import); RUN_TEST(resolve_import_map_alias_with_suffix_hits_method); + RUN_TEST(resolve_scala_companion_owner_matches_class_name); + RUN_TEST(resolve_dollar_owner_is_companion_only_when_registered_from_scala); + RUN_TEST(resolve_java_ts_dollar_class_next_to_class_is_unchanged); RUN_TEST(resolve_unique_name); RUN_TEST(resolve_unresolved); RUN_TEST(resolve_many_nodes);