diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 9fac8cf55..9589e730f 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -1021,6 +1021,9 @@ typedef struct { * POD section index. */ void *doc_memo; void *doc_pod_index; + /* Markdown: the sorted names of the file's headings (extract_defs.c), + * NULL until the first heading; allocated in `scratch`. */ + void *md_headings; const cbm_test_declarations_t *test_declarations; /* borrowed for this call only */ bool test_declarations_raw_source; struct CBMTestDefinitionMatch *test_definition_matches; /* traversal scratch */ diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index a017a5518..67af6fc11 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -7030,13 +7030,15 @@ static const char *extract_markdown_section_body(CBMExtractCtx *ctx, TSNode head /* Push a config-language definition that carries prose. push_simple_class_def * delegates here with no docstring. */ -static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *name, - const char *label, const char *docstring) { +/* Push a definition whose qualified name ends with `qn_name` (already QN-safe) + * rather than with its name. */ +static void push_class_def_qn(CBMExtractCtx *ctx, TSNode node, char *name, const char *qn_name, + const char *label, const char *docstring) { CBMArena *a = ctx->arena; CBMDefinition def; memset(&def, 0, sizeof(def)); def.name = name; - def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, qn_safe_segment(a, name)); + def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, qn_name); def.label = label; def.file_path = ctx->rel_path; def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; @@ -7046,6 +7048,11 @@ static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *nam cbm_defs_push(&ctx->result->defs, a, def); } +static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *name, + const char *label, const char *docstring) { + push_class_def_qn(ctx, node, name, qn_safe_segment(ctx->arena, name), label, docstring); +} + static void push_simple_class_def(CBMExtractCtx *ctx, TSNode node, char *name, const char *label) { push_simple_class_def_doc(ctx, node, name, label, NULL); } @@ -7145,6 +7152,231 @@ static char *extract_markdown_heading_name(CBMArena *a, TSNode node, const char return trim_heading_name(name); } +/* #2586: a heading name that repeats in a file (`## Implementation` under two + * parents) gave its Sections one qualified name, so the graph kept one node + * and the references under them fell back to the File. A repeated heading's + * qualified name therefore takes the names of the headings above it as well + * (`Service-A.Implementation`). A heading whose name is unique in the file + * keeps its name alone, always. A heading text may itself hold dots, so a + * qualified name can equal another heading's final name (`# Service-A. + * Implementation`): the qualified one then takes `~2`, `~3`, ... until no + * other heading of the file has that name. Headings that repeat under the + * same parents too still share a name, as before. All final names are + * settled once per file, on the first heading. */ +typedef struct { + uint32_t start; /* the heading node's first byte */ + TSNode node; /* valid while the file's tree is */ + const char *seg; /* the heading's own QN-safe name */ + const char *key; /* repeated only: the chain's segments, '\n'-joined */ + const char *qn_name; /* the final QN segment */ + bool repeated; +} md_heading_t; + +typedef struct { + md_heading_t *v; /* in document order */ + int count; +} md_headings_t; + +/* The QN-safe name of a heading node, or NULL. */ +static const char *md_heading_segment(CBMExtractCtx *ctx, CBMArena *a, TSNode node) { + const char *kind = ts_node_type(node); + if (!is_markdown_heading_kind(kind)) { + return NULL; + } + char *name = extract_markdown_heading_name(a, node, kind, ctx->source); + return name ? qn_safe_segment(a, name) : NULL; +} + +/* Visit every heading of the file: count them (v NULL), or fill v. */ +static int md_collect_headings(CBMExtractCtx *ctx, CBMArena *a, md_heading_t *v, int cap) { + int n = 0; + if (ts_node_is_null(ctx->root)) { + return 0; + } + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (;;) { + TSNode node = ts_tree_cursor_current_node(&cur); + if (is_markdown_heading_kind(ts_node_type(node))) { + if (!v) { + n++; + } else if (n < cap) { + const char *seg = md_heading_segment(ctx, a, node); + if (seg) { + v[n++] = (md_heading_t){.start = ts_node_start_byte(node), + .node = node, + .seg = seg, + .qn_name = seg}; + } + } + } else if (ts_tree_cursor_goto_first_child(&cur)) { + continue; + } + while (!ts_tree_cursor_goto_next_sibling(&cur)) { + if (!ts_tree_cursor_goto_parent(&cur)) { + ts_tree_cursor_delete(&cur); + return n; + } + } + } +} + +static int md_by_seg(const void *x, const void *y) { + return strcmp((*(const md_heading_t *const *)x)->seg, (*(const md_heading_t *const *)y)->seg); +} + +static int md_by_name(const void *x, const void *y) { + const md_heading_t *p = *(const md_heading_t *const *)x; + const md_heading_t *q = *(const md_heading_t *const *)y; + int c = strcmp(p->qn_name, q->qn_name); + return c ? c : (p->start > q->start) - (p->start < q->start); +} + +/* A repeated heading's chain: the names of the headings above it and its own, + * dot-joined (its QN segment) and '\n'-joined (its identity: a QN-safe name + * holds no whitespace). tree-sitter-markdown nests each heading's `section` in + * the section of the heading above it. False on allocation failure. */ +static bool md_heading_chain(CBMExtractCtx *ctx, CBMArena *a, md_heading_t *h) { + const char *dotted = h->seg; + const char *key = h->seg; + TSNode sec = ts_node_parent(h->node); + while (!ts_node_is_null(sec) && strcmp(ts_node_type(sec), "section") == 0) { + TSNode up = ts_node_parent(sec); + if (ts_node_is_null(up) || strcmp(ts_node_type(up), "section") != 0) { + break; + } + const char *above = ts_node_named_child_count(up) > 0 + ? md_heading_segment(ctx, a, ts_node_named_child(up, 0)) + : NULL; + if (above) { + dotted = cbm_arena_sprintf(a, "%s.%s", above, dotted); + key = cbm_arena_sprintf(a, "%s\n%s", above, key); + if (!dotted || !key) { + return false; + } + } + sec = up; + } + h->qn_name = dotted; + h->key = key; + return true; +} + +static bool md_name_taken(const md_headings_t *hs, const char *name) { + for (int i = 0; i < hs->count; i++) { + if (strcmp(hs->v[i].qn_name, name) == 0) { + return true; + } + } + return false; +} + +/* Within a run of headings that share a final name: a unique heading keeps + * it, or else the first chain does; every other chain takes the first free + * `~N`. Headings with one chain (the same headings) move together. */ +static bool md_split_run(CBMArena *a, md_headings_t *hs, md_heading_t **run, int n) { + const char *keep = run[0]->key; /* NULL: a unique heading keeps the name */ + for (int i = 0; i < n; i++) { + if (!run[i]->repeated) { + keep = NULL; + } + } + const char *base = run[0]->qn_name; + for (int i = 0; i < n; i++) { + md_heading_t *h = run[i]; + if (!h->repeated || (keep && strcmp(h->key, keep) == 0) || strcmp(h->qn_name, base) != 0) { + continue; /* the name's keeper, or a chain already moved */ + } + const char *name = NULL; + for (int k = PAIR_LEN; !name || md_name_taken(hs, name); k++) { + name = cbm_arena_sprintf(a, "%s~%d", base, k); + if (!name) { + return false; + } + } + for (int j = i; j < n; j++) { + if (run[j]->repeated && strcmp(run[j]->key, h->key) == 0) { + run[j]->qn_name = name; + } + } + } + return true; +} + +static bool md_settle_names(CBMExtractCtx *ctx, CBMArena *a, md_headings_t *hs) { + int n = hs->count; + md_heading_t **by = (md_heading_t **)cbm_arena_alloc(a, (size_t)n * sizeof(*by)); + if (!by) { + return false; + } + for (int i = 0; i < n; i++) { + by[i] = &hs->v[i]; + hs->v[i].qn_name = hs->v[i].seg; + } + qsort(by, (size_t)n, sizeof(*by), md_by_seg); + for (int i = 1; i < n; i++) { + if (strcmp(by[i]->seg, by[i - SKIP_ONE]->seg) == 0) { + by[i]->repeated = by[i - SKIP_ONE]->repeated = true; + } + } + for (int i = 0; i < n; i++) { + if (hs->v[i].repeated && !md_heading_chain(ctx, a, &hs->v[i])) { + return false; + } + } + qsort(by, (size_t)n, sizeof(*by), md_by_name); + for (int i = 0; i < n;) { + int j = i + SKIP_ONE; + while (j < n && strcmp(by[j]->qn_name, by[i]->qn_name) == 0) { + j++; + } + if (j - i > SKIP_ONE && !md_split_run(a, hs, by + i, j - i)) { + return false; + } + i = j; + } + return true; +} + +static const md_headings_t *md_headings(CBMExtractCtx *ctx) { + if (ctx->md_headings) { + return (const md_headings_t *)ctx->md_headings; + } + CBMArena *a = doc_scratch(ctx); + md_headings_t *hs = (md_headings_t *)cbm_arena_alloc(a, sizeof(*hs)); + if (!hs) { + return NULL; + } + int cap = md_collect_headings(ctx, a, NULL, 0); + hs->v = cap > 0 ? (md_heading_t *)cbm_arena_alloc(a, (size_t)cap * sizeof(md_heading_t)) : NULL; + hs->count = hs->v ? md_collect_headings(ctx, a, hs->v, cap) : 0; + if (hs->count > SKIP_ONE && !md_settle_names(ctx, a, hs)) { + hs->count = 0; /* every heading keeps its own name, as before */ + } + ctx->md_headings = hs; + return hs; +} + +/* The QN segment of a heading: its own name unless the file's names say + * otherwise. */ +static const char *md_heading_qn_name(CBMExtractCtx *ctx, TSNode heading, const char *seg) { + const md_headings_t *hs = md_headings(ctx); + uint32_t start = ts_node_start_byte(heading); + int lo = 0; + int hi = hs ? hs->count : 0; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (hs->v[mid].start < start) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + if (hs && lo < hs->count && hs->v[lo].start == start && strcmp(hs->v[lo].seg, seg) == 0) { + return hs->v[lo].qn_name; + } + return seg; +} + // INI: extract section name from section node. static char *find_ini_section_name(CBMArena *a, TSNode node, const char *source) { uint32_t nc = ts_node_child_count(node); @@ -7245,7 +7477,10 @@ static bool extract_config_class_def(CBMExtractCtx *ctx, TSNode node, const char return false; } - if (name && name[0]) { + if (name && name[0] && ctx->language == CBM_LANG_MARKDOWN) { + const char *seg = qn_safe_segment(a, name); + push_class_def_qn(ctx, node, name, md_heading_qn_name(ctx, node, seg), label, docstring); + } else if (name && name[0]) { push_simple_class_def_doc(ctx, node, name, label, docstring); } return true; diff --git a/tests/test_doc_links_md.c b/tests/test_doc_links_md.c index 21e74f37e..843b48681 100644 --- a/tests/test_doc_links_md.c +++ b/tests/test_doc_links_md.c @@ -193,6 +193,156 @@ TEST(doc_links_md_repeated_headings) { PASS(); } +/* MENTIONS edges from the Section whose qualified name ends with `src` to the + * node whose qualified name ends with `tgt`. */ +static int rep_edge(const char *db, const char *src, const char *tgt) { + char props[512]; + int n = 0; + dm_edge(db, src, tgt, props, sizeof(props), &n); + return n; +} + +/* #2586: two `## Implementation` headings under different parents had one + * qualified name, so the graph kept one Section (the last) and the references + * under both went to the File. A repeated heading is now qualified by the + * headings above it; a unique heading keeps its name alone. */ +TEST(doc_links_md_repeated_headings_by_parent_issue2586) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_rep_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + th_write_file(TH_PATH(tmp, "docs/repeated.md"), "# Service A\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::balance`.\n" + "\n" + "# Service B\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::post_entry`.\n" + "\n" + "```mermaid\n" + "graph LR\n" + " Ledger --> Balance\n" + "```\n" + "\n" + "## Notes\n" + "\n" + "See `src/ledger.py`.\n"); + th_write_file(TH_PATH(tmp, "src/ledger.py"), "def post_entry(ledger, amount):\n" + " ledger.append(amount)\n" + " return balance(ledger)\n" + "\n" + "\n" + "def balance(ledger):\n" + " return sum(ledger)\n"); + char db[512]; + snprintf(db, sizeof(db), "%s/md.db", tmp); + ASSERT_EQ(dm_index(tmp, db, NULL), 0); + + /* two Sections, each qualified by its parent heading */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND name = 'Implementation' AND file_path = 'docs/repeated.md'"), + 2); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation' " + "AND start_line = 3"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-B.Implementation' " + "AND start_line = 9"), + 1); + /* unique headings keep their names */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' AND " + "(qualified_name LIKE '%.repeated.Service-A' OR " + "qualified_name LIKE '%.repeated.Notes')"), + 2); + + /* each reference from its own Section, none from the File */ + ASSERT_EQ(rep_edge(db, "Service-A.Implementation", "ledger.balance"), 1); + ASSERT_EQ(rep_edge(db, "Service-B.Implementation", "ledger.post_entry"), 1); + ASSERT_EQ(rep_edge(db, "Service-A.Implementation", "ledger.post_entry"), 0); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "WHERE e.type = 'MENTIONS' AND s.label = 'File' " + "AND s.file_path = 'docs/repeated.md'"), + 0); + + /* the diagram under the second heading is that Section's */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id WHERE e.type = 'DEFINES' " + "AND t.label = 'Diagram' " + "AND s.qualified_name LIKE '%.Service-B.Implementation'"), + 1); + + th_cleanup(tmp); + PASS(); +} + +/* #2586 review: a heading text may hold dots, so `# Service-A.Implementation` + * named the same as the qualified `## Implementation` under `# Service A`. + * The unique heading keeps its name; the qualified one takes `~2`. Five + * headings, five Sections, every reference from its own. */ +TEST(doc_links_md_repeated_heading_name_collision_issue2586) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_col_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + th_write_file(TH_PATH(tmp, "docs/repeated.md"), "# Service A\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::balance`.\n" + "\n" + "# Service B\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::post_entry`.\n" + "\n" + "# Service-A.Implementation\n" + "\n" + "See `src/ledger.py`.\n"); + th_write_file(TH_PATH(tmp, "src/ledger.py"), "def post_entry(ledger, amount):\n" + " ledger.append(amount)\n" + " return balance(ledger)\n" + "\n" + "\n" + "def balance(ledger):\n" + " return sum(ledger)\n"); + char db[512]; + snprintf(db, sizeof(db), "%s/md.db", tmp); + ASSERT_EQ(dm_index(tmp, db, NULL), 0); + + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND file_path = 'docs/repeated.md'"), + 5); + /* the unique heading keeps its name; the qualified one moves aside */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation' " + "AND start_line = 13"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation~2' " + "AND start_line = 3"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-B.Implementation' " + "AND start_line = 9"), + 1); + + /* each reference from its own Section, none from the File */ + ASSERT_EQ(rep_edge(db, "Service-A.Implementation~2", "ledger.balance"), 1); + ASSERT_EQ(rep_edge(db, "Service-B.Implementation", "ledger.post_entry"), 1); + ASSERT_EQ(rep_edge(db, "repeated.Service-A.Implementation", "ledger.py.__file__"), 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "WHERE e.type = 'MENTIONS' AND s.label = 'File' " + "AND s.file_path = 'docs/repeated.md'"), + 0); + + th_cleanup(tmp); + PASS(); +} + typedef struct { const char *text; CBMDocLinkMdShape shape; @@ -863,6 +1013,8 @@ SUITE(doc_links_md) { dm_ship_held_families(); RUN_TEST(doc_links_md_extract_tokens); RUN_TEST(doc_links_md_repeated_headings); + RUN_TEST(doc_links_md_repeated_headings_by_parent_issue2586); + RUN_TEST(doc_links_md_repeated_heading_name_collision_issue2586); RUN_TEST(doc_links_md_classify_span); RUN_TEST(doc_links_md_resolve); RUN_TEST(doc_links_md_incremental);