diff --git a/Makefile.cbm b/Makefile.cbm index cd5eb4124..4a3e45d59 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -386,6 +386,16 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/pdf/pdf_text.c \ $(CBM_DIR)/pdf/pdf_tables.c \ $(CBM_DIR)/pdf/pdf_unicode.c \ + $(CBM_DIR)/doclink_go.c \ + $(CBM_DIR)/doclink_jvm.c \ + $(CBM_DIR)/doclink_rs.c \ + $(CBM_DIR)/doclink_perl.c \ + $(CBM_DIR)/doclink_ts.c \ + $(CBM_DIR)/doclink_php.c \ + $(CBM_DIR)/doclink_c.c \ + $(CBM_DIR)/diagram.c \ + $(CBM_DIR)/route_path.c \ + $(CBM_DIR)/openapi.c \ $(CBM_DIR)/sql_values.c \ $(CBM_DIR)/helpers.c \ $(CBM_DIR)/cpp_branch_views.c \ @@ -512,6 +522,14 @@ PIPELINE_SRCS = \ src/pipeline/doc_links_pdf.c \ src/pipeline/doc_links_rst.c \ src/pipeline/doc_links_adoc.c \ + src/pipeline/doc_links_go.c \ + src/pipeline/doc_links_jvm.c \ + src/pipeline/doc_links_rs.c \ + src/pipeline/doc_links_perl.c \ + src/pipeline/doc_links_ts.c \ + src/pipeline/doc_links_php.c \ + src/pipeline/doc_links_c.c \ + src/pipeline/pass_diagrams.c \ src/pipeline/doc_links_msbuild.c \ src/pipeline/pass_route_nodes.c \ src/pipeline/pass_enrichment.c \ @@ -774,7 +792,7 @@ TEST_DISCOVER_SRCS = \ TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c -TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_pipeline_conventions.c tests/test_pipeline_frozen.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_doc_mentions.c tests/test_doc_links_md.c tests/test_doc_links_pdf.c tests/test_doc_links_rst.c tests/test_doc_links_adoc.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c +TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_pipeline_conventions.c tests/test_pipeline_frozen.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_doc_mentions.c tests/test_doc_links_md.c tests/test_doc_links_pdf.c tests/test_doc_links_rst.c tests/test_doc_links_adoc.c tests/test_doc_mentions_go.c tests/test_doc_mentions_jvm.c tests/test_doc_mentions_rs.c tests/test_doc_mentions_perl.c tests/test_doc_mentions_ts.c tests/test_doc_mentions_php.c tests/test_doc_mentions_c.c tests/test_diagrams.c tests/test_openapi.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c TEST_WATCHER_SRCS = tests/test_watcher.c diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index b9e3237b2..d60c9a9bd 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -277,6 +277,11 @@ void cbm_channels_push(CBMChannelArray *arr, CBMArena *a, CBMChannel ch) { arr->items[arr->count++] = ch; } +void cbm_diagram_arrows_push(CBMDiagramArrowArray *arr, CBMArena *a, CBMDiagramArrow da) { + GROW_ARRAY(arr, a); + arr->items[arr->count++] = da; +} + // --- String input reader (for parse_with_options) --- typedef struct { @@ -2837,6 +2842,26 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C .module_qn = result->module_qn, }; cbm_adoc_extract_document(&adoc_ctx); + cbm_extract_diagrams(&adoc_ctx); /* after the Sections they hang under */ + cbm_index_mark_done(rel_path); + return result; + } + /* Diagram files have no grammar either: each format is read from the text + * (diagram.c). */ + if (language == CBM_LANG_DIAGRAM) { + result->module_qn = cbm_fqn_module_source_lang(a, project, rel_path, language); + CBMExtractCtx diagram_ctx = { + .arena = a, + .scratch = scratch, + .result = result, + .source = source, + .source_len = source_len, + .language = language, + .project = project, + .rel_path = rel_path, + .module_qn = result->module_qn, + }; + cbm_extract_diagram_file(&diagram_ctx); cbm_index_mark_done(rel_path); return result; } @@ -3021,6 +3046,12 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C // Channel detection (Socket.IO / EventEmitter) — JS/TS only. cbm_extract_channels(&ctx); + // Diagrams: blocks of documents and diagram files (self-gated by language). + cbm_extract_diagrams(&ctx); + + // OpenAPI operations as Routes (self-gated: API description documents). + cbm_extract_openapi(&ctx); + // K8s / Kustomize semantic pass (additional structured extraction for YAML-based infra files). if (ctx.language == CBM_LANG_KUSTOMIZE || ctx.language == CBM_LANG_K8S) { cbm_extract_k8s(&ctx); diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 6cc5d4743..f5b90a1f5 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -211,6 +211,10 @@ typedef enum { CBM_LANG_CHIALISP, // Chialisp (.clsp/.clib/.clinc — Chia smart-coin s-expression language) CBM_LANG_PDF, // PDF documents: the text layer, read without a grammar (doc_pdf.c) CBM_LANG_ASCIIDOC, // AsciiDoc documents, read without a grammar (doc_adoc.c) + CBM_LANG_OPENAPI, // openapi.json / swagger.json: the JSON grammar, no node per key; their + // operations are Routes (openapi.c) + CBM_LANG_DIAGRAM, // diagram files (PlantUML, DOT, D2, draw.io, Excalidraw), read + // without a grammar (diagram.c) CBM_LANG_COUNT } CBMLanguage; @@ -612,6 +616,23 @@ typedef struct { int cap; } CBMChannelArray; +/* One arrow of a diagram (diagram.c): from one DiagramElement definition of + * the file to another, written as a DIAGRAM_ARROW edge. */ +typedef struct { + const char *from_qn; + const char *to_qn; + const char *label; /* the arrow's text, NULL for none */ + const char *style; /* what the arrow means as its format writes it: "flow", + * "message", "inherits", "composition", ... */ + uint32_t line; +} CBMDiagramArrow; + +typedef struct { + CBMDiagramArrow *items; + int count; + int cap; +} CBMDiagramArrowArray; + typedef struct { CBMFieldType *items; int count; @@ -661,6 +682,7 @@ typedef struct CBMFileResult { CBMStringRefArray string_refs; // URL/config string literals from AST CBMInfraBindingArray infra_bindings; // topic→URL pairs from IaC configs CBMChannelArray channels; // Socket.IO / EventEmitter pub/sub participation + CBMDiagramArrowArray diagram_arrows; // arrows between the file's DiagramElement defs CBMFieldTypeArray field_types; // Python: annotated instance fields (#1277) const char *module_qn; // module qualified name @@ -1122,6 +1144,7 @@ void cbm_infrabinding_push(CBMInfraBindingArray *arr, CBMArena *a, CBMInfraBindi 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); +void cbm_diagram_arrows_push(CBMDiagramArrowArray *arr, CBMArena *a, CBMDiagramArrow da); // --- Sub-extractor entry points --- @@ -1140,6 +1163,31 @@ void cbm_extract_type_refs(CBMExtractCtx *ctx); void cbm_extract_env_accesses(CBMExtractCtx *ctx); void cbm_extract_type_assigns(CBMExtractCtx *ctx); void cbm_extract_channels(CBMExtractCtx *ctx); +/* Diagrams (diagram.c): a Diagram definition per diagram -- a Markdown, reST + * or AsciiDoc block, or a diagram file -- with a DiagramElement definition per + * element and the arrows between them. */ +void cbm_extract_diagrams(CBMExtractCtx *ctx); +/* Is a Markdown block a diagram fence, and the labels its parser reads from + * it (space-joined; NULL for none): a Section's text keeps those labels in + * place of the fence (extract_markdown_section_body). */ +bool cbm_diagram_fence_is_diagram(const char *source, TSNode block); +const char *cbm_diagram_fence_labels(CBMExtractCtx *ctx, TSNode block); +/* A diagram file (CBM_LANG_DIAGRAM): its format comes from its extension. */ +void cbm_extract_diagram_file(CBMExtractCtx *ctx); + +/* OpenAPI / Swagger documents (openapi.c): a Route definition per operation and + * a doc link per operationId. Self-gated: openapi.json / swagger.json, and YAML + * or JSON files whose root says `openapi` or `swagger`. */ +void cbm_extract_openapi(CBMExtractCtx *ctx); + +/* Canonicalize route-path parameter placeholders (":id", "{id}", "", + * "${...}") to a single "{}" token so that client call sites, server handlers + * and OpenAPI operations rendezvous on the same Route QN regardless of + * framework syntax. Parameter names are intentionally discarded ("/u/{id}" and + * "/u/{slug}" both canonicalize to "/u/{}"). The result never exceeds the + * input length, so out_sz >= strlen(in) + 1 always suffices. Returns out. + * (route_path.c) */ +const char *cbm_route_canon_path(const char *in, char *out, size_t out_sz); // Single-pass unified extraction (replaces the 7 calls above except defs+imports). void cbm_extract_unified(CBMExtractCtx *ctx); diff --git a/internal/cbm/diagram.c b/internal/cbm/diagram.c new file mode 100644 index 000000000..cdd3785f4 --- /dev/null +++ b/internal/cbm/diagram.c @@ -0,0 +1,3622 @@ +/* + * diagram.c — diagrams as graph nodes: a Diagram definition per diagram and a + * DiagramElement definition per element, with the arrows between elements + * (CBMDiagramArrow, written as DIAGRAM_ARROW edges by pass_diagrams.c). + * + * Where diagrams come from: + * - a fenced block of a Markdown file whose info string names a diagram + * language (mermaid; plantuml, puml, uml; dot, graphviz; d2): the Diagram + * hangs under the Section the block is written in (the last heading above + * it), or under the file before any heading; + * - a Mermaid file (.mmd, .mermaid): one Diagram, under the file. + * + * Qualified names: the parent's (a Section's, else the file's module QN) + + * "#diagram-", k counting the diagrams of that parent from 1; an element's + * is the diagram's + "." + its id (dots and blanks as `_`). + * + * A Diagram's docstring is the text its elements and arrows carry (their + * labels), which is what a reader of the diagram reads; its properties hold + * the format and the diagram kind. An element's name is its label (its id + * when it has none); its properties hold the id and the element kind. An + * element named only by an arrow is an element too. + * + * Formats (one parser each, line by line, no grammar): Mermaid flowchart and + * graph, sequenceDiagram, classDiagram, erDiagram, stateDiagram, C4 (Context, + * Container, Component, Dynamic, Deployment) and architecture-beta. Every + * other Mermaid kind (gantt, pie, journey, mindmap, ...) is a Diagram without + * elements. + */ +#include "cbm.h" +#include "doclink.h" + +#include "arena.h" +#include "helpers.h" +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/mem_core.h" +#include "tree_sitter/api.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +enum { + DG_DOC_MAX = 2048, /* a Diagram's docstring */ + DG_LABEL_MAX = 256, /* an element's or an arrow's label */ + DG_ID_MAX = 128, + DG_ID_HEAD = DG_ID_MAX - 18, /* a long id's head: `~` and 16 hex digits follow */ + DG_KIND_MAX = 32, + DG_ARGS_MAX = 8, +}; + +static const uint64_t DG_FNV_OFFSET = 0xcbf29ce484222325ULL; /* FNV-1a 64 */ +static const uint64_t DG_FNV_PRIME = 0x100000001b3ULL; +enum { DG_UTF8_CONT_MASK = 0xC0, DG_UTF8_CONT = 0x80 }; /* a continuation byte: 10xxxxxx */ + +/* ── The builder ─────────────────────────────────────────────────── */ + +typedef struct { + CBMExtractCtx *ctx; + const char *qn; /* the diagram's */ + const char *format; + char kind[DG_KIND_MAX]; + const char *element_kind; /* what an element named only by an arrow is */ + int def_index; /* the Diagram definition, in ctx->result->defs */ + uint32_t line0; /* the source line of the diagram's text */ + CBMHashTable *ids; /* element id -> its definition's index + 1 */ + char doc[DG_DOC_MAX]; /* the labels, for the docstring */ + size_t doc_len; + bool oom; +} dg_t; + +typedef struct { + const char *p; + size_t n; +} dg_str_t; + +static dg_str_t dg_s(const char *p, size_t n) { + return (dg_str_t){p, n}; +} + +static dg_str_t dg_trim(dg_str_t s) { + while (s.n && isspace((unsigned char)s.p[0])) { + s.p++; + s.n--; + } + while (s.n && isspace((unsigned char)s.p[s.n - SKIP_ONE])) { + s.n--; + } + return s; +} + +/* Quotes and Markdown-string backticks around a label dropped. */ +static dg_str_t dg_unquote(dg_str_t s) { + s = dg_trim(s); + if (s.n >= PAIR_LEN && (s.p[0] == '"' || s.p[0] == '\'' || s.p[0] == '`') && + s.p[s.n - SKIP_ONE] == s.p[0]) { + s.p++; + s.n -= PAIR_LEN; + } + if (s.n >= PAIR_LEN && s.p[0] == '`' && s.p[s.n - SKIP_ONE] == '`') { + s.p++; + s.n -= PAIR_LEN; + } + return dg_trim(s); +} + +static bool dg_eq(dg_str_t s, const char *lit) { + return strlen(lit) == s.n && memcmp(s.p, lit, s.n) == 0; +} + +static bool dg_starts(dg_str_t s, const char *lit) { + size_t l = strlen(lit); + return s.n >= l && memcmp(s.p, lit, l) == 0; +} + +/* A label's text: tags (`
`), the line breaks DOT, PlantUML and D2 write + * as `\n` (also `\l`, `\r`, `\t`) and blank runs as one space, at most cap-1 + * bytes. */ +static size_t dg_label_text(dg_str_t s, char *out, size_t cap) { + size_t w = 0; + bool space = false; + for (size_t i = 0; i < s.n && w + SKIP_ONE < cap; i++) { + char c = s.p[i]; + if (c == '\\' && i + SKIP_ONE < s.n && strchr("nlrt", s.p[i + SKIP_ONE]) && + s.p[i + SKIP_ONE]) { + i++; + space = true; + continue; + } + if (c == '<') { + const char *gt = memchr(s.p + i, '>', s.n - i); + if (gt) { + i = (size_t)(gt - s.p); + space = true; + continue; + } + } + if (isspace((unsigned char)c)) { + space = true; + continue; + } + if (space && w) { + out[w++] = ' '; + } + space = false; + out[w++] = c; + } + out[w] = '\0'; + return w; +} + +static void dg_doc_add(dg_t *dg, const char *text, size_t n) { + if (!n || dg->doc_len + n + PAIR_LEN >= sizeof(dg->doc)) { + return; + } + if (dg->doc_len) { + memcpy(dg->doc + dg->doc_len, "; ", PAIR_LEN); + dg->doc_len += PAIR_LEN; + } + memcpy(dg->doc + dg->doc_len, text, n); + dg->doc_len += n; + dg->doc[dg->doc_len] = '\0'; +} + +static const char *dg_dup(dg_t *dg, const char *s, size_t n) { + char *c = cbm_arena_strndup(dg->ctx->arena, s, n); + dg->oom = dg->oom || !c; + return c; +} + +static const char **dg_props(dg_t *dg, const char *k1, const char *v1, const char *k2, + const char *v2) { + const char **kv = (const char **)cbm_arena_alloc(dg->ctx->arena, CBM_SZ_5 * sizeof(char *)); + if (!kv) { + dg->oom = true; + return NULL; + } + kv[0] = k1; + kv[SKIP_ONE] = v1; + kv[PAIR_LEN] = k2; + kv[CBM_SZ_3] = v2; + kv[CBM_SZ_4] = NULL; + return kv; +} + +/* An element id as a QN segment: dots, blanks, backslashes, quotes and `#` + * (QN suffix syntax) as `_`. */ +static const char *dg_element_qn(dg_t *dg, dg_str_t id) { + char seg[DG_ID_MAX]; + size_t n = id.n < sizeof(seg) - SKIP_ONE ? id.n : sizeof(seg) - SKIP_ONE; + for (size_t i = 0; i < n; i++) { + char c = id.p[i]; + seg[i] = + (c == '.' || c == '\\' || c == '"' || c == '#' || isspace((unsigned char)c)) ? '_' : c; + } + seg[n] = '\0'; + char *qn = cbm_arena_sprintf(dg->ctx->arena, "%s.%s", dg->qn, seg); + dg->oom = dg->oom || !qn; + return qn; +} + +/* An element id as its table key and QN source. An id too long for one (a + * DOT node named by a sentence) keeps its head, cut at a character boundary, + * and the FNV-1a 64 of the whole: still one key per id. */ +static size_t dg_id_key(dg_str_t id, char *key) { + if (id.n < DG_ID_MAX) { + memcpy(key, id.p, id.n); + key[id.n] = '\0'; + return id.n; + } + uint64_t h = DG_FNV_OFFSET; + for (size_t i = 0; i < id.n; i++) { + h = (h ^ (unsigned char)id.p[i]) * DG_FNV_PRIME; + } + size_t head = DG_ID_HEAD; + while (head > 0 && ((unsigned char)id.p[head] & DG_UTF8_CONT_MASK) == DG_UTF8_CONT) { + head--; /* not inside a UTF-8 sequence */ + } + int n = snprintf(key, DG_ID_MAX, "%.*s~%016llx", (int)head, id.p, (unsigned long long)h); + return n > 0 ? (size_t)n : 0; +} + +/* An explicit link written on an element (or, for a PlantUML !include, on the + * diagram): a doc-link record the pipeline resolves as a Markdown link. An + * in-page anchor, a data URI or a script links nothing. */ +static void dg_link(dg_t *dg, const char *source_qn, dg_str_t raw, int syntax, uint32_t line) { + raw = dg_unquote(raw); + if (!source_qn || !raw.n || raw.p[0] == '#' || dg_starts(raw, "data:") || + dg_starts(raw, "javascript:")) { + return; + } + CBMDocLink link = { + .source_qn = source_qn, + .raw = dg_dup(dg, raw.p, raw.n), + .line = line, + .def_line = line, + .syntax = (uint16_t)syntax, + }; + if (link.raw) { + cbm_doclinks_push(&dg->ctx->result->doc_links, dg->ctx->arena, link); + } +} + +/* The element `id` (made on first sight), its label set when it has none. */ +static const char *dg_element(dg_t *dg, dg_str_t id, dg_str_t label, const char *kind, + uint32_t line) { + id = dg_trim(id); + if (!id.n || dg->oom) { + return NULL; + } + char key[DG_ID_MAX]; + size_t kn = dg_id_key(id, key); + char text[DG_LABEL_MAX]; + size_t tl = dg_label_text(dg_unquote(label), text, sizeof(text)); + CBMDefArray *defs = &dg->ctx->result->defs; + void *v = cbm_ht_get(dg->ids, key); + if (v) { + CBMDefinition *d = &defs->items[(uintptr_t)v - SKIP_ONE]; + if (tl && d->name == d->extra_props[SKIP_ONE]) { /* still named by its id */ + d->name = dg_dup(dg, text, tl); + dg_doc_add(dg, text, tl); + } + return d->qualified_name; + } + const char *qn = dg_element_qn(dg, dg_s(key, kn)); + const char *idc = dg_dup(dg, id.p, id.n); + if (!qn || !idc) { + return NULL; + } + CBMDefinition def; + memset(&def, 0, sizeof(def)); + def.name = tl ? dg_dup(dg, text, tl) : idc; + def.qualified_name = qn; + def.label = "DiagramElement"; + def.file_path = dg->ctx->rel_path; + def.start_line = line; + def.end_line = line; + def.parent_class = dg->qn; + /* the kind may live in a parser buffer (a JSON value, a draw.io cell) */ + const char *kindc = dg_dup(dg, kind, strlen(kind)); + def.extra_props = dg_props(dg, "element_id", idc, "element_kind", kindc); + if (dg->oom || !def.name) { + return NULL; + } + cbm_defs_push(defs, dg->ctx->arena, def); + const char *k = dg_dup(dg, key, kn); + if (k) { + cbm_ht_set(dg->ids, k, (void *)(uintptr_t)defs->count); + } + if (tl) { + dg_doc_add(dg, text, tl); + } + return qn; +} + +static void dg_arrow(dg_t *dg, dg_str_t from, dg_str_t to, dg_str_t label, const char *style, + uint32_t line) { + const char *kind = dg->element_kind ? dg->element_kind : "node"; + const char *a = dg_element(dg, from, dg_s(NULL, 0), kind, line); + const char *b = dg_element(dg, to, dg_s(NULL, 0), kind, line); + if (!a || !b) { + return; + } + char text[DG_LABEL_MAX]; + size_t tl = dg_label_text(dg_unquote(label), text, sizeof(text)); + CBMDiagramArrow arrow = {.from_qn = a, + .to_qn = b, + .label = tl ? dg_dup(dg, text, tl) : NULL, + .style = style, + .line = line}; + if (!dg->oom) { + cbm_diagram_arrows_push(&dg->ctx->result->diagram_arrows, dg->ctx->arena, arrow); + } + if (tl) { + dg_doc_add(dg, text, tl); + } +} + +/* ── Lines ───────────────────────────────────────────────────────── */ + +typedef struct { + const char *p; + const char *end; + uint32_t line; +} dg_lines_t; + +/* The next line (no newline), false at the end. */ +static bool dg_next_line(dg_lines_t *it, dg_str_t *out, uint32_t *line) { + if (it->p >= it->end) { + return false; + } + const char *nl = memchr(it->p, '\n', (size_t)(it->end - it->p)); + const char *le = nl ? nl : it->end; + *out = dg_s(it->p, (size_t)(le - it->p)); + if (out->n && out->p[out->n - SKIP_ONE] == '\r') { + out->n--; + } + *line = it->line++; + it->p = nl ? nl + SKIP_ONE : it->end; + return true; +} + +/* A Mermaid line without its `%%` comment. */ +static dg_str_t dg_mermaid_strip(dg_str_t s) { + for (size_t i = 0; i + SKIP_ONE < s.n; i++) { + if (s.p[i] == '%' && s.p[i + SKIP_ONE] == '%') { + s.n = i; + break; + } + } + return dg_trim(s); +} + +/* ── Mermaid: flowchart ──────────────────────────────────────────── */ + +static bool dg_id_char(char c) { + return isalnum((unsigned char)c) || c == '_' || c == '-' || (unsigned char)c >= 0x80; +} + +/* The closing bracket of a node shape opened at s[i]: the shape's text in + * *label, the position after it. */ +static size_t dg_shape(dg_str_t s, size_t i, dg_str_t *label) { + static const char *const opens[] = {"(((", "([", "[[", "[(", "((", "{{", "[/", + "[\\", "[", "(", "{", ">", NULL}; + static const char *const closes[] = {")))", "])", "]]", ")]", "))", "}}", "/]", + "\\]", "]", ")", "}", "]", NULL}; + for (int k = 0; opens[k]; k++) { + size_t ol = strlen(opens[k]); + if (i + ol > s.n || memcmp(s.p + i, opens[k], ol) != 0) { + continue; + } + size_t cl = strlen(closes[k]); + bool quoted = false; + for (size_t j = i + ol; j + cl <= s.n; j++) { + if (s.p[j] == '"') { + quoted = !quoted; + } + if (!quoted && memcmp(s.p + j, closes[k], cl) == 0) { + *label = dg_s(s.p + i + ol, j - i - ol); + return j + cl; + } + } + /* `[/text\]` and `[\text/]` close with the other slash */ + if (k == CBM_SZ_6 || k == CBM_SZ_7) { + const char *alt = k == CBM_SZ_6 ? "\\]" : "/]"; + for (size_t j = i + ol; j + PAIR_LEN <= s.n; j++) { + if (memcmp(s.p + j, alt, PAIR_LEN) == 0) { + *label = dg_s(s.p + i + ol, j - i - ol); + return j + PAIR_LEN; + } + } + } + return i; + } + return i; +} + +/* A node at s[i]: its id and label; the position after it (i when none). */ +static size_t dg_flow_node(dg_str_t s, size_t i, dg_str_t *id, dg_str_t *label) { + size_t j = i; + while (j < s.n && dg_id_char(s.p[j])) { + if (s.p[j] == '-' && j + SKIP_ONE < s.n && + (s.p[j + SKIP_ONE] == '-' || s.p[j + SKIP_ONE] == '.' || s.p[j + SKIP_ONE] == '>')) { + break; /* an edge starts */ + } + j++; + } + if (j == i) { + return i; + } + *id = dg_s(s.p + i, j - i); + *label = dg_s(NULL, 0); + if (j < s.n && s.p[j] == '@' && j + SKIP_ONE < s.n && s.p[j + SKIP_ONE] == '{') { + const char *close = memchr(s.p + j, '}', s.n - j); + if (close) { + dg_str_t body = dg_s(s.p + j + PAIR_LEN, (size_t)(close - s.p) - j - PAIR_LEN); + const char *lab = NULL; + for (size_t k = 0; k + CBM_SZ_6 <= body.n && !lab; k++) { + lab = memcmp(body.p + k, "label:", CBM_SZ_6) == 0 ? body.p + k + CBM_SZ_6 : NULL; + } + if (lab) { + const char *comma = memchr(lab, ',', (size_t)(body.p + body.n - lab)); + *label = dg_s(lab, (size_t)((comma ? comma : body.p + body.n) - lab)); + } + return (size_t)(close - s.p) + SKIP_ONE; + } + } + size_t k = dg_shape(s, j, label); + if (k > j) { + j = k; + } + if (j + CBM_SZ_3 <= s.n && memcmp(s.p + j, ":::", CBM_SZ_3) == 0) { + j += CBM_SZ_3; + while (j < s.n && dg_id_char(s.p[j])) { + j++; + } + } + return j; +} + +static size_t dg_skip_ws(dg_str_t s, size_t i) { + while (i < s.n && isspace((unsigned char)s.p[i])) { + i++; + } + return i; +} + +static bool dg_link_char(char c) { + return c == '-' || c == '=' || c == '.' || c == '>' || c == '<' || c == '~'; +} + +/* An edge at s[i]: its style and label; the position after it (i when none). */ +static size_t dg_flow_edge(dg_str_t s, size_t i, const char **style, dg_str_t *label) { + size_t j = i; + if (j < s.n && (s.p[j] == '<' || s.p[j] == 'x' || s.p[j] == 'o') && j + SKIP_ONE < s.n && + (s.p[j + SKIP_ONE] == '-' || s.p[j + SKIP_ONE] == '=')) { + j++; + } + size_t start = j; + while (j < s.n && dg_link_char(s.p[j])) { + j++; + } + size_t run = j - start; + if (run < PAIR_LEN) { + return i; + } + if (j < s.n && (s.p[j] == 'x' || s.p[j] == 'o') && + (j + SKIP_ONE >= s.n || !dg_id_char(s.p[j + SKIP_ONE]))) { + j++; /* an arrowhead x or o */ + } + dg_str_t op = dg_s(s.p + i, j - i); + *label = dg_s(NULL, 0); + bool head = + memchr(op.p, '>', op.n) || op.p[op.n - SKIP_ONE] == 'x' || op.p[op.n - SKIP_ONE] == 'o'; + if (!head && (dg_eq(op, "--") || dg_eq(op, "==") || dg_eq(op, "-."))) { + /* `-- text -->`, `== text ==>`, `-. text .->`: the label runs to the + * closing link, which opens with `--`, `==` or `.-` */ + char c0 = op.p[0] == '-' && op.p[SKIP_ONE] == '.' ? '.' : op.p[0]; + char c1 = op.p[0] == '-' && op.p[SKIP_ONE] == '.' ? '-' : op.p[SKIP_ONE]; + for (size_t k = j; k + SKIP_ONE < s.n; k++) { + if (s.p[k] == c0 && s.p[k + SKIP_ONE] == c1) { + size_t e = k; + while (e < s.n && dg_link_char(s.p[e])) { + e++; + } + *label = dg_s(s.p + j, k - j); + op = dg_s(s.p + i, e - i); + j = e; + break; + } + } + } + bool dotted = memchr(op.p, '.', op.n) != NULL; + bool thick = memchr(op.p, '=', op.n) != NULL; + bool both = op.p[0] == '<' && memchr(op.p + SKIP_ONE, '>', op.n - SKIP_ONE); + head = memchr(op.p, '>', op.n) != NULL; + *style = both ? "bidirectional" : dotted ? "dotted" : thick ? "thick" : head ? "arrow" : "line"; + j = dg_skip_ws(s, j); + if (j < s.n && s.p[j] == '|') { + const char *bar = memchr(s.p + j + SKIP_ONE, '|', s.n - j - SKIP_ONE); + if (bar) { + *label = dg_s(s.p + j + SKIP_ONE, (size_t)(bar - s.p) - j - SKIP_ONE); + j = (size_t)(bar - s.p) + SKIP_ONE; + } + } + return j; +} + +enum { DG_GROUP_MAX = 16 }; + +typedef struct { + dg_str_t ids[DG_GROUP_MAX]; + int n; +} dg_group_t; + +/* `A & B[label]`: the nodes of a group, made; the position after it. */ +static size_t dg_flow_group(dg_t *dg, dg_str_t s, size_t i, uint32_t line, dg_group_t *g) { + g->n = 0; + for (;;) { + i = dg_skip_ws(s, i); + dg_str_t id; + dg_str_t label; + size_t j = dg_flow_node(s, i, &id, &label); + if (j == i) { + return i; + } + dg_element(dg, id, label, "node", line); + if (g->n < DG_GROUP_MAX) { + g->ids[g->n++] = id; + } + i = dg_skip_ws(s, j); + if (i < s.n && s.p[i] == '&') { + i++; + continue; + } + return i; + } +} + +/* `click A "url"`, `click A href "url"` (a class diagram's `link A "url"`): + * the element's link; a callback (`click A call f()`) links nothing. */ +static void dg_mermaid_click(dg_t *dg, dg_str_t s, uint32_t line) { + size_t kw = dg_starts(s, "click ") ? CBM_SZ_6 : CBM_SZ_5; + dg_str_t rest = dg_trim(dg_s(s.p + kw, s.n - kw)); + size_t j = 0; + while (j < rest.n && !isspace((unsigned char)rest.p[j])) { + j++; + } + dg_str_t tail = dg_trim(dg_s(rest.p + j, rest.n - j)); + if (dg_starts(tail, "href ")) { + tail = dg_trim(dg_s(tail.p + CBM_SZ_5, tail.n - CBM_SZ_5)); + } + const char *close = + tail.n && tail.p[0] == '"' ? memchr(tail.p + SKIP_ONE, '"', tail.n - SKIP_ONE) : NULL; + if (!close) { + return; + } + const char *qn = dg_element(dg, dg_s(rest.p, j), dg_s(NULL, 0), + dg->element_kind ? dg->element_kind : "node", line); + dg_link(dg, qn, dg_s(tail.p + SKIP_ONE, (size_t)(close - tail.p) - SKIP_ONE), + CBM_DOCLINK_DG_MERMAID_CLICK, line); +} + +static void dg_flow_statement(dg_t *dg, dg_str_t s, uint32_t line) { + if (dg_starts(s, "click ")) { + dg_mermaid_click(dg, s, line); + return; + } + static const char *const skip[] = {"classDef ", "class ", "style ", "linkStyle ", + "click ", "direction ", "end", NULL}; + for (int k = 0; skip[k]; k++) { + if (dg_starts(s, skip[k])) { + return; + } + } + if (dg_starts(s, "subgraph")) { + /* `subgraph id [title]`, `subgraph id[title]`, or `subgraph Some title` + * (the title is its id too) */ + dg_str_t rest = dg_trim(dg_s(s.p + CBM_SZ_8, s.n - CBM_SZ_8)); + dg_str_t id; + dg_str_t label; + size_t j = dg_flow_node(rest, 0, &id, &label); + if (j == 0) { + return; + } + size_t k = dg_skip_ws(rest, j); + if (!label.n && k < rest.n && rest.p[k] == '[') { + dg_shape(rest, k, &label); + } else if (!label.n && k < rest.n) { + id = rest; /* a title of several words */ + label = rest; + } + dg_element(dg, id, label, "subgraph", line); + return; + } + dg_group_t left; + size_t i = dg_flow_group(dg, s, 0, line, &left); + while (i < s.n && left.n) { + const char *style = NULL; + dg_str_t label; + size_t j = dg_flow_edge(s, i, &style, &label); + if (j == i) { + return; + } + dg_group_t right; + i = dg_flow_group(dg, s, j, line, &right); + for (int a = 0; a < left.n; a++) { + for (int b = 0; b < right.n; b++) { + dg_arrow(dg, left.ids[a], right.ids[b], label, style, line); + } + } + left = right; + } +} + +static void dg_flowchart(dg_t *dg, dg_lines_t *it) { + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + size_t start = 0; + bool quoted = false; + int depth = 0; + for (size_t i = 0; i <= ln.n; i++) { + char c = i < ln.n ? ln.p[i] : ';'; + quoted = c == '"' ? !quoted : quoted; + depth += !quoted && (c == '[' || c == '(' || c == '{'); + depth -= !quoted && (c == ']' || c == ')' || c == '}') && depth > 0; + if (c == ';' && !quoted && depth == 0) { + dg_str_t st = dg_trim(dg_s(ln.p + start, i - start)); + if (st.n) { + dg_flow_statement(dg, st, line); + } + start = i + SKIP_ONE; + } + } + } +} + +/* ── Mermaid: sequence ───────────────────────────────────────────── */ + +/* A message line `A->>+B: text`: its arrow; false when it has none. */ +static bool dg_sequence_message(dg_t *dg, dg_str_t ln, uint32_t line) { + static const char *const arrows[] = {"<<-->>", "<<->>", "-->>", "->>", "--x", "-x", + "--)", "-)", "-->", "->", NULL}; + const char *colon = memchr(ln.p, ':', ln.n); + dg_str_t head = colon ? dg_s(ln.p, (size_t)(colon - ln.p)) : ln; + for (int k = 0; arrows[k]; k++) { + size_t al = strlen(arrows[k]); + for (size_t i = 0; i + al <= head.n; i++) { + if (memcmp(head.p + i, arrows[k], al) != 0) { + continue; + } + dg_str_t to = dg_trim(dg_s(head.p + i + al, head.n - i - al)); + while (to.n && (to.p[0] == '+' || to.p[0] == '-')) { + to.p++; + to.n--; + } + dg_str_t label = colon + ? dg_s(colon + SKIP_ONE, (size_t)(ln.p + ln.n - colon) - SKIP_ONE) + : dg_s(NULL, 0); + bool reply = arrows[k][0] == '-' && arrows[k][SKIP_ONE] == '-'; + dg_arrow(dg, dg_s(head.p, i), to, label, reply ? "reply" : "message", line); + return true; + } + } + return false; +} + +static void dg_sequence(dg_t *dg, dg_lines_t *it) { + static const char participant[] = "participant "; + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + if (dg_starts(ln, "create ")) { + ln = dg_trim(dg_s(ln.p + CBM_SZ_7, ln.n - CBM_SZ_7)); + } + bool actor = dg_starts(ln, "actor "); + if (actor || dg_starts(ln, participant)) { + size_t kl = actor ? CBM_SZ_6 : sizeof(participant) - SKIP_ONE; + dg_str_t rest = dg_trim(dg_s(ln.p + kl, ln.n - kl)); + dg_str_t id = rest; + dg_str_t label = dg_s(NULL, 0); + for (size_t i = 0; i + CBM_SZ_4 <= rest.n; i++) { + if (memcmp(rest.p + i, " as ", CBM_SZ_4) == 0) { + id = dg_s(rest.p, i); + label = dg_s(rest.p + i + CBM_SZ_4, rest.n - i - CBM_SZ_4); + break; + } + } + dg_element(dg, id, label, actor ? "actor" : "participant", line); + continue; + } + (void)dg_sequence_message(dg, ln, line); + } +} + +/* ── Mermaid: class and ER ───────────────────────────────────────── */ + +typedef struct { + const char *op; + const char *style; + bool to_left; /* the arrow points at the left side (child -> parent) */ +} dg_rel_t; + +static const dg_rel_t DG_CLASS_RELS[] = { + {"<|--|>", "inheritance", false}, + {"<|--", "inheritance", true}, + {"--|>", "inheritance", false}, + {"<|..", "realization", true}, + {"..|>", "realization", false}, + {"*--", "composition", false}, + {"--*", "composition", true}, + {"o--", "aggregation", false}, + {"--o", "aggregation", true}, + {"<--", "association", true}, + {"-->", "association", false}, + {"<..", "dependency", true}, + {"..>", "dependency", false}, + {"--", "link", false}, + {"..", "link", false}, + {NULL, NULL, false}, +}; + +/* `A "1" op "*" B : label`: the relation of a class line, false when none. */ +static bool dg_class_relation(dg_t *dg, dg_str_t ln, uint32_t line) { + const char *colon = NULL; + bool quoted = false; + for (size_t i = 0; i < ln.n && !colon; i++) { + quoted = ln.p[i] == '"' ? !quoted : quoted; + colon = !quoted && ln.p[i] == ':' ? ln.p + i : NULL; + } + dg_str_t head = colon ? dg_s(ln.p, (size_t)(colon - ln.p)) : ln; + for (int k = 0; DG_CLASS_RELS[k].op; k++) { + size_t ol = strlen(DG_CLASS_RELS[k].op); + for (size_t i = 0; i + ol <= head.n; i++) { + if (memcmp(head.p + i, DG_CLASS_RELS[k].op, ol) != 0) { + continue; + } + dg_str_t l = dg_trim(dg_s(head.p, i)); + dg_str_t r = dg_trim(dg_s(head.p + i + ol, head.n - i - ol)); + if (l.n && l.p[l.n - SKIP_ONE] == '"') { /* a cardinality */ + const char *q = memchr(l.p, '"', l.n - SKIP_ONE); + l = q ? dg_trim(dg_s(l.p, (size_t)(q - l.p))) : l; + } + if (r.n && r.p[0] == '"') { + const char *q = memchr(r.p + SKIP_ONE, '"', r.n - SKIP_ONE); + r = q ? dg_trim(dg_s(q + SKIP_ONE, (size_t)(r.p + r.n - q) - SKIP_ONE)) : r; + } + if (!l.n || !r.n) { + return false; + } + dg_str_t label = colon + ? dg_s(colon + SKIP_ONE, (size_t)(ln.p + ln.n - colon) - SKIP_ONE) + : dg_s(NULL, 0); + dg_element(dg, l, dg_s(NULL, 0), "class", line); + dg_element(dg, r, dg_s(NULL, 0), "class", line); + if (DG_CLASS_RELS[k].to_left) { + dg_arrow(dg, r, l, label, DG_CLASS_RELS[k].style, line); + } else { + dg_arrow(dg, l, r, label, DG_CLASS_RELS[k].style, line); + } + return true; + } + } + return false; +} + +/* The name of `class Name~T~["Label"] {`, its label in *label. */ +static dg_str_t dg_class_decl(dg_str_t rest, dg_str_t *label) { + size_t j = 0; + while (j < rest.n && (dg_id_char(rest.p[j]) || rest.p[j] == '.')) { + j++; + } + dg_str_t name = dg_s(rest.p, j); + *label = dg_s(NULL, 0); + const char *lb = memchr(rest.p + j, '[', rest.n - j); + const char *rb = lb ? memchr(lb, ']', (size_t)(rest.p + rest.n - lb)) : NULL; + if (lb && rb) { + *label = dg_s(lb + SKIP_ONE, (size_t)(rb - lb) - SKIP_ONE); + } + return name; +} + +static void dg_classdiagram(dg_t *dg, dg_lines_t *it) { + dg_str_t ln; + uint32_t line = 0; + int body = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + if (body > 0) { + body -= ln.n && ln.p[0] == '}'; + continue; + } + if (dg_starts(ln, "class ")) { + dg_str_t label; + dg_str_t name = dg_class_decl(dg_trim(dg_s(ln.p + CBM_SZ_6, ln.n - CBM_SZ_6)), &label); + dg_element(dg, name, label, "class", line); + body += ln.n && ln.p[ln.n - SKIP_ONE] == '{'; + continue; + } + if (dg_starts(ln, "click ") || dg_starts(ln, "link ")) { + dg_mermaid_click(dg, ln, line); + continue; + } + if (dg_starts(ln, "namespace ") || dg_starts(ln, "note") || dg_starts(ln, "<<") || + dg_starts(ln, "style ") || dg_starts(ln, "cssClass") || dg_starts(ln, "classDef") || + dg_starts(ln, "direction") || dg_starts(ln, "link ") || dg_starts(ln, "callback ") || + dg_starts(ln, "click ") || dg_eq(ln, "}")) { + continue; + } + if (!dg_class_relation(dg, ln, line)) { + const char *colon = memchr(ln.p, ':', ln.n); + if (colon) { /* `Name : +member`: the class, no member */ + dg_element(dg, dg_s(ln.p, (size_t)(colon - ln.p)), dg_s(NULL, 0), "class", line); + } + } + } +} + +static bool dg_er_card(char c) { + return c == '|' || c == 'o' || c == '}' || c == '{'; +} + +static void dg_erdiagram(dg_t *dg, dg_lines_t *it) { + dg_str_t ln; + uint32_t line = 0; + int body = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + if (body > 0) { + body -= ln.n && ln.p[0] == '}'; + continue; + } + const char *colon = memchr(ln.p, ':', ln.n); + dg_str_t head = colon ? dg_s(ln.p, (size_t)(colon - ln.p)) : ln; + bool done = false; + for (size_t i = 0; i + PAIR_LEN <= head.n && !done; i++) { + bool solid = head.p[i] == '-' && head.p[i + SKIP_ONE] == '-'; + bool dashed = head.p[i] == '.' && head.p[i + SKIP_ONE] == '.'; + if (!(solid || dashed) || i == 0 || !dg_er_card(head.p[i - SKIP_ONE])) { + continue; + } + size_t a = i; + while (a > 0 && dg_er_card(head.p[a - SKIP_ONE])) { + a--; + } + size_t b = i + PAIR_LEN; + while (b < head.n && dg_er_card(head.p[b])) { + b++; + } + dg_str_t l = dg_trim(dg_s(head.p, a)); + dg_str_t r = dg_trim(dg_s(head.p + b, head.n - b)); + dg_str_t label = colon + ? dg_s(colon + SKIP_ONE, (size_t)(ln.p + ln.n - colon) - SKIP_ONE) + : dg_s(NULL, 0); + dg_element(dg, l, dg_s(NULL, 0), "entity", line); + dg_element(dg, r, dg_s(NULL, 0), "entity", line); + dg_arrow(dg, l, r, label, solid ? "identifying" : "non_identifying", line); + done = true; + } + if (!done && ln.n && ln.p[ln.n - SKIP_ONE] == '{') { + dg_str_t name = dg_trim(dg_s(ln.p, ln.n - SKIP_ONE)); + dg_str_t label = dg_s(NULL, 0); + const char *lb = memchr(name.p, '[', name.n); + if (lb && name.p[name.n - SKIP_ONE] == ']') { + label = dg_s(lb + SKIP_ONE, (size_t)(name.p + name.n - lb) - PAIR_LEN); + name = dg_trim(dg_s(name.p, (size_t)(lb - name.p))); + } + dg_element(dg, name, label, "entity", line); + body++; + } + } +} + +/* ── Mermaid: state ──────────────────────────────────────────────── */ + +enum { DG_STATE_DEPTH = 8 }; + +/* A state of a transition: `[*]` is the start (left) or the end (right) of + * the composite state it is written in, `composite` (empty at the top). */ +static dg_str_t dg_state_id(dg_str_t s, bool left, dg_str_t composite, char *buf, size_t cap) { + s = dg_trim(s); + if (!dg_eq(s, "[*]")) { + return s; + } + const char *end = left ? "start" : "end"; + int n = composite.n ? snprintf(buf, cap, "%.*s_%s", (int)composite.n, composite.p, end) + : snprintf(buf, cap, "%s", end); + return n > 0 && (size_t)n < cap ? dg_s(buf, (size_t)n) : dg_s(end, strlen(end)); +} + +static void dg_statediagram(dg_t *dg, dg_lines_t *it) { + dg_str_t ln; + uint32_t line = 0; + dg_str_t stack[DG_STATE_DEPTH]; + int depth = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + if (dg_eq(ln, "}")) { + depth -= depth > 0; + continue; + } + if (dg_starts(ln, "state ")) { + dg_str_t rest = dg_trim(dg_s(ln.p + CBM_SZ_6, ln.n - CBM_SZ_6)); + if (rest.n && rest.p[0] == '"') { + const char *q = memchr(rest.p + SKIP_ONE, '"', rest.n - SKIP_ONE); + if (q) { + dg_str_t label = dg_s(rest.p + SKIP_ONE, (size_t)(q - rest.p) - SKIP_ONE); + dg_str_t after = + dg_trim(dg_s(q + SKIP_ONE, (size_t)(rest.p + rest.n - q) - SKIP_ONE)); + if (dg_starts(after, "as ")) { + dg_element(dg, dg_trim(dg_s(after.p + CBM_SZ_3, after.n - CBM_SZ_3)), label, + "state", line); + } + } + continue; + } + size_t j = 0; + while (j < rest.n && dg_id_char(rest.p[j])) { + j++; + } + dg_element(dg, dg_s(rest.p, j), dg_s(NULL, 0), "state", line); + if (rest.n && rest.p[rest.n - SKIP_ONE] == '{') { + if (depth < DG_STATE_DEPTH) { + stack[depth] = dg_s(rest.p, j); + } + depth++; + } + continue; + } + const char *arrow = NULL; + for (size_t i = 0; i + CBM_SZ_3 <= ln.n && !arrow; i++) { + arrow = memcmp(ln.p + i, "-->", CBM_SZ_3) == 0 ? ln.p + i : NULL; + } + if (!arrow) { + continue; + } + dg_str_t rest = dg_s(arrow + CBM_SZ_3, (size_t)(ln.p + ln.n - arrow) - CBM_SZ_3); + const char *colon = memchr(rest.p, ':', rest.n); + dg_str_t to = colon ? dg_s(rest.p, (size_t)(colon - rest.p)) : rest; + dg_str_t label = colon + ? dg_s(colon + SKIP_ONE, (size_t)(rest.p + rest.n - colon) - SKIP_ONE) + : dg_s(NULL, 0); + dg_str_t composite = + depth > 0 && depth <= DG_STATE_DEPTH ? stack[depth - SKIP_ONE] : dg_s(NULL, 0); + char from_buf[DG_ID_MAX]; + char to_buf[DG_ID_MAX]; + dg_arrow(dg, + dg_state_id(dg_s(ln.p, (size_t)(arrow - ln.p)), true, composite, from_buf, + sizeof(from_buf)), + dg_state_id(to, false, composite, to_buf, sizeof(to_buf)), label, "transition", + line); + } +} + +/* ── Mermaid: C4 ─────────────────────────────────────────────────── */ + +/* The arguments of `Name(a, "b", c)`: up to DG_ARGS_MAX, quotes dropped. */ +static int dg_args(dg_str_t s, dg_str_t *args) { + const char *open = memchr(s.p, '(', s.n); + if (!open) { + return 0; + } + int n = 0; + bool quoted = false; + const char *start = open + SKIP_ONE; + for (const char *p = start; p < s.p + s.n && n < DG_ARGS_MAX; p++) { + quoted = *p == '"' ? !quoted : quoted; + if (!quoted && (*p == ',' || *p == ')')) { + args[n++] = dg_unquote(dg_s(start, (size_t)(p - start))); + start = p + SKIP_ONE; + if (*p == ')') { + break; + } + } + } + return n; +} + +/* A C4 macro line `Person(id, "Label", ...)` or `Rel(a, b, "label")`: false + * when the line is none. */ +static bool dg_c4_line(dg_t *dg, dg_str_t ln, uint32_t line) { + size_t j = 0; + while (j < ln.n && (isalnum((unsigned char)ln.p[j]) || ln.p[j] == '_')) { + j++; + } + if (j == 0 || j >= ln.n || ln.p[j] != '(') { + return false; + } + { + dg_str_t fn = dg_s(ln.p, j); + dg_str_t args[DG_ARGS_MAX]; + int na = dg_args(ln, args); + if (dg_starts(fn, "Rel") || dg_starts(fn, "BiRel")) { + int base = dg_eq(fn, "RelIndex") ? SKIP_ONE : 0; /* RelIndex(i, from, to, ...) */ + if (na >= base + PAIR_LEN) { + dg_str_t label = na > base + PAIR_LEN ? args[base + PAIR_LEN] : dg_s(NULL, 0); + dg_arrow(dg, args[base], args[base + SKIP_ONE], label, + dg_starts(fn, "BiRel") ? "bidirectional" : "relation", line); + } + return true; + } + if (dg_starts(fn, "Update") || dg_starts(fn, "Add") || dg_starts(fn, "Lay_") || + dg_eq(fn, "title") || na < SKIP_ONE) { + return true; + } + char kind[DG_KIND_MAX]; + size_t kl = fn.n < sizeof(kind) - SKIP_ONE ? fn.n : sizeof(kind) - SKIP_ONE; + for (size_t k = 0; k < kl; k++) { + kind[k] = (char)tolower((unsigned char)fn.p[k]); + } + kind[kl] = '\0'; + const char *kc = dg_dup(dg, kind, kl); + dg_element(dg, args[0], na > SKIP_ONE ? args[SKIP_ONE] : dg_s(NULL, 0), kc ? kc : "c4", + line); + } + return true; +} + +static void dg_c4(dg_t *dg, dg_lines_t *it) { + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(it, &ln, &line)) { + (void)dg_c4_line(dg, dg_mermaid_strip(ln), line); + } +} + +/* ── Mermaid: architecture ───────────────────────────────────────── */ + +/* `id(icon)[Label] in group`: the id; the label in *label. */ +static dg_str_t dg_arch_decl(dg_str_t rest, dg_str_t *label) { + size_t j = 0; + while (j < rest.n && dg_id_char(rest.p[j])) { + j++; + } + dg_str_t id = dg_s(rest.p, j); + *label = dg_s(NULL, 0); + const char *lb = memchr(rest.p + j, '[', rest.n - j); + const char *rb = lb ? memchr(lb, ']', (size_t)(rest.p + rest.n - lb)) : NULL; + if (lb && rb) { + *label = dg_s(lb + SKIP_ONE, (size_t)(rb - lb) - SKIP_ONE); + } + return id; +} + +/* `id{group}:R` or `R:id`: the id. */ +static dg_str_t dg_arch_end(dg_str_t s) { + s = dg_trim(s); + const char *colon = memchr(s.p, ':', s.n); + if (colon) { + dg_str_t a = dg_s(s.p, (size_t)(colon - s.p)); + dg_str_t b = dg_s(colon + SKIP_ONE, (size_t)(s.p + s.n - colon) - SKIP_ONE); + s = a.n > SKIP_ONE ? a : b; /* the side is one letter */ + } + const char *brace = memchr(s.p, '{', s.n); + return dg_trim(brace ? dg_s(s.p, (size_t)(brace - s.p)) : s); +} + +static void dg_architecture(dg_t *dg, dg_lines_t *it) { + static const char *const decls[] = {"group ", "service ", "junction ", NULL}; + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + bool decl = false; + for (int k = 0; decls[k] && !decl; k++) { + if (!dg_starts(ln, decls[k])) { + continue; + } + size_t kl = strlen(decls[k]); + dg_str_t label; + dg_str_t id = dg_arch_decl(dg_trim(dg_s(ln.p + kl, ln.n - kl)), &label); + char kind[DG_KIND_MAX]; + snprintf(kind, sizeof(kind), "%.*s", (int)(kl - SKIP_ONE), decls[k]); + dg_element(dg, id, label, dg_dup(dg, kind, kl - SKIP_ONE), line); + decl = true; + } + if (decl) { + continue; + } + static const char *const ops[] = {"<-->", "-->", "<--", "--", NULL}; + for (int k = 0; ops[k]; k++) { + size_t ol = strlen(ops[k]); + const char *at = NULL; + for (size_t i = 0; i + ol <= ln.n && !at; i++) { + at = memcmp(ln.p + i, ops[k], ol) == 0 ? ln.p + i : NULL; + } + if (!at) { + continue; + } + dg_str_t l = dg_arch_end(dg_s(ln.p, (size_t)(at - ln.p))); + dg_str_t r = dg_arch_end(dg_s(at + ol, (size_t)(ln.p + ln.n - at) - ol)); + if (k == PAIR_LEN) { /* <-- points left */ + dg_arrow(dg, r, l, dg_s(NULL, 0), "arrow", line); + } else { + dg_arrow(dg, l, r, dg_s(NULL, 0), + k == 0 ? "bidirectional" + : k == 1 ? "arrow" + : "line", + line); + } + break; + } + } +} + +/* ── Mermaid: the header ─────────────────────────────────────────── */ + +typedef void (*dg_parser_fn)(dg_t *dg, dg_lines_t *it); + +typedef struct { + const char *keyword; + const char *kind; + const char *element_kind; /* of an element named only by an arrow */ + dg_parser_fn parse; +} dg_kind_t; + +static const dg_kind_t DG_MERMAID_KINDS[] = { + {"flowchart", "flowchart", "node", dg_flowchart}, + {"graph", "flowchart", "node", dg_flowchart}, + {"sequenceDiagram", "sequence", "participant", dg_sequence}, + {"classDiagram", "class", "class", dg_classdiagram}, + {"erDiagram", "er", "entity", dg_erdiagram}, + {"stateDiagram", "state", "state", dg_statediagram}, + {"C4Context", "c4_context", "c4", dg_c4}, + {"C4Container", "c4_container", "c4", dg_c4}, + {"C4Component", "c4_component", "c4", dg_c4}, + {"C4Dynamic", "c4_dynamic", "c4", dg_c4}, + {"C4Deployment", "c4_deployment", "c4", dg_c4}, + {"architecture", "architecture", "service", dg_architecture}, + {NULL, NULL, NULL, NULL}, +}; + +/* A `title Text` line of the diagram (or `pie title Text` in its header), + * when the front matter named none. */ +static dg_str_t dg_title_line(dg_lines_t it, dg_str_t head) { + for (size_t i = 0; i + CBM_SZ_7 <= head.n; i++) { + if (memcmp(head.p + i, " title ", CBM_SZ_7) == 0) { + return dg_unquote(dg_s(head.p + i + CBM_SZ_7, head.n - i - CBM_SZ_7)); + } + } + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(&it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + if (dg_starts(ln, "title ")) { + return dg_unquote(dg_s(ln.p + CBM_SZ_6, ln.n - CBM_SZ_6)); + } + } + return dg_s(NULL, 0); +} + +/* The first line that says what the diagram is: front matter (`---` ... + * `---`, its `title:` kept) and `%%` lines skipped. A title of the diagram's + * own replaces one given from outside (a caption); none keeps it. */ +static dg_str_t dg_mermaid_header(dg_lines_t *it, dg_str_t *title) { + dg_str_t ln; + uint32_t line = 0; + bool front = false; + while (dg_next_line(it, &ln, &line)) { + dg_str_t t = dg_trim(ln); + if (dg_eq(t, "---")) { + front = !front; + continue; + } + if (front) { + if (dg_starts(t, "title:")) { + *title = dg_unquote(dg_s(t.p + CBM_SZ_6, t.n - CBM_SZ_6)); + } + continue; + } + t = dg_mermaid_strip(t); + if (t.n) { + return t; + } + } + return dg_s(NULL, 0); +} + +static void dg_mermaid(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + dg_str_t head = dg_mermaid_header(it, title); + dg_str_t own = dg_title_line(*it, head); + *title = own.n ? own : *title; + size_t j = 0; + while (j < head.n && !isspace((unsigned char)head.p[j]) && head.p[j] != ';') { + j++; + } + dg_str_t word = dg_s(head.p, j); + for (int k = 0; DG_MERMAID_KINDS[k].keyword; k++) { + if (dg_starts(word, DG_MERMAID_KINDS[k].keyword)) { + snprintf(dg->kind, sizeof(dg->kind), "%s", DG_MERMAID_KINDS[k].kind); + dg->element_kind = DG_MERMAID_KINDS[k].element_kind; + DG_MERMAID_KINDS[k].parse(dg, it); + return; + } + } + snprintf(dg->kind, sizeof(dg->kind), "%.*s", + (int)(word.n < DG_KIND_MAX - SKIP_ONE ? word.n : DG_KIND_MAX - SKIP_ONE), word.p); +} + +/* ── PlantUML ────────────────────────────────────────────────────── */ + +/* Declarations whose `{ ... }` body holds members, not elements. */ +static const char *const DG_PUML_CLASSLIKE[] = { + "abstract class", "abstract", "class", "interface", "enum", "annotation", "protocol", + "struct", "exception", "object", "map", "metaclass", NULL}; +/* Declarations whose `{ ... }` body holds further elements. */ +static const char *const DG_PUML_ELEMENTS[] = { + "participant", "actor", "boundary", "control", "database", "collections", "queue", + "usecase", "component", "node", "cloud", "frame", "folder", "package", + "rectangle", "artifact", "file", "storage", "card", "agent", "stack", + "hexagon", "person", "process", "state", "entity", "namespace", NULL}; + +static bool dg_word_at(dg_str_t s, const char *word) { + size_t l = strlen(word); + return s.n > l && memcmp(s.p, word, l) == 0 && (s.p[l] == ' ' || s.p[l] == '\t'); +} + +/* An operand of a PlantUML line: `[Comp]`, `(use case)`, `:actor:`, `"text"`, + * an id, or `[*]`; its id in *id; the position after it (i when none). */ +static size_t dg_puml_operand(dg_str_t s, size_t i, dg_str_t *id, dg_str_t *label) { + i = dg_skip_ws(s, i); + *label = dg_s(NULL, 0); + if (i + CBM_SZ_3 <= s.n && memcmp(s.p + i, "[*]", CBM_SZ_3) == 0) { + *id = dg_s(s.p + i, CBM_SZ_3); + return i + CBM_SZ_3; + } + static const char opens[] = "[(:\""; + static const char closes[] = "]):\""; + const char *o = i < s.n ? strchr(opens, s.p[i]) : NULL; + if (o && s.p[i]) { + char close = closes[o - opens]; + const char *c = memchr(s.p + i + SKIP_ONE, close, s.n - i - SKIP_ONE); + if (!c) { + return i; + } + *id = dg_s(s.p + i + SKIP_ONE, (size_t)(c - s.p) - i - SKIP_ONE); + *label = *id; + return (size_t)(c - s.p) + SKIP_ONE; + } + size_t j = i; + while (j < s.n && (dg_id_char(s.p[j]) || s.p[j] == '.' || s.p[j] == ':' || s.p[j] == '$')) { + if (s.p[j] == '-' || s.p[j] == ':') { /* an arrow or a label starts */ + break; + } + j++; + } + *id = dg_s(s.p + i, j - i); + return j; +} + +/* What a PlantUML arrow means, and whether it points at its left side. */ +static const char *dg_puml_arrow_style(dg_str_t op, bool sequence, bool *to_left) { + *to_left = false; + bool lt = memchr(op.p, '<', op.n) != NULL; + bool gt = memchr(op.p, '>', op.n) != NULL; + bool dotted = memchr(op.p, '.', op.n) != NULL; + bool inherit = false; + for (size_t i = 0; i + SKIP_ONE < op.n; i++) { + if (op.p[i] == '<' && op.p[i + SKIP_ONE] == '|') { + inherit = true; + *to_left = true; + } + if (op.p[i] == '|' && op.p[i + SKIP_ONE] == '>') { + inherit = true; + } + } + if (inherit) { + return dotted ? "realization" : "inheritance"; + } + if (op.p[0] == '+' || op.p[op.n - SKIP_ONE] == '+') { /* an inner class */ + *to_left = op.p[0] == '+'; /* nested -> its container, as `A ..+ B` reads */ + return "nesting"; + } + if (op.p[0] == '*' || op.p[op.n - SKIP_ONE] == '*') { + *to_left = op.p[op.n - SKIP_ONE] == '*'; + return "composition"; + } + if (op.p[0] == 'o' || op.p[op.n - SKIP_ONE] == 'o') { + *to_left = op.p[op.n - SKIP_ONE] == 'o'; + return "aggregation"; + } + if (lt && gt) { + return "bidirectional"; + } + *to_left = lt; + if (sequence) { + return memchr(op.p, '-', op.n) && op.n >= PAIR_LEN && op.p[SKIP_ONE] == '-' ? "reply" + : "message"; + } + return dotted ? (gt || lt ? "dependency" : "dotted") : (gt || lt ? "arrow" : "line"); +} + +static bool dg_puml_arrow_char(char c) { + return c == '-' || c == '.' || c == '<' || c == '>' || c == '|' || c == '*' || c == 'o' || + c == '#' || c == '+' || c == '^' || c == '/' || c == '\\' || c == 'x' || c == '[' || + c == ']' || c == ',' || c == '=' || isalpha((unsigned char)c); +} + +/* `A "1" -up-> "n" B : label`: false when the line holds no relation. */ +static bool dg_puml_relation(dg_t *dg, dg_str_t ln, uint32_t line, bool sequence) { + dg_str_t lid; + dg_str_t llab; + size_t i = dg_puml_operand(ln, 0, &lid, &llab); + if (!lid.n) { + return false; + } + i = dg_skip_ws(ln, i); + if (i < ln.n && ln.p[i] == '"') { /* a cardinality */ + const char *q = memchr(ln.p + i + SKIP_ONE, '"', ln.n - i - SKIP_ONE); + i = q ? dg_skip_ws(ln, (size_t)(q - ln.p) + SKIP_ONE) : i; + } + size_t a = i; + while (i < ln.n && !isspace((unsigned char)ln.p[i]) && dg_puml_arrow_char(ln.p[i])) { + i++; + } + dg_str_t op = dg_s(ln.p + a, i - a); + bool arrow = false; + for (size_t k = 0; k + SKIP_ONE < op.n && !arrow; k++) { + arrow = (op.p[k] == '-' || op.p[k] == '.') && + (op.p[k + SKIP_ONE] == '-' || op.p[k + SKIP_ONE] == '.' || + op.p[k + SKIP_ONE] == '>' || op.p[k + SKIP_ONE] == '|' || + op.p[k + SKIP_ONE] == '[' || isalpha((unsigned char)op.p[k + SKIP_ONE])); + arrow = + arrow || (op.p[k] == '<' && (op.p[k + SKIP_ONE] == '-' || op.p[k + SKIP_ONE] == '.')); + } + if (!arrow) { + return false; + } + i = dg_skip_ws(ln, i); + if (i < ln.n && ln.p[i] == '"') { + const char *q = memchr(ln.p + i + SKIP_ONE, '"', ln.n - i - SKIP_ONE); + i = q ? (size_t)(q - ln.p) + SKIP_ONE : i; + } + dg_str_t rid; + dg_str_t rlab; + i = dg_puml_operand(ln, i, &rid, &rlab); + if (!rid.n) { + return false; + } + i = dg_skip_ws(ln, i); + dg_str_t label = + i < ln.n && ln.p[i] == ':' ? dg_s(ln.p + i + SKIP_ONE, ln.n - i - SKIP_ONE) : dg_s(NULL, 0); + dg_element(dg, lid, llab, dg->element_kind, line); + dg_element(dg, rid, rlab, dg->element_kind, line); + bool to_left = false; + const char *style = dg_puml_arrow_style(op, sequence, &to_left); + if (to_left) { + dg_arrow(dg, rid, lid, label, style, line); + } else { + dg_arrow(dg, lid, rid, label, style, line); + } + return true; +} + +/* `[[url]]`, `[[url label]]`, `[[url{tooltip}]]` in a PlantUML line: the URL. */ +static dg_str_t dg_puml_url(dg_str_t ln) { + for (size_t i = 0; i + SKIP_ONE < ln.n; i++) { + if (ln.p[i] == '[' && ln.p[i + SKIP_ONE] == '[') { + size_t e = i + PAIR_LEN; + while (e < ln.n && ln.p[e] != ']' && ln.p[e] != '{' && + !isspace((unsigned char)ln.p[e])) { + e++; + } + return dg_s(ln.p + i + PAIR_LEN, e - i - PAIR_LEN); + } + } + return dg_s(NULL, 0); +} + +/* `!include path` (also _many, _once, url): the diagram's link to the file it + * takes in; `` names none of the repo's. `url of X is [[url]]`: X's. */ +static bool dg_puml_link_line(dg_t *dg, dg_str_t ln, uint32_t line) { + if (dg_starts(ln, "!include")) { + size_t i = CBM_SZ_8; + while (i < ln.n && !isspace((unsigned char)ln.p[i])) { + i++; + } + dg_str_t arg = dg_trim(dg_s(ln.p + i, ln.n - i)); + const char *bang = memchr(arg.p, '!', arg.n); /* `file!2`: a block of it */ + arg = bang ? dg_trim(dg_s(arg.p, (size_t)(bang - arg.p))) : arg; + if (arg.n && arg.p[0] != '<') { + dg_link(dg, dg->qn, arg, CBM_DOCLINK_DG_PLANTUML_INCLUDE, line); + } + return true; + } + if (dg_starts(ln, "url of ") || dg_starts(ln, "url for ")) { + size_t kw = dg_starts(ln, "url of ") ? CBM_SZ_7 : CBM_SZ_8; + dg_str_t rest = dg_s(ln.p + kw, ln.n - kw); + size_t j = 0; + while (j + CBM_SZ_4 <= rest.n && memcmp(rest.p + j, " is ", CBM_SZ_4) != 0) { + j++; + } + if (j + CBM_SZ_4 <= rest.n) { + const char *qn = + dg_element(dg, dg_unquote(dg_s(rest.p, j)), dg_s(NULL, 0), dg->element_kind, line); + dg_link(dg, qn, dg_puml_url(rest), CBM_DOCLINK_DG_PLANTUML_URL, line); + } + return true; + } + return false; +} + +/* `kw "Label" as Id`, `kw Id as "Label"`, `kw Id`, `kw [Label] as Id`: an + * element. *body is set when its `{` opens a member body. */ +static bool dg_puml_decl(dg_t *dg, dg_str_t ln, uint32_t line, bool *members) { + const char *kw = NULL; + bool classlike = false; + for (int k = 0; DG_PUML_CLASSLIKE[k] && !kw; k++) { + if (dg_word_at(ln, DG_PUML_CLASSLIKE[k])) { + kw = DG_PUML_CLASSLIKE[k]; + classlike = true; + } + } + for (int k = 0; DG_PUML_ELEMENTS[k] && !kw; k++) { + kw = dg_word_at(ln, DG_PUML_ELEMENTS[k]) ? DG_PUML_ELEMENTS[k] : NULL; + } + if (!kw) { + return false; + } + dg_str_t rest = dg_trim(dg_s(ln.p + strlen(kw), ln.n - strlen(kw))); + dg_str_t id; + dg_str_t label; + size_t j = dg_puml_operand(rest, 0, &id, &label); + dg_str_t after = dg_trim(dg_s(rest.p + j, rest.n - j)); + if (dg_starts(after, "as ")) { + dg_str_t aid; + dg_str_t alab; + dg_puml_operand(dg_trim(dg_s(after.p + CBM_SZ_3, after.n - CBM_SZ_3)), 0, &aid, &alab); + if (label.n || !alab.n) { + label = label.n ? label : id; + id = aid; + } else { + label = alab; /* `kw Id as "Label"` */ + } + } + *members = classlike && rest.n && rest.p[rest.n - SKIP_ONE] == '{'; + const char *kind = strcmp(kw, "abstract class") == 0 ? "abstract" : kw; + const char *qn = dg_element(dg, id, label, kind, line); + dg_link(dg, qn, dg_puml_url(after), CBM_DOCLINK_DG_PLANTUML_URL, line); + return true; +} + +/* What a PlantUML diagram is: the strongest kind any of its lines tells (the + * packages around classes make no component diagram). */ +enum { + PK_C4, + PK_CLASS, + PK_SEQUENCE, + PK_USECASE, + PK_ACTOR, /* an actor alone: a sequence, unless use cases say otherwise */ + PK_STATE, + PK_ACTIVITY, + PK_COMPONENT, + PK_COUNT +}; + +static const char *dg_puml_kind(dg_lines_t it) { + static const char *const names[PK_COUNT] = {"c4", "class", "sequence", "usecase", + "sequence", "state", "activity", "component"}; + bool seen[PK_COUNT] = {false}; + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(&it, &ln, &line)) { + ln = dg_trim(ln); + seen[PK_C4] |= dg_starts(ln, "Person(") || dg_starts(ln, "System(") || + dg_starts(ln, "Container(") || dg_starts(ln, "Component(") || + dg_starts(ln, "Rel("); + for (int k = 0; DG_PUML_CLASSLIKE[k]; k++) { + seen[PK_CLASS] |= dg_word_at(ln, DG_PUML_CLASSLIKE[k]); + } + seen[PK_SEQUENCE] |= dg_word_at(ln, "participant") || dg_word_at(ln, "boundary") || + dg_word_at(ln, "control") || dg_word_at(ln, "collections"); + seen[PK_USECASE] |= dg_word_at(ln, "usecase") || (ln.n && ln.p[0] == '('); + seen[PK_ACTOR] |= dg_word_at(ln, "actor"); + seen[PK_STATE] |= dg_word_at(ln, "state") || dg_starts(ln, "[*]"); + seen[PK_ACTIVITY] |= + dg_eq(ln, "start") || (ln.n && ln.p[0] == ':' && ln.p[ln.n - SKIP_ONE] == ';'); + seen[PK_COMPONENT] |= dg_word_at(ln, "component") || dg_word_at(ln, "node") || + dg_word_at(ln, "package") || dg_word_at(ln, "database") || + (ln.n && ln.p[0] == '['); + } + for (int k = 0; k < PK_COUNT; k++) { + if (seen[k]) { + return names[k]; + } + } + return "uml"; +} + +static bool dg_c4_line(dg_t *dg, dg_str_t ln, uint32_t line); + +static void dg_plantuml(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + const char *kind = dg_puml_kind(*it); + snprintf(dg->kind, sizeof(dg->kind), "%s", kind); + bool sequence = strcmp(kind, "sequence") == 0; + dg->element_kind = sequence ? "participant" + : strcmp(kind, "class") == 0 ? "class" + : strcmp(kind, "usecase") == 0 ? "usecase" + : strcmp(kind, "state") == 0 ? "state" + : strcmp(kind, "component") == 0 ? "component" + : "node"; + if (strcmp(kind, "activity") == 0) { + return; /* an activity's steps are prose: the Diagram alone */ + } + dg_str_t ln; + uint32_t line = 0; + int members = 0; + bool comment = false; + while (dg_next_line(it, &ln, &line)) { + ln = dg_trim(ln); + if (comment) { + comment = !(ln.n >= PAIR_LEN && ln.p[ln.n - PAIR_LEN] == '\'' && + ln.p[ln.n - SKIP_ONE] == '/'); + continue; + } + if (dg_starts(ln, "/'")) { + comment = !(ln.n >= CBM_SZ_4 && ln.p[ln.n - PAIR_LEN] == '\'' && + ln.p[ln.n - SKIP_ONE] == '/'); + continue; + } + if (members > 0) { + members += ln.n && ln.p[ln.n - SKIP_ONE] == '{'; + members -= ln.n && ln.p[0] == '}'; + continue; + } + if (dg_puml_link_line(dg, ln, line)) { + continue; + } + if (!ln.n || ln.p[0] == '\'' || ln.p[0] == '@' || ln.p[0] == '!' || dg_eq(ln, "}") || + dg_starts(ln, "skinparam") || dg_starts(ln, "hide ") || dg_starts(ln, "show ") || + dg_starts(ln, "note ") || dg_starts(ln, "end note") || dg_starts(ln, "legend") || + dg_starts(ln, "header") || dg_starts(ln, "footer") || dg_starts(ln, "left to right") || + dg_starts(ln, "top to bottom") || dg_starts(ln, "autonumber") || + dg_starts(ln, "activate") || dg_starts(ln, "deactivate")) { + continue; + } + if (dg_starts(ln, "title ")) { + if (!title->n) { + *title = dg_unquote(dg_s(ln.p + CBM_SZ_6, ln.n - CBM_SZ_6)); + } + continue; + } + if (strcmp(kind, "c4") == 0 && dg_c4_line(dg, ln, line)) { + continue; + } + bool body = false; + if (dg_puml_decl(dg, ln, line, &body)) { + members += body; + continue; + } + (void)dg_puml_relation(dg, ln, line, sequence); + } +} + +/* ── Graphviz DOT ────────────────────────────────────────────────── */ + +typedef enum { + DT_END = 0, + DT_ID, + DT_LBRACE, + DT_RBRACE, + DT_LBRACK, + DT_RBRACK, + DT_SEMI, + DT_COMMA, + DT_EQ, + DT_COLON, + DT_EDGE, +} dt_kind_t; + +typedef struct { + dt_kind_t k; + dg_str_t s; + uint32_t line; +} dt_tok_t; + +typedef struct { + const char *p; + const char *end; + uint32_t line; + dt_tok_t tok; /* the current token */ +} dt_lex_t; + +static void dt_skip(dt_lex_t *lx) { + for (;;) { + while (lx->p < lx->end && isspace((unsigned char)*lx->p)) { + lx->line += *lx->p == '\n'; + lx->p++; + } + if (lx->p + SKIP_ONE < lx->end && lx->p[0] == '/' && lx->p[SKIP_ONE] == '/') { + while (lx->p < lx->end && *lx->p != '\n') { + lx->p++; + } + continue; + } + if (lx->p < lx->end && lx->p[0] == '#') { + while (lx->p < lx->end && *lx->p != '\n') { + lx->p++; + } + continue; + } + if (lx->p + SKIP_ONE < lx->end && lx->p[0] == '/' && lx->p[SKIP_ONE] == '*') { + lx->p += PAIR_LEN; + while (lx->p + SKIP_ONE < lx->end && !(lx->p[0] == '*' && lx->p[SKIP_ONE] == '/')) { + lx->line += *lx->p == '\n'; + lx->p++; + } + lx->p = lx->p + PAIR_LEN <= lx->end ? lx->p + PAIR_LEN : lx->end; + continue; + } + return; + } +} + +static void dt_next(dt_lex_t *lx) { + dt_skip(lx); + lx->tok = (dt_tok_t){DT_END, dg_s(NULL, 0), lx->line}; + if (lx->p >= lx->end) { + return; + } + char c = *lx->p; + static const char single[] = "{}[];,=:"; + static const dt_kind_t kinds[] = {DT_LBRACE, DT_RBRACE, DT_LBRACK, DT_RBRACK, + DT_SEMI, DT_COMMA, DT_EQ, DT_COLON}; + const char *hit = strchr(single, c); + if (c && hit) { + lx->tok.k = kinds[hit - single]; + lx->p++; + return; + } + if (c == '-' && lx->p + SKIP_ONE < lx->end && + (lx->p[SKIP_ONE] == '>' || lx->p[SKIP_ONE] == '-')) { + lx->tok.k = DT_EDGE; + lx->tok.s = dg_s(lx->p, PAIR_LEN); + lx->p += PAIR_LEN; + return; + } + if (c == '"') { + const char *q = lx->p + SKIP_ONE; + while (q < lx->end && *q != '"') { + lx->line += *q == '\n'; + q += *q == '\\' && q + SKIP_ONE < lx->end ? PAIR_LEN : SKIP_ONE; + } + lx->tok.k = DT_ID; + lx->tok.s = dg_s(lx->p + SKIP_ONE, (size_t)(q - lx->p) - SKIP_ONE); + lx->p = q < lx->end ? q + SKIP_ONE : lx->end; + return; + } + if (c == '<') { /* an HTML string */ + int depth = 0; + const char *q = lx->p; + for (; q < lx->end; q++) { + depth += *q == '<'; + depth -= *q == '>'; + lx->line += *q == '\n'; + if (depth == 0) { + break; + } + } + lx->tok.k = DT_ID; + lx->tok.s = dg_s(lx->p + SKIP_ONE, (size_t)(q - lx->p) - (q > lx->p ? SKIP_ONE : 0)); + lx->p = q < lx->end ? q + SKIP_ONE : lx->end; + return; + } + const char *q = lx->p; + while (q < lx->end && (isalnum((unsigned char)*q) || *q == '_' || *q == '.' || + (unsigned char)*q >= 0x80 || (*q == '-' && q == lx->p))) { + q++; + } + if (q == lx->p) { + lx->p++; /* a stray byte */ + dt_next(lx); + return; + } + lx->tok.k = DT_ID; + lx->tok.s = dg_s(lx->p, (size_t)(q - lx->p)); + lx->p = q; +} + +/* `[a=b, c=d]` lists: the value of `label`, when one is set; url (when given) + * gets the value of `URL` or `href`. */ +static dg_str_t dt_attrs(dt_lex_t *lx, dg_str_t *url) { + dg_str_t label = dg_s(NULL, 0); + while (lx->tok.k == DT_LBRACK) { + dt_next(lx); + while (lx->tok.k != DT_RBRACK && lx->tok.k != DT_END) { + if (lx->tok.k == DT_ID) { + bool is_label = dg_eq(lx->tok.s, "label"); + bool is_url = dg_eq(lx->tok.s, "URL") || dg_eq(lx->tok.s, "href"); + dt_next(lx); + if (lx->tok.k == DT_EQ) { + dt_next(lx); + if (lx->tok.k == DT_ID && is_label) { + label = lx->tok.s; + } else if (lx->tok.k == DT_ID && is_url && url) { + *url = lx->tok.s; + } + } + } + dt_next(lx); + } + dt_next(lx); + } + return label; +} + +enum { DT_SET_MAX = 64, DT_DEPTH_MAX = 32 }; + +typedef struct { + dg_str_t ids[DT_SET_MAX]; + int n; +} dt_set_t; + +static void dt_stmts(dg_t *dg, dt_lex_t *lx, bool directed, dt_set_t *all, int depth, + dg_str_t *title); + +/* An edge operand: a node id (its port dropped) or a subgraph's nodes. */ +static bool dt_operand(dg_t *dg, dt_lex_t *lx, bool directed, dt_set_t *out, int depth, + dg_str_t *title) { + out->n = 0; + if (lx->tok.k == DT_ID && dg_eq(lx->tok.s, "subgraph")) { + dt_next(lx); + if (lx->tok.k == DT_ID) { + dt_next(lx); + } + } + if (lx->tok.k == DT_LBRACE) { + dt_next(lx); + dt_stmts(dg, lx, directed, out, depth + SKIP_ONE, title); + if (lx->tok.k == DT_RBRACE) { + dt_next(lx); + } + return true; + } + if (lx->tok.k != DT_ID) { + return false; + } + out->ids[out->n++] = lx->tok.s; + dt_next(lx); + while (lx->tok.k == DT_COLON) { /* a port, a compass point */ + dt_next(lx); + if (lx->tok.k == DT_ID) { + dt_next(lx); + } + } + return true; +} + +static void dt_stmt(dg_t *dg, dt_lex_t *lx, bool directed, dt_set_t *all, int depth, + dg_str_t *title) { + uint32_t line = lx->tok.line; + if (lx->tok.k == DT_ID && + (dg_eq(lx->tok.s, "graph") || dg_eq(lx->tok.s, "node") || dg_eq(lx->tok.s, "edge"))) { + dt_next(lx); + (void)dt_attrs(lx, NULL); + return; + } + dt_set_t left; + if (!dt_operand(dg, lx, directed, &left, depth, title)) { + dt_next(lx); + return; + } + if (lx->tok.k == DT_EQ) { /* a graph attribute */ + dt_next(lx); + if (lx->tok.k == DT_ID && left.n && dg_eq(left.ids[0], "label") && depth == 0 && + !title->n) { + *title = lx->tok.s; + } + dt_next(lx); + return; + } + if (lx->tok.k != DT_EDGE) { + dg_str_t url = dg_s(NULL, 0); + dg_str_t label = dt_attrs(lx, &url); + for (int i = 0; i < left.n; i++) { + dg_link(dg, dg_element(dg, left.ids[i], label, "node", line), url, + CBM_DOCLINK_DG_DOT_URL, line); + if (all->n < DT_SET_MAX) { + all->ids[all->n++] = left.ids[i]; + } + } + return; + } + /* an edge chain: a -> b -> {c d} [label=x] */ + dt_set_t chain[DT_DEPTH_MAX]; + int nc = 0; + chain[nc++] = left; + while (lx->tok.k == DT_EDGE && nc < DT_DEPTH_MAX) { + dt_next(lx); + if (!dt_operand(dg, lx, directed, &chain[nc], depth, title)) { + break; + } + nc++; + } + dg_str_t label = dt_attrs(lx, NULL); + for (int k = 0; k < nc; k++) { + for (int i = 0; i < chain[k].n; i++) { + dg_element(dg, chain[k].ids[i], dg_s(NULL, 0), "node", line); + if (all->n < DT_SET_MAX) { + all->ids[all->n++] = chain[k].ids[i]; + } + } + } + for (int k = 0; k + SKIP_ONE < nc; k++) { + for (int i = 0; i < chain[k].n; i++) { + for (int j = 0; j < chain[k + SKIP_ONE].n; j++) { + dg_arrow(dg, chain[k].ids[i], chain[k + SKIP_ONE].ids[j], label, + directed ? "arrow" : "line", line); + } + } + } +} + +static void dt_stmts(dg_t *dg, dt_lex_t *lx, bool directed, dt_set_t *all, int depth, + dg_str_t *title) { + if (depth > DT_DEPTH_MAX) { + return; + } + while (lx->tok.k != DT_RBRACE && lx->tok.k != DT_END && !dg->oom) { + if (lx->tok.k == DT_SEMI || lx->tok.k == DT_COMMA) { + dt_next(lx); + continue; + } + dt_stmt(dg, lx, directed, all, depth, title); + } +} + +static void dg_dot(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + dt_lex_t lx = {it->p, it->end, it->line, {DT_END, {NULL, 0}, 0}}; + dt_next(&lx); + if (lx.tok.k == DT_ID && dg_eq(lx.tok.s, "strict")) { + dt_next(&lx); + } + bool directed = lx.tok.k == DT_ID && dg_eq(lx.tok.s, "digraph"); + snprintf(dg->kind, sizeof(dg->kind), "%s", directed ? "digraph" : "graph"); + dg->element_kind = "node"; + while (lx.tok.k != DT_LBRACE && lx.tok.k != DT_END) { + dt_next(&lx); + } + if (lx.tok.k == DT_END) { + return; + } + dt_next(&lx); + dt_set_t all = {.n = 0}; + dt_stmts(dg, &lx, directed, &all, 0, title); +} + +/* ── D2 ──────────────────────────────────────────────────────────── */ + +enum { D2_DEPTH_MAX = 16, D2_PATH_MAX = 512, D2_DELIM_MAX = 8 }; + +/* Keys that set an attribute of the shape they are written in. */ +static bool d2_reserved(dg_str_t key) { + static const char *const words[] = { + "shape", "style", "icon", "near", "direction", "tooltip", + "link", "width", "height", "class", "classes", "vars", + "label", "constraint", "top", "left", "grid-rows", "grid-columns", + "grid-gap", "layers", "scenarios", "steps", "source-arrowhead", "target-arrowhead", + "filled", "font-size", NULL}; + for (int k = 0; words[k]; k++) { + if (dg_eq(key, words[k])) { + return true; + } + } + return false; +} + +/* The containers a line is written in: the path of ids, and how far it + * reached at each enclosing level (restored by the closing brace). */ +typedef struct { + char path[D2_PATH_MAX]; + size_t lens[D2_DEPTH_MAX]; + int depth; + int skip_depth; /* inside a style, vars or connection block; -1 for none */ +} d2_scope_t; + +/* One level deeper: into the container `id`, or (no id) into a block that + * keeps the path. */ +static void d2_push(d2_scope_t *sc, dg_str_t id) { + if (sc->depth < D2_DEPTH_MAX) { + sc->lens[sc->depth] = strlen(sc->path); + if (id.n) { + char next[D2_PATH_MAX]; + snprintf(next, sizeof(next), "%.*s", (int)id.n, id.p); + memcpy(sc->path, next, sizeof(next)); + } + } + sc->depth++; +} + +static void d2_pop(d2_scope_t *sc) { + if (sc->depth == 0) { + return; + } + if (sc->skip_depth == sc->depth) { + sc->skip_depth = -1; + } + sc->depth--; + if (sc->depth < D2_DEPTH_MAX) { + sc->path[sc->lens[sc->depth]] = '\0'; + } +} + +/* The full id of a reference written inside the containers `path`. */ +static dg_str_t d2_id(dg_str_t ref, const char *path, char *buf, size_t cap) { + ref = dg_unquote(ref); + if (!path[0]) { + return ref; + } + int n = snprintf(buf, cap, "%s.%.*s", path, (int)ref.n, ref.p); + return n > 0 && (size_t)n < cap ? dg_s(buf, (size_t)n) : ref; +} + +/* A line without its `#` comment; a block string (`|`) ends the scan, its + * text is no comment. */ +static dg_str_t d2_strip_comment(dg_str_t ln) { + bool quoted = false; + for (size_t i = 0; i < ln.n; i++) { + quoted = ln.p[i] == '"' ? !quoted : quoted; + if (quoted) { + continue; + } + if (ln.p[i] == '|') { + break; + } + if (ln.p[i] == '#') { + return dg_trim(dg_s(ln.p, i)); + } + } + return ln; +} + +/* The first `:` outside quotes, NULL for none. */ +static const char *d2_colon(dg_str_t ln) { + bool quoted = false; + for (size_t i = 0; i < ln.n; i++) { + quoted = ln.p[i] == '"' ? !quoted : quoted; + if (!quoted && ln.p[i] == ':') { + return ln.p + i; + } + } + return NULL; +} + +/* A block string value (`|md ...|`, `||...||`, `|||...|||`, |`...`|): its + * text, read on through the lines it spans, and what follows its closing + * delimiter on that line. False when value opens none. */ +static bool d2_block(dg_lines_t *it, dg_str_t value, dg_str_t *text, dg_str_t *after) { + size_t pipes = 0; + while (pipes < value.n && value.p[pipes] == '|') { + pipes++; + } + if (pipes == 0 || pipes + PAIR_LEN > D2_DELIM_MAX) { + return false; + } + char close[D2_DELIM_MAX]; + size_t cl = 0; + size_t open = pipes; + if (open < value.n && value.p[open] == '`') { + close[cl++] = '`'; + open++; + } + for (size_t i = 0; i < pipes; i++) { + close[cl++] = '|'; + } + while (open < value.n && !isspace((unsigned char)value.p[open])) { /* the language tag */ + open++; + } + const char *from = value.p + open; + const char *at = NULL; + for (const char *q = from; q + cl <= it->end && !at; q++) { + at = memcmp(q, close, cl) == 0 ? q : NULL; + } + at = at ? at : it->end; + *text = dg_s(from, (size_t)(at - from)); + const char *rest = at + (at < it->end ? cl : 0); + const char *eol = memchr(rest, '\n', (size_t)(it->end - rest)); + eol = eol ? eol : it->end; + *after = dg_trim(dg_s(rest, (size_t)(eol - rest))); + dg_str_t skipped; + uint32_t l = 0; + while (it->p <= at && dg_next_line(it, &skipped, &l)) { /* the lines the block spans */ + } + return true; +} + +/* A block string's label: its first line of text, tags and heading marks + * dropped. */ +static size_t d2_block_label(dg_str_t text, char *out, size_t cap) { + dg_lines_t li = {text.p, text.p + text.n, 0}; + dg_str_t ln; + uint32_t l = 0; + while (dg_next_line(&li, &ln, &l)) { + size_t n = dg_label_text(ln, out, cap); + size_t k = 0; + while (k < n && (out[k] == '#' || out[k] == ' ')) { + k++; + } + if (k < n) { + memmove(out, out + k, n - k + SKIP_ONE); + return n - k; + } + } + out[0] = '\0'; + return 0; +} + +/* A value that is a block string read whole: the label it gives in buf, its + * text added to the diagram's docstring, and whether a `{` follows it. */ +static dg_str_t d2_value(dg_t *dg, dg_lines_t *it, dg_str_t value, char *buf, size_t cap, + bool *opens) { + dg_str_t text; + dg_str_t after; + if (!value.n || value.p[0] != '|' || !d2_block(it, value, &text, &after)) { + return value; + } + *opens = after.n && after.p[after.n - SKIP_ONE] == '{'; + size_t n = d2_block_label(text, buf, cap); + char all[DG_LABEL_MAX]; + size_t an = dg_label_text(text, all, sizeof(all)); + if (an > n) { + dg_doc_add(dg, all, an); + } + return dg_s(buf, n); +} + +/* A connection chain `a -> b <- c: label`: true when the line is one. */ +static bool d2_connection(dg_t *dg, dg_lines_t *it, dg_str_t ln, const char *path, uint32_t line, + bool *opens) { + static const char *const ops[] = {"<->", "->", "<-", "--", NULL}; + dg_str_t parts[DT_SET_MAX]; + const char *styles[DT_SET_MAX]; + int np = 0; + const char *colon = NULL; + bool quoted = false; + size_t start = 0; + for (size_t i = 0; i < ln.n && np + SKIP_ONE < DT_SET_MAX; i++) { + quoted = ln.p[i] == '"' ? !quoted : quoted; + if (quoted) { + continue; + } + if (ln.p[i] == ':') { + colon = ln.p + i; + break; + } + for (int k = 0; ops[k]; k++) { + size_t l = strlen(ops[k]); + if (i + l <= ln.n && memcmp(ln.p + i, ops[k], l) == 0) { + parts[np] = dg_trim(dg_s(ln.p + start, i - start)); + styles[np++] = k == 0 ? "bidirectional" + : k == 1 ? "arrow" + : k == PAIR_LEN ? "<" + : "line"; + start = i + l; + i += l - SKIP_ONE; + break; + } + } + } + if (np == 0) { + return false; + } + size_t end = colon ? (size_t)(colon - ln.p) : ln.n; + parts[np] = dg_trim(dg_s(ln.p + start, end - start)); + dg_str_t label = colon + ? dg_trim(dg_s(colon + SKIP_ONE, (size_t)(ln.p + ln.n - colon) - SKIP_ONE)) + : dg_s(NULL, 0); + char buf[DG_LABEL_MAX]; + label = d2_value(dg, it, label, buf, sizeof(buf), opens); + if (label.n && label.p[label.n - SKIP_ONE] == '{') { + label = dg_trim(dg_s(label.p, label.n - SKIP_ONE)); + } + for (int k = 0; k < np; k++) { + char ba[D2_PATH_MAX]; + char bb[D2_PATH_MAX]; + dg_str_t a = d2_id(parts[k], path, ba, sizeof(ba)); + dg_str_t b = d2_id(parts[k + SKIP_ONE], path, bb, sizeof(bb)); + if (!a.n || !b.n) { + continue; + } + if (strcmp(styles[k], "<") == 0) { + dg_arrow(dg, b, a, label, "arrow", line); + } else { + dg_arrow(dg, a, b, label, styles[k], line); + } + } + return true; +} + +/* The first reserved segment of a dotted key: its index, -1 for none. */ +static int d2_reserved_segment(dg_str_t key, size_t *seg_start, size_t *seg_len) { + int index = 0; + size_t s = 0; + bool quoted = false; + for (size_t i = 0; i <= key.n; i++) { + quoted = i < key.n && key.p[i] == '"' ? !quoted : quoted; + if (i < key.n && (quoted || key.p[i] != '.')) { + continue; + } + if (d2_reserved(dg_trim(dg_s(key.p + s, i - s)))) { + *seg_start = s; + *seg_len = i - s; + return index; + } + index++; + s = i + SKIP_ONE; + } + return -1; +} + +/* `key: value` (or `key: value {`) that is no connection. */ +static void d2_key(dg_t *dg, d2_scope_t *sc, dg_lines_t *it, dg_str_t ln, uint32_t line, bool opens, + dg_str_t *title) { + const char *colon = d2_colon(ln); + dg_str_t key = dg_trim(colon ? dg_s(ln.p, (size_t)(colon - ln.p)) + : dg_s(ln.p, ln.n - (opens ? SKIP_ONE : 0))); + dg_str_t value = colon + ? dg_trim(dg_s(colon + SKIP_ONE, (size_t)(ln.p + ln.n - colon) - SKIP_ONE)) + : dg_s(NULL, 0); + char vbuf[DG_LABEL_MAX]; + value = d2_value(dg, it, value, vbuf, sizeof(vbuf), &opens); + if (value.n && value.p[value.n - SKIP_ONE] == '{') { + value = dg_trim(dg_s(value.p, value.n - SKIP_ONE)); + } + size_t rs = 0; + size_t rl = 0; + int r = d2_reserved_segment(key, &rs, &rl); + if (r >= 0) { + /* an attribute: `label` names the shape it is set on */ + bool label = dg_eq(dg_trim(dg_s(key.p + rs, rl)), "label") && rs + rl == key.n; + char buf[D2_PATH_MAX]; + dg_str_t owner = r == 0 ? dg_s(sc->path, strlen(sc->path)) + : d2_id(dg_s(key.p, rs - SKIP_ONE), sc->path, buf, sizeof(buf)); + bool link = dg_eq(dg_trim(dg_s(key.p + rs, rl)), "link") && rs + rl == key.n; + if (label && owner.n && value.n) { + dg_element(dg, owner, value, "shape", line); + } else if (link && owner.n && value.n) { + dg_link(dg, dg_element(dg, owner, dg_s(NULL, 0), "shape", line), value, + CBM_DOCLINK_DG_D2_LINK, line); + } else if (label && value.n && r == 0) { /* the board's own label */ + const char *t = dg_dup(dg, value.p, value.n); + *title = t ? dg_s(t, value.n) : *title; + } + if (opens) { + d2_push(sc, dg_s(NULL, 0)); + sc->skip_depth = sc->depth; + } + return; + } + char buf[D2_PATH_MAX]; + dg_str_t id = d2_id(key, sc->path, buf, sizeof(buf)); + dg_element(dg, id, value, opens ? "container" : "shape", line); + if (opens) { + d2_push(sc, id); + } +} + +static void dg_d2(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + snprintf(dg->kind, sizeof(dg->kind), "%s", "d2"); + dg->element_kind = "shape"; + d2_scope_t sc; + memset(&sc, 0, sizeof(sc)); + sc.skip_depth = -1; + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(it, &ln, &line)) { + ln = d2_strip_comment(dg_trim(ln)); + if (!ln.n) { + continue; + } + if (dg_eq(ln, "}")) { + d2_pop(&sc); + continue; + } + bool opens = ln.p[ln.n - SKIP_ONE] == '{'; + if (sc.skip_depth >= 0 || ln.p[0] == '(' || ln.p[0] == '|' || ln.p[0] == '*' || + ln.p[0] == '.' || ln.p[0] == '@') { + if (opens) { + d2_push(&sc, dg_s(NULL, 0)); + } + continue; + } + if (d2_connection(dg, it, ln, sc.path, line, &opens)) { + if (opens) { + d2_push(&sc, dg_s(NULL, 0)); + sc.skip_depth = sc.depth; /* a connection's attribute block */ + } + continue; + } + d2_key(dg, &sc, it, ln, line, opens, title); + } +} + +/* ── XML attributes (draw.io) ────────────────────────────────────── */ + +/* The value of attribute `name` in the tag text [p, end), entities + * decoded, into out; false when the tag has none. */ +static bool dg_xml_attr(const char *p, const char *end, const char *name, char *out, size_t cap) { + size_t nl = strlen(name); + for (const char *q = p; q + nl + PAIR_LEN < end; q++) { + if (memcmp(q, name, nl) != 0 || q[nl] != '=' || + (q[nl + SKIP_ONE] != '"' && q[nl + SKIP_ONE] != '\'') || + (q > p && !isspace((unsigned char)q[-SKIP_ONE]))) { + continue; + } + char quote = q[nl + SKIP_ONE]; + const char *v = q + nl + PAIR_LEN; + size_t w = 0; + for (; v < end && *v != quote && w + SKIP_ONE < cap; v++) { + if (*v != '&') { + out[w++] = *v; + continue; + } + static const struct { + const char *ent; + char c; + } ents[] = {{"<", '<'}, {">", '>'}, {"&", '&'}, {""", '"'}, + {"'", '\''}, {" ", ' '}, {" ", ' '}, {" ", ' '}, + {" ", ' '}, {NULL, 0}}; + bool hit = false; + for (int k = 0; ents[k].ent && !hit; k++) { + size_t el = strlen(ents[k].ent); + if (v + el <= end && memcmp(v, ents[k].ent, el) == 0) { + out[w++] = ents[k].c; + v += el - SKIP_ONE; + hit = true; + } + } + if (!hit) { + out[w++] = '&'; + } + } + out[w] = '\0'; + return true; + } + out[0] = '\0'; + return false; +} + +enum { DG_DRAWIO_CELLS = 4096, DG_ATTR_MAX = 4096 }; + +/* The text of a draw.io label: its HTML entities once more (an HTML label is + * escaped twice), its tags dropped. */ +static size_t dg_drawio_text(const char *html, char *out, size_t cap) { + char tmp[DG_ATTR_MAX]; + size_t w = 0; + for (const char *v = html; *v && w + SKIP_ONE < sizeof(tmp); v++) { + static const struct { + const char *ent; + char c; + } ents[] = {{"<", '<'}, {">", '>'}, {"&", '&'}, + {""", '"'}, {" ", ' '}, {NULL, 0}}; + bool hit = false; + for (int k = 0; ents[k].ent && !hit && *v == '&'; k++) { + size_t el = strlen(ents[k].ent); + if (strncmp(v, ents[k].ent, el) == 0) { + tmp[w++] = ents[k].c; + v += el - SKIP_ONE; + hit = true; + } + } + if (!hit) { + tmp[w++] = *v; + } + } + tmp[w] = '\0'; + /* a label cut short can end inside a tag: drop it, not show it */ + char *lt = strrchr(tmp, '<'); + if (lt && !strchr(lt, '>') && + (isalpha((unsigned char)lt[SKIP_ONE]) || lt[SKIP_ONE] == '/' || lt[SKIP_ONE] == '!')) { + *lt = '\0'; + w = (size_t)(lt - tmp); + } + return dg_label_text(dg_s(tmp, w), out, cap); +} + +typedef struct { + char id[DG_ID_MAX]; + char label[DG_LABEL_MAX]; + char source[DG_ID_MAX]; + char target[DG_ID_MAX]; + char parent[DG_ID_MAX]; + bool vertex; + bool edge; + bool text; /* a free text box */ + bool edge_label; /* the label of the edge `parent` */ + bool dashed; + bool no_arrow; + char kind[DG_KIND_MAX]; + const char *link; /* the cell's `link` attribute, NULL for none */ +} dg_cell_t; + +/* The cell a `` tag (inside an `` wrapper: `wrap`) says. */ +static void dg_drawio_cell(CBMArena *a, const char *tag, const char *tag_end, const char *wrap, + const char *wrap_end, dg_cell_t *c) { + char buf[DG_ATTR_MAX]; + memset(c, 0, sizeof(*c)); + if (dg_xml_attr(wrap ? wrap : tag, wrap ? wrap_end : tag_end, "link", buf, sizeof(buf)) && + buf[0]) { + c->link = cbm_arena_strdup(a, buf); + } + const char *idp = wrap ? wrap : tag; + const char *ide = wrap ? wrap_end : tag_end; + dg_xml_attr(idp, ide, "id", c->id, sizeof(c->id)); + if (dg_xml_attr(wrap ? wrap : tag, wrap ? wrap_end : tag_end, wrap ? "label" : "value", buf, + sizeof(buf))) { + dg_drawio_text(buf, c->label, sizeof(c->label)); + } + c->vertex = dg_xml_attr(tag, tag_end, "vertex", buf, sizeof(buf)) && buf[0] == '1'; + c->edge = dg_xml_attr(tag, tag_end, "edge", buf, sizeof(buf)) && buf[0] == '1'; + dg_xml_attr(tag, tag_end, "source", c->source, sizeof(c->source)); + dg_xml_attr(tag, tag_end, "target", c->target, sizeof(c->target)); + dg_xml_attr(tag, tag_end, "parent", c->parent, sizeof(c->parent)); + if (dg_xml_attr(tag, tag_end, "style", buf, sizeof(buf))) { + c->text = strncmp(buf, "text;", CBM_SZ_5) == 0 || strncmp(buf, "text", CBM_SZ_4) == 0; + c->edge_label = strstr(buf, "edgeLabel") != NULL; + c->dashed = strstr(buf, "dashed=1") != NULL; + c->no_arrow = strstr(buf, "endArrow=none") != NULL; + const char *shape = strstr(buf, "shape="); + const char *k = shape ? shape + CBM_SZ_6 : buf; + size_t kl = 0; + while (k[kl] && k[kl] != ';' && k[kl] != '=' && kl + SKIP_ONE < sizeof(c->kind)) { + kl++; + } + if (k[kl] == '=' && !shape) { + kl = 0; /* the first entry is a setting, not a shape */ + } + memcpy(c->kind, k, kl); + c->kind[kl] = '\0'; + } + if (!c->kind[0]) { + snprintf(c->kind, sizeof(c->kind), "%s", "shape"); + } +} + +/* The elements and arrows of one draw.io page's mxGraphModel. */ +static void dg_drawio(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + (void)title; + snprintf(dg->kind, sizeof(dg->kind), "%s", "drawio"); + dg->element_kind = "shape"; + const char *p = it->p; + const char *end = it->end; + dg_cell_t *cells = + (dg_cell_t *)cbm_arena_alloc(dg->ctx->arena, DG_DRAWIO_CELLS * sizeof(dg_cell_t)); + if (!cells) { + dg->oom = true; + return; + } + int n = 0; + for (const char *q = p; q < end && n < DG_DRAWIO_CELLS;) { + const char *lt = memchr(q, '<', (size_t)(end - q)); + if (!lt) { + break; + } + const char *gt = memchr(lt, '>', (size_t)(end - lt)); + if (!gt) { + break; + } + bool object = (size_t)(end - lt) > CBM_SZ_8 && (memcmp(lt, "', (size_t)(end - cell)); + if (cend) { + dg_drawio_cell(dg->ctx->arena, cell, cend, lt, gt, &cells[n++]); + q = cend + SKIP_ONE; + continue; + } + } + } else if ((size_t)(end - lt) > CBM_SZ_7 && memcmp(lt, "ctx->arena, lt, gt, NULL, NULL, &cells[n++]); + } + q = gt + SKIP_ONE; + } + uint32_t line = dg->line0; + for (int i = 0; i < n; i++) { /* the shapes with text */ + dg_cell_t *c = &cells[i]; + if (c->vertex && !c->edge_label && c->label[0]) { + if (c->text) { + dg_doc_add(dg, c->label, strlen(c->label)); + } else { + const char *qn = dg_element(dg, dg_s(c->id, strlen(c->id)), + dg_s(c->label, strlen(c->label)), c->kind, line); + dg_link(dg, qn, dg_s(c->link, c->link ? strlen(c->link) : 0), + CBM_DOCLINK_DG_DRAWIO_LINK, line); + } + } + } + for (int i = 0; i < n; i++) { /* the connections */ + dg_cell_t *c = &cells[i]; + if (!c->edge || !c->source[0] || !c->target[0]) { + continue; + } + const char *label = c->label; + for (int k = 0; k < n && !label[0]; k++) { + if (cells[k].edge_label && strcmp(cells[k].parent, c->id) == 0) { + label = cells[k].label; + } + } + if (!cbm_ht_get(dg->ids, c->source) || !cbm_ht_get(dg->ids, c->target)) { + continue; /* an end without text names nothing */ + } + dg_arrow(dg, dg_s(c->source, strlen(c->source)), dg_s(c->target, strlen(c->target)), + dg_s(label, strlen(label)), + c->dashed ? "dotted" + : c->no_arrow ? "line" + : "arrow", + line); + } +} + +/* ── Excalidraw ──────────────────────────────────────────────────── */ + +enum { DG_EX_MARGIN = 8 }; /* px: how near a loose arrow end comes to the shape it meets */ + +/* A shape of the scene: its box, and the label it gets. */ +typedef struct { + const char *id; + const char *type; + double x0; + double y0; + double x1; + double y1; + double area; + const char *label; + double label_x; /* a loose label's place: the topmost text inside wins */ + double label_y; + const char *link; /* the shape's `link`, NULL for none */ + bool fixed; /* bound text or a frame's name: no loose text replaces it */ + bool region; /* a box that can hold loose text and meet loose arrow ends */ + bool frame; /* a region around every other shape, the drawing's frame: loose + * arrow ends meet it at its border only */ +} ex_shape_t; + +static const char *dg_json_str(yyjson_val *obj, const char *key) { + yyjson_val *v = yyjson_obj_get(obj, key); + return yyjson_is_str(v) ? yyjson_get_str(v) : NULL; +} + +static double ex_num(yyjson_val *obj, const char *key) { + yyjson_val *v = yyjson_obj_get(obj, key); + return yyjson_is_num(v) ? yyjson_get_num(v) : 0.0; +} + +static bool ex_live(yyjson_val *e) { + return !yyjson_get_bool(yyjson_obj_get(e, "isDeleted")); +} + +static bool ex_is(const char *type, const char *const *kinds) { + for (int k = 0; kinds[k]; k++) { + if (strcmp(type, kinds[k]) == 0) { + return true; + } + } + return false; +} + +static const char *const EX_REGIONS[] = {"rectangle", "ellipse", "diamond", "frame", + "magicframe", "embeddable", "iframe", NULL}; +static const char *const EX_NOT_SHAPES[] = {"text", "arrow", "line", "freedraw", "selection", NULL}; + +static int ex_by_area(const void *a, const void *b) { + const ex_shape_t *x = (const ex_shape_t *)a; + const ex_shape_t *y = (const ex_shape_t *)b; + if (x->area != y->area) { + return x->area < y->area ? -1 : SKIP_ONE; + } + return strcmp(x->id, y->id); +} + +/* The smallest region whose box, grown by margin, holds (x, y): -1 for none. + * The shapes are sorted by area, so the first hit is the smallest. An arrow + * end (labelled) deep inside the drawing's frame points at something in the + * drawing, not at the frame: none. */ +static int ex_region_at(const ex_shape_t *s, int n, double x, double y, double margin, + bool labelled) { + for (int i = 0; i < n; i++) { + if (!s[i].region || (labelled && !s[i].label)) { + continue; + } + if (x >= s[i].x0 - margin && x <= s[i].x1 + margin && y >= s[i].y0 - margin && + y <= s[i].y1 + margin) { + bool deep = x > s[i].x0 + margin && x < s[i].x1 - margin && y > s[i].y0 + margin && + y < s[i].y1 - margin; + return labelled && s[i].frame && deep ? -1 : i; + } + } + return -1; +} + +/* A loose text: the label of the smallest region it sits in (the topmost + * text of several), else the diagram's docstring. */ +static void ex_loose_text(dg_t *dg, ex_shape_t *s, int n, yyjson_val *e, const char *text) { + double x = ex_num(e, "x"); + double y = ex_num(e, "y"); + int i = ex_region_at(s, n, x + ex_num(e, "width") / PAIR_LEN, + y + ex_num(e, "height") / PAIR_LEN, 0.0, false); + if (i < 0 || s[i].fixed) { + dg_doc_add(dg, text, strlen(text)); + return; + } + if (s[i].label && (s[i].label_y < y || (s[i].label_y == y && s[i].label_x <= x))) { + dg_doc_add(dg, text, strlen(text)); + return; + } + if (s[i].label) { + dg_doc_add(dg, s[i].label, strlen(s[i].label)); + } + s[i].label = text; + s[i].label_x = x; + s[i].label_y = y; +} + +/* The end bound to free text: the shape that text names, else the text as an + * element of its own. */ +static const char *ex_text_end(dg_t *dg, const ex_shape_t *s, int n, CBMHashTable *texts, + const char *id) { + const char *text = (const char *)cbm_ht_get(texts, id); + if (!text) { + return NULL; + } + for (int i = 0; i < n; i++) { + if (s[i].label == text) { + return s[i].id; + } + } + return dg_element(dg, dg_s(id, strlen(id)), dg_s(text, strlen(text)), "text", dg->line0) ? id + : NULL; +} + +/* An arrow end: the shape (or free text) it is bound to, else (arrows only) + * the smallest labelled region the point meets. */ +static const char *ex_end(dg_t *dg, const ex_shape_t *s, int n, CBMHashTable *texts, yyjson_val *e, + const char *binding, bool first, bool loose_ok) { + const char *bound = dg_json_str(yyjson_obj_get(e, binding), "elementId"); + if (bound) { + return cbm_ht_get(dg->ids, bound) ? bound : ex_text_end(dg, s, n, texts, bound); + } + yyjson_val *pts = yyjson_obj_get(e, "points"); + yyjson_val *pt = first ? yyjson_arr_get_first(pts) : yyjson_arr_get_last(pts); + if (!loose_ok || !yyjson_is_arr(pt)) { + return NULL; + } + double x = ex_num(e, "x") + yyjson_get_num(yyjson_arr_get(pt, 0)); + double y = ex_num(e, "y") + yyjson_get_num(yyjson_arr_get(pt, SKIP_ONE)); + int i = ex_region_at(s, n, x, y, DG_EX_MARGIN, true); + return i >= 0 ? s[i].id : NULL; +} + +static void ex_arrows(dg_t *dg, const ex_shape_t *s, int n, yyjson_val *els, CBMHashTable *labels, + CBMHashTable *texts) { + size_t i; + size_t max; + yyjson_val *e; + yyjson_arr_foreach(els, i, max, e) { + const char *type = dg_json_str(e, "type"); + const char *id = dg_json_str(e, "id"); + if (!type || !id || !ex_live(e) || + (strcmp(type, "arrow") != 0 && strcmp(type, "line") != 0)) { + continue; + } + bool arrow = strcmp(type, "arrow") == 0; + const char *from = ex_end(dg, s, n, texts, e, "startBinding", true, arrow); + const char *to = ex_end(dg, s, n, texts, e, "endBinding", false, arrow); + bool loose = !dg_json_str(yyjson_obj_get(e, "startBinding"), "elementId") || + !dg_json_str(yyjson_obj_get(e, "endBinding"), "elementId"); + if (!from || !to || (loose && strcmp(from, to) == 0)) { + continue; /* unbound, bound to a shape without text, or a loose loop in one box */ + } + const char *label = (const char *)cbm_ht_get(labels, id); + dg_arrow(dg, dg_s(from, strlen(from)), dg_s(to, strlen(to)), + dg_s(label ? label : "", label ? strlen(label) : 0), arrow ? "arrow" : "line", + dg->line0); + } +} + +static void dg_excalidraw(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + (void)title; + snprintf(dg->kind, sizeof(dg->kind), "%s", "excalidraw"); + dg->element_kind = "shape"; + yyjson_doc *doc = yyjson_read(it->p, (size_t)(it->end - it->p), 0); + yyjson_val *els = doc ? yyjson_obj_get(yyjson_doc_get_root(doc), "elements") : NULL; + size_t total = yyjson_arr_size(els); + CBMHashTable *labels = + yyjson_is_arr(els) ? cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_64) : NULL; + ex_shape_t *s = labels && total + ? (ex_shape_t *)cbm_arena_alloc(dg->ctx->arena, total * sizeof(ex_shape_t)) + : NULL; + CBMHashTable *texts = s ? cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_64) : NULL; + if (!texts) { + cbm_ht_free(labels); + yyjson_doc_free(doc); + return; + } + size_t i; + size_t max; + yyjson_val *e; + yyjson_arr_foreach(els, i, max, e) { /* bound text: the label of its container */ + const char *text = dg_json_str(e, "originalText"); + text = text ? text : dg_json_str(e, "text"); + const char *type = dg_json_str(e, "type"); + const char *container = dg_json_str(e, "containerId"); + const char *id = dg_json_str(e, "id"); + if (ex_live(e) && type && strcmp(type, "text") == 0 && text && container) { + cbm_ht_set(labels, container, (void *)text); + } else if (ex_live(e) && type && strcmp(type, "text") == 0 && text && id) { + cbm_ht_set(texts, id, (void *)text); /* free text an arrow may be bound to */ + } + } + int n = 0; + yyjson_arr_foreach(els, i, max, e) { /* the shapes */ + const char *type = dg_json_str(e, "type"); + const char *id = dg_json_str(e, "id"); + if (!type || !id || !ex_live(e) || ex_is(type, EX_NOT_SHAPES)) { + continue; + } + ex_shape_t *sh = &s[n++]; + memset(sh, 0, sizeof(*sh)); + sh->id = id; + sh->type = type; + sh->x0 = ex_num(e, "x"); + sh->y0 = ex_num(e, "y"); + sh->x1 = sh->x0 + ex_num(e, "width"); + sh->y1 = sh->y0 + ex_num(e, "height"); + sh->area = (sh->x1 - sh->x0) * (sh->y1 - sh->y0); + sh->region = ex_is(type, EX_REGIONS); + sh->label = (const char *)cbm_ht_get(labels, id); + sh->label = sh->label ? sh->label : dg_json_str(e, "name"); /* a frame's name */ + sh->fixed = sh->label != NULL; + sh->link = dg_json_str(e, "link"); + } + qsort(s, (size_t)n, sizeof(ex_shape_t), ex_by_area); + yyjson_arr_foreach(els, i, max, e) { /* loose text: in a shape, or the docstring */ + const char *type = dg_json_str(e, "type"); + const char *text = dg_json_str(e, "originalText"); + text = text ? text : dg_json_str(e, "text"); + if (ex_live(e) && type && strcmp(type, "text") == 0 && text && + !dg_json_str(e, "containerId")) { + ex_loose_text(dg, s, n, e, text); + } + } + if (n > SKIP_ONE && s[n - SKIP_ONE].region) { /* only the largest box can frame the rest */ + ex_shape_t *f = &s[n - SKIP_ONE]; + bool all = true; + for (int j = 0; j < n - SKIP_ONE && all; j++) { + all = s[j].x0 >= f->x0 && s[j].x1 <= f->x1 && s[j].y0 >= f->y0 && s[j].y1 <= f->y1; + } + f->frame = all; + } + for (int k = 0; k < n; k++) { + if (s[k].label) { + const char *qn = dg_element(dg, dg_s(s[k].id, strlen(s[k].id)), + dg_s(s[k].label, strlen(s[k].label)), s[k].type, dg->line0); + dg_link(dg, qn, dg_s(s[k].link, s[k].link ? strlen(s[k].link) : 0), + CBM_DOCLINK_DG_EXCALIDRAW_LINK, dg->line0); + } + } + ex_arrows(dg, s, n, els, labels, texts); + cbm_ht_free(texts); + cbm_ht_free(labels); + yyjson_doc_free(doc); +} + +/* ── One diagram ─────────────────────────────────────────────────── */ + +typedef struct { + const char *format; + const char *title; /* a draw.io page's name, NULL for none */ + const char *fallback; /* the name when the text gives none: a diagram file's */ + const char *parent_qn; /* the Section's, NULL: the file */ + int ordinal; + const char *text; + size_t len; + uint32_t first_line; /* the text's first line in the file */ + uint32_t start_line; /* the block's lines */ + uint32_t end_line; +} dg_source_t; + +static void dg_build(CBMExtractCtx *ctx, const dg_source_t *src) { + dg_t dg; + memset(&dg, 0, sizeof(dg)); + dg.ctx = ctx; + dg.format = src->format; + const char *parent = src->parent_qn ? src->parent_qn : ctx->module_qn; + if (!parent) { + return; + } + dg.qn = cbm_arena_sprintf(ctx->arena, "%s#diagram-%d", parent, src->ordinal); + dg.ids = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_32); + if (!dg.qn || !dg.ids) { + cbm_ht_free(dg.ids); + return; + } + CBMDefinition def; + memset(&def, 0, sizeof(def)); + def.label = "Diagram"; + def.qualified_name = dg.qn; + def.file_path = ctx->rel_path; + def.start_line = src->start_line; + def.end_line = src->end_line; + def.parent_class = src->parent_qn; + def.name = src->format; + cbm_defs_push(&ctx->result->defs, ctx->arena, def); + dg.def_index = ctx->result->defs.count - SKIP_ONE; + dg.line0 = src->first_line; + dg_lines_t it = {src->text, src->text + src->len, src->first_line}; + dg_str_t title = src->title ? dg_s(src->title, strlen(src->title)) : dg_s(NULL, 0); + if (strcmp(src->format, "mermaid") == 0) { + dg_mermaid(&dg, &it, &title); + } else if (strcmp(src->format, "plantuml") == 0) { + dg_plantuml(&dg, &it, &title); + } else if (strcmp(src->format, "dot") == 0) { + dg_dot(&dg, &it, &title); + } else if (strcmp(src->format, "d2") == 0) { + dg_d2(&dg, &it, &title); + } else if (strcmp(src->format, "drawio") == 0) { + dg_drawio(&dg, &it, &title); + } else if (strcmp(src->format, "excalidraw") == 0) { + dg_excalidraw(&dg, &it, &title); + } + cbm_ht_free(dg.ids); + CBMDefinition *d = &ctx->result->defs.items[dg.def_index]; + char tbuf[DG_LABEL_MAX]; + size_t tl = dg_label_text(title, tbuf, sizeof(tbuf)); + d->name = tl ? cbm_arena_strndup(ctx->arena, tbuf, tl) + : src->fallback ? src->fallback + : cbm_arena_sprintf(ctx->arena, "%s %s", src->format, + dg.kind[0] ? dg.kind : "diagram"); + d->docstring = dg.doc_len ? cbm_arena_strndup(ctx->arena, dg.doc, dg.doc_len) : NULL; + const char *kind = dg.kind[0] ? cbm_arena_strdup(ctx->arena, dg.kind) : "unknown"; + const char **kv = (const char **)cbm_arena_alloc(ctx->arena, CBM_SZ_5 * sizeof(char *)); + if (kv) { + kv[0] = "format"; + kv[SKIP_ONE] = src->format; + kv[PAIR_LEN] = "diagram_kind"; + kv[CBM_SZ_3] = kind; + kv[CBM_SZ_4] = NULL; + d->extra_props = kv; + } +} + +/* ── Sources ─────────────────────────────────────────────────────── */ + +/* The diagram format an info string names, NULL for none. */ +static const char *dg_format_of(const char *s, size_t n) { + static const struct { + const char *word; + const char *format; + } names[] = { + {"mermaid", "mermaid"}, {"plantuml", "plantuml"}, {"puml", "plantuml"}, {"uml", "plantuml"}, + {"dot", "dot"}, {"graphviz", "dot"}, {"d2", "d2"}, {NULL, NULL}}; + for (int k = 0; names[k].word; k++) { + size_t l = strlen(names[k].word); + bool same = n == l; + for (size_t i = 0; same && i < l; i++) { + same = tolower((unsigned char)s[i]) == names[k].word[i]; + } + if (same) { + return names[k].format; + } + } + return NULL; +} + +/* The Section a line of the file is written under: the last heading at or + * before it, NULL above the first. */ +static const char *dg_section_at(const CBMExtractCtx *ctx, uint32_t line) { + const char *qn = NULL; + uint32_t best = 0; + const CBMDefArray *defs = &ctx->result->defs; + for (int i = 0; i < defs->count; i++) { + const CBMDefinition *d = &defs->items[i]; + if (d->label && strcmp(d->label, "Section") == 0 && d->start_line <= line && + d->start_line >= best) { + best = d->start_line; + qn = d->qualified_name; + } + } + return qn; +} + +typedef struct { + const char *parent; + int count; +} dg_ordinal_t; + +enum { DG_PARENTS_MAX = 512 }; + +/* The next ordinal of a parent's diagrams. */ +static int dg_next_ordinal(dg_ordinal_t *ords, int *n, const char *parent) { + for (int i = 0; i < *n; i++) { + if (ords[i].parent == parent) { + return ++ords[i].count; + } + } + if (*n < DG_PARENTS_MAX) { + ords[(*n)++] = (dg_ordinal_t){parent, SKIP_ONE}; + } + return SKIP_ONE; +} + +static bool dg_kind(TSNode n, const char *k) { + return !ts_node_is_null(n) && strcmp(ts_node_type(n), k) == 0; +} + +/* The diagram format a Markdown fenced block's info string names, NULL for + * none; *content is the block's text. */ +static const char *dg_fence_format(const char *source, TSNode block, TSNode *content) { + TSNode info = {0}; + *content = (TSNode){0}; + uint32_t nc = ts_node_named_child_count(block); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(block, i); + info = dg_kind(c, "info_string") ? c : info; + *content = dg_kind(c, "code_fence_content") ? c : *content; + } + if (ts_node_is_null(info) || ts_node_is_null(*content)) { + return NULL; + } + TSNode lang = ts_node_named_child(info, 0); + TSNode word = dg_kind(lang, "language") ? lang : info; + uint32_t wb = ts_node_start_byte(word); + uint32_t we = ts_node_end_byte(word); + return dg_format_of(source + wb, we - wb); +} + +/* A fenced block of a Markdown file: a diagram when its info string says so. */ +static void dg_markdown_fence(CBMExtractCtx *ctx, TSNode block, dg_ordinal_t *ords, int *nords) { + TSNode content; + const char *format = dg_fence_format(ctx->source, block, &content); + if (!format) { + return; + } + uint32_t start = ts_node_start_point(block).row + SKIP_ONE; + const char *parent = dg_section_at(ctx, start); + uint32_t cb = ts_node_start_byte(content); + dg_source_t src = { + .format = format, + .parent_qn = parent, + .ordinal = dg_next_ordinal(ords, nords, parent), + .text = ctx->source + cb, + .len = ts_node_end_byte(content) - cb, + .first_line = ts_node_start_point(content).row + SKIP_ONE, + .start_line = start, + .end_line = ts_node_end_point(block).row + SKIP_ONE, + }; + dg_build(ctx, &src); +} + +static void dg_markdown(CBMExtractCtx *ctx) { + dg_ordinal_t *ords = + (dg_ordinal_t *)cbm_arena_alloc(ctx->arena, DG_PARENTS_MAX * sizeof(dg_ordinal_t)); + if (!ords) { + return; + } + int nords = 0; + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (bool more = true; more;) { + TSNode n = ts_tree_cursor_current_node(&cur); + if (dg_kind(n, "fenced_code_block")) { + dg_markdown_fence(ctx, n, ords, &nords); + } 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)) { + more = false; + break; + } + } + } + ts_tree_cursor_delete(&cur); +} + +/* ── RST directives and AsciiDoc blocks ──────────────────────────── */ + +/* The diagram format a directive (RST) or block style (AsciiDoc) names; for + * Sphinx's `digraph` / `graph` also the graph keyword its body goes inside. */ +static const char *dg_block_format(dg_str_t word, const char **wrap) { + static const struct { + const char *word; + const char *format; + const char *wrap; + } names[] = { + {"mermaid", "mermaid", NULL}, {"uml", "plantuml", NULL}, {"plantuml", "plantuml", NULL}, + {"graphviz", "dot", NULL}, {"dot", "dot", NULL}, {"digraph", "dot", "digraph"}, + {"graph", "dot", "graph"}, {"d2", "d2", NULL}, {NULL, NULL, NULL}}; + *wrap = NULL; + for (int k = 0; names[k].word; k++) { + if (dg_eq(word, names[k].word)) { + *wrap = names[k].wrap; + return names[k].format; + } + } + return NULL; +} + +static dg_str_t dg_node_text(const CBMExtractCtx *ctx, TSNode n) { + uint32_t b = ts_node_start_byte(n); + return dg_s(ctx->source + b, ts_node_end_byte(n) - b); +} + +/* The first descendant of kind k, a null node for none. */ +static TSNode dg_find(TSNode n, const char *k) { + uint32_t nc = ts_node_named_child_count(n); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(n, i); + if (dg_kind(c, k)) { + return c; + } + TSNode d = dg_find(c, k); + if (!ts_node_is_null(d)) { + return d; + } + } + return (TSNode){0}; +} + +/* A directive's `:caption:` option, NULL for none. */ +static const char *dg_rst_caption(const CBMExtractCtx *ctx, TSNode options) { + uint32_t nc = ts_node_is_null(options) ? 0 : ts_node_named_child_count(options); + for (uint32_t i = 0; i < nc; i++) { + TSNode f = ts_node_named_child(options, i); + TSNode name = dg_find(f, "field_name"); + TSNode body = dg_find(f, "field_body"); + dg_str_t key = ts_node_is_null(name) ? dg_s("", 0) : dg_trim(dg_node_text(ctx, name)); + while (key.n && key.p[0] == ':') { /* `:caption:` or `caption` */ + key = dg_s(key.p + SKIP_ONE, key.n - SKIP_ONE); + } + while (key.n && key.p[key.n - SKIP_ONE] == ':') { + key.n--; + } + if (!ts_node_is_null(body) && dg_eq(key, "caption")) { + dg_str_t v = dg_trim(dg_node_text(ctx, body)); + return cbm_arena_strndup(ctx->arena, v.p, v.n); + } + } + return NULL; +} + +/* `.. mermaid::`, `.. uml::`, `.. graphviz::`, `.. digraph:: name`: a + * diagram when the directive holds its text (a file argument is the file, + * indexed as itself). */ +static void dg_rst_directive(CBMExtractCtx *ctx, TSNode dir, dg_ordinal_t *ords, int *nords) { + TSNode type = dg_find(dir, "type"); + const char *wrap = NULL; + const char *format = + ts_node_is_null(type) ? NULL : dg_block_format(dg_trim(dg_node_text(ctx, type)), &wrap); + TSNode content = format ? dg_find(dir, "content") : (TSNode){0}; + if (ts_node_is_null(content)) { + return; + } + dg_str_t text = dg_node_text(ctx, content); + uint32_t first = ts_node_start_point(content).row + SKIP_ONE; + if (wrap) { /* Sphinx writes the statements only */ + TSNode args = dg_find(dir, "arguments"); + dg_str_t name = ts_node_is_null(args) ? dg_s("", 0) : dg_trim(dg_node_text(ctx, args)); + char *w = cbm_arena_sprintf(ctx->arena, "%s \"%.*s\" {\n%.*s\n}\n", wrap, (int)name.n, + name.p, (int)text.n, text.p); + if (!w) { + return; + } + text = dg_s(w, strlen(w)); + first--; + } + uint32_t start = ts_node_start_point(dir).row + SKIP_ONE; + const char *parent = dg_section_at(ctx, start); + dg_source_t src = { + .format = format, + .title = dg_rst_caption(ctx, dg_find(dir, "options")), + .parent_qn = parent, + .ordinal = dg_next_ordinal(ords, nords, parent), + .text = text.p, + .len = text.n, + .first_line = first, + .start_line = start, + .end_line = ts_node_end_point(dir).row + SKIP_ONE, + }; + dg_build(ctx, &src); +} + +static void dg_rst(CBMExtractCtx *ctx) { + dg_ordinal_t *ords = + (dg_ordinal_t *)cbm_arena_alloc(ctx->arena, DG_PARENTS_MAX * sizeof(dg_ordinal_t)); + if (!ords) { + return; + } + int nords = 0; + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (bool more = true; more;) { + TSNode n = ts_tree_cursor_current_node(&cur); + if (dg_kind(n, "directive")) { + dg_rst_directive(ctx, n, ords, &nords); + } 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)) { + more = false; + break; + } + } + } + ts_tree_cursor_delete(&cur); +} + +/* An AsciiDoc block attribute line `[plantuml, target=x]` or + * `[source,mermaid]`: the diagram format it names, NULL for none. */ +static const char *dg_adoc_style(dg_str_t ln) { + if (ln.n < CBM_SZ_3 || ln.p[0] != '[' || ln.p[ln.n - SKIP_ONE] != ']' || + ln.p[SKIP_ONE] == '[') { + return NULL; + } + dg_str_t attrs = dg_s(ln.p + SKIP_ONE, ln.n - PAIR_LEN); + const char *comma = memchr(attrs.p, ',', attrs.n); + dg_str_t style = dg_trim(dg_s(attrs.p, comma ? (size_t)(comma - attrs.p) : attrs.n)); + if (dg_eq(style, "source") && comma) { /* a listing in a diagram language */ + dg_str_t rest = dg_s(comma + SKIP_ONE, (size_t)(attrs.p + attrs.n - comma) - SKIP_ONE); + const char *c2 = memchr(rest.p, ',', rest.n); + style = dg_trim(dg_s(rest.p, c2 ? (size_t)(c2 - rest.p) : rest.n)); + } + const char *wrap = NULL; + const char *format = dg_block_format(style, &wrap); + return wrap ? NULL : format; +} + +/* `----` or `....` (four or more): a listing or literal block delimiter. */ +static bool dg_adoc_delimiter(dg_str_t ln) { + if (ln.n < CBM_SZ_4 || (ln.p[0] != '-' && ln.p[0] != '.')) { + return false; + } + for (size_t i = SKIP_ONE; i < ln.n; i++) { + if (ln.p[i] != ln.p[0]) { + return false; + } + } + return true; +} + +/* AsciiDoc diagram blocks (Asciidoctor Diagram): the delimited block after a + * `[mermaid]`, `[plantuml]`, `[graphviz]` or `[d2]` line, titled by the + * `.Title` line before it. */ +static void dg_asciidoc(CBMExtractCtx *ctx) { + dg_ordinal_t *ords = + (dg_ordinal_t *)cbm_arena_alloc(ctx->arena, DG_PARENTS_MAX * sizeof(dg_ordinal_t)); + if (!ords) { + return; + } + int nords = 0; + dg_lines_t it = {ctx->source, ctx->source + ctx->source_len, SKIP_ONE}; + dg_str_t title = dg_s(NULL, 0); + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(&it, &ln, &line)) { + dg_str_t t = dg_trim(ln); + if (t.n > SKIP_ONE && t.p[0] == '.' && t.p[SKIP_ONE] != '.' && + !isspace((unsigned char)t.p[SKIP_ONE])) { + title = dg_s(t.p + SKIP_ONE, t.n - SKIP_ONE); + continue; + } + const char *format = dg_adoc_style(t); + dg_str_t delim; + uint32_t attr_line = line; + if (!format || !dg_next_line(&it, &delim, &line) || !dg_adoc_delimiter(dg_trim(delim))) { + title = t.n && t.p[0] == '[' ? title : dg_s(NULL, 0); /* attribute lines stack */ + continue; + } + delim = dg_trim(delim); + const char *body = it.p; + uint32_t first = it.line; + const char *stop = it.end; + uint32_t last = line; + while (dg_next_line(&it, &ln, &line)) { + dg_str_t c = dg_trim(ln); + if (c.n == delim.n && memcmp(c.p, delim.p, c.n) == 0) { + stop = ln.p; + last = line; + break; + } + } + const char *parent = dg_section_at(ctx, attr_line); + dg_source_t src = { + .format = format, + .title = title.n ? cbm_arena_strndup(ctx->arena, title.p, title.n) : NULL, + .parent_qn = parent, + .ordinal = dg_next_ordinal(ords, &nords, parent), + .text = body, + .len = (size_t)(stop - body), + .first_line = first, + .start_line = attr_line, + .end_line = last, + }; + dg_build(ctx, &src); + title = dg_s(NULL, 0); + } +} + +/* ── Diagram files ───────────────────────────────────────────────── */ + +enum { + DG_INFLATE_MAX = 64 * 1024 * 1024, /* a draw.io page; beyond it the page is skipped */ + DG_STARTUML_LEN = 9, /* @startuml */ + DG_DIAGRAM_CLOSE_LEN = 10, /* */ + DG_CONTENT_ATTR_LEN = 9, /* content=\" */ + DG_PAYLOAD_START_LEN = 22, /* */ + DG_PAYLOAD_VERSION_LEN = 21, /* "); + const char *stop = start ? strstr(start, "") : NULL; + if (!stop) { + return NULL; + } + const char *b = start + DG_PAYLOAD_START_LEN; + size_t bn = (size_t)(stop - b); + unsigned char *raw = (unsigned char *)cbm_arena_alloc(ctx->arena, bn + SKIP_ONE); + size_t rn = raw ? dg_base64(b, bn, raw, bn) : 0; + if (!rn) { + return NULL; + } + raw[rn] = '\0'; + const char *ver = strstr(ctx->source, ""}, {""}}; + for (size_t k = 0; k < sizeof(kinds) / sizeof(kinds[0]); k++) { + size_t ol = strlen(kinds[k].open); + if (d->n - i < ol || memcmp(d->t + i, kinds[k].open, ol) != 0) { + continue; + } + const char *close = strstr(d->t + i + ol, kinds[k].close); + size_t past = close ? (size_t)(close - d->t) + strlen(kinds[k].close) : d->n; + for (size_t p = i; p < past; p++) { + *line += d->t[p] == '\n'; + } + return past; + } + return i; +} + +void cbm_doclink_java_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line) { + /* A field is also emitted as a package-level Variable with the same doc: + * the Field carries the references. A module declaration is no code + * definition (its "class" is named like a package). */ + if (!def->label || (strcmp(def->label, "Variable") == 0 && def->parent_class) || + (strcmp(def->label, "Class") == 0 && strchr(def->name, '.'))) { + return; + } + CBMArena *scratch = ctx->scratch ? ctx->scratch : ctx->arena; + jv_doc_t d = {.ctx = ctx, .def = def}; + d.t = jv_clean_doc(scratch, doc, false, &d.n); + if (!d.t) { + return; + } + const char *t = d.t; + size_t n = d.n; + jv_tag_t tags[JV_TAG_NEST_MAX]; + int sp = 0; /* open inline tags */ + uint32_t line = doc_line; + size_t i = 0; + bool bol = true; /* only blanks since the line began */ + while (i < n) { + char c = t[i]; + if (c == '\n') { + line++; + bol = true; + i++; + continue; + } + if (bol && (c == ' ' || c == '\t')) { + i++; + continue; + } + bool in_text = sp > 0 && tags[sp - SKIP_ONE].syntax == JV_TAG_TEXT; + if (bol && !in_text && c == '@' && i + SKIP_ONE < n && + isalpha((unsigned char)t[i + SKIP_ONE])) { + /* A block tag. javadoc ends an open {@link ..} (or any inline tag + * whose content is markup) at it: that tag never closed and is no + * link. Inside {@code}, {@literal} and {@snippet} an `@` at the + * start of a line is text (annotations in sample code). */ + size_t e = i + SKIP_ONE; + while (e < n && isalpha((unsigned char)t[e])) { + e++; + } + const char *name = t + i + SKIP_ONE; + size_t len = e - i - SKIP_ONE; + bool gap = e >= n || jv_blank(t[e]); + if (gap && jv_word_is(name, len, "see")) { + jv_java_block_tag(&d, e, line, true); + } else if (gap && + (jv_word_is(name, len, "throws") || jv_word_is(name, len, "exception"))) { + jv_java_block_tag(&d, e, line, false); + } + sp = 0; + bol = false; + i = e; + continue; + } + bol = false; + bool text = in_text; + if (c == '<' && sp == 0) { + size_t past = jv_skip_hidden(&d, i, &line); + if (past > i) { + i = past; + continue; + } + } + if (c == '{' && !text && i + SKIP_ONE < n && t[i + SKIP_ONE] == '@' && + sp < JV_TAG_NEST_MAX) { + size_t ne = i + PAIR_LEN; + while (ne < n && isalpha((unsigned char)t[ne])) { + ne++; + } + tags[sp++] = (jv_tag_t){.syntax = jv_java_tag_kind(t + i + PAIR_LEN, ne - i - PAIR_LEN, + ne < n ? t[ne] : '}'), + .body = ne, + .line = line}; + i = ne; + continue; + } + if (c == '{' && sp > 0) { + tags[sp - SKIP_ONE].depth++; + } else if (c == '}' && sp > 0) { + if (tags[sp - SKIP_ONE].depth > 0) { + tags[sp - SKIP_ONE].depth--; + } else { + jv_tag_t done = tags[--sp]; + if (done.syntax > CBM_DOCLINK_NONE) { + jv_java_inline(&d, done.body, i, done.line, done.syntax); + } + } + } + i++; + } +} + +/* ── KDoc ────────────────────────────────────────────────────────── */ + +/* Length of the KDoc link target at s[0, n): a qualified name whose segments + * are identifiers or backticked names. 0 when s is not exactly one. */ +static size_t jv_kdoc_target_len(const char *s, size_t n) { + size_t i = 0; + for (;;) { + if (i < n && s[i] == '`') { + size_t e = i + SKIP_ONE; + while (e < n && s[e] != '`' && s[e] != '\n') { + e++; + } + if (e >= n || s[e] != '`' || e == i + SKIP_ONE) { + return 0; + } + i = e + SKIP_ONE; + } else { + if (i >= n || !jv_ident_start(s[i]) || s[i] == '$') { + return 0; + } + while (i < n && jv_ident_char(s[i]) && s[i] != '$') { + i++; + } + } + if (i < n && s[i] == '.') { + i++; + continue; + } + return i == n ? i : 0; + } +} + +/* A `[...]` group opening at `i` on the line that ends at `eol`: the index of + * its `]`, or eol when it holds a bracket or does not close on the line. */ +static size_t jv_kdoc_bracket_close(const char *t, size_t i, size_t eol) { + for (size_t k = i + SKIP_ONE; k < eol; k++) { + if (t[k] == ']') { + return k; + } + if (t[k] == '[') { + return eol; + } + } + return eol; +} + +/* One line of KDoc text. */ +typedef struct { + size_t first; /* its first byte that is no blank */ + size_t eol; /* its end */ + uint32_t line; /* its source line */ +} jv_kline_t; + +/* `[Name]` / `[text][Name]` at `i`. Returns the index to continue from. */ +static size_t jv_kdoc_link(jv_doc_t *d, size_t i, const jv_kline_t *ln) { + const char *t = d->t; + size_t eol = ln->eol; + size_t close = jv_kdoc_bracket_close(t, i, eol); + if (close >= eol || close == i + SKIP_ONE) { + return i + SKIP_ONE; + } + size_t b = i + SKIP_ONE; + size_t e = close; + size_t next = close + SKIP_ONE; + if (next < eol && t[next] == '(') { + return next; /* [text](url): a Markdown link, not a code reference */ + } + if (next < eol && t[next] == '[') { + size_t close2 = jv_kdoc_bracket_close(t, next, eol); + if (close2 >= eol) { + return next; + } + if (close2 > next + SKIP_ONE) { + b = next + SKIP_ONE; /* [text][Name] */ + e = close2; + } + next = close2 + SKIP_ONE; + } else if (next < eol && t[next] == ':' && i == ln->first) { + return next; /* [label]: url -- a link definition */ + } + while (b < e && (t[b] == ' ' || t[b] == '\t')) { + b++; + } + while (e > b && (t[e - SKIP_ONE] == ' ' || t[e - SKIP_ONE] == '\t')) { + e--; + } + if (jv_kdoc_target_len(t + b, e - b) > 0) { + jv_push(d, t + b, e - b, ln->line, CBM_DOCLINK_KT_KDOC_LINK); + } + return next; +} + +/* The subject of `@see` / `@throws` / `@sample` starting at `i`: one word. + * Returns the index to continue the line's text scan from. */ +static size_t jv_kdoc_subject(jv_doc_t *d, size_t i, const jv_kline_t *ln, int syntax) { + const char *t = d->t; + size_t eol = ln->eol; + while (i < eol && (t[i] == ' ' || t[i] == '\t')) { + i++; + } + size_t e = i; + while (e < eol && t[e] != ' ' && t[e] != '\t') { + e++; + } + if (e == i) { + return i; + } + size_t b = i; + size_t w = e; + if (t[b] == '[') { + size_t close = jv_kdoc_bracket_close(t, b, eol); + if (close >= eol) { + return e; + } + b++; + w = close; + e = close + SKIP_ONE; + } else { + while (w > b && strchr(".,;:", t[w - SKIP_ONE])) { + w--; + } + } + bool url = + t[b] == '<' || (w - b > 4 && memcmp(t + b, "http", 4) == 0 && memchr(t + b, ':', w - b)); + if (w > b && !url) { + jv_push(d, t + b, w - b, ln->line, syntax); + } + return e; +} + +static int jv_kdoc_tag_syntax(const char *name, size_t len) { + if (jv_word_is(name, len, "see")) { + return CBM_DOCLINK_KT_SEE; + } + if (jv_word_is(name, len, "throws") || jv_word_is(name, len, "exception")) { + return CBM_DOCLINK_KT_THROWS; + } + if (jv_word_is(name, len, "sample")) { + return CBM_DOCLINK_KT_SAMPLE; + } + return CBM_DOCLINK_NONE; +} + +/* Just past the code span that the run of `len` backticks ending before `run` + * opens: the next run of exactly that many. 0 when the line has none (the + * backticks are then text). */ +static size_t jv_kdoc_span_end(const char *t, size_t run, size_t len, size_t eol) { + size_t k = run; + while (k < eol) { + if (t[k] != '`') { + k++; + continue; + } + size_t r = k; + while (r < eol && t[r] == '`') { + r++; + } + if (r - k == len) { + return r; + } + k = r; + } + return 0; +} + +/* The text of one KDoc line from `i`: code spans and HTML comments are + * skipped, bracket links pushed. *in_comment: an HTML comment is open (it may + * have begun on an earlier line). */ +static void jv_kdoc_text(jv_doc_t *d, size_t i, const jv_kline_t *ln, bool *in_comment) { + const char *t = d->t; + size_t eol = ln->eol; + /* A run of n backticks was looked for once and the rest of the line has no + * second one: a later run of n is text as well. Longer runs than a fence + * are not looked for (they would be searched once per length). */ + bool no_span[JV_QUOTES + SKIP_ONE] = {false}; + while (i < eol) { + char c = t[i]; + if (*in_comment) { + if (c == '-' && eol - i >= 3 && t[i + 1] == '-' && t[i + 2] == '>') { + *in_comment = false; + i += 3; + } else { + i++; + } + } else if (c == '\\') { + i += PAIR_LEN; + } else if (c == '`') { + size_t run = i; + while (run < eol && t[run] == '`') { + run++; + } + size_t len = run - i; + size_t past = + (len > JV_QUOTES || no_span[len]) ? 0 : jv_kdoc_span_end(t, run, len, eol); + if (past == 0 && len <= JV_QUOTES) { + no_span[len] = true; + } + i = past ? past : run; + } else if (c == '<' && eol - i >= 4 && memcmp(t + i, " */ + RS_HTML_PI = 3, /* until ?> */ + RS_HTML_DECL = 4, /* until > */ + RS_HTML_CDATA = 5, /* until ]]> */ + RS_HTML_BLOCK = 6, /* a block tag: until a blank line */ + RS_HTML_ANY = 7, /* any single tag alone on its line: until a blank line */ +}; + +typedef struct { + const char *label; /* normalized: trimmed, whitespace collapsed, ASCII lower case */ + const char *dest; + uint32_t dest_len; + int order; +} rs_def_t; + +typedef struct { + CBMArena *a; + rs_def_t *v; + int n; + int cap; + bool failed; +} rs_defs_t; + +typedef struct { + bool fence; + char fence_ch; + uint32_t fence_len; + int html; /* RS_HTML_* of the open HTML block */ + bool para; + bool quoted; /* the open paragraph is inside a block quote */ + CBMArena *a; + int *list; /* content column of every open list item */ + int nlist; + int list_cap; + bool failed; /* out of memory: the doc text gets no tokens */ +} rs_md_t; + +/* A cursor in one line: byte position and the column it stands at. */ +typedef struct { + const char *p; + uint32_t n; + uint32_t pos; + int col; +} rs_cur_t; + +static void rs_cur_skip_ws(rs_cur_t *c) { + while (c->pos < c->n && (c->p[c->pos] == ' ' || c->p[c->pos] == '\t')) { + c->col += c->p[c->pos] == '\t' ? RS_MD_TAB - (c->col % RS_MD_TAB) : SKIP_ONE; + c->pos++; + } +} + +static bool rs_cur_eol(const rs_cur_t *c) { + return rs_blank(c->p + c->pos, c->n - c->pos); +} + +/* An opening code fence at the cursor: its character and length. */ +static bool rs_md_fence_open(const rs_cur_t *c, char *ch, uint32_t *len) { + if (c->pos >= c->n || (c->p[c->pos] != '`' && c->p[c->pos] != '~')) { + return false; + } + char f = c->p[c->pos]; + uint32_t k = c->pos; + while (k < c->n && c->p[k] == f) { + k++; + } + if (k - c->pos < RS_MD_FENCE_MIN) { + return false; + } + if (f == '`' && memchr(c->p + k, '`', c->n - k)) { + return false; /* the info string of a backtick fence holds no backtick */ + } + *ch = f; + *len = k - c->pos; + return true; +} + +static bool rs_md_fence_close(const rs_line_t *ln, const rs_md_t *st) { + rs_cur_t c = {ln->p, ln->len, 0, 0}; + rs_cur_skip_ws(&c); + uint32_t k = c.pos; + while (k < c.n && c.p[k] == st->fence_ch) { + k++; + } + return k - c.pos >= st->fence_len && rs_blank(c.p + k, c.n - k); +} + +/* `---`, `***`, `___` (three or more, spaces allowed between). */ +static bool rs_md_thematic(const rs_cur_t *c) { + if (c->pos >= c->n) { + return false; + } + char m = c->p[c->pos]; + if (m != '-' && m != '*' && m != '_') { + return false; + } + int count = 0; + for (uint32_t k = c->pos; k < c->n; k++) { + char ch = c->p[k]; + if (ch == m) { + count++; + } else if (ch != ' ' && ch != '\t' && ch != '\r') { + return false; + } + } + return count >= RS_MD_FENCE_MIN; +} + +/* `# heading`: the cursor moves to the heading's text. */ +static bool rs_md_atx(rs_cur_t *c) { + uint32_t k = c->pos; + while (k < c->n && c->p[k] == '#') { + k++; + } + uint32_t hashes = k - c->pos; + if (hashes == 0 || hashes > RS_MD_ATX_MAX || (k < c->n && c->p[k] != ' ' && c->p[k] != '\t')) { + return false; + } + c->col += (int)hashes; + c->pos = k; + return true; +} + +/* A list marker at the cursor: its width in bytes, 0 when there is none. + * *first is false for an ordered marker other than `1`, which cannot + * interrupt a paragraph. */ +static uint32_t rs_md_list_marker(const rs_cur_t *c, bool *first) { + *first = true; + uint32_t k = c->pos; + if (k >= c->n) { + return 0; + } + char m = c->p[k]; + if (m == '-' || m == '+' || m == '*') { + k++; + } else if (isdigit((unsigned char)m)) { + while (k < c->n && isdigit((unsigned char)c->p[k]) && k - c->pos < RS_MD_ORDERED_DIGITS) { + k++; + } + if (k >= c->n || (c->p[k] != '.' && c->p[k] != ')')) { + return 0; + } + *first = k - c->pos == SKIP_ONE && m == '1'; + k++; + } else { + return 0; + } + if (k < c->n && c->p[k] != ' ' && c->p[k] != '\t' && c->p[k] != '\r') { + return 0; + } + return k - c->pos; +} + +static bool rs_ci_prefix(const char *p, uint32_t n, const char *word) { + size_t wl = strlen(word); + if (n < wl) { + return false; + } + for (size_t i = 0; i < wl; i++) { + if (tolower((unsigned char)p[i]) != word[i]) { + return false; + } + } + return true; +} + +static bool rs_ci_find(const char *p, uint32_t n, const char *word) { + size_t wl = strlen(word); + for (uint32_t i = 0; i + wl <= n; i++) { + if (rs_ci_prefix(p + i, n - i, word)) { + return true; + } + } + return false; +} + +static bool rs_html_block_tag(const char *name, size_t len) { + static const char *const tags[] = { + "address", "article", "aside", "base", "basefont", "blockquote", "body", + "caption", "center", "col", "colgroup", "dd", "details", "dialog", + "dir", "div", "dl", "dt", "fieldset", "figcaption", "figure", + "footer", "form", "frame", "frameset", "h1", "h2", "h3", + "h4", "h5", "h6", "head", "header", "hr", "html", + "iframe", "legend", "li", "link", "main", "menu", "menuitem", + "nav", "noframes", "ol", "optgroup", "option", "p", "param", + "search", "section", "summary", "table", "tbody", "td", "tfoot", + "th", "thead", "title", "tr", "track", "ul", + }; + char low[RS_MD_TAG_NAME_MAX]; + if (len == 0 || len >= sizeof(low)) { + return false; + } + for (size_t i = 0; i < len; i++) { + low[i] = (char)tolower((unsigned char)name[i]); + } + low[len] = '\0'; + for (size_t i = 0; i < sizeof(tags) / sizeof(tags[0]); i++) { + if (strcmp(tags[i], low) == 0) { + return true; + } + } + return false; +} + +/* End of an HTML tag that starts at p[0] == '<' (a quoted attribute value may + * hold '>'): index just past its '>', or 0 when the line has none. */ +static uint32_t rs_html_tag_end(const char *p, uint32_t n) { + char quote = 0; + for (uint32_t i = SKIP_ONE; i < n; i++) { + char c = p[i]; + if (quote) { + quote = c == quote ? 0 : quote; + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '>') { + return i + SKIP_ONE; + } else if (c == '<') { + return 0; + } + } + return 0; +} + +/* The HTML block that starts at the cursor (RS_HTML_*), none inside a + * paragraph for the kind that cannot interrupt one. */ +static int rs_md_html_start(const rs_cur_t *c, bool in_para) { + const char *p = c->p + c->pos; + uint32_t n = c->n - c->pos; + if (n < PAIR_LEN || p[0] != '<') { + return RS_HTML_NONE; + } + static const char *const raw[] = {"' || p[rl] == '\r')) { + return RS_HTML_RAW; + } + } + if (rs_ci_prefix(p, n, ""); + case RS_HTML_PI: + return rs_ci_find(ln->p, ln->len, "?>"); + case RS_HTML_DECL: + return memchr(ln->p, '>', ln->len) != NULL; + case RS_HTML_CDATA: + return rs_ci_find(ln->p, ln->len, "]]>"); + default: + return false; + } +} + +/* ── Link reference definitions ──────────────────────────────────── */ + +/* A link label, normalized into the arena: trimmed, inner whitespace + * collapsed, ASCII letters lower-cased. NULL when it holds no text. */ +static const char *rs_label_norm(CBMArena *a, const char *s, size_t n) { + char *out = (char *)cbm_arena_alloc(a, n + SKIP_ONE); + if (!out) { + return NULL; + } + size_t w = 0; + bool space = false; + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)s[i]; + if (isspace(c)) { + space = w > 0; + continue; + } + if (space) { + out[w++] = ' '; + space = false; + } + out[w++] = (char)tolower(c); + } + out[w] = '\0'; + return w > 0 ? out : NULL; +} + +/* End of the label that starts after the '[' at s[from - 1]: the index of its + * closing ']', or UINT32_MAX when the text is no label (an unescaped bracket + * inside, too long, or unclosed). */ +static uint32_t rs_label_end(const char *s, uint32_t n, uint32_t from) { + for (uint32_t i = from; i < n && i - from <= RS_MD_LABEL_MAX; i++) { + char c = s[i]; + if (c == '\\' && i + SKIP_ONE < n) { + i++; + } else if (c == '[') { + return UINT32_MAX; + } else if (c == ']') { + return i; + } + } + return UINT32_MAX; +} + +/* True when s[0..n) can be a link label: some text, no unescaped bracket, not + * longer than CommonMark allows. */ +static bool rs_label_clean(const char *s, uint32_t n) { + if (n == 0 || n > RS_MD_LABEL_MAX || rs_blank(s, n)) { + return false; + } + for (uint32_t i = 0; i < n; i++) { + if (s[i] == '\\' && i + SKIP_ONE < n) { + i++; + } else if (s[i] == '[' || s[i] == ']') { + return false; + } + } + return true; +} + +/* A link destination at s[*pos]: `<...>` or a run without spaces and with + * balanced parentheses. On success *pos moves past it. */ +static bool rs_link_dest(const char *s, uint32_t n, uint32_t *pos, const char **dest, + uint32_t *dest_len) { + uint32_t i = *pos; + if (i < n && s[i] == '<') { + uint32_t k = i + SKIP_ONE; + while (k < n && s[k] != '>' && s[k] != '<' && s[k] != '\n') { + k += (s[k] == '\\' && k + SKIP_ONE < n) ? PAIR_LEN : SKIP_ONE; + } + if (k >= n || s[k] != '>') { + return false; + } + *dest = s + i + SKIP_ONE; + *dest_len = k - i - SKIP_ONE; + *pos = k + SKIP_ONE; + return true; + } + int depth = 0; + uint32_t k = i; + while (k < n) { + unsigned char c = (unsigned char)s[k]; + if (c == '\\' && k + SKIP_ONE < n) { + k += PAIR_LEN; + continue; + } + if (c <= ' ') { + break; + } + if (c == '(') { + depth++; + } else if (c == ')') { + if (depth == 0) { + break; + } + depth--; + } + k++; + } + if (depth != 0) { + return false; + } + *dest = s + i; + *dest_len = k - i; + *pos = k; + return true; +} + +/* An optional link title at s[*pos] (`"..."`, `'...'`, `(...)`): *pos moves + * past it. false when a title starts and does not end. */ +static bool rs_link_title(const char *s, uint32_t n, uint32_t *pos) { + uint32_t i = *pos; + if (i >= n || (s[i] != '"' && s[i] != '\'' && s[i] != '(')) { + return true; + } + char close = s[i] == '(' ? ')' : s[i]; + for (uint32_t k = i + SKIP_ONE; k < n; k++) { + if (s[k] == '\\' && k + SKIP_ONE < n) { + k++; + } else if (s[k] == close) { + *pos = k + SKIP_ONE; + return true; + } + } + return false; +} + +static void rs_defs_push(rs_defs_t *d, rs_def_t def) { + if (d->n >= d->cap) { + int ncap = d->cap ? d->cap * PAIR_LEN : CBM_SZ_8; + rs_def_t *grown = (rs_def_t *)cbm_arena_alloc(d->a, (size_t)ncap * sizeof(rs_def_t)); + if (!grown) { + d->failed = true; + return; + } + if (d->n > 0) { + memcpy(grown, d->v, (size_t)d->n * sizeof(rs_def_t)); + } + d->v = grown; + d->cap = ncap; + } + def.order = d->n; + d->v[d->n++] = def; +} + +static uint32_t rs_skip_spaces(const char *s, uint32_t n, uint32_t i) { + while (i < n && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r')) { + i++; + } + return i; +} + +/* A link reference definition `[label]: dest "title"` that starts at the + * cursor of lines[i]; the destination may stand on the next line. Returns the + * number of lines it takes (0: this is no definition). */ +static int rs_md_definition(rs_line_t *lines, int n, int i, const rs_cur_t *c, rs_defs_t *defs) { + const char *s = c->p; + uint32_t len = c->n; + uint32_t pos = c->pos; + if (pos >= len || s[pos] != '[' || (pos + SKIP_ONE < len && s[pos + SKIP_ONE] == '^')) { + return 0; + } + uint32_t close = rs_label_end(s, len, pos + SKIP_ONE); + if (close == UINT32_MAX || close + SKIP_ONE >= len || s[close + SKIP_ONE] != ':') { + return 0; + } + const char *label = rs_label_norm(defs->a, s + pos + SKIP_ONE, close - pos - SKIP_ONE); + if (!label) { + return 0; + } + int taken = SKIP_ONE; + uint32_t k = rs_skip_spaces(s, len, close + PAIR_LEN); + if (k >= len) { + /* the destination is on the next line */ + if (i + SKIP_ONE >= n || rs_blank(lines[i + SKIP_ONE].p, lines[i + SKIP_ONE].len)) { + return 0; + } + s = lines[i + SKIP_ONE].p; + len = lines[i + SKIP_ONE].len; + k = rs_skip_spaces(s, len, 0); + taken = PAIR_LEN; + } + const char *dest = NULL; + uint32_t dest_len = 0; + if (!rs_link_dest(s, len, &k, &dest, &dest_len) || dest_len == 0) { + return 0; + } + uint32_t after = rs_skip_spaces(s, len, k); + if (after < len && + (after == k || !rs_link_title(s, len, &after) || rs_skip_spaces(s, len, after) < len)) { + return 0; /* something that is no title follows the destination */ + } + rs_defs_push(defs, (rs_def_t){.label = label, .dest = dest, .dest_len = dest_len}); + return taken; +} + +static int rs_def_cmp(const void *a, const void *b) { + const rs_def_t *x = (const rs_def_t *)a; + const rs_def_t *y = (const rs_def_t *)b; + int c = strcmp(x->label, y->label); + return c ? c : (x->order > y->order) - (x->order < y->order); +} + +/* The first definition of `label` (normalized), or NULL. */ +static const rs_def_t *rs_def_find(const rs_defs_t *d, const char *label) { + int lo = 0; + int hi = d->n; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (strcmp(d->v[mid].label, label) < 0) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return (lo < d->n && strcmp(d->v[lo].label, label) == 0) ? &d->v[lo] : NULL; +} + +/* ── Markdown blocks: one line ───────────────────────────────────── */ + +static void rs_md_pop_lists(rs_md_t *st, int col) { + while (st->nlist > 0 && st->list[st->nlist - SKIP_ONE] > col) { + st->nlist--; + } +} + +/* Column the text of a line is measured from: the content column of the + * innermost open list item it reaches. The stack is strictly increasing (an + * item opens to the right of everything still open), so this is a binary + * search. */ +static int rs_md_base(const rs_md_t *st, int col) { + int lo = 0; + int hi = st->nlist; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (st->list[mid] <= col) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return lo > 0 ? st->list[lo - SKIP_ONE] : 0; +} + +static void rs_md_push_list(rs_md_t *st, int content) { + if (st->nlist >= st->list_cap) { + int ncap = st->list_cap ? st->list_cap * PAIR_LEN : RS_MD_LIST_INIT; + int *grown = (int *)cbm_arena_alloc(st->a, (size_t)ncap * sizeof(int)); + if (!grown) { + st->failed = true; + return; + } + if (st->nlist > 0) { + memcpy(grown, st->list, (size_t)st->nlist * sizeof(int)); + } + st->list = grown; + st->list_cap = ncap; + } + st->list[st->nlist++] = content; +} + +/* Block-quote markers at the cursor; true when there was one. */ +static bool rs_md_quote(rs_cur_t *c) { + bool quoted = false; + while (c->pos < c->n && c->p[c->pos] == '>') { + quoted = true; + c->pos++; + c->col++; + if (c->pos < c->n && c->p[c->pos] == ' ') { + c->pos++; + c->col++; + } + rs_cur_skip_ws(c); + } + return quoted; +} + +/* Open a list item whose marker is `width` bytes at the cursor; the cursor + * moves to the item's content (past a task marker). */ +static void rs_md_open_item(rs_md_t *st, rs_cur_t *c, uint32_t width) { + c->pos += width; + c->col += (int)width; + int marker_end = c->col; + rs_cur_skip_ws(c); + int content = c->col; + if (rs_cur_eol(c) || content - marker_end > RS_MD_CODE_INDENT) { + content = marker_end + SKIP_ONE; + } + rs_md_push_list(st, content); + /* a task marker `[ ]` / `[x]` is no link */ + const char *p = c->p + c->pos; + uint32_t left = c->n - c->pos; + if (left > RS_MARK_LEN && p[0] == '[' && (p[1] == ' ' || p[1] == 'x' || p[1] == 'X') && + p[PAIR_LEN] == ']' && (p[RS_MARK_LEN] == ' ' || p[RS_MARK_LEN] == '\t')) { + c->pos += RS_MARK_LEN; + c->col += RS_MARK_LEN; + } +} + +/* What starts at the cursor. */ +enum { RS_BLK_TEXT = 0, RS_BLK_FENCE, RS_BLK_HTML, RS_BLK_RULE, RS_BLK_HEADING, RS_BLK_ITEM }; + +typedef struct { + int kind; + char fence_ch; + uint32_t fence_len; + int html; + uint32_t marker; /* width of a list marker */ + bool interrupts; /* may end an open paragraph */ +} rs_blk_t; + +static rs_blk_t rs_md_block_at(const rs_cur_t *c, bool in_para) { + rs_blk_t b = {.kind = RS_BLK_TEXT, .interrupts = true}; + if (rs_md_fence_open(c, &b.fence_ch, &b.fence_len)) { + b.kind = RS_BLK_FENCE; + return b; + } + b.html = rs_md_html_start(c, in_para); + if (b.html != RS_HTML_NONE) { + b.kind = RS_BLK_HTML; + return b; + } + if (rs_md_thematic(c)) { + b.kind = RS_BLK_RULE; + return b; + } + rs_cur_t probe = *c; + if (rs_md_atx(&probe)) { + b.kind = RS_BLK_HEADING; + return b; + } + bool first = true; + b.marker = rs_md_list_marker(c, &first); + if (b.marker > 0) { + b.kind = RS_BLK_ITEM; + /* an empty item, or an ordered one not numbered 1, continues a paragraph */ + rs_cur_t rest = *c; + rest.pos += b.marker; + b.interrupts = first && !rs_cur_eol(&rest); + } + return b; +} + +/* A line of an open fenced block or HTML block, or a blank line: classified + * here. false for any other line. */ +static bool rs_md_line_in_block(rs_md_t *st, rs_line_t *ln) { + if (st->fence) { + st->fence = !rs_md_fence_close(ln, st); + return true; + } + bool blank = rs_blank(ln->p, ln->len); + if (st->html != RS_HTML_NONE) { + if (st->html >= RS_HTML_BLOCK ? blank : rs_md_html_end(ln, st->html)) { + st->html = RS_HTML_NONE; + } + ln->kind = blank ? RS_L_BLANK : RS_L_CODE; + return true; + } + if (blank) { + ln->kind = RS_L_BLANK; + st->para = false; + } + return blank; +} + +/* The line is inline text from the cursor on. */ +static void rs_md_text(rs_line_t *ln, const rs_cur_t *c, bool para_start) { + ln->kind = RS_L_TEXT; + ln->para_start = para_start; + ln->len -= c->pos; + ln->p += c->pos; +} + +/* What is no list item starts at the cursor of lines[i]. Returns the lines it + * takes, or 0 when the rest of the line is inline text (a heading's, or a + * paragraph's first line). */ +static int rs_md_start_block(rs_md_t *st, rs_line_t *lines, int n, int i, rs_cur_t *c, + const rs_blk_t *b, bool quoted, rs_defs_t *defs) { + rs_line_t *ln = &lines[i]; + if (b->kind == RS_BLK_FENCE) { + st->fence = true; + st->fence_ch = b->fence_ch; + st->fence_len = b->fence_len; + return SKIP_ONE; + } + if (b->kind == RS_BLK_HTML) { + bool closed = b->html < RS_HTML_BLOCK && rs_md_html_end(ln, b->html); + st->html = closed ? RS_HTML_NONE : b->html; + return SKIP_ONE; + } + if (b->kind == RS_BLK_RULE) { + ln->kind = RS_L_BLANK; + return SKIP_ONE; + } + if (b->kind == RS_BLK_HEADING) { + (void)rs_md_atx(c); + return 0; + } + int taken = rs_md_definition(lines, n, i, c, defs); + if (taken > 0) { + ln->kind = RS_L_DEF; + if (taken > SKIP_ONE) { + lines[i + SKIP_ONE].kind = RS_L_DEF; + } + return taken; + } + st->para = true; + st->quoted = quoted; + return 0; +} + +/* Classify lines[i] and strip its block markers. Returns how many lines were + * consumed (2 when a link definition's destination is on the next line). */ +static int rs_md_line(rs_md_t *st, rs_line_t *lines, int n, int i, rs_defs_t *defs) { + rs_line_t *ln = &lines[i]; + ln->kind = RS_L_CODE; + if (rs_md_line_in_block(st, ln)) { + return SKIP_ONE; + } + rs_cur_t c = {ln->p, ln->len, 0, 0}; + rs_cur_skip_ws(&c); + bool quoted = rs_md_quote(&c); + if (quoted && !st->quoted) { + st->para = false; /* a block quote interrupts a paragraph */ + } + /* every round opens one list item, which moves the cursor on */ + for (;;) { + rs_blk_t b = rs_md_block_at(&c, st->para); + if (st->para) { + bool breaks = b.kind != RS_BLK_TEXT && b.interrupts && + c.col - rs_md_base(st, c.col) < RS_MD_CODE_INDENT; + if (!breaks) { + rs_md_text(ln, &c, false); /* the paragraph goes on, however indented */ + return SKIP_ONE; + } + st->para = false; + } + rs_md_pop_lists(st, c.col); + if (c.col - rs_md_base(st, c.col) >= RS_MD_CODE_INDENT) { + return SKIP_ONE; /* an indented code block */ + } + if (b.kind != RS_BLK_ITEM) { + int taken = rs_md_start_block(st, lines, n, i, &c, &b, quoted, defs); + if (taken > 0) { + return taken; + } + break; + } + rs_md_open_item(st, &c, b.marker); + if (rs_cur_eol(&c)) { + ln->kind = RS_L_BLANK; + return SKIP_ONE; + } + } + rs_md_text(ln, &c, true); + return SKIP_ONE; +} + +/* ── Inline links ────────────────────────────────────────────────── */ + +enum { RS_LINK_INLINE = 0, RS_LINK_REFERENCE, RS_LINK_COLLAPSED, RS_LINK_SHORTCUT }; + +typedef struct { + uint32_t pos; + bool image; +} rs_opener_t; + +/* A maximal run of backticks. */ +typedef struct { + uint32_t pos; + uint32_t len; +} rs_run_t; + +/* What a found link is handed to. */ +typedef struct { + CBMExtractCtx *ctx; + const char *source_qn; + uint32_t def_line; + const rs_defs_t *defs; + const rs_line_t *lines; /* the paragraph's lines */ + int nlines; + int line_cursor; +} rs_sink_t; + +/* Inline state of one paragraph. The arrays are allocated once per doc text, + * large enough for any paragraph of it (one slot per '[' and per backtick), so + * nothing here has a limit a long doc could hit. */ +typedef struct { + const char *s; /* the paragraph */ + uint32_t n; + rs_opener_t *open; /* the '[' not yet closed */ + int nopen; + int dead; /* openers below this index are inside a link: they open none */ + rs_run_t *runs; /* backtick runs in text order */ + rs_run_t *by_len; /* the same, sorted by (length, position) */ + int nruns; + int run_cursor; /* first run that ends after the scan position */ + bool no_comment_end; /* no later `-->` / `]]>` / `?>`: searched once */ + bool no_cdata_end; + bool no_pi_end; + rs_sink_t *sink; +} rs_inline_t; + +/* Source line of paragraph offset `off`. Links are reported in the order of + * their opening brackets, so the cursor rarely moves back. */ +static uint32_t rs_sink_line(rs_sink_t *k, uint32_t off) { + if (k->lines[k->line_cursor].boff > off) { + k->line_cursor = 0; + } + while (k->line_cursor + SKIP_ONE < k->nlines && + k->lines[k->line_cursor + SKIP_ONE].boff <= off) { + k->line_cursor++; + } + return k->lines[k->line_cursor].line; +} + +static bool rs_is_url(const char *s, size_t n) { + static const char mailto[] = "mailto:"; + static const char sep[] = "://"; + if (rs_ci_prefix(s, (uint32_t)n, mailto)) { + return true; + } + size_t i = 0; + if (n == 0 || !isalpha((unsigned char)s[0])) { + return false; + } + while (i < n && (isalnum((unsigned char)s[i]) || s[i] == '+' || s[i] == '.' || s[i] == '-')) { + i++; + } + return i + sizeof(sep) - SKIP_ONE <= n && memcmp(s + i, sep, sizeof(sep) - SKIP_ONE) == 0; +} + +static void rs_push_token(rs_sink_t *k, const char *raw, size_t n, int syntax, uint32_t line) { + static const char cut[] = "..."; + char buf[CBM_DOCLINK_RS_RAW_CAP]; + if (n >= sizeof(buf) - sizeof(cut)) { + /* oversized: a prefix that can never parse as a path */ + n = sizeof(buf) - sizeof(cut) - SKIP_ONE; + memcpy(buf, raw, n); + memcpy(buf + n, cut, sizeof(cut)); + n += sizeof(cut) - SKIP_ONE; + } else { + memcpy(buf, raw, n); + buf[n] = '\0'; + } + for (size_t i = 0; i < n; i++) { + if (buf[i] == '\n' || buf[i] == '\t' || buf[i] == '\r' || buf[i] == '\0') { + buf[i] = ' '; + } + } + const char *copy = cbm_arena_strndup(k->ctx->arena, buf, n); + if (!copy) { + return; + } + CBMDocLink link = { + .source_qn = k->source_qn, + .raw = copy, + .line = line, + .def_line = k->def_line, + .syntax = (uint16_t)syntax, + }; + cbm_doclinks_push(&k->ctx->result->doc_links, k->ctx->arena, link); +} + +/* A found link: decide whether rustdoc treats its destination as an intra-doc + * link, and push the token. `defined`: the destination was written (inline) + * or comes from a definition, so it may be an ordinary URL. */ +static void rs_emit_link(rs_sink_t *k, int kind, const char *dest, uint32_t dest_len, bool from_def, + uint32_t off) { + while (dest_len > 0 && isspace((unsigned char)dest[0])) { + dest++; + dest_len--; + } + while (dest_len > 0 && isspace((unsigned char)dest[dest_len - SKIP_ONE])) { + dest_len--; + } + if (dest_len == 0) { + return; + } + uint32_t line = rs_sink_line(k, off); + bool written = kind == RS_LINK_INLINE || from_def; /* rustdoc's can_be_url */ + int family = from_def ? CBM_DOCLINK_RS_LINK_DEF : CBM_DOCLINK_RS_INTRA_DOC; + if (written && memchr(dest, '/', dest_len)) { + if (rs_is_url(dest, dest_len)) { + rs_push_token(k, dest, dest_len, CBM_DOCLINK_HREF, line); + } + return; /* a relative file is not harvested */ + } + /* backticks go, the fragment is cut */ + char clean[CBM_DOCLINK_RS_RAW_CAP]; + size_t w = 0; + int hashes = 0; + bool backtick = false; + for (uint32_t i = 0; i < dest_len; i++) { + if (dest[i] == '`') { + backtick = true; + continue; + } + hashes += dest[i] == '#'; + if (w + SKIP_ONE < sizeof(clean)) { + clean[w++] = dest[i]; + } + } + clean[w] = '\0'; + if (w + SKIP_ONE >= sizeof(clean)) { + rs_push_token(k, clean, w, family, line); /* oversized */ + return; + } + /* rustdoc looks at the text before the first '#' first: empty means an + * anchor of this page, whatever follows. A second '#' is then an error. */ + size_t path_len = hashes > 0 ? (size_t)((const char *)memchr(clean, '#', w) - clean) : w; + while (path_len > 0 && isspace((unsigned char)clean[path_len - SKIP_ONE])) { + path_len--; + } + if (path_len == 0) { + return; /* an anchor of this page */ + } + if (hashes > SKIP_ONE) { + path_len = w; /* the row shows the whole destination */ + } + bool lenient = written || (kind == RS_LINK_SHORTCUT && !backtick); + CBMDocLinkRsPath path; + CBMDocLinkRsPathStatus st = hashes > SKIP_ONE + ? CBM_DOCLINK_RS_PATH_MALFORMED + : cbm_doclink_rs_parse_path(clean, path_len, lenient, &path); + if (st == CBM_DOCLINK_RS_PATH_NOT_LINK) { + if (written && rs_is_url(dest, dest_len)) { + rs_push_token(k, dest, dest_len, CBM_DOCLINK_HREF, line); + } + return; + } + rs_push_token(k, clean, path_len, family, line); +} + +/* A reference-style link whose label is s[0..n): its destination is the + * definition's, or (rustdoc's broken-link callback) the label itself. */ +static void rs_emit_reference(rs_sink_t *k, int kind, const char *label, uint32_t n, uint32_t off) { + const char *norm = rs_label_norm(rs_scratch(k->ctx), label, n); + if (!norm || norm[0] == '^') { + return; /* empty, or a footnote */ + } + const rs_def_t *def = rs_def_find(k->defs, norm); + if (def) { + rs_emit_link(k, kind, def->dest, def->dest_len, true, off); + } else { + rs_emit_link(k, kind, label, n, false, off); + } +} + +static int rs_run_len_cmp(const void *a, const void *b) { + const rs_run_t *x = (const rs_run_t *)a; + const rs_run_t *y = (const rs_run_t *)b; + if (x->len != y->len) { + return x->len < y->len ? -1 : 1; + } + return (x->pos > y->pos) - (x->pos < y->pos); +} + +/* Record the paragraph's backtick runs, in text order and by length: a code + * span closes at the first later run of its opener's length, which is then a + * binary search instead of a scan of the rest of the paragraph. */ +static void rs_inline_runs(rs_inline_t *in) { + in->nruns = 0; + in->run_cursor = 0; + for (uint32_t i = 0; i < in->n;) { + if (in->s[i] != '`') { + i++; + continue; + } + uint32_t e = i; + while (e < in->n && in->s[e] == '`') { + e++; + } + in->runs[in->nruns++] = (rs_run_t){.pos = i, .len = e - i}; + i = e; + } + if (in->nruns > 0) { + memcpy(in->by_len, in->runs, (size_t)in->nruns * sizeof(rs_run_t)); + qsort(in->by_len, (size_t)in->nruns, sizeof(rs_run_t), rs_run_len_cmp); + } +} + +/* Past the code span that opens at the backtick s[i], or past its run alone + * when no later run has the opener's length. The opener is what is left of + * its run from s[i] on (a backslash before the run takes its first backtick). */ +static uint32_t rs_inline_backticks(rs_inline_t *in, uint32_t i) { + while (in->run_cursor < in->nruns && + in->runs[in->run_cursor].pos + in->runs[in->run_cursor].len <= i) { + in->run_cursor++; + } + if (in->run_cursor >= in->nruns) { + return i + SKIP_ONE; + } + uint32_t run_end = in->runs[in->run_cursor].pos + in->runs[in->run_cursor].len; + rs_run_t want = {.pos = run_end, .len = run_end - i}; + int lo = 0; + int hi = in->nruns; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (rs_run_len_cmp(&in->by_len[mid], &want) < 0) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + if (lo < in->nruns && in->by_len[lo].len == want.len) { + return in->by_len[lo].pos + want.len; + } + return run_end; +} + +/* Index just past the first `mark` at or after s[from], or 0. */ +static uint32_t rs_find_after(const rs_inline_t *in, uint32_t from, const char *mark) { + size_t ml = strlen(mark); + for (uint32_t k = from; k + ml <= in->n; k++) { + if (memcmp(in->s + k, mark, ml) == 0) { + return k + (uint32_t)ml; + } + } + return 0; +} + +/* Past the HTML comment, CDATA section or processing instruction that starts + * at s[i]; 0 when s[i] starts none of them. One whose end marker is missing is + * plain text, and so is every later one of its kind: the search for the marker + * runs once per paragraph. */ +static uint32_t rs_inline_raw_html(rs_inline_t *in, uint32_t i, bool *is_raw) { + static const char comment[] = ""; + none = &in->no_comment_end; + skip = sizeof(comment) - SKIP_ONE; + } else if (n >= sizeof(cdata) - SKIP_ONE && memcmp(p, cdata, sizeof(cdata) - SKIP_ONE) == 0) { + end_mark = "]]>"; + none = &in->no_cdata_end; + skip = sizeof(cdata) - SKIP_ONE; + } else if (n >= sizeof(pi) - SKIP_ONE && memcmp(p, pi, sizeof(pi) - SKIP_ONE) == 0) { + end_mark = "?>"; + none = &in->no_pi_end; + skip = sizeof(pi) - SKIP_ONE; + } + *is_raw = end_mark != NULL; + if (!end_mark || *none) { + return 0; + } + uint32_t end = rs_find_after(in, i + skip, end_mark); + *none = end == 0; + return end; +} + +/* Past the autolink or HTML construct at s[i] == '<', or i + 1 when it is + * neither. */ +static uint32_t rs_inline_angle(rs_inline_t *in, uint32_t i) { + const char *p = in->s + i; + uint32_t n = in->n - i; + if (n < PAIR_LEN) { + return i + SKIP_ONE; + } + bool is_raw = false; + uint32_t raw_end = rs_inline_raw_html(in, i, &is_raw); + if (is_raw) { + return raw_end ? raw_end : i + SKIP_ONE; + } + char c = p[1]; + if (!(isalpha((unsigned char)c) || c == '/' || c == '!')) { + return i + SKIP_ONE; + } + uint32_t end = rs_html_tag_end(p, n); + if (end == 0) { + return i + SKIP_ONE; + } + if (isalpha((unsigned char)c)) { + /* `` is a tag; `` and `` are autolinks; text + * with a space before any of that is neither */ + uint32_t k = SKIP_ONE; + while (k < end && + (isalnum((unsigned char)p[k]) || p[k] == '-' || p[k] == '+' || p[k] == '.')) { + k++; + } + bool tag = p[k] == '>' || p[k] == ' ' || p[k] == '\t' || p[k] == '\n' || p[k] == '/'; + bool autolink = (p[k] == ':' || p[k] == '@') && !memchr(p, ' ', end); + if (!tag && !autolink) { + return i + SKIP_ONE; + } + } + return i + end; +} + +/* The inline link `(dest "title")` at s[i] == '('. On success *end is past the + * closing parenthesis. */ +static bool rs_inline_dest(const rs_inline_t *in, uint32_t i, const char **dest, uint32_t *dest_len, + uint32_t *end) { + const char *s = in->s; + uint32_t n = in->n; + uint32_t k = i + SKIP_ONE; + while (k < n && isspace((unsigned char)s[k])) { + k++; + } + if (!rs_link_dest(s, n, &k, dest, dest_len)) { + return false; + } + uint32_t after = k; + while (after < n && isspace((unsigned char)s[after])) { + after++; + } + if (after > k && !rs_link_title(s, n, &after)) { + return false; + } + while (after < n && isspace((unsigned char)s[after])) { + after++; + } + if (after >= n || s[after] != ')') { + return false; + } + *end = after + SKIP_ONE; + return true; +} + +/* The reference-style link the ']' at s[i] ends, when the text after it or + * the bracket text itself is a label: full `[text][label]`, collapsed + * `[label][]`, shortcut `[label]`. *made is set when it is one; returns where + * scanning continues. */ +static uint32_t rs_inline_reference(rs_inline_t *in, const rs_opener_t *op, uint32_t i, + bool *made) { + const char *s = in->s; + const char *text = s + op->pos + SKIP_ONE; + uint32_t text_len = i - op->pos - SKIP_ONE; + uint32_t after = i + SKIP_ONE; + bool text_is_label = rs_label_clean(text, text_len); + if (after < in->n && s[after] == '[') { + uint32_t close = rs_label_end(s, in->n, after + SKIP_ONE); + if (close != UINT32_MAX && close > after + SKIP_ONE) { + *made = true; + if (!op->image) { + rs_emit_reference(in->sink, RS_LINK_REFERENCE, s + after + SKIP_ONE, + close - after - SKIP_ONE, op->pos); + } + return close + SKIP_ONE; + } + if (close == after + SKIP_ONE && text_is_label) { + *made = true; + if (!op->image) { + rs_emit_reference(in->sink, RS_LINK_COLLAPSED, text, text_len, op->pos); + } + return close + SKIP_ONE; + } + } + if (text_is_label) { + *made = true; + if (!op->image) { + rs_emit_reference(in->sink, RS_LINK_SHORTCUT, text, text_len, op->pos); + } + } + return i + SKIP_ONE; +} + +/* The ']' at s[i]: make a link of the bracket pair it closes, when it is one. + * Returns where scanning continues. */ +static uint32_t rs_inline_close(rs_inline_t *in, uint32_t i) { + if (in->nopen == 0) { + return i + SKIP_ONE; + } + int idx = --in->nopen; + rs_opener_t op = in->open[idx]; + bool dead = idx < in->dead && !op.image; /* inside a link: links do not nest */ + if (in->dead > in->nopen) { + in->dead = in->nopen; + } + if (dead) { + return i + SKIP_ONE; + } + uint32_t after = i + SKIP_ONE; + const char *dest = NULL; + uint32_t dest_len = 0; + uint32_t end = 0; + bool made = false; + if (after < in->n && in->s[after] == '(' && rs_inline_dest(in, after, &dest, &dest_len, &end)) { + made = true; + if (!op.image) { + rs_emit_link(in->sink, RS_LINK_INLINE, dest, dest_len, false, op.pos); + } + } else { + end = rs_inline_reference(in, &op, i, &made); + } + if (made && !op.image) { + in->dead = in->nopen; /* every '[' still open encloses this link */ + } + return end; +} + +/* Find the links of one paragraph. */ +static void rs_inline_scan(rs_inline_t *in) { + uint32_t i = 0; + while (i < in->n) { + char c = in->s[i]; + if (c == '\\' && i + SKIP_ONE < in->n && ispunct((unsigned char)in->s[i + SKIP_ONE])) { + i += PAIR_LEN; + } else if (c == '`') { + i = rs_inline_backticks(in, i); + } else if (c == '<') { + i = rs_inline_angle(in, i); + } else if (c == '[') { + bool image = i > 0 && in->s[i - SKIP_ONE] == '!' && + !(i > SKIP_ONE && in->s[i - PAIR_LEN] == '\\'); + in->open[in->nopen++] = (rs_opener_t){.pos = i, .image = image}; + i++; + } else if (c == ']') { + i = rs_inline_close(in, i); + } else { + i++; + } + } +} + +/* ── Tokens of one doc text ──────────────────────────────────────── */ + +/* Scan the paragraph lines[first..last] (RS_L_TEXT lines): their contents are + * joined by newlines in `buf`. */ +static void rs_scan_paragraph(rs_inline_t *in, rs_sink_t *sink, rs_line_t *lines, int first, + int last, char *buf) { + uint32_t w = 0; + for (int i = first; i <= last; i++) { + lines[i].boff = w; + memcpy(buf + w, lines[i].p, lines[i].len); + w += lines[i].len; + buf[w++] = '\n'; + } + in->s = buf; + in->n = w; + in->nopen = 0; + in->dead = 0; + in->no_comment_end = false; + in->no_cdata_end = false; + in->no_pi_end = false; + in->sink = sink; + sink->lines = lines + first; + sink->nlines = last - first + SKIP_ONE; + sink->line_cursor = 0; + rs_inline_runs(in); + rs_inline_scan(in); +} + +/* The inline state for a doc text whose inline lines are lines[0..n): arrays + * with one slot per '[' and per backtick of the whole text, so no paragraph can + * outgrow them. NULL when there is no inline text or memory ran out; *buf_out + * is the paragraph buffer. */ +static rs_inline_t *rs_inline_new(CBMArena *tmp, const rs_line_t *lines, int n, char **buf_out) { + size_t text_bytes = 0; + size_t brackets = 0; + size_t backticks = 0; + for (int i = 0; i < n; i++) { + if (lines[i].kind != RS_L_TEXT) { + continue; + } + text_bytes += (size_t)lines[i].len + SKIP_ONE; + for (uint32_t k = 0; k < lines[i].len; k++) { + brackets += lines[i].p[k] == '['; + backticks += lines[i].p[k] == '`'; + } + } + if (text_bytes == 0 || brackets == 0) { + return NULL; /* no bracket, no link */ + } + rs_inline_t *in = (rs_inline_t *)cbm_arena_alloc(tmp, sizeof(*in)); + char *buf = (char *)cbm_arena_alloc(tmp, text_bytes + SKIP_ONE); + if (!in || !buf) { + return NULL; + } + memset(in, 0, sizeof(*in)); + in->open = (rs_opener_t *)cbm_arena_alloc(tmp, brackets * sizeof(rs_opener_t)); + in->runs = (rs_run_t *)cbm_arena_alloc(tmp, (backticks + SKIP_ONE) * sizeof(rs_run_t)); + in->by_len = (rs_run_t *)cbm_arena_alloc(tmp, (backticks + SKIP_ONE) * sizeof(rs_run_t)); + if (!in->open || !in->runs || !in->by_len) { + return NULL; + } + *buf_out = buf; + return in; +} + +/* The references of one doc text, pushed as tokens of the node `source_qn`. + * `file_doc`: it is the file's own doc, whose lines need not be contiguous. */ +static void rs_parse_text(CBMExtractCtx *ctx, const char *source_qn, uint32_t def_line, + const char *doc, uint32_t doc_line, bool file_doc) { + if (!doc || !source_qn || !strchr(doc, '[')) { + return; /* no bracket, no link */ + } + CBMArena *tmp = rs_scratch(ctx); + int n = 0; + rs_line_t *lines = rs_doc_lines(tmp, doc, doc_line, &n); + if (!lines || n == 0) { + return; + } + if (file_doc) { + rs_lines_resync(ctx, lines, n, doc, doc_line); + } + rs_defs_t defs = {.a = tmp}; + rs_md_t st; + memset(&st, 0, sizeof(st)); + st.a = tmp; + for (int i = 0; i < n;) { + i += rs_md_line(&st, lines, n, i, &defs); + } + char *buf = NULL; + rs_inline_t *in = (defs.failed || st.failed) ? NULL : rs_inline_new(tmp, lines, n, &buf); + if (!in) { + return; + } + if (defs.n > SKIP_ONE) { + qsort(defs.v, (size_t)defs.n, sizeof(rs_def_t), rs_def_cmp); + } + rs_sink_t sink = {.ctx = ctx, .source_qn = source_qn, .def_line = def_line, .defs = &defs}; + int first = CBM_NOT_FOUND; + for (int i = 0; i <= n; i++) { + bool text = i < n && lines[i].kind == RS_L_TEXT; + if (first >= 0 && (!text || lines[i].para_start)) { + rs_scan_paragraph(in, &sink, lines, first, i - SKIP_ONE, buf); + first = CBM_NOT_FOUND; + } + if (text && first < 0) { + first = i; + } + } +} + +void cbm_doclink_rs_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line) { + if (!ctx || !ctx->result || !def) { + return; + } + /* the driver's stand-in definition for the file's own doc is labelled File */ + bool file_doc = def->label && strcmp(def->label, "File") == 0; + rs_parse_text(ctx, def->qualified_name, def->start_line, doc, doc_line, file_doc); +} + +/* ── Scope scan: state ───────────────────────────────────────────── */ + +enum { + RS_SCAN_NAME_CAP = 128, /* bytes of one identifier, NUL included */ + RS_SCAN_PATH_CAP = 512, /* bytes of one written path, NUL included */ + RS_SCAN_ATTR_CAP = 1024, /* bytes of an attribute that are read */ + RS_SCAN_DERIVES_CAP = 256, /* bytes of the derive names of one item, NUL included */ + RS_SCAN_INIT = 16, +}; + +/* A block whose items are still to be read: the file, an inline module's + * body, an extern block's body. Blocks are queued, never recursed into. */ +typedef struct { + TSNode node; + int mod; /* module the items belong to */ +} rs_block_t; + +/* A definition of the file, keyed for the items the scan meets. */ +typedef struct { + uint32_t line; + const char *name; + int def; +} rs_defkey_t; + +/* What the attributes above an item say about it. */ +typedef struct { + bool test; /* #[cfg(test)], #[test], #[bench], #[tokio::test] ... */ + bool macro_export; /* #[macro_export] */ + bool cond_path; /* #[cfg_attr(.., path = ..)]: where the module file is depends on cfg */ + bool derives_more; /* derive names that did not fit, or were not read */ + char macro_kind; /* P #[proc_macro], B #[proc_macro_attribute], V #[proc_macro_derive] */ + char path[RS_SCAN_PATH_CAP]; /* #[path = "..."] */ + char derive[RS_SCAN_NAME_CAP]; /* the derive macro's name */ + char derives[RS_SCAN_DERIVES_CAP]; /* #[derive(A, b::C)] -> "A,C": impls not in the tree */ +} rs_attrs_t; + +typedef struct { + CBMExtractCtx *ctx; + CBMArena *tmp; /* nothing of it outlives the scan */ + const char *src; + uint32_t src_len; + rs_sb_t sb; + bool failed; /* out of memory */ + bool broken; /* something the blob cannot represent, or a parse error */ + uint32_t err_byte; /* first byte that is not trusted; UINT32_MAX: none */ + int next_mod; + rs_block_t *blocks; + int nblocks; + int blocks_cap; + const char *item_prefix; /* "." */ + size_t item_prefix_len; + rs_defkey_t *defkeys; + int ndefkeys; + unsigned char *owner; /* per definition: 1 when it owns its graph node */ + int *owner_of; /* per definition: the definition that owns its qualified name */ + int *def_mod; /* per definition: 1 + the module the scan met it in, 0: not met */ + uint32_t *ref_lines; /* start lines of the definitions that carry references, sorted */ + int nref_lines; + int last_use_mod; /* the V record written last: a run of */ + char last_use[RS_SCAN_NAME_CAP]; /* invocations of one macro is written once */ +} rs_scan_t; + +static bool rs_kind(TSNode n, const char *kind) { + return strcmp(ts_node_type(n), kind) == 0; +} + +static TSNode rs_field(TSNode n, const char *field) { + return ts_node_child_by_field_name(n, field, (uint32_t)strlen(field)); +} + +static uint32_t rs_line_of(TSNode n) { + return ts_node_start_point(n).row + TS_LINE_OFFSET; +} + +static uint32_t rs_end_line_of(TSNode n) { + return ts_node_end_point(n).row + TS_LINE_OFFSET; +} + +static void *rs_tmp_alloc(rs_scan_t *s, size_t n) { + void *p = cbm_arena_alloc(s->tmp, n ? n : SKIP_ONE); + s->failed = s->failed || !p; + return p; +} + +/* The node's text as a name: copied into `out` when it is an identifier that + * fits (raw `r#name` keeps its marker, as the extractor's names do). false + * for anything else -- error recovery can hand back a "name" spanning code. */ +static bool rs_name_of(const rs_scan_t *s, TSNode n, char *out, size_t cap) { + if (ts_node_is_null(n)) { + return false; + } + uint32_t a = ts_node_start_byte(n); + uint32_t b = ts_node_end_byte(n); + if (b <= a || b > s->src_len || (size_t)(b - a) >= cap || !rs_is_segment(s->src + a, b - a)) { + return false; + } + memcpy(out, s->src + a, b - a); + out[b - a] = '\0'; + return true; +} + +/* A written path (`a::b::C`, `crate::x`, `::std::fmt`) of the node's text, + * whitespace removed: copied into `out` when it holds nothing but segments. + * false for a type that is no path (a reference, a tuple, generics inside). */ +static bool rs_path_of(const rs_scan_t *s, TSNode n, char *out, size_t cap) { + if (ts_node_is_null(n)) { + return false; + } + uint32_t a = ts_node_start_byte(n); + uint32_t b = ts_node_end_byte(n); + if (b <= a || b > s->src_len) { + return false; + } + size_t w = 0; + for (uint32_t i = a; i < b; i++) { + char c = s->src[i]; + if (isspace((unsigned char)c)) { + continue; + } + if (w + SKIP_ONE >= cap) { + return false; + } + out[w++] = c; + } + out[w] = '\0'; + CBMDocLinkRsPath path; + return w > 0 && !memchr(out, '<', w) && !memchr(out, '@', w) && !memchr(out, '!', w) && + !memchr(out, '(', w) && + cbm_doclink_rs_parse_path(out, w, false, &path) == CBM_DOCLINK_RS_PATH_OK; +} + +/* ── Scope scan: definitions ─────────────────────────────────────── */ + +static int rs_defkey_cmp(const void *a, const void *b) { + const rs_defkey_t *x = (const rs_defkey_t *)a; + const rs_defkey_t *y = (const rs_defkey_t *)b; + if (x->line != y->line) { + return x->line < y->line ? -1 : 1; + } + int c = strcmp(x->name, y->name); + return c ? c : (x->def > y->def) - (x->def < y->def); +} + +/* The order in which the graph buffer lets definitions of one qualified name + * replace each other (cbm_gbuf_upsert_node, same file): start line, then + * name, then label; the last one owns the node. */ +static int rs_defqn_cmp_ctx(const CBMDefinition *x, const CBMDefinition *y) { + int c = strcmp(x->qualified_name, y->qualified_name); + if (c) { + return c; + } + if (x->start_line != y->start_line) { + return x->start_line < y->start_line ? -1 : 1; + } + c = strcmp(x->name, y->name); + return c ? c : strcmp(x->label ? x->label : "", y->label ? y->label : ""); +} + +typedef struct { + const CBMDefinition *def; + int idx; +} rs_defref_t; + +static int rs_defref_cmp(const void *a, const void *b) { + const rs_defref_t *x = (const rs_defref_t *)a; + const rs_defref_t *y = (const rs_defref_t *)b; + int c = rs_defqn_cmp_ctx(x->def, y->def); + return c ? c : (x->idx > y->idx) - (x->idx < y->idx); +} + +/* Index the file's definitions: by (line, name) for the items the scan meets, + * and which definition of every qualified name owns the node. */ +static void rs_scan_defs(rs_scan_t *s) { + const CBMDefArray *defs = &s->ctx->result->defs; + int n = defs->count; + s->defkeys = (rs_defkey_t *)rs_tmp_alloc(s, (size_t)n * sizeof(rs_defkey_t)); + s->owner = (unsigned char *)rs_tmp_alloc(s, (size_t)n + SKIP_ONE); + s->owner_of = (int *)rs_tmp_alloc(s, ((size_t)n + SKIP_ONE) * sizeof(int)); + s->def_mod = (int *)rs_tmp_alloc(s, ((size_t)n + SKIP_ONE) * sizeof(int)); + rs_defref_t *refs = (rs_defref_t *)rs_tmp_alloc(s, (size_t)n * sizeof(rs_defref_t)); + if (s->failed) { + return; + } + memset(s->owner, 0, (size_t)n + SKIP_ONE); + memset(s->def_mod, 0, ((size_t)n + SKIP_ONE) * sizeof(int)); + for (int i = 0; i <= n; i++) { + s->owner_of[i] = CBM_NOT_FOUND; + } + int k = 0; + for (int i = 0; i < n; i++) { + const CBMDefinition *d = &defs->items[i]; + if (!d->name || !d->qualified_name) { + continue; + } + s->defkeys[k] = (rs_defkey_t){.line = d->start_line, .name = d->name, .def = i}; + refs[k] = (rs_defref_t){.def = d, .idx = i}; + k++; + } + s->ndefkeys = k; + if (k == 0) { + return; + } + qsort(s->defkeys, (size_t)k, sizeof(rs_defkey_t), rs_defkey_cmp); + qsort(refs, (size_t)k, sizeof(rs_defref_t), rs_defref_cmp); + int owner = CBM_NOT_FOUND; /* of the run of equal qualified names being walked, last first */ + for (int i = k - SKIP_ONE; i >= 0; i--) { + bool last = i + SKIP_ONE >= k || strcmp(refs[i + SKIP_ONE].def->qualified_name, + refs[i].def->qualified_name) != 0; + owner = last ? refs[i].idx : owner; + s->owner[refs[i].idx] = last ? SKIP_ONE : 0; + s->owner_of[refs[i].idx] = owner; + } +} + +/* The definition the extractor made of an item of module `mod`: same start + * line, name and label, declared by `parent` (the qualified name of its type, + * NULL for a module-level item). CBM_NOT_FOUND when there is none. The module + * of a definition that is found is remembered. */ +static int rs_scan_def(rs_scan_t *s, int mod, uint32_t line, const char *name, const char *label, + const char *parent) { + const CBMDefArray *defs = &s->ctx->result->defs; + int lo = 0; + int hi = s->ndefkeys; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + const rs_defkey_t *key = &s->defkeys[mid]; + bool less = key->line < line || (key->line == line && strcmp(key->name, name) < 0); + if (less) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + for (int i = lo; i < s->ndefkeys; i++) { + const rs_defkey_t *key = &s->defkeys[i]; + if (key->line != line || strcmp(key->name, name) != 0) { + break; + } + const CBMDefinition *d = &defs->items[key->def]; + bool same_parent = parent ? (d->parent_class && strcmp(d->parent_class, parent) == 0) + : d->parent_class == NULL; + if (d->label && strcmp(d->label, label) == 0 && same_parent) { + s->def_mod[key->def] = mod + SKIP_ONE; + return key->def; + } + } + return CBM_NOT_FOUND; +} + +static char rs_label_code(const char *label) { + static const struct { + const char *label; + char code; + } table[] = { + {"Struct", 'S'}, {"Enum", 'E'}, {"Class", 'C'}, {"Interface", 'I'}, {"Type", 'T'}, + {"Function", 'F'}, {"Variable", 'V'}, {"Method", 'M'}, {"Field", 'D'}, + }; + for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); i++) { + if (strcmp(table[i].label, label) == 0) { + return table[i].code; + } + } + return 0; +} + +/* Write an item's node field: '!' when definition `def` does not exist, does + * not own its node, or has a qualified name the blob cannot carry; else the + * label code and the qualified name's part after the file's item prefix. */ +static void rs_put_node(rs_scan_t *s, int def, const char *name) { + if (def < 0) { + rs_sb_putc(&s->sb, '!'); + return; + } + const CBMDefinition *d = &s->ctx->result->defs.items[def]; + char code = rs_label_code(d->label); + const char *qn = d->qualified_name; + bool ok = code && qn && s->owner[def] && strlen(qn) > s->item_prefix_len && + memcmp(qn, s->item_prefix, s->item_prefix_len) == 0; + const char *suffix = ok ? qn + s->item_prefix_len : ""; + if (!ok || strpbrk(suffix, "\t\n\r")) { + rs_sb_putc(&s->sb, '!'); + return; + } + rs_sb_putc(&s->sb, code); + if (strcmp(suffix, name) == 0) { + rs_sb_putc(&s->sb, '='); + } else { + rs_sb_putc(&s->sb, ':'); + rs_sb_puts(&s->sb, suffix); + } +} + +/* The qualified name the extractor gives the type `owner` of this file: the + * parent_class of its members' definitions. */ +static const char *rs_owner_qn(rs_scan_t *s, const char *owner, size_t owner_len) { + char *out = (char *)rs_tmp_alloc(s, s->item_prefix_len + owner_len + SKIP_ONE); + if (!out) { + return NULL; + } + memcpy(out, s->item_prefix, s->item_prefix_len); + memcpy(out + s->item_prefix_len, owner, owner_len); + out[s->item_prefix_len + owner_len] = '\0'; + return out; +} + +/* ── Scope scan: records ─────────────────────────────────────────── */ + +static void rs_rec(rs_scan_t *s, char tag) { + rs_sb_putc(&s->sb, tag); +} + +static void rs_rec_u(rs_scan_t *s, uint32_t v) { + rs_sb_putc(&s->sb, '\t'); + rs_sb_putu(&s->sb, v); +} + +static void rs_rec_s(rs_scan_t *s, const char *v) { + rs_sb_putc(&s->sb, '\t'); + rs_sb_puts(&s->sb, v); +} + +static void rs_rec_end(rs_scan_t *s) { + rs_sb_putc(&s->sb, '\n'); +} + +/* A name some macro invocation may declare in module `mod` (-1: somewhere in + * the file). */ +static void rs_rec_maybe(rs_scan_t *s, int mod, const char *name) { + rs_rec(s, 'Q'); + if (mod < 0) { + rs_rec_s(s, "*"); + } else { + rs_rec_u(s, (uint32_t)mod); + } + rs_rec_s(s, name); + rs_rec_end(s); +} + +/* The visibility of an item and what its attributes add: the flags field. */ +static void rs_rec_flags(rs_scan_t *s, TSNode item, const rs_attrs_t *at) { + char vis = '-'; + TSTreeCursor cur = ts_tree_cursor_new(item); + if (ts_tree_cursor_goto_first_child(&cur)) { + do { + TSNode ch = ts_tree_cursor_current_node(&cur); + if (!rs_kind(ch, "visibility_modifier")) { + continue; + } + vis = 'p'; + uint32_t nc = ts_node_named_child_count(ch); + if (nc > 0) { + const char *k = ts_node_type(ts_node_named_child(ch, 0)); + vis = strcmp(k, "crate") == 0 + ? 'c' + : (strcmp(k, "super") == 0 ? 's' : (strcmp(k, "self") == 0 ? '-' : 'i')); + } + break; + } while (ts_tree_cursor_goto_next_sibling(&cur)); + } + ts_tree_cursor_delete(&cur); + rs_sb_putc(&s->sb, '\t'); + rs_sb_putc(&s->sb, vis); + if (at && at->test) { + rs_sb_putc(&s->sb, 't'); + } + if (at && at->macro_export) { + rs_sb_putc(&s->sb, 'e'); + } + if (at && at->cond_path) { + rs_sb_putc(&s->sb, 'c'); + } +} + +/* ── Scope scan: attributes ──────────────────────────────────────── */ + +static bool rs_word_is(const char *p, size_t n, const char *word) { + return strlen(word) == n && memcmp(p, word, n) == 0; +} + +/* True when the identifier `word` stands in text[0..n). */ +static bool rs_has_word(const char *text, size_t n, const char *word) { + size_t wl = strlen(word); + for (size_t i = 0; i + wl <= n; i++) { + bool starts = + i == 0 || !(isalnum((unsigned char)text[i - SKIP_ONE]) || text[i - SKIP_ONE] == '_'); + bool ends = i + wl == n || !(isalnum((unsigned char)text[i + wl]) || text[i + wl] == '_'); + if (starts && ends && memcmp(text + i, word, wl) == 0) { + return true; + } + } + return false; +} + +/* The string literal that follows `key =` in text[0..n), copied into `out`. + * false when there is none, it holds an escape, or it does not fit. */ +static bool rs_attr_string(const char *text, size_t n, char *out, size_t cap) { + const char *eq = (const char *)memchr(text, '=', n); + if (!eq) { + return false; + } + size_t i = (size_t)(eq - text) + SKIP_ONE; + while (i < n && isspace((unsigned char)text[i])) { + i++; + } + if (i >= n || text[i] != '"') { + return false; + } + size_t start = ++i; + while (i < n && text[i] != '"' && text[i] != '\\' && (unsigned char)text[i] >= ' ') { + i++; + } + if (i >= n || text[i] != '"' || i - start >= cap || i == start) { + return false; + } + memcpy(out, text + start, i - start); + out[i - start] = '\0'; + return true; +} + +/* The first identifier inside the parentheses of text[0..n). */ +static void rs_attr_first_arg(const char *text, size_t n, char *out, size_t cap) { + out[0] = '\0'; + const char *open = (const char *)memchr(text, '(', n); + if (!open) { + return; + } + size_t i = (size_t)(open - text) + SKIP_ONE; + while (i < n && isspace((unsigned char)text[i])) { + i++; + } + size_t start = i; + while (i < n && (isalnum((unsigned char)text[i]) || text[i] == '_')) { + i++; + } + if (i > start && i - start < cap) { + memcpy(out, text + start, i - start); + out[i - start] = '\0'; + } +} + +/* Append a derive name to the list of `at` (comma-joined). */ +static void rs_derive_put(rs_attrs_t *at, const char *name, size_t n) { + size_t have = strlen(at->derives); + if (n == 0) { + return; + } + if (have + n + PAIR_LEN >= sizeof(at->derives)) { + at->derives_more = true; + return; + } + if (have > 0) { + at->derives[have++] = ','; + } + memcpy(at->derives + have, name, n); + at->derives[have + n] = '\0'; +} + +/* The names inside every `derive(..)` of an attribute's text (also inside + * `cfg_attr(x, derive(..))`): the last segment of each path. */ +static void rs_attr_derives(const char *text, size_t n, rs_attrs_t *at) { + static const char word[] = "derive"; + const size_t wl = sizeof(word) - SKIP_ONE; + for (size_t i = 0; i + wl < n; i++) { + bool starts = + i == 0 || !(isalnum((unsigned char)text[i - SKIP_ONE]) || text[i - SKIP_ONE] == '_'); + size_t j = i + wl; + if (!starts || memcmp(text + i, word, wl) != 0) { + continue; + } + while (j < n && isspace((unsigned char)text[j])) { + j++; + } + if (j >= n || text[j] != '(') { + continue; + } + size_t id = 0; /* the identifier read last, and its length */ + size_t idlen = 0; + bool in_id = false; + for (j++; j < n && text[j] != ')'; j++) { + unsigned char c = (unsigned char)text[j]; + if (isalnum(c) || c == '_') { + id = in_id ? id : j; + idlen = in_id ? idlen + SKIP_ONE : SKIP_ONE; + in_id = true; + continue; + } + in_id = false; + if (c == ',') { + rs_derive_put(at, text + id, idlen); + } + idlen = (c == ',' || c == ':') ? 0 : idlen; + } + rs_derive_put(at, text + id, idlen); + at->derives_more = at->derives_more || j >= n; /* cut off before its `)` */ + i = j; + } +} + +/* Read one `#[...]` above an item into `at`. */ +static void rs_attr_read(const rs_scan_t *s, TSNode attr_item, rs_attrs_t *at) { + uint32_t a = ts_node_start_byte(attr_item); + uint32_t b = ts_node_end_byte(attr_item); + if (b <= a || b > s->src_len) { + return; + } + const char *text = s->src + a; + size_t n = b - a; + bool long_attr = n > RS_SCAN_ATTR_CAP; + n = long_attr ? RS_SCAN_ATTR_CAP : n; + /* the attribute's own name: the path after `#[` up to `(`, `=` or `]` */ + size_t i = 0; + while (i < n && (text[i] == '#' || text[i] == '[' || isspace((unsigned char)text[i]))) { + i++; + } + size_t name = i; + while (i < n && (isalnum((unsigned char)text[i]) || text[i] == '_' || text[i] == ':')) { + i++; + } + size_t nl = i - name; + const char *p = text + name; + static const char test_suffix[] = "::test"; + const size_t tsl = sizeof(test_suffix) - SKIP_ONE; + if (rs_word_is(p, nl, "test") || rs_word_is(p, nl, "bench") || + (nl > tsl && memcmp(p + nl - tsl, test_suffix, tsl) == 0)) { + at->test = true; + } else if (rs_word_is(p, nl, "cfg")) { + at->test = at->test || (rs_has_word(text, n, "test") && !rs_has_word(text, n, "not")); + } else if (rs_word_is(p, nl, "path")) { + if (long_attr || !rs_attr_string(text, n, at->path, sizeof(at->path))) { + at->cond_path = true; /* a path this scan cannot read */ + } + } else if (rs_word_is(p, nl, "cfg_attr")) { + at->cond_path = at->cond_path || long_attr || rs_has_word(text, n, "path"); + at->derives_more = at->derives_more || long_attr; + rs_attr_derives(text, n, at); + } else if (rs_word_is(p, nl, "derive")) { + at->derives_more = at->derives_more || long_attr; + rs_attr_derives(text, n, at); + } else if (rs_word_is(p, nl, "macro_export")) { + at->macro_export = true; + } else if (rs_word_is(p, nl, "proc_macro")) { + at->macro_kind = 'P'; + } else if (rs_word_is(p, nl, "proc_macro_attribute")) { + at->macro_kind = 'B'; + } else if (rs_word_is(p, nl, "proc_macro_derive")) { + at->macro_kind = 'V'; + rs_attr_first_arg(text, n, at->derive, sizeof(at->derive)); + } +} + +/* ── Scope scan: names local to a documented item ────────────────── */ + +/* True when a definition that starts at `line` carries references. */ +static bool rs_has_refs(const rs_scan_t *s, uint32_t line) { + int lo = 0; + int hi = s->nref_lines; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (s->ref_lines[mid] < line) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return lo < s->nref_lines && s->ref_lines[lo] == line; +} + +static void rs_put_local(rs_scan_t *s, TSNode name, bool *first) { + char buf[RS_SCAN_NAME_CAP]; + if (!rs_name_of(s, name, buf, sizeof(buf))) { + return; + } + if (!*first) { + rs_sb_putc(&s->sb, ','); + } + rs_sb_puts(&s->sb, buf); + *first = false; +} + +/* The type and const parameters of a `type_parameters` list. */ +static void rs_put_generics(rs_scan_t *s, TSNode tparams, bool *first) { + if (ts_node_is_null(tparams)) { + return; + } + uint32_t nc = ts_node_named_child_count(tparams); + for (uint32_t i = 0; i < nc; i++) { + TSNode p = ts_node_named_child(tparams, i); + if (rs_kind(p, "type_parameter") || rs_kind(p, "const_parameter")) { + rs_put_local(s, rs_field(p, "name"), first); + } else if (rs_kind(p, "type_identifier")) { + rs_put_local(s, p, first); + } + } +} + +/* Every name the patterns of a `parameters` list bind. The walk is a cursor + * walk of the list's subtree: no recursion. */ +static void rs_put_params(rs_scan_t *s, TSNode params, bool *first) { + if (ts_node_is_null(params)) { + return; + } + TSTreeCursor cur = ts_tree_cursor_new(params); + for (;;) { + TSNode n = ts_tree_cursor_current_node(&cur); + const char *field = ts_tree_cursor_current_field_name(&cur); + bool in_type = field && strcmp(field, "type") == 0; + if (rs_kind(n, "identifier") || rs_kind(n, "shorthand_field_identifier")) { + rs_put_local(s, n, first); + } + if (!in_type && ts_tree_cursor_goto_first_child(&cur)) { + continue; + } + bool moved = ts_tree_cursor_goto_next_sibling(&cur); + while (!moved && ts_tree_cursor_goto_parent(&cur)) { + moved = ts_tree_cursor_goto_next_sibling(&cur); + } + if (!moved) { + break; + } + } + ts_tree_cursor_delete(&cur); +} + +/* The P record of the definition at `line`: the type parameters of its owner + * (`outer`, may be null) and, for `item`, its own type parameters and + * parameter names. Written only for a definition with references that has + * such names. */ +static void rs_rec_locals(rs_scan_t *s, uint32_t line, TSNode outer, TSNode item) { + if (!rs_has_refs(s, line) || s->sb.failed) { + return; + } + size_t mark = s->sb.len; + rs_rec(s, 'P'); + rs_rec_u(s, line); + rs_sb_putc(&s->sb, '\t'); + size_t names_at = s->sb.len; + bool first = true; + rs_put_generics(s, outer, &first); + if (!ts_node_is_null(item)) { + rs_put_generics(s, rs_field(item, "type_parameters"), &first); + rs_put_params(s, rs_field(item, "parameters"), &first); + } + if (s->sb.failed) { + return; + } + if (s->sb.len == names_at) { + s->sb.len = mark; /* nothing local: no record */ + s->sb.buf[mark] = '\0'; + return; + } + rs_rec_end(s); +} + +/* ── Scope scan: members ─────────────────────────────────────────── */ + +static void rs_rec_member(rs_scan_t *s, uint32_t line, char kind, const char *name, int def) { + rs_rec(s, 'M'); + rs_rec_u(s, line); + rs_sb_putc(&s->sb, '\t'); + rs_sb_putc(&s->sb, kind); + rs_rec_s(s, name); + rs_sb_putc(&s->sb, '\t'); + rs_put_node(s, def, name); + rs_rec_end(s); +} + +/* The members a type's or an impl's body declares. `owner_qn` is the + * qualified name their definitions carry as parent (NULL: no definition is + * looked up). `callable_label` is the label of the functions' definitions. */ +static void rs_scan_members(rs_scan_t *s, int mod, TSNode body, const char *owner_qn, TSNode outer, + bool in_trait) { + if (ts_node_is_null(body)) { + return; + } + TSTreeCursor cur = ts_tree_cursor_new(body); + bool more = ts_tree_cursor_goto_first_child(&cur); + for (; more && !s->failed; more = ts_tree_cursor_goto_next_sibling(&cur)) { + TSNode m = ts_tree_cursor_current_node(&cur); + const char *k = ts_node_type(m); + char kind = 0; + const char *label = NULL; + if (strcmp(k, "function_item") == 0 || strcmp(k, "function_signature_item") == 0) { + kind = 'f'; + label = "Method"; + } else if (strcmp(k, "const_item") == 0) { + kind = 'c'; + label = in_trait ? "Variable" : NULL; /* an impl's constant has no definition */ + } else if (strcmp(k, "type_item") == 0 || strcmp(k, "associated_type") == 0) { + kind = 't'; + } else if (strcmp(k, "field_declaration") == 0) { + kind = 'd'; + label = "Field"; + } else if (strcmp(k, "enum_variant") == 0) { + kind = 'v'; + } + if (!kind) { + continue; + } + char name[RS_SCAN_NAME_CAP]; + if (ts_node_start_byte(m) >= s->err_byte || + !rs_name_of(s, rs_field(m, "name"), name, sizeof(name))) { + s->broken = true; /* a member this scan cannot show */ + continue; + } + uint32_t line = rs_line_of(m); + int def = + (label && owner_qn) ? rs_scan_def(s, mod, line, name, label, owner_qn) : CBM_NOT_FOUND; + rs_rec_member(s, line, kind, name, def); + TSNode none = {0}; + rs_rec_locals(s, line, outer, kind == 'f' ? m : none); + } + ts_tree_cursor_delete(&cur); +} + +/* The positional fields of a tuple struct: members `0`, `1` ... without a + * node. */ +static void rs_scan_tuple_fields(rs_scan_t *s, TSNode body) { + TSTreeCursor cur = ts_tree_cursor_new(body); + uint32_t index = 0; + bool more = ts_tree_cursor_goto_first_child(&cur); + for (; more && !s->failed; more = ts_tree_cursor_goto_next_sibling(&cur)) { + const char *field = ts_tree_cursor_current_field_name(&cur); + if (!field || strcmp(field, "type") != 0) { + continue; + } + char name[RS_UINT_DIGITS]; + snprintf(name, sizeof(name), "%u", index++); + rs_rec_member(s, 0, 'd', name, CBM_NOT_FOUND); + } + ts_tree_cursor_delete(&cur); +} + +/* ── Scope scan: `use` ───────────────────────────────────────────── */ + +typedef struct { + TSNode node; + const char *prefix; /* the path written before this part of the tree, "" at the top */ +} rs_use_frame_t; + +typedef struct { + rs_use_frame_t *v; + int n; + int cap; +} rs_use_stack_t; + +static void rs_use_push(rs_scan_t *s, rs_use_stack_t *st, TSNode node, const char *prefix) { + if (ts_node_is_null(node) || s->failed) { + return; + } + if (st->n >= st->cap) { + int ncap = st->cap ? st->cap * PAIR_LEN : RS_SCAN_INIT; + rs_use_frame_t *grown = + (rs_use_frame_t *)rs_tmp_alloc(s, (size_t)ncap * sizeof(rs_use_frame_t)); + if (!grown) { + return; + } + if (st->n > 0) { + memcpy(grown, st->v, (size_t)st->n * sizeof(rs_use_frame_t)); + } + st->v = grown; + st->cap = ncap; + } + st->v[st->n++] = (rs_use_frame_t){.node = node, .prefix = prefix}; +} + +/* `prefix::path` in the scratch arena. NULL (and the file is marked) when + * `node` is no plain path or the result is too long to carry. */ +static const char *rs_use_join(rs_scan_t *s, const char *prefix, TSNode node) { + char path[RS_SCAN_PATH_CAP]; + path[0] = '\0'; + if (!ts_node_is_null(node) && !rs_path_of(s, node, path, sizeof(path))) { + s->broken = true; + return NULL; + } + size_t pl = strlen(prefix); + size_t nl = strlen(path); + if (pl + nl + PAIR_LEN >= RS_SCAN_PATH_CAP) { + s->broken = true; + return NULL; + } + char *out = (char *)rs_tmp_alloc(s, pl + nl + PAIR_LEN + SKIP_ONE); + if (!out) { + return NULL; + } + snprintf(out, pl + nl + PAIR_LEN + SKIP_ONE, "%s%s%s", prefix, (pl && nl) ? "::" : "", path); + return out; +} + +static void rs_rec_use(rs_scan_t *s, int mod, TSNode decl, char kind, const char *local, + const char *path) { + rs_rec(s, 'U'); + rs_rec_u(s, (uint32_t)mod); + rs_rec_flags(s, decl, NULL); + rs_sb_putc(&s->sb, '\t'); + rs_sb_putc(&s->sb, kind); + rs_rec_s(s, local); + rs_rec_s(s, path); + rs_rec_end(s); +} + +/* A leaf of a use tree: `full` is the whole path, `alias` the name after + * `as` (NULL when there is none). `a::b::self` imports `b` itself, in the type + * namespace only. */ +static void rs_use_leaf(rs_scan_t *s, int mod, TSNode decl, const char *full, const char *alias) { + static const char self_seg[] = "::self"; + const size_t sl = sizeof(self_seg) - SKIP_ONE; + char path[RS_SCAN_PATH_CAP]; + snprintf(path, sizeof(path), "%s", full); + size_t n = strlen(path); + char kind = 'b'; + if (n > sl && strcmp(path + n - sl, self_seg) == 0) { + path[n - sl] = '\0'; + kind = 's'; + } + const char *last = strrchr(path, ':'); + const char *local = alias ? alias : (last ? last + SKIP_ONE : path); + bool keyword = + strcmp(local, "crate") == 0 || strcmp(local, "super") == 0 || strcmp(local, "self") == 0; + if (!local[0] || (keyword && !alias)) { + return; /* `use crate;` binds nothing */ + } + rs_rec_use(s, mod, decl, kind, local, path); +} + +/* The bindings of one `use` declaration: its tree is walked with a stack. */ +static void rs_item_use(rs_scan_t *s, TSNode decl, int mod) { + rs_use_stack_t st = {0}; + rs_use_push(s, &st, rs_field(decl, "argument"), ""); + while (st.n > 0 && !s->failed) { + rs_use_frame_t f = st.v[--st.n]; + const char *k = ts_node_type(f.node); + if (strcmp(k, "use_list") == 0) { + for (uint32_t i = ts_node_named_child_count(f.node); i > 0; i--) { + rs_use_push(s, &st, ts_node_named_child(f.node, i - SKIP_ONE), f.prefix); + } + } else if (strcmp(k, "scoped_use_list") == 0) { + const char *prefix = rs_use_join(s, f.prefix, rs_field(f.node, "path")); + if (prefix) { + rs_use_push(s, &st, rs_field(f.node, "list"), prefix); + } + } else if (strcmp(k, "use_wildcard") == 0) { + TSNode none = {0}; + TSNode inner = + ts_node_named_child_count(f.node) > 0 ? ts_node_named_child(f.node, 0) : none; + const char *path = rs_use_join(s, f.prefix, inner); + if (path && path[0]) { + rs_rec_use(s, mod, decl, 'g', "-", path); + } + } else if (strcmp(k, "use_as_clause") == 0) { + char alias[RS_SCAN_NAME_CAP]; + const char *full = rs_use_join(s, f.prefix, rs_field(f.node, "path")); + if (full && rs_name_of(s, rs_field(f.node, "alias"), alias, sizeof(alias))) { + rs_use_leaf(s, mod, decl, full, alias); + } else { + s->broken = true; + } + } else if (strcmp(k, "line_comment") != 0 && strcmp(k, "block_comment") != 0) { + const char *full = rs_use_join(s, f.prefix, f.node); + if (full) { + rs_use_leaf(s, mod, decl, full, NULL); + } + } + } +} + +static void rs_item_extern_crate(rs_scan_t *s, TSNode decl, int mod) { + char name[RS_SCAN_NAME_CAP]; + char alias[RS_SCAN_NAME_CAP]; + if (!rs_name_of(s, rs_field(decl, "name"), name, sizeof(name))) { + s->broken = true; + return; + } + bool renamed = rs_name_of(s, rs_field(decl, "alias"), alias, sizeof(alias)); + rs_rec_use(s, mod, decl, 'x', renamed ? alias : name, name); +} + +/* ── Scope scan: macro invocations ───────────────────────────────── */ + +typedef struct { + TSNode *v; + int n; + int cap; +} rs_node_stack_t; + +static void rs_node_push(rs_scan_t *s, rs_node_stack_t *st, TSNode node) { + if (s->failed) { + return; + } + if (st->n >= st->cap) { + int ncap = st->cap ? st->cap * PAIR_LEN : RS_SCAN_INIT; + TSNode *grown = (TSNode *)rs_tmp_alloc(s, (size_t)ncap * sizeof(TSNode)); + if (!grown) { + return; + } + if (st->n > 0) { + memcpy(grown, st->v, (size_t)st->n * sizeof(TSNode)); + } + st->v = grown; + st->cap = ncap; + } + st->v[st->n++] = node; +} + +/* A keyword after which a name is declared. */ +static bool rs_item_keyword(const char *t) { + static const char *const words[] = {"struct", "enum", "union", "trait", "fn", + "mod", "type", "const", "static"}; + for (size_t i = 0; i < sizeof(words) / sizeof(words[0]); i++) { + if (strcmp(words[i], t) == 0) { + return true; + } + } + return false; +} + +/* A word that may stand between such a keyword and the name (`static ref X`, + * `const unsafe fn f`). */ +static bool rs_item_modifier(const char *t) { + static const char *const words[] = {"ref", "mut", "unsafe", "async", "extern"}; + for (size_t i = 0; i < sizeof(words) / sizeof(words[0]); i++) { + if (strcmp(words[i], t) == 0) { + return true; + } + } + return false; +} + +static bool rs_delimiter(const char *t, bool open) { + return open ? (strcmp(t, "(") == 0 || strcmp(t, "[") == 0 || strcmp(t, "{") == 0) + : (strcmp(t, ")") == 0 || strcmp(t, "]") == 0 || strcmp(t, "}") == 0); +} + +/* A name a token tree may declare: a Q record for module `mod`, or (reading a + * macro definition) one more name of the list `names`. */ +static void rs_macro_name(rs_scan_t *s, int mod, const char *name, rs_sb_t *names) { + if (!names) { + rs_rec_maybe(s, mod, name); + return; + } + rs_sb_puts(names, name); + rs_sb_putc(names, ','); + s->failed = s->failed || names->failed; +} + +/* The names one token tree may declare: an identifier after an item keyword + * (at any depth), and at the top a whole argument that is one identifier + * (`define_label!(AppLabel, ..)`). Nested trees are pushed, not recursed. + * A metavariable (`struct $name`) is no identifier and is not recorded. */ +static void rs_macro_tree(rs_scan_t *s, rs_node_stack_t *st, TSNode tree, bool top, int mod, + rs_sb_t *names) { + bool after_keyword = false; + bool arg_start = false; + TSNode candidate = {0}; + TSTreeCursor cur = ts_tree_cursor_new(tree); + bool more = ts_tree_cursor_goto_first_child(&cur); + for (; more && !s->failed; more = ts_tree_cursor_goto_next_sibling(&cur)) { + TSNode c = ts_tree_cursor_current_node(&cur); + const char *t = ts_node_type(c); + bool ends_arg = strcmp(t, ",") == 0 || rs_delimiter(t, false); + if (!ts_node_is_null(candidate) && ends_arg) { + char name[RS_SCAN_NAME_CAP]; + if (rs_name_of(s, candidate, name, sizeof(name))) { + rs_macro_name(s, mod, name, names); + } + } + candidate = (TSNode){0}; + if (strcmp(t, "identifier") == 0) { + /* inside a token tree a contextual keyword (`union`) and a + * modifier (`ref` of `static ref X`) are plain identifiers */ + char name[RS_SCAN_NAME_CAP]; + bool named = rs_name_of(s, c, name, sizeof(name)); + if (named && after_keyword && rs_item_modifier(name)) { + arg_start = false; + continue; + } + if (named && after_keyword) { + rs_macro_name(s, mod, name, names); + } else if (top && arg_start) { + candidate = c; + } + after_keyword = named && !after_keyword && rs_item_keyword(name); + } else if (strcmp(t, "token_tree") == 0) { + rs_node_push(s, st, c); + after_keyword = false; + } else if (rs_item_keyword(t)) { + after_keyword = true; + } else if (!rs_item_modifier(t)) { + after_keyword = false; + } + arg_start = strcmp(t, ",") == 0 || rs_delimiter(t, true); + } + ts_tree_cursor_delete(&cur); +} + +/* A macro invocation among a module's items: what it expands to is not in + * the tree. The names written at the invocation that it may declare are + * recorded (Q), and so is the invocation itself (V): the macro's own body may + * declare names too (W records, of this file or another). */ +static void rs_item_macro(rs_scan_t *s, TSNode invocation, int mod) { + rs_node_stack_t st = {0}; + char name[RS_SCAN_NAME_CAP]; + TSNode macro = rs_field(invocation, "macro"); + if (!ts_node_is_null(macro) && rs_kind(macro, "scoped_identifier")) { + macro = rs_field(macro, "name"); + } + if (rs_name_of(s, macro, name, sizeof(name)) && + !(s->last_use_mod == mod && strcmp(s->last_use, name) == 0)) { + rs_rec(s, 'V'); + rs_rec_u(s, (uint32_t)mod); + rs_rec_s(s, name); + rs_rec_end(s); + s->last_use_mod = mod; + snprintf(s->last_use, sizeof(s->last_use), "%s", name); + } + uint32_t nc = ts_node_named_child_count(invocation); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(invocation, i); + if (rs_kind(c, "token_tree")) { + rs_macro_tree(s, &st, c, true, mod, NULL); + } + } + while (st.n > 0 && !s->failed) { + TSNode tree = st.v[--st.n]; + rs_macro_tree(s, &st, tree, false, mod, NULL); + } +} + +/* What the body of a `macro_rules!` definition declares by a written name + * (`pub struct Options { .. }` in a transcriber): a W record. A module that + * invokes the macro may then hold those names. */ +static void rs_macro_declares(rs_scan_t *s, TSNode def, const char *name) { + rs_node_stack_t st = {0}; + rs_sb_t names = {.a = s->tmp}; + uint32_t nc = ts_node_named_child_count(def); + for (uint32_t i = 0; i < nc; i++) { + TSNode rule = ts_node_named_child(def, i); + if (rs_kind(rule, "macro_rule")) { + rs_node_push(s, &st, rs_field(rule, "right")); + } + } + while (st.n > 0 && !s->failed) { + TSNode tree = st.v[--st.n]; + if (!ts_node_is_null(tree)) { + rs_macro_tree(s, &st, tree, false, 0, &names); + } + } + if (names.len > SKIP_ONE && !s->failed) { + rs_rec(s, 'W'); + rs_rec_s(s, name); + rs_sb_putc(&s->sb, '\t'); + rs_sb_putn(&s->sb, names.buf, names.len - SKIP_ONE); /* without the last comma */ + rs_rec_end(s); + } +} + +/* ── Scope scan: items ───────────────────────────────────────────── */ + +static void rs_block_push(rs_scan_t *s, TSNode node, int mod) { + if (ts_node_is_null(node) || s->failed) { + return; + } + if (s->nblocks >= s->blocks_cap) { + int ncap = s->blocks_cap ? s->blocks_cap * PAIR_LEN : RS_SCAN_INIT; + rs_block_t *grown = (rs_block_t *)rs_tmp_alloc(s, (size_t)ncap * sizeof(rs_block_t)); + if (!grown) { + return; + } + if (s->nblocks > 0) { + memcpy(grown, s->blocks, (size_t)s->nblocks * sizeof(rs_block_t)); + } + s->blocks = grown; + s->blocks_cap = ncap; + } + s->blocks[s->nblocks++] = (rs_block_t){.node = node, .mod = mod}; +} + +/* The first fields of a T record: T mod start end flags kind name, then the + * tab before the node field. */ +static void rs_rec_item(rs_scan_t *s, int mod, TSNode item, const rs_attrs_t *at, char kind, + const char *name) { + rs_rec(s, 'T'); + rs_rec_u(s, (uint32_t)mod); + rs_rec_u(s, rs_line_of(item)); + rs_rec_u(s, rs_end_line_of(item)); + rs_rec_flags(s, item, at); + rs_sb_putc(&s->sb, '\t'); + rs_sb_putc(&s->sb, kind); + rs_rec_s(s, name); + rs_sb_putc(&s->sb, '\t'); +} + +/* The path a type node names, generics dropped: `a::S` -> `a::S`. false + * for a type that is no path. */ +static bool rs_type_path(const rs_scan_t *s, TSNode type, char *out, size_t cap) { + if (!ts_node_is_null(type) && rs_kind(type, "generic_type")) { + type = rs_field(type, "type"); + } + if (ts_node_is_null(type) || + !(rs_kind(type, "type_identifier") || rs_kind(type, "scoped_type_identifier") || + rs_kind(type, "identifier") || rs_kind(type, "scoped_identifier"))) { + return false; + } + return rs_path_of(s, type, out, cap); +} + +/* True when `name` is one of the type parameters of `tparams`: `impl Tr + * for T` implements for every type, not for a type named T. */ +static bool rs_is_generic(const rs_scan_t *s, TSNode tparams, const char *name) { + if (ts_node_is_null(tparams)) { + return false; + } + uint32_t nc = ts_node_named_child_count(tparams); + for (uint32_t i = 0; i < nc; i++) { + TSNode p = ts_node_named_child(tparams, i); + TSNode pn = rs_kind(p, "type_parameter") ? rs_field(p, "name") : p; + char buf[RS_SCAN_NAME_CAP]; + if (rs_name_of(s, pn, buf, sizeof(buf)) && strcmp(buf, name) == 0) { + return true; + } + } + return false; +} + +static void rs_item_impl(rs_scan_t *s, TSNode n, int mod) { + TSNode type = rs_field(n, "type"); + TSNode trait = rs_field(n, "trait"); + TSNode tparams = rs_field(n, "type_parameters"); + char self_path[RS_SCAN_PATH_CAP]; + char trait_path[RS_SCAN_PATH_CAP]; + bool has_self = rs_type_path(s, type, self_path, sizeof(self_path)) && + !rs_is_generic(s, tparams, self_path); + bool has_trait = !ts_node_is_null(trait); + bool trait_ok = has_trait && rs_type_path(s, trait, trait_path, sizeof(trait_path)); + rs_rec(s, 'I'); + rs_rec_u(s, (uint32_t)mod); + rs_rec_u(s, rs_line_of(n)); + rs_rec_u(s, rs_end_line_of(n)); + rs_rec_s(s, has_self ? self_path : "?"); + rs_rec_s(s, has_trait ? (trait_ok ? trait_path : "?") : "-"); + rs_rec_end(s); + /* the extractor names a method . */ + const char *owner_qn = NULL; + if (has_self && !ts_node_is_null(type)) { + uint32_t a = ts_node_start_byte(type); + uint32_t b = ts_node_end_byte(type); + const char *lt = b > a ? (const char *)memchr(s->src + a, '<', b - a) : NULL; + size_t len = lt ? (size_t)(lt - (s->src + a)) : (size_t)(b - a); + owner_qn = rs_owner_qn(s, s->src + a, len); + } + rs_scan_members(s, mod, rs_field(n, "body"), owner_qn, tparams, false); +} + +/* What the scan does with a module-level item of one node kind. */ +typedef struct { + const char *node_kind; + char kind; + const char *label; /* of its definition; NULL: the extractor makes none */ +} rs_item_kind_t; + +static const rs_item_kind_t *rs_item_kind(const char *k) { + static const rs_item_kind_t kinds[] = { + {"struct_item", 'S', "Struct"}, + {"enum_item", 'E', "Enum"}, + {"union_item", 'U', "Class"}, + {"trait_item", 'R', "Interface"}, + {"type_item", 'A', "Type"}, + {"function_item", 'F', "Function"}, + {"function_signature_item", 'F', "Function"}, + {"const_item", 'C', "Variable"}, + {"static_item", 'G', "Variable"}, + {"macro_definition", 'M', NULL}, + }; + for (size_t i = 0; i < sizeof(kinds) / sizeof(kinds[0]); i++) { + if (strcmp(kinds[i].node_kind, k) == 0) { + return &kinds[i]; + } + } + return NULL; +} + +/* The members of a struct, union, enum or trait. */ +static void rs_item_members(rs_scan_t *s, int mod, TSNode n, char kind, const char *name) { + TSNode body = rs_field(n, "body"); + TSNode tparams = rs_field(n, "type_parameters"); + if (ts_node_is_null(body)) { + return; + } + if (rs_kind(body, "ordered_field_declaration_list")) { + rs_scan_tuple_fields(s, body); + return; + } + rs_scan_members(s, mod, body, rs_owner_qn(s, name, strlen(name)), tparams, kind == 'R'); +} + +/* A named item: its T record, its members, its local names. */ +static void rs_item_named(rs_scan_t *s, TSNode n, const rs_item_kind_t *ik, int mod, + const rs_attrs_t *at) { + char name[RS_SCAN_NAME_CAP]; + if (!rs_name_of(s, rs_field(n, "name"), name, sizeof(name))) { + s->broken = true; + return; + } + if (strcmp(name, "_") == 0) { + return; /* `const _: () = ..` declares nothing */ + } + char kind = ik->kind; + const char *shown = name; + if (kind == 'F' && at->macro_kind) { + kind = at->macro_kind; /* the function IS a procedural macro */ + shown = (kind == 'V' && at->derive[0]) ? at->derive : name; + } + uint32_t line = rs_line_of(n); + int def = ik->label ? rs_scan_def(s, mod, line, name, ik->label, NULL) : CBM_NOT_FOUND; + rs_rec_item(s, mod, n, at, kind, shown); + rs_put_node(s, def, shown); + char alias[RS_SCAN_PATH_CAP]; + bool aliased = kind == 'A' && rs_type_path(s, rs_field(n, "type"), alias, sizeof(alias)); + bool derived = + (kind == 'S' || kind == 'E' || kind == 'U') && (at->derives[0] || at->derives_more); + if (derived) { + rs_rec_s(s, at->derives); + rs_sb_puts(&s->sb, at->derives_more ? (at->derives[0] ? ",*" : "*") : ""); + } else { + rs_rec_s(s, aliased ? alias : "-"); + } + rs_rec_end(s); + if (rs_kind(n, "macro_definition")) { + rs_macro_declares(s, n, name); + } + if (kind == 'S' || kind == 'U' || kind == 'E' || kind == 'R') { + rs_item_members(s, mod, n, kind, name); + } + TSNode none = {0}; + rs_rec_locals(s, line, none, n); +} + +static void rs_item_mod(rs_scan_t *s, TSNode n, int mod, const rs_attrs_t *at) { + char name[RS_SCAN_NAME_CAP]; + if (!rs_name_of(s, rs_field(n, "name"), name, sizeof(name))) { + s->broken = true; + return; + } + TSNode body = rs_field(n, "body"); + if (ts_node_is_null(body)) { + rs_rec(s, 'D'); + rs_rec_u(s, (uint32_t)mod); + rs_rec_flags(s, n, at); + rs_rec_s(s, name); + rs_rec_s(s, at->path[0] ? at->path : "-"); + rs_rec_end(s); + return; + } + int id = s->next_mod++; + rs_rec(s, 'N'); + rs_rec_u(s, (uint32_t)id); + rs_rec_u(s, (uint32_t)mod); + rs_rec_u(s, rs_line_of(n)); + rs_rec_u(s, rs_end_line_of(n)); + rs_rec_flags(s, n, at); + if (at->path[0] && !at->cond_path) { + rs_sb_putc(&s->sb, 'c'); /* `#[path]` on an inline module moves its `mod x;` files */ + } + rs_rec_s(s, name); + rs_rec_end(s); + rs_block_push(s, body, id); +} + +/* An item that starts where the tree is no longer trusted: only its name is + * kept, as one that may be declared somewhere in the file. */ +static void rs_item_untrusted(rs_scan_t *s, TSNode n) { + char name[RS_SCAN_NAME_CAP]; + s->broken = true; + if (rs_name_of(s, rs_field(n, "name"), name, sizeof(name))) { + rs_rec_maybe(s, CBM_NOT_FOUND, name); + } +} + +static void rs_scan_item(rs_scan_t *s, TSNode n, int mod, const rs_attrs_t *at) { + const char *k = ts_node_type(n); + if (ts_node_start_byte(n) >= s->err_byte || strcmp(k, "ERROR") == 0) { + rs_item_untrusted(s, n); + return; + } + const rs_item_kind_t *ik = rs_item_kind(k); + if (ik) { + rs_item_named(s, n, ik, mod, at); + } else if (strcmp(k, "mod_item") == 0) { + rs_item_mod(s, n, mod, at); + } else if (strcmp(k, "use_declaration") == 0) { + rs_item_use(s, n, mod); + } else if (strcmp(k, "extern_crate_declaration") == 0) { + rs_item_extern_crate(s, n, mod); + } else if (strcmp(k, "impl_item") == 0) { + rs_item_impl(s, n, mod); + } else if (strcmp(k, "foreign_mod_item") == 0) { + rs_block_push(s, rs_field(n, "body"), mod); /* its items are the module's */ + } else if (strcmp(k, "macro_invocation") == 0) { + rs_item_macro(s, n, mod); + } else if (strcmp(k, "expression_statement") == 0 && ts_node_named_child_count(n) > 0 && + rs_kind(ts_node_named_child(n, 0), "macro_invocation")) { + rs_item_macro(s, ts_node_named_child(n, 0), mod); + } +} + +/* The items of one block, in order; attributes belong to the item after + * them. */ +static void rs_scan_block(rs_scan_t *s, rs_block_t block) { + rs_attrs_t *at = (rs_attrs_t *)rs_tmp_alloc(s, sizeof(*at)); + if (!at) { + return; + } + memset(at, 0, sizeof(*at)); + TSTreeCursor cur = ts_tree_cursor_new(block.node); + bool more = ts_tree_cursor_goto_first_child(&cur); + for (; more && !s->failed; more = ts_tree_cursor_goto_next_sibling(&cur)) { + TSNode n = ts_tree_cursor_current_node(&cur); + const char *k = ts_node_type(n); + if (strcmp(k, "attribute_item") == 0) { + rs_attr_read(s, n, at); + continue; + } + if (!ts_node_is_named(n) || strcmp(k, "line_comment") == 0 || + strcmp(k, "block_comment") == 0 || strcmp(k, "inner_attribute_item") == 0) { + continue; + } + rs_scan_item(s, n, block.mod, at); + memset(at, 0, sizeof(*at)); + } + ts_tree_cursor_delete(&cur); +} + +/* ── Scope scan: the driver ──────────────────────────────────────── */ + +/* True when the delimiters in text[0..n) pair up. */ +static bool rs_delims_balanced(const char *text, size_t n) { + static const char open[] = "([{"; + static const char close[] = ")]}"; + enum { KINDS = 3 }; + long depth[KINDS] = {0, 0, 0}; + for (size_t i = 0; i < n; i++) { + for (int k = 0; k < KINDS; k++) { + depth[k] += text[i] == open[k]; + depth[k] -= text[i] == close[k]; + if (depth[k] < 0) { + return false; + } + } + } + return depth[0] == 0 && depth[1] == 0 && depth[PAIR_LEN] == 0; +} + +/* An error node that cannot have moved an item boundary: it lies inside a + * block (a function body: nothing in there is scanned), and the parser + * neither skipped unpaired delimiters nor made one up. Syntax the grammar + * does not know yet parses this way; the items around the function are then + * exactly where the tree puts them. */ +static bool rs_error_contained(const rs_scan_t *s, TSNode n, bool in_block) { + uint32_t a = ts_node_start_byte(n); + uint32_t b = ts_node_end_byte(n); + if (!in_block || b > s->src_len) { + return false; + } + if (ts_node_is_missing(n)) { + const char *t = ts_node_type(n); + return !rs_delimiter(t, true) && !rs_delimiter(t, false); + } + return b <= a || rs_delims_balanced(s->src + a, b - a); +} + +/* The first ERROR or MISSING node of the tree, in document order, that is not + * contained (rs_error_contained); a null node when there is none. Walks only + * the nodes that hold an error, with a cursor. */ +static TSNode rs_first_error(const rs_scan_t *s, TSNode root) { + TSNode found = {0}; + if (!ts_node_has_error(root)) { + return found; + } + TSTreeCursor cur = ts_tree_cursor_new(root); + int depth = 0; + int block_depth = CBM_NOT_FOUND; /* depth of the outermost block around the cursor */ + for (bool more = true; more;) { + TSNode n = ts_tree_cursor_current_node(&cur); + bool error = ts_node_is_missing(n) || rs_kind(n, "ERROR"); + if (error && !rs_error_contained(s, n, block_depth >= 0)) { + found = n; + break; + } + if (block_depth < 0 && rs_kind(n, "block")) { + block_depth = depth; + } + if (!error && ts_node_has_error(n) && ts_tree_cursor_goto_first_child(&cur)) { + depth++; + continue; + } + for (;;) { /* on to the next node: a sibling, or an ancestor's sibling */ + if (block_depth == depth) { + block_depth = CBM_NOT_FOUND; /* leaving the block itself */ + } + if (ts_tree_cursor_goto_next_sibling(&cur)) { + break; + } + if (!ts_tree_cursor_goto_parent(&cur)) { + more = false; + break; + } + depth--; + } + } + ts_tree_cursor_delete(&cur); + return found; +} + +static int rs_u32_cmp(const void *a, const void *b) { + uint32_t x = *(const uint32_t *)a; + uint32_t y = *(const uint32_t *)b; + return (x > y) - (x < y); +} + +/* The start lines of the definitions whose docs hold references (the file's + * own doc is no definition), sorted. */ +static void rs_scan_ref_lines(rs_scan_t *s) { + const CBMDocLinkArray *links = &s->ctx->result->doc_links; + s->ref_lines = (uint32_t *)rs_tmp_alloc(s, (size_t)links->count * sizeof(uint32_t)); + if (!s->ref_lines) { + return; + } + int n = 0; + for (int i = 0; i < links->count; i++) { + if (!(links->items[i].flags & CBM_DOCLINK_FLAG_FILE)) { + s->ref_lines[n++] = links->items[i].def_line; + } + } + if (n > SKIP_ONE) { + qsort(s->ref_lines, (size_t)n, sizeof(uint32_t), rs_u32_cmp); + } + s->nref_lines = n; +} + +/* Two definitions of one qualified name that are one member name of one type + * (a field and the method named like it), or one item name of one module + * (declared twice under different cfg): the node stands for both, and a + * reference in the doc of either may leave from it. Definitions of different + * modules or parents only share a name the graph cannot tell apart. */ +static bool rs_same_declared_name(const rs_scan_t *s, int a, int b) { + const CBMDefArray *defs = &s->ctx->result->defs; + const char *pa = defs->items[a].parent_class; + const char *pb = defs->items[b].parent_class; + bool same_parent = (!pa && !pb) || (pa && pb && strcmp(pa, pb) == 0); + return same_parent && s->def_mod[a] != 0 && s->def_mod[a] == s->def_mod[b]; +} + +/* Z records: a documented definition with references that does not own its + * node and shares it with an unrelated definition (rs_same_declared_name). + * Its references would otherwise leave from that other definition's node. */ +static void rs_scan_unowned(rs_scan_t *s) { + const CBMDefArray *defs = &s->ctx->result->defs; + for (int i = 0; i < defs->count && !s->failed; i++) { + const CBMDefinition *d = &defs->items[i]; + if (s->owner[i] || !d->docstring || !d->qualified_name || !d->name || + !rs_has_refs(s, d->start_line) || + strncmp(d->qualified_name, s->item_prefix, s->item_prefix_len) != 0 || + (s->owner_of[i] >= 0 && rs_same_declared_name(s, i, s->owner_of[i]))) { + continue; + } + const char *suffix = d->qualified_name + s->item_prefix_len; + if (strpbrk(suffix, "\t\n\r")) { + continue; + } + rs_rec(s, 'Z'); + rs_rec_u(s, d->start_line); + rs_rec_s(s, suffix); + rs_rec_end(s); + } +} + +const char *cbm_doclink_rs_scan_scope(CBMExtractCtx *ctx) { + if (!ctx || !ctx->result || ts_node_is_null(ctx->root) || !ctx->source) { + return NULL; + } + rs_scan_t s = {.ctx = ctx, + .tmp = rs_scratch(ctx), + .src = ctx->source, + .src_len = ctx->source_len > 0 ? (uint32_t)ctx->source_len : 0, + .sb = {.a = ctx->arena}, + .err_byte = UINT32_MAX, + .next_mod = SKIP_ONE, + .last_use_mod = CBM_NOT_FOUND}; + /* the qualified name of an item `x` of this file, minus the `x` */ + const char *probe = cbm_fqn_compute(s.tmp, ctx->project, ctx->rel_path, "x"); + if (!probe || strlen(probe) < PAIR_LEN) { + return NULL; + } + s.item_prefix = probe; + s.item_prefix_len = strlen(probe) - SKIP_ONE; + rs_scan_defs(&s); + rs_scan_ref_lines(&s); + TSNode err = rs_first_error(&s, ctx->root); + rs_sb_puts(&s.sb, CBM_DOCLINK_RS_SCOPE_TAG "\n"); + if (!ts_node_is_null(err)) { + s.err_byte = ts_node_start_byte(err); + s.broken = true; + rs_rec(&s, 'E'); + rs_rec_u(&s, rs_line_of(err)); + rs_rec_end(&s); + } + rs_block_push(&s, ctx->root, 0); + for (int b = 0; b < s.nblocks && !s.failed; b++) { + rs_scan_block(&s, s.blocks[b]); + } + if (!s.failed) { + rs_scan_unowned(&s); + } + if (s.broken) { + rs_sb_puts(&s.sb, "B\n"); + } + if (s.failed || s.sb.failed || !s.sb.buf) { + return NULL; + } + return s.sb.buf; +} + +/* ── Cargo manifest ────────────────────────────────────────────────── + * + * The scope blob of a `Cargo.toml` (tag line "rs1", then "C"): what the Rust + * resolver knows of packages, targets and dependencies. The indexer extracts + * the file anyway, so the resolver reads no file of its own. + * K table key value a key of [package], [lib] or + * [workspace.package] the resolver uses + * A kind one [[bin]] / [[example]] / [[test]] / + * [[bench]] target; its keys follow as + * a key value `name` or `path` of that target + * W the manifest has a [workspace] table + * X kind name attr value a dependency. kind: d dependencies, v dev, + * b build, w workspace.dependencies; attr '-' + * when the entry is only named, else `path`, + * `package` or `workspace` + * B the TOML tree has a parse error + * A value this scan cannot carry (an escape, a multi-line string, a tab) is + * written as "?": the resolver then knows the key is set, not to what. */ + +enum { RS_TOML_SEGS = 8 }; + +typedef struct { + char seg[RS_TOML_SEGS][RS_SCAN_NAME_CAP]; + int n; + bool ok; /* false: more segments, a longer one, or an escape */ +} rs_toml_key_t; + +enum { + RS_TABLE_OTHER = 0, + RS_TABLE_PACKAGE, + RS_TABLE_LIB, + RS_TABLE_WORKSPACE_PACKAGE, + RS_TABLE_TARGET, /* one element of a target array */ + RS_TABLE_DEPS, /* a dependency table: `dep` is its kind */ + RS_TABLE_DEP, /* the table of one dependency: `dep` and `name` */ +}; + +typedef struct { + int kind; + char dep; + const char *name; /* RS_TABLE_DEP: the dependency; else the table's dotted name */ + bool is_workspace; /* [workspace] or a table below it */ +} rs_table_t; + +void cbm_doclink_rs_manifest_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, + const char *doc, uint32_t doc_line) { + (void)ctx; + (void)def; + (void)doc; + (void)doc_line; /* a manifest holds no doc-comment references */ +} + +static bool rs_is_manifest(const char *rel_path) { + static const char name[] = "Cargo.toml"; + const char *slash = strrchr(rel_path, '/'); + return strcmp(slash ? slash + SKIP_ONE : rel_path, name) == 0; +} + +/* The dotted key written at text[0..n): `a.'b c'."d"` -> a, b c, d. */ +static void rs_toml_key(const char *text, size_t n, rs_toml_key_t *out) { + out->n = 0; + out->ok = true; + size_t i = 0; + while (i < n && out->ok) { + while (i < n && (text[i] == ' ' || text[i] == '\t')) { + i++; + } + char quote = (i < n && (text[i] == '"' || text[i] == '\'')) ? text[i] : 0; + size_t start = quote ? i + SKIP_ONE : i; + size_t e = start; + while (e < n && + (quote ? text[e] != quote : (text[e] != '.' && text[e] != ' ' && text[e] != '\t'))) { + out->ok = out->ok && text[e] != '\\'; + e++; + } + if (out->n >= RS_TOML_SEGS || e - start >= RS_SCAN_NAME_CAP || e == start || + (quote && e >= n)) { + out->ok = false; + return; + } + memcpy(out->seg[out->n], text + start, e - start); + out->seg[out->n][e - start] = '\0'; + out->n++; + i = quote ? e + SKIP_ONE : e; + while (i < n && (text[i] == ' ' || text[i] == '\t')) { + i++; + } + if (i < n && text[i] != '.') { + out->ok = false; + return; + } + i++; + } +} + +static void rs_toml_key_of(const rs_scan_t *s, TSNode key, rs_toml_key_t *out) { + uint32_t a = ts_node_start_byte(key); + uint32_t b = ts_node_end_byte(key); + if (ts_node_is_null(key) || b <= a || b > s->src_len) { + out->n = 0; + out->ok = false; + return; + } + rs_toml_key(s->src + a, b - a, out); +} + +static bool rs_toml_is_key(TSNode n) { + return rs_kind(n, "bare_key") || rs_kind(n, "quoted_key") || rs_kind(n, "dotted_key"); +} + +/* A string or boolean value as text; "?" for one the blob cannot carry. NULL + * for a value of another kind. */ +static const char *rs_toml_scalar(rs_scan_t *s, TSNode v) { + if (ts_node_is_null(v)) { + return NULL; + } + uint32_t a = ts_node_start_byte(v); + uint32_t b = ts_node_end_byte(v); + if (b <= a || b > s->src_len) { + return NULL; + } + const char *text = s->src + a; + size_t n = b - a; + if (rs_kind(v, "boolean")) { + return rs_word_is(text, n, "true") ? "true" : "false"; + } + if (!rs_kind(v, "string")) { + return NULL; + } + char q = text[0]; + bool plain = n >= PAIR_LEN && (q == '"' || q == '\'') && text[n - SKIP_ONE] == q && + n - PAIR_LEN < RS_SCAN_PATH_CAP; + for (size_t i = SKIP_ONE; plain && i + SKIP_ONE < n; i++) { + unsigned char c = (unsigned char)text[i]; + plain = c != (unsigned char)q && c >= ' ' && !(q == '"' && c == '\\'); + } + if (!plain || n == PAIR_LEN) { + return "?"; + } + char *out = (char *)rs_tmp_alloc(s, n); + if (!out) { + return NULL; + } + memcpy(out, text + SKIP_ONE, n - PAIR_LEN); + out[n - PAIR_LEN] = '\0'; + return out; +} + +static char rs_dep_kind(const char *seg) { + static const struct { + const char *name; + char kind; + } table[] = { + {"dependencies", 'd'}, {"dev-dependencies", 'v'}, {"dev_dependencies", 'v'}, + {"build-dependencies", 'b'}, {"build_dependencies", 'b'}, + }; + for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); i++) { + if (strcmp(table[i].name, seg) == 0) { + return table[i].kind; + } + } + return 0; +} + +/* What the table named `key` is. `array`: it is written `[[key]]`. */ +static rs_table_t rs_table_class(const rs_toml_key_t *key, bool array) { + rs_table_t t = {.kind = RS_TABLE_OTHER}; + if (!key->ok || key->n == 0) { + return t; + } + const char *first = key->seg[0]; + const char *last = key->seg[key->n - SKIP_ONE]; + t.is_workspace = strcmp(first, "workspace") == 0; + static const char *const targets[] = {"bin", "example", "test", "bench"}; + for (size_t i = 0; array && key->n == SKIP_ONE && i < sizeof(targets) / sizeof(targets[0]); + i++) { + if (strcmp(first, targets[i]) == 0) { + t.kind = RS_TABLE_TARGET; + t.name = targets[i]; + return t; + } + } + if (array) { + return t; + } + if (key->n == SKIP_ONE && strcmp(first, "package") == 0) { + t.kind = RS_TABLE_PACKAGE; + t.name = "package"; + } else if (key->n == SKIP_ONE && strcmp(first, "lib") == 0) { + t.kind = RS_TABLE_LIB; + t.name = "lib"; + } else if (key->n == PAIR_LEN && t.is_workspace && strcmp(last, "package") == 0) { + t.kind = RS_TABLE_WORKSPACE_PACKAGE; + t.name = "workspace.package"; + } else if (rs_dep_kind(last)) { + t.kind = RS_TABLE_DEPS; + t.dep = t.is_workspace ? 'w' : rs_dep_kind(last); + } else if (key->n >= PAIR_LEN && rs_dep_kind(key->seg[key->n - PAIR_LEN])) { + t.kind = RS_TABLE_DEP; + t.dep = t.is_workspace ? 'w' : rs_dep_kind(key->seg[key->n - PAIR_LEN]); + t.name = last; + } + return t; +} + +static void rs_rec_dep(rs_scan_t *s, char kind, const char *name, const char *attr, + const char *value) { + rs_rec(s, 'X'); + rs_sb_putc(&s->sb, '\t'); + rs_sb_putc(&s->sb, kind); + rs_rec_s(s, name); + rs_rec_s(s, attr); + rs_rec_s(s, value); + rs_rec_end(s); +} + +static bool rs_dep_attr(const char *key) { + return strcmp(key, "path") == 0 || strcmp(key, "package") == 0 || strcmp(key, "workspace") == 0; +} + +/* The key and value node of a `pair`. */ +static void rs_toml_pair(TSNode pair, TSNode *key, TSNode *value) { + *key = (TSNode){0}; + *value = (TSNode){0}; + uint32_t nc = ts_node_named_child_count(pair); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(pair, i); + if (rs_kind(c, "comment")) { + continue; + } + if (ts_node_is_null(*key) && rs_toml_is_key(c)) { + *key = c; + } else if (!ts_node_is_null(*key)) { + *value = c; + } + } +} + +/* One entry of a dependency table: `name = "1"`, `name = { path = .. }`, + * `name.workspace = true`. */ +static void rs_manifest_dep(rs_scan_t *s, char kind, const rs_toml_key_t *key, TSNode value) { + const char *name = key->seg[0]; + if (strpbrk(name, "\t\n\r")) { + return; + } + rs_rec_dep(s, kind, name, "-", "-"); + if (key->n == PAIR_LEN && rs_dep_attr(key->seg[1])) { + const char *v = rs_toml_scalar(s, value); + rs_rec_dep(s, kind, name, key->seg[1], v ? v : "?"); + return; + } + if (key->n != SKIP_ONE || ts_node_is_null(value) || !rs_kind(value, "inline_table")) { + return; + } + uint32_t nc = ts_node_named_child_count(value); + for (uint32_t i = 0; i < nc && !s->failed; i++) { + TSNode inner = ts_node_named_child(value, i); + TSNode ik; + TSNode iv; + if (!rs_kind(inner, "pair")) { + continue; + } + rs_toml_pair(inner, &ik, &iv); + rs_toml_key_t in_key; + rs_toml_key_of(s, ik, &in_key); + if (in_key.ok && in_key.n == SKIP_ONE && rs_dep_attr(in_key.seg[0])) { + const char *v = rs_toml_scalar(s, iv); + rs_rec_dep(s, kind, name, in_key.seg[0], v ? v : "?"); + } + } +} + +/* True for the keys of [package], [lib], [workspace.package] and a target + * that the resolver reads. */ +static bool rs_manifest_key_used(const char *key) { + static const char *const keys[] = {"name", "edition", "build", "path", + "autobins", "autoexamples", "autotests", "autobenches", + "proc-macro", "proc_macro"}; + for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { + if (strcmp(keys[i], key) == 0) { + return true; + } + } + return false; +} + +/* `key = value` of [package], [lib], [workspace.package] or a target. + * `edition.workspace = true` and `edition = { workspace = true }` are both + * written as the key `edition.workspace`. */ +static void rs_manifest_setting(rs_scan_t *s, const rs_table_t *t, const rs_toml_key_t *key, + TSNode value) { + if (!rs_manifest_key_used(key->seg[0])) { + return; + } + const char *v = rs_toml_scalar(s, value); + bool inherited = key->n == PAIR_LEN && strcmp(key->seg[1], "workspace") == 0; + if (key->n == SKIP_ONE && !v && !ts_node_is_null(value) && rs_kind(value, "inline_table")) { + uint32_t a = ts_node_start_byte(value); + uint32_t b = ts_node_end_byte(value); + inherited = b > a && b <= s->src_len && rs_has_word(s->src + a, b - a, "workspace"); + v = "true"; + } + if (key->n > PAIR_LEN || (key->n == PAIR_LEN && !inherited)) { + return; + } + if (t->kind == RS_TABLE_TARGET) { + rs_rec(s, 'a'); + } else { + rs_rec(s, 'K'); + rs_rec_s(s, t->name); + } + rs_sb_putc(&s->sb, '\t'); + rs_sb_puts(&s->sb, key->seg[0]); + if (inherited) { + rs_sb_puts(&s->sb, ".workspace"); + } + rs_rec_s(s, v ? v : "?"); + rs_rec_end(s); +} + +/* The pairs of one table (a `table` or `table_array_element` node). */ +static void rs_manifest_table(rs_scan_t *s, TSNode table, bool array) { + rs_toml_key_t name = {.n = 0, .ok = false}; + rs_table_t t = {.kind = RS_TABLE_OTHER}; + TSTreeCursor cur = ts_tree_cursor_new(table); + bool more = ts_tree_cursor_goto_first_child(&cur); + bool named = false; + for (; more && !s->failed; more = ts_tree_cursor_goto_next_sibling(&cur)) { + TSNode c = ts_tree_cursor_current_node(&cur); + if (!named && rs_toml_is_key(c)) { + named = true; + rs_toml_key_of(s, c, &name); + t = rs_table_class(&name, array); + if (t.is_workspace) { + rs_sb_puts(&s->sb, "W\n"); + } + if (t.kind == RS_TABLE_TARGET) { + rs_rec(s, 'A'); + rs_rec_s(s, t.name); + rs_rec_end(s); + } else if (t.kind == RS_TABLE_DEP && !strpbrk(t.name, "\t\n\r")) { + rs_rec_dep(s, t.dep, t.name, "-", "-"); + } + continue; + } + if (!rs_kind(c, "pair") || t.kind == RS_TABLE_OTHER) { + continue; + } + TSNode k; + TSNode v; + rs_toml_pair(c, &k, &v); + rs_toml_key_t key; + rs_toml_key_of(s, k, &key); + if (!key.ok || key.n == 0) { + continue; + } + if (t.kind == RS_TABLE_DEPS) { + rs_manifest_dep(s, t.dep, &key, v); + } else if (t.kind == RS_TABLE_DEP) { + if (key.n == SKIP_ONE && rs_dep_attr(key.seg[0])) { + const char *val = rs_toml_scalar(s, v); + rs_rec_dep(s, t.dep, t.name, key.seg[0], val ? val : "?"); + } + } else { + rs_manifest_setting(s, &t, &key, v); + } + } + ts_tree_cursor_delete(&cur); +} + +const char *cbm_doclink_rs_manifest_scan_scope(CBMExtractCtx *ctx) { + if (!ctx || !ctx->result || !ctx->rel_path || !ctx->source || ts_node_is_null(ctx->root) || + !rs_is_manifest(ctx->rel_path)) { + return NULL; + } + rs_scan_t s = {.ctx = ctx, + .tmp = rs_scratch(ctx), + .src = ctx->source, + .src_len = ctx->source_len > 0 ? (uint32_t)ctx->source_len : 0, + .sb = {.a = ctx->arena}}; + rs_sb_puts(&s.sb, CBM_DOCLINK_RS_SCOPE_TAG "\nC\n"); + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + bool more = ts_tree_cursor_goto_first_child(&cur); + for (; more && !s.failed; more = ts_tree_cursor_goto_next_sibling(&cur)) { + TSNode c = ts_tree_cursor_current_node(&cur); + if (rs_kind(c, "table")) { + rs_manifest_table(&s, c, false); + } else if (rs_kind(c, "table_array_element")) { + rs_manifest_table(&s, c, true); + } + } + ts_tree_cursor_delete(&cur); + if (ts_node_has_error(ctx->root)) { + rs_sb_puts(&s.sb, "B\n"); + } + if (s.failed || s.sb.failed || !s.sb.buf) { + return NULL; + } + return s.sb.buf; +} + +/* ── Persisted form ──────────────────────────────────────────────── */ + +/* Field positions (0-based, the tag is field 0) that hold line numbers. */ +static bool rs_line_field(char tag, int field) { + enum { F1 = 1, F2 = 2, F3 = 3, F4 = 4 }; + switch (tag) { + case 'N': + return field == F3 || field == F4; + case 'T': + case 'I': + return field == F2 || field == F3; + case 'M': + return field == F1; + default: + return false; + } +} + +/* Records only the file's own references need: never persisted. */ +static bool rs_fresh_only(char tag) { + return tag == 'P' || tag == 'Z' || tag == 'E'; +} + +char *cbm_doclink_rs_portable_scope(const char *scope) { + size_t n = strlen(scope); + char *out = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, n + SKIP_ONE); + if (!out) { + return NULL; + } + size_t w = 0; + const char *p = scope; + bool first = true; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + char tag = first ? 0 : p[0]; + const char *next = nl ? nl + SKIP_ONE : p + len; + if (rs_fresh_only(tag)) { + p = next; + first = false; + continue; + } + int field = 0; + size_t i = 0; + while (i < len) { + size_t fend = i; + while (fend < len && p[fend] != '\t') { + fend++; + } + if (rs_line_field(tag, field)) { + out[w++] = '0'; + } else { + memcpy(out + w, p + i, fend - i); + w += fend - i; + } + i = fend; + if (i < len) { + out[w++] = '\t'; + i++; + field++; + } + } + if (nl) { + out[w++] = '\n'; + } + p = next; + first = false; + } + out[w] = '\0'; + return out; +} diff --git a/internal/cbm/doclink_ts.c b/internal/cbm/doclink_ts.c new file mode 100644 index 000000000..9fbcd5eb7 --- /dev/null +++ b/internal/cbm/doclink_ts.c @@ -0,0 +1,905 @@ +/* + * doclink_ts.c — TypeScript and JavaScript doc-comment references (JSDoc, + * TSDoc) and the TS/JS doc-link scope. + * + * Tokens. Only a block comment that opens with a slash and two stars is a + * doc. Its lines lose their leading white space, one `*` and one space. A + * reference is + * - {@link Ref}, {@link Ref label}, {@link Ref | label}: family `link`; + * - {@linkcode Ref ...} the same way: family `linkcode`; + * - `@see Ref` (or `@see {Ref}`) at the start of a tag line that holds no + * inline link tag: family `see`. + * The reference is the text up to the first `|` or white space; {@linkplain} + * is not harvested. A URL is the core's href family. Text in a code span + * (backticks on one line) or in a fenced code block holds no reference, and a + * hyphenated word after @see (aria-colcount) is prose. A reference that names + * a parameter of the documented declaration is the declaration's own: no + * token. + * + * Scope blob, one record per line after the tag line "ts1" (tab-separated): + * M the file has module syntax (an import or an export) + * I local kind spec orig an import binding: kind d (default), n (named, + * `orig` is the name the module exports), s (a + * namespace: `* as local`, `local = require(spec)`) + * A spec export * from spec + * S name spec export * as name from spec + * F name spec orig export { orig as name } from spec + * L name local export { local as name } (and `export default x;`: + * name "default") + * D name flags a top-level declaration: e exported, d the default + * export (flags "-" for none) + * N path first last a namespace body, path dotted from the file's top + * level, with its lines; the persisted blob keeps + * "N path" (cbm_doclink_ts_portable_scope) + * V name line first last a function or class declared inside a function + * body: its line and the body's lines (where it is + * visible); only the file's own references read it, so + * the persisted blob drops it + */ +#include "doclink.h" + +#include "arena.h" +#include "helpers.h" +#include "foundation/constants.h" +#include "foundation/mem_core.h" +#include "tree_sitter/api.h" + +#include +#include +#include +#include + +enum { TS_PREFIX_LEN = 3, TS_FENCE_LEN = 3, TS_WALK_MAX_DEPTH = 64 }; + +/* ── Tokens ──────────────────────────────────────────────────────── */ + +typedef struct { + CBMExtractCtx *ctx; + const CBMDefinition *def; +} ts_emit_t; + +static bool ts_ident_char(char c) { + return isalnum((unsigned char)c) || c == '_' || c == '$' || (unsigned char)c >= 0x80; +} + +/* A parameter named in the signature text "(a: T, ...rest, public b = 1)": + * after a top-level `(` or `,`, past modifiers and `...`, the identifier. */ +static bool ts_param_in_signature(const char *sig, const char *ref, size_t n) { + static const char *const mods[] = {"public ", "private ", "protected ", + "readonly ", "override ", NULL}; + int depth = 0; + for (const char *p = sig; *p; p++) { + if (*p == '(' || *p == '[' || *p == '{' || *p == '<') { + depth++; + } else if (*p == ')' || *p == ']' || *p == '}' || *p == '>') { + depth--; + } + bool start = (*p == '(' && depth == SKIP_ONE) || (*p == ',' && depth == SKIP_ONE); + if (!start) { + continue; + } + const char *q = p + SKIP_ONE; + for (bool again = true; again;) { + while (*q == ' ' || *q == '\t' || *q == '\n') { + q++; + } + again = false; + for (int m = 0; mods[m]; m++) { + size_t ml = strlen(mods[m]); + if (strncmp(q, mods[m], ml) == 0) { + q += ml; + again = true; + } + } + } + while (*q == '.') { + q++; + } + size_t k = 0; + while (q[k] && ts_ident_char(q[k])) { + k++; + } + if (k == n && memcmp(q, ref, n) == 0) { + return true; + } + } + return false; +} + +/* A parameter of the documented declaration. */ +static bool ts_is_param(const CBMDefinition *def, const char *ref, size_t n) { + if (!def->param_names) { + return def->signature && ts_param_in_signature(def->signature, ref, n); + } + for (int i = 0; def->param_names[i]; i++) { + const char *p = def->param_names[i]; + while (*p == '.') { + p++; /* a rest parameter */ + } + if (strlen(p) == n && memcmp(p, ref, n) == 0) { + return true; + } + } + return false; +} + +static bool ts_is_url(const char *t, size_t n) { + size_t i = 0; + while (i < n && (isalnum((unsigned char)t[i]) || t[i] == '+' || t[i] == '.' || t[i] == '-')) { + i++; + } + if (i > 0 && i + CBM_SZ_3 <= n && memcmp(t + i, "://", CBM_SZ_3) == 0) { + return true; + } + return (n > CBM_SZ_4 && strncmp(t, "www.", CBM_SZ_4) == 0) || + (n > CBM_SZ_7 && strncmp(t, "mailto:", CBM_SZ_7) == 0); +} + +static void ts_push(const ts_emit_t *em, const char *ref, size_t n, int syntax, uint32_t line) { + if (n == 0) { + return; + } + if (n > CBM_DOCLINK_TS_REF_MAX) { + n = CBM_DOCLINK_TS_REF_MAX + SKIP_ONE; /* clipped: the resolver calls it unparseable */ + } + if (ts_is_url(ref, n)) { + syntax = CBM_DOCLINK_HREF; + } else { + size_t k = 0; + while (k < n && ts_ident_char(ref[k])) { + k++; + } + if (k == n && ts_is_param(em->def, ref, n)) { + return; /* the declaration's own parameter */ + } + } + char *copy = cbm_arena_strndup(em->ctx->arena, ref, n); + if (!copy) { + return; + } + CBMDocLink link = { + .source_qn = em->def->qualified_name, + .raw = copy, + .line = line, + .def_line = em->def->start_line, + .syntax = (uint16_t)syntax, + }; + cbm_doclinks_push(&em->ctx->result->doc_links, em->ctx->arena, link); +} + +/* One doc line: its text after the leading `*`, and its source line. */ +typedef struct { + const char *p; + size_t n; + uint32_t line; +} ts_line_t; + +/* The doc's lines, its opening and closing marks cut off. */ +static int ts_doc_lines(CBMArena *a, const char *doc, uint32_t doc_line, ts_line_t **out) { + size_t dl = strlen(doc); + if (dl < TS_PREFIX_LEN || strncmp(doc, "/**", TS_PREFIX_LEN) != 0) { + return 0; + } + const char *b = doc + TS_PREFIX_LEN; + const char *e = doc + dl; + if (e - b >= PAIR_LEN && e[-SKIP_ONE] == '/' && e[-PAIR_LEN] == '*') { + e -= PAIR_LEN; + } + int cap = SKIP_ONE; + for (const char *p = b; p < e; p++) { + cap += *p == '\n'; + } + ts_line_t *lines = (ts_line_t *)cbm_arena_alloc(a, (size_t)cap * sizeof(ts_line_t)); + if (!lines) { + return 0; + } + int n = 0; + uint32_t line = doc_line; + for (const char *p = b; p <= e && n < cap;) { + const char *le = p; + while (le < e && *le != '\n') { + le++; + } + const char *s = p; + while (s < le && (*s == ' ' || *s == '\t')) { + s++; + } + if (s < le && *s == '*') { + s++; + if (s < le && *s == ' ') { + s++; + } + } + const char *t = le; + while (t > s && (t[-SKIP_ONE] == ' ' || t[-SKIP_ONE] == '\t' || t[-SKIP_ONE] == '\r')) { + t--; + } + lines[n++] = (ts_line_t){s, (size_t)(t - s), line}; + line++; + if (le >= e) { + break; + } + p = le + SKIP_ONE; + } + *out = lines; + return n; +} + +/* Mark the bytes of a line that sit in a code span (backticks paired on the + * line). */ +static void ts_mask_spans(const ts_line_t *ln, bool *mask) { + size_t i = 0; + while (i < ln->n) { + if (ln->p[i] != '`') { + i++; + continue; + } + size_t run = 0; + while (i + run < ln->n && ln->p[i + run] == '`') { + run++; + } + size_t j = i + run; + size_t close = SIZE_MAX; + while (j < ln->n) { + size_t r = 0; + while (j + r < ln->n && ln->p[j + r] == '`') { + r++; + } + if (r == run) { + close = j; + break; + } + j += r ? r : SKIP_ONE; + } + if (close == SIZE_MAX) { + i += run; + continue; + } + for (size_t k = i; k < close + run; k++) { + mask[k] = true; + } + i = close + run; + } +} + +/* The first position >= j of p[0, n) that is no white space. */ +static size_t ts_skip_space(const char *p, size_t j, size_t n) { + while (j < n && isspace((unsigned char)p[j])) { + j++; + } + return j; +} + +/* The reference of an inline tag body: up to `|`, white space or `}`. */ +static size_t ts_ref_len(const char *p, size_t n) { + size_t k = 0; + while (k < n && p[k] != '|' && p[k] != '}' && !isspace((unsigned char)p[k])) { + k++; + } + return k; +} + +/* {@link ...} and {@linkcode ...} of one line; true when the line holds one. */ +static bool ts_line_inline(const ts_emit_t *em, const ts_line_t *ln, const bool *mask) { + bool any = false; + static const char open[] = "{@link"; + size_t ol = sizeof(open) - SKIP_ONE; + for (size_t i = 0; i + ol <= ln->n; i++) { + if (mask[i] || memcmp(ln->p + i, open, ol) != 0) { + continue; + } + size_t j = i + ol; + int syntax = CBM_DOCLINK_TS_LINK; + if (j + CBM_SZ_4 <= ln->n && memcmp(ln->p + j, "code", CBM_SZ_4) == 0) { + syntax = CBM_DOCLINK_TS_LINKCODE; + j += CBM_SZ_4; + } + if (j < ln->n && ln->p[j] != '}' && !isspace((unsigned char)ln->p[j])) { + continue; /* {@linkplain} or another word */ + } + any = true; + j = ts_skip_space(ln->p, j, ln->n); + ts_push(em, ln->p + j, ts_ref_len(ln->p + j, ln->n - j), syntax, ln->line); + i = j; + } + return any; +} + +/* `@see Ref` at the start of a tag line. */ +static void ts_line_see(const ts_emit_t *em, const ts_line_t *ln) { + static const char tag[] = "@see"; + size_t tl = sizeof(tag) - SKIP_ONE; + if (ln->n < tl || memcmp(ln->p, tag, tl) != 0 || + (ln->n > tl && !isspace((unsigned char)ln->p[tl]))) { + return; + } + size_t j = ts_skip_space(ln->p, tl, ln->n); + const char *r = ln->p + j; + size_t n = ln->n - j; + if (n > 0 && r[0] == '{') { + r++; + n--; + size_t k = 0; + while (k < n && r[k] != '}') { + k++; + } + n = k; + } + size_t k = 0; + while (k < n && !isspace((unsigned char)r[k])) { + k++; + } + while (k > 0 && (r[k - SKIP_ONE] == '.' || r[k - SKIP_ONE] == ',' || r[k - SKIP_ONE] == ';' || + r[k - SKIP_ONE] == ':')) { + k--; /* sentence punctuation */ + } + size_t w = 0; + while (w < k && ts_ident_char(r[w])) { + w++; + } + if (w > 0 && w < k && r[w] == '-') { + return; /* a hyphenated word: prose */ + } + ts_push(em, r, k, CBM_DOCLINK_TS_SEE, ln->line); +} + +void cbm_doclink_ts_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line) { + if (!ctx || !ctx->result || !def || !doc || !def->qualified_name) { + return; + } + CBMArena *a = ctx->scratch ? ctx->scratch : ctx->arena; + ts_line_t *lines = NULL; + int n = ts_doc_lines(a, doc, doc_line, &lines); + ts_emit_t em = {ctx, def}; + bool fence = false; + for (int i = 0; i < n; i++) { + const ts_line_t *ln = &lines[i]; + if (ln->n >= TS_FENCE_LEN && memcmp(ln->p, "```", TS_FENCE_LEN) == 0) { + fence = !fence; + continue; + } + if (fence || ln->n == 0) { + continue; + } + bool *mask = (bool *)cbm_arena_calloc(a, ln->n + SKIP_ONE); + if (!mask) { + return; + } + ts_mask_spans(ln, mask); + if (!ts_line_inline(&em, ln, mask)) { + ts_line_see(&em, ln); + } + } +} + +/* ── Scope blob ──────────────────────────────────────────────────── */ + +typedef struct { + CBMArena *a; + char *buf; + size_t len; + size_t cap; + bool oom; +} ts_sb_t; + +static void ts_sb_put(ts_sb_t *sb, const char *s, size_t n) { + if (sb->oom) { + return; + } + if (sb->len + n + SKIP_ONE > sb->cap) { + size_t ncap = sb->cap ? sb->cap * PAIR_LEN : CBM_SZ_256; + while (ncap < sb->len + n + SKIP_ONE) { + ncap *= PAIR_LEN; + } + char *grown = (char *)cbm_arena_alloc(sb->a, ncap); + if (!grown) { + sb->oom = true; + return; + } + if (sb->len) { + memcpy(grown, sb->buf, sb->len); + } + sb->buf = grown; + sb->cap = ncap; + } + memcpy(sb->buf + sb->len, s, n); + sb->len += n; + sb->buf[sb->len] = '\0'; +} + +static void ts_sb_puts(ts_sb_t *sb, const char *s) { + ts_sb_put(sb, s, strlen(s)); +} + +/* A field's text with tabs and newlines as spaces, never empty. */ +static void ts_sb_field(ts_sb_t *sb, const char *s, size_t n) { + ts_sb_put(sb, "\t", SKIP_ONE); + if (n == 0) { + ts_sb_put(sb, "-", SKIP_ONE); + return; + } + for (size_t i = 0; i < n; i++) { + char c = (s[i] == '\t' || s[i] == '\n' || s[i] == '\r') ? ' ' : s[i]; + ts_sb_put(sb, &c, SKIP_ONE); + } +} + +typedef struct { + CBMExtractCtx *ctx; + ts_sb_t *sb; + bool module_syntax; +} ts_walk_t; + +static bool ts_kind(TSNode n, const char *k) { + return !ts_node_is_null(n) && strcmp(ts_node_type(n), k) == 0; +} + +static void ts_node_field(ts_walk_t *w, TSNode n) { + uint32_t b = ts_node_start_byte(n); + uint32_t e = ts_node_end_byte(n); + ts_sb_field(w->sb, w->ctx->source + b, e - b); +} + +/* A string literal's content (quotes dropped). */ +static void ts_string_field(ts_walk_t *w, TSNode s) { + uint32_t b = ts_node_start_byte(s); + uint32_t e = ts_node_end_byte(s); + const char *src = w->ctx->source; + if (e - b >= PAIR_LEN && (src[b] == '"' || src[b] == '\'' || src[b] == '`')) { + b++; + e--; + } + ts_sb_field(w->sb, src + b, e - b); +} + +static TSNode ts_source_of(TSNode stmt) { + TSNode s = ts_node_child_by_field_name(stmt, TS_FIELD("source")); + if (!ts_node_is_null(s)) { + return s; + } + uint32_t nc = ts_node_child_count(stmt); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(stmt, i); + if (ts_kind(c, "string")) { + return c; + } + } + return s; +} + +static void ts_import_rec(ts_walk_t *w, TSNode local, char kind, TSNode src, TSNode orig) { + ts_sb_puts(w->sb, "\nI"); + ts_node_field(w, local); + char k = kind; + ts_sb_field(w->sb, &k, SKIP_ONE); + ts_string_field(w, src); + if (ts_node_is_null(orig)) { + ts_sb_field(w->sb, "", 0); + } else { + ts_node_field(w, orig); + } +} + +static void ts_walk_import(ts_walk_t *w, TSNode stmt) { + w->module_syntax = true; + TSNode src = ts_source_of(stmt); + uint32_t nc = ts_node_child_count(stmt); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(stmt, i); + if (ts_kind(c, "import_require_clause")) { + TSNode id = ts_node_named_child(c, 0); + TSNode s = ts_node_child_by_field_name(c, TS_FIELD("source")); + if (ts_kind(id, "identifier") && !ts_node_is_null(s)) { + TSNode none = {0}; + ts_import_rec(w, id, 's', s, none); + } + continue; + } + if (!ts_kind(c, "import_clause") || ts_node_is_null(src)) { + continue; + } + uint32_t cc = ts_node_child_count(c); + for (uint32_t k = 0; k < cc; k++) { + TSNode sub = ts_node_child(c, k); + TSNode none = {0}; + if (ts_kind(sub, "identifier")) { + ts_import_rec(w, sub, 'd', src, none); + } else if (ts_kind(sub, "namespace_import")) { + TSNode id = ts_node_named_child(sub, ts_node_named_child_count(sub) - SKIP_ONE); + if (ts_kind(id, "identifier")) { + ts_import_rec(w, id, 's', src, none); + } + } else if (ts_kind(sub, "named_imports")) { + TSTreeCursor cur = ts_tree_cursor_new(sub); + for (bool more = ts_tree_cursor_goto_first_child(&cur); more; + more = ts_tree_cursor_goto_next_sibling(&cur)) { + TSNode spec = ts_tree_cursor_current_node(&cur); + if (!ts_kind(spec, "import_specifier")) { + continue; + } + TSNode name = ts_node_child_by_field_name(spec, TS_FIELD("name")); + TSNode alias = ts_node_child_by_field_name(spec, TS_FIELD("alias")); + if (!ts_node_is_null(name)) { + ts_import_rec(w, ts_node_is_null(alias) ? name : alias, 'n', src, name); + } + } + ts_tree_cursor_delete(&cur); + } + } + } +} + +/* The name a declaration node declares; null when it has none. */ +static TSNode ts_decl_name(TSNode d) { + return ts_node_child_by_field_name(d, TS_FIELD("name")); +} + +static bool ts_is_decl(TSNode d) { + static const char *const kinds[] = {"class_declaration", + "abstract_class_declaration", + "function_declaration", + "generator_function_declaration", + "function_signature", + "interface_declaration", + "type_alias_declaration", + "enum_declaration", + "internal_module", + "module", + "class", + NULL}; + for (int i = 0; kinds[i]; i++) { + if (ts_kind(d, kinds[i])) { + return true; + } + } + return false; +} + +static void ts_decl_rec(ts_walk_t *w, TSNode name, bool exported, bool dflt) { + ts_sb_puts(w->sb, "\nD"); + ts_node_field(w, name); + char flags[CBM_SZ_4]; + size_t f = 0; + if (exported) { + flags[f++] = 'e'; + } + if (dflt) { + flags[f++] = 'd'; + } + ts_sb_field(w->sb, flags, f); +} + +static void ts_walk_namespaces(ts_walk_t *w, TSNode body, const char *prefix, int depth); + +/* One top-level (or namespace-level, when prefix) declaration. */ +static void ts_walk_decl(ts_walk_t *w, TSNode d, bool exported, bool dflt, const char *prefix, + int depth) { + if (ts_kind(d, "ambient_declaration")) { + uint32_t nc = ts_node_named_child_count(d); + for (uint32_t i = 0; i < nc; i++) { + ts_walk_decl(w, ts_node_named_child(d, i), exported, dflt, prefix, depth); + } + return; + } + if (ts_kind(d, "lexical_declaration") || ts_kind(d, "variable_declaration")) { + if (prefix) { + return; + } + TSTreeCursor cur = ts_tree_cursor_new(d); + for (bool more = ts_tree_cursor_goto_first_child(&cur); more; + more = ts_tree_cursor_goto_next_sibling(&cur)) { + TSNode v = ts_tree_cursor_current_node(&cur); + TSNode name = ts_kind(v, "variable_declarator") ? ts_decl_name(v) : v; + if (ts_kind(name, "identifier")) { + ts_decl_rec(w, name, exported, false); + } + } + ts_tree_cursor_delete(&cur); + return; + } + if (!ts_is_decl(d)) { + return; + } + TSNode name = ts_decl_name(d); + if (ts_node_is_null(name)) { + return; + } + if (!prefix) { + ts_decl_rec(w, name, exported, dflt); + } + if ((ts_kind(d, "internal_module") || ts_kind(d, "module")) && depth < TS_WALK_MAX_DEPTH) { + TSNode body = ts_node_child_by_field_name(d, TS_FIELD("body")); + if (ts_node_is_null(body)) { + return; + } + uint32_t b = ts_node_start_byte(name); + uint32_t e = ts_node_end_byte(name); + size_t pl = prefix ? strlen(prefix) : 0; + char *path = (char *)cbm_arena_alloc(w->sb->a, pl + (e - b) + PAIR_LEN); + if (!path) { + w->sb->oom = true; + return; + } + size_t k = 0; + if (pl) { + memcpy(path, prefix, pl); + path[pl] = '.'; + k = pl + SKIP_ONE; + } + memcpy(path + k, w->ctx->source + b, e - b); + path[k + (e - b)] = '\0'; + ts_sb_puts(w->sb, "\nN"); + ts_sb_field(w->sb, path, strlen(path)); + char num[CBM_SZ_32]; + snprintf(num, sizeof(num), "%u", ts_node_start_point(body).row + SKIP_ONE); + ts_sb_field(w->sb, num, strlen(num)); + snprintf(num, sizeof(num), "%u", ts_node_end_point(body).row + SKIP_ONE); + ts_sb_field(w->sb, num, strlen(num)); + ts_walk_namespaces(w, body, path, depth + SKIP_ONE); + } +} + +/* The namespaces declared in a namespace body, exported or not. */ +static void ts_walk_namespaces(ts_walk_t *w, TSNode body, const char *prefix, int depth) { + TSTreeCursor cur = ts_tree_cursor_new(body); + for (bool more = ts_tree_cursor_goto_first_child(&cur); more; + more = ts_tree_cursor_goto_next_sibling(&cur)) { + TSNode c = ts_tree_cursor_current_node(&cur); + if (!ts_node_is_named(c)) { + continue; + } + if (ts_kind(c, "export_statement")) { + TSNode d = ts_node_child_by_field_name(c, TS_FIELD("declaration")); + if (!ts_node_is_null(d)) { + ts_walk_decl(w, d, true, false, prefix, depth); + } + } else if (ts_kind(c, "expression_statement")) { + uint32_t ec = ts_node_named_child_count(c); + for (uint32_t k = 0; k < ec; k++) { + ts_walk_decl(w, ts_node_named_child(c, k), false, false, prefix, depth); + } + } else { + ts_walk_decl(w, c, false, false, prefix, depth); + } + } + ts_tree_cursor_delete(&cur); +} + +static bool ts_has_default(TSNode stmt) { + uint32_t nc = ts_node_child_count(stmt); + for (uint32_t i = 0; i < nc; i++) { + if (ts_kind(ts_node_child(stmt, i), "default")) { + return true; + } + } + return false; +} + +static void ts_walk_export_clause(ts_walk_t *w, TSNode clause, TSNode src) { + TSTreeCursor cur = ts_tree_cursor_new(clause); + for (bool more = ts_tree_cursor_goto_first_child(&cur); more; + more = ts_tree_cursor_goto_next_sibling(&cur)) { + TSNode spec = ts_tree_cursor_current_node(&cur); + if (!ts_kind(spec, "export_specifier")) { + continue; + } + TSNode name = ts_node_child_by_field_name(spec, TS_FIELD("name")); + TSNode alias = ts_node_child_by_field_name(spec, TS_FIELD("alias")); + if (ts_node_is_null(name)) { + continue; + } + TSNode as = ts_node_is_null(alias) ? name : alias; + if (ts_node_is_null(src)) { + ts_sb_puts(w->sb, "\nL"); + ts_node_field(w, as); + ts_node_field(w, name); + } else { + ts_sb_puts(w->sb, "\nF"); + ts_node_field(w, as); + ts_string_field(w, src); + ts_node_field(w, name); + } + } + ts_tree_cursor_delete(&cur); +} + +static void ts_walk_export(ts_walk_t *w, TSNode stmt) { + w->module_syntax = true; + TSNode src = ts_source_of(stmt); + TSNode decl = ts_node_child_by_field_name(stmt, TS_FIELD("declaration")); + bool dflt = ts_has_default(stmt); + if (!ts_node_is_null(decl)) { + ts_walk_decl(w, decl, true, dflt, NULL, 0); + return; + } + uint32_t nc = ts_node_child_count(stmt); + bool star = false; + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(stmt, i); + if (ts_kind(c, "export_clause")) { + ts_walk_export_clause(w, c, src); + return; + } + if (ts_kind(c, "namespace_export") && !ts_node_is_null(src)) { + TSNode id = ts_node_named_child(c, 0); + if (!ts_node_is_null(id)) { + ts_sb_puts(w->sb, "\nS"); + ts_node_field(w, id); + ts_string_field(w, src); + } + return; + } + star = star || ts_kind(c, "*"); + } + if (star && !ts_node_is_null(src)) { + ts_sb_puts(w->sb, "\nA"); + ts_string_field(w, src); + return; + } + TSNode value = ts_node_child_by_field_name(stmt, TS_FIELD("value")); + if (dflt && ts_kind(value, "identifier")) { + ts_sb_puts(w->sb, "\nL\tdefault"); + ts_node_field(w, value); + } +} + +static bool ts_is_function_like(TSNode n) { + static const char *const kinds[] = {"function_declaration", "generator_function_declaration", + "function_expression", "function", + "generator_function", "arrow_function", + "method_definition", NULL}; + for (int i = 0; kinds[i]; i++) { + if (ts_kind(n, kinds[i])) { + return true; + } + } + return false; +} + +typedef struct { + uint32_t first; /* a function body's lines */ + uint32_t last; + int depth; /* the function's depth in the tree */ +} ts_scope_t; + +static void ts_put_nested(ts_walk_t *w, TSNode decl, const ts_scope_t *in) { + TSNode name = ts_decl_name(decl); + if (!ts_kind(name, "identifier") && !ts_kind(name, "type_identifier")) { + return; + } + char num[CBM_SZ_32]; + ts_sb_puts(w->sb, "\nV"); + ts_node_field(w, name); + snprintf(num, sizeof(num), "%u", ts_node_start_point(decl).row + SKIP_ONE); + ts_sb_field(w->sb, num, strlen(num)); + snprintf(num, sizeof(num), "%u", in->first); + ts_sb_field(w->sb, num, strlen(num)); + snprintf(num, sizeof(num), "%u", in->last); + ts_sb_field(w->sb, num, strlen(num)); +} + +/* V records: every function or class declared inside a function body, with + * its line and the body's lines (the scope it is visible in). The graph gives + * such a declaration a file-level qualified name. One cursor walk over the + * tree; the stack holds the function bodies around the current node. */ +static void ts_walk_nested(ts_walk_t *w, TSNode root) { + int cap = CBM_SZ_64; + int ns = 0; + ts_scope_t *scopes = (ts_scope_t *)cbm_arena_alloc(w->sb->a, (size_t)cap * sizeof(ts_scope_t)); + if (!scopes) { + w->sb->oom = true; + return; + } + TSTreeCursor cur = ts_tree_cursor_new(root); + int depth = 0; + for (bool more = true; more && !w->sb->oom;) { + TSNode n = ts_tree_cursor_current_node(&cur); + while (ns > 0 && scopes[ns - SKIP_ONE].depth >= depth) { + ns--; /* left that function */ + } + if (ts_node_is_named(n)) { + if (ns > 0 && + (ts_kind(n, "function_declaration") || + ts_kind(n, "generator_function_declaration") || ts_kind(n, "class_declaration"))) { + ts_put_nested(w, n, &scopes[ns - SKIP_ONE]); + } + TSNode body = ts_is_function_like(n) ? ts_node_child_by_field_name(n, TS_FIELD("body")) + : (TSNode){0}; + if (!ts_node_is_null(body)) { + if (ns == cap) { + int ncap = cap * PAIR_LEN; + ts_scope_t *g = + (ts_scope_t *)cbm_arena_alloc(w->sb->a, (size_t)ncap * sizeof(ts_scope_t)); + if (!g) { + w->sb->oom = true; + break; + } + memcpy(g, scopes, (size_t)ns * sizeof(ts_scope_t)); + scopes = g; + cap = ncap; + } + scopes[ns++] = (ts_scope_t){ts_node_start_point(body).row + SKIP_ONE, + ts_node_end_point(body).row + SKIP_ONE, depth}; + } + } + if (ts_tree_cursor_goto_first_child(&cur)) { + depth++; + continue; + } + while (!ts_tree_cursor_goto_next_sibling(&cur)) { + if (!ts_tree_cursor_goto_parent(&cur)) { + more = false; + break; + } + depth--; + } + } + ts_tree_cursor_delete(&cur); +} + +const char *cbm_doclink_ts_scan_scope(CBMExtractCtx *ctx) { + if (!ctx || ts_node_is_null(ctx->root)) { + return NULL; + } + ts_sb_t sb = {.a = ctx->arena}; + ts_sb_puts(&sb, CBM_DOCLINK_TS_SCOPE_TAG); + ts_walk_t w = {.ctx = ctx, .sb = &sb}; + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (bool more = ts_tree_cursor_goto_first_child(&cur); more; + more = ts_tree_cursor_goto_next_sibling(&cur)) { + TSNode c = ts_tree_cursor_current_node(&cur); + if (!ts_node_is_named(c)) { + continue; + } + if (ts_kind(c, "import_statement")) { + ts_walk_import(&w, c); + } else if (ts_kind(c, "export_statement")) { + ts_walk_export(&w, c); + } else if (ts_kind(c, "expression_statement")) { + uint32_t ec = ts_node_named_child_count(c); + for (uint32_t k = 0; k < ec; k++) { + ts_walk_decl(&w, ts_node_named_child(c, k), false, false, NULL, 0); + } + } else { + ts_walk_decl(&w, c, false, false, NULL, 0); + } + } + ts_tree_cursor_delete(&cur); + ts_walk_nested(&w, ctx->root); + if (w.module_syntax) { + ts_sb_puts(&sb, "\nM"); + } + return sb.oom ? NULL : sb.buf; +} + +/* The blob without lines: "N path first last" -> "N path", V records dropped. */ +char *cbm_doclink_ts_portable_scope(const char *scope) { + size_t n = strlen(scope); + char *out = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, n + SKIP_ONE); + if (!out) { + return NULL; + } + size_t w = 0; + for (const char *p = scope; *p;) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + size_t keep = len; + if (len >= PAIR_LEN && p[0] == 'V' && p[SKIP_ONE] == '\t') { + p = nl ? nl + SKIP_ONE : p + len; + continue; + } + if (len >= PAIR_LEN && p[0] == 'N' && p[SKIP_ONE] == '\t') { + const char *path_end = memchr(p + PAIR_LEN, '\t', len - PAIR_LEN); + keep = path_end ? (size_t)(path_end - p) : len; + } + memcpy(out + w, p, keep); + w += keep; + if (nl) { + out[w++] = '\n'; + } + p = nl ? nl + SKIP_ONE : p + len; + } + out[w] = '\0'; + return out; +} diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 2a3b9d14d..e046bca83 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -2327,6 +2327,7 @@ typedef struct { size_t name_len; uint32_t sb; uint32_t eb; + bool used; /* a sub's doc holds it (pod_doc_by_name): not the module's */ } pod_sec_t; typedef struct { @@ -2587,15 +2588,16 @@ static void pod_index_block(CBMExtractCtx *ctx, pod_index_t *ix, TSNode pod) { continue; } uint32_t se = pod_section_end(cmds, n, ci, pe); - pod_sec_t sec = {nm, nl, cmds[ci].sb, pod_trim_end(ctx->source, cmds[ci].sb, se)}; + pod_sec_t sec = {nm, nl, cmds[ci].sb, pod_trim_end(ctx->source, cmds[ci].sb, se), false}; pod_index_push(a, ix, &sec); } } -/* The file's POD sections by name, built on the first Perl sub. */ -static const pod_index_t *pod_index(CBMExtractCtx *ctx) { +/* The file's POD sections by name, built on the first Perl sub (or for the + * module doc of a file without one). */ +static pod_index_t *pod_index(CBMExtractCtx *ctx) { if (ctx->doc_pod_index) { - return (const pod_index_t *)ctx->doc_pod_index; + return (pod_index_t *)ctx->doc_pod_index; } pod_index_t *ix = (pod_index_t *)cbm_arena_alloc(doc_scratch(ctx), sizeof(pod_index_t)); if (!ix) { @@ -2623,7 +2625,7 @@ static bool pod_sec_names(const pod_sec_t *s, const char *name, size_t nl) { /* Every POD section whose =head / =item names the sub exactly, joined in * document order; NULL when none does. */ static const char *pod_doc_by_name(CBMExtractCtx *ctx, const char *name) { - const pod_index_t *ix = pod_index(ctx); + pod_index_t *ix = pod_index(ctx); if (!ix || !name || !name[0]) { return NULL; } @@ -2643,10 +2645,11 @@ static const char *pod_doc_by_name(CBMExtractCtx *ctx, const char *name) { } size_t w = 0; for (int i = 0; i < ix->count; i++) { - const pod_sec_t *s = &ix->items[i]; + pod_sec_t *s = &ix->items[i]; if (!pod_sec_names(s, name, nl)) { continue; } + s->used = true; if (w > 0) { buf[w++] = '\n'; buf[w++] = '\n'; @@ -2658,6 +2661,137 @@ static const char *pod_doc_by_name(CBMExtractCtx *ctx, const char *name) { return buf; } +/* True when src[b, e) holds a paragraph that is text, or a =head / =item + * command: not only =cut, =pod, =over and =back lines. */ +static bool pod_run_has_text(const char *src, uint32_t b, uint32_t e) { + uint32_t pos = b; + uint32_t sb = 0; + uint32_t eb = 0; + int depth = 0; + while (pod_next_para(src, &pos, e, &sb, &eb)) { + if (src[sb] != '=' || sb + SKIP_ONE >= eb || !isalpha((unsigned char)src[sb + SKIP_ONE])) { + return true; + } + pod_cmd_t c = pod_classify(src, sb, eb, &depth); + if (c.type == POD_HEAD || c.type == POD_ITEM || c.type == POD_OTHER) { + return true; + } + } + return false; +} + +typedef struct { + CBMExtractCtx *ctx; + char *buf; + size_t w; + uint32_t first_line; /* source line of the first run kept, 0 before it */ + TSNode block; + int next_sec; /* the first index section not yet passed: blocks go in order */ +} pod_module_t; + +/* Append src[b, e) of the current block to the module doc, after a blank + * line, unless it holds no text. */ +static void pod_module_run(pod_module_t *m, uint32_t b, uint32_t e) { + const char *src = m->ctx->source; + while (b < e) { /* leading blank lines */ + uint32_t le = b; + while (le < e && src[le] != '\n') { + le++; + } + if (!pod_line_is_blank(src, b, le)) { + break; + } + b = le < e ? le + SKIP_ONE : e; + } + e = pod_trim_end(src, b, e); + if (b >= e || !pod_run_has_text(src, b, e)) { + return; + } + if (m->first_line == 0) { + uint32_t line = ts_node_start_point(m->block).row + SKIP_ONE; + for (uint32_t k = ts_node_start_byte(m->block); k < b; k++) { + line += src[k] == '\n' ? SKIP_ONE : 0; + } + m->first_line = line; + } + if (m->w > 0) { + m->buf[m->w++] = '\n'; + m->buf[m->w++] = '\n'; + } + memcpy(m->buf + m->w, src + b, e - b); + m->w += e - b; +} + +/* Perl's module doc, for the File node: the file's POD outside the sections a + * sub's doc holds, so that no reference is read twice. Every POD block with + * those sections cut out, each remaining run as it is in the source, in + * document order and joined by a blank line (a sub's doc joins its sections + * the same way); a run of only =cut / =pod / =over / =back lines is dropped. + * Built after the definitions, which mark the sections they take. NULL when + * nothing is left. */ +static const char *doc_perl_module(CBMExtractCtx *ctx) { + pod_index_t *ix = pod_index(ctx); + if (!ix) { + return NULL; + } + size_t total = 0; + int blocks = 0; + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + if (ts_tree_cursor_goto_first_child(&cur)) { + do { + TSNode ch = ts_tree_cursor_current_node(&cur); + if (strcmp(ts_node_type(ch), "pod") == 0) { + total += ts_node_end_byte(ch) - ts_node_start_byte(ch); + blocks++; + } + } while (ts_tree_cursor_goto_next_sibling(&cur)); + } + if (blocks == 0) { + ts_tree_cursor_delete(&cur); + return NULL; + } + /* each block yields at most one run more than the sections it loses */ + size_t runs = (size_t)blocks + (size_t)ix->count; + pod_module_t m = {.ctx = ctx}; + m.buf = (char *)cbm_arena_alloc(ctx->arena, total + (runs * PAIR_LEN) + SKIP_ONE); + if (!m.buf) { + ts_tree_cursor_delete(&cur); + return NULL; + } + ts_tree_cursor_reset(&cur, ctx->root); + if (ts_tree_cursor_goto_first_child(&cur)) { + do { + TSNode ch = ts_tree_cursor_current_node(&cur); + if (strcmp(ts_node_type(ch), "pod") != 0) { + continue; + } + m.block = ch; + uint32_t pb = ts_node_start_byte(ch); + uint32_t pe = ts_node_end_byte(ch); + uint32_t at = pb; + /* the index holds the blocks' sections in document order */ + for (; m.next_sec < ix->count && ix->items[m.next_sec].sb < pe; m.next_sec++) { + const pod_sec_t *s = &ix->items[m.next_sec]; + if (!s->used || s->sb < pb) { + continue; + } + if (s->sb > at) { + pod_module_run(&m, at, s->sb); + } + at = s->eb > at ? s->eb : at; + } + pod_module_run(&m, at, pe); + } while (ts_tree_cursor_goto_next_sibling(&cur)); + } + ts_tree_cursor_delete(&cur); + if (m.w == 0) { + return NULL; + } + m.buf[m.w] = '\0'; + cbm_doclink_note_doc_line(ctx, m.buf, m.first_line); + return m.buf; +} + /* ── Doc entry points ── */ /* go/doc: a type spec's own doc, else its declaration's (the doc of a @@ -6706,10 +6840,22 @@ static bool is_markdown_heading_kind(const char *kind) { * with nested subsections as further `section` children that own their own * text. Stopping at either a `section` or another heading therefore gives each * Section exactly its OWN body under both that shape and a flat one. The bytes - * between are contiguous in the source, so one slice beats concatenation. */ -static const char *extract_markdown_section_body(CBMArena *a, TSNode heading, const char *source) { + * between are contiguous in the source, so one slice beats concatenation. + * + * A top-level diagram fence (```mermaid, plantuml, dot, d2 ...) is the one + * exception: the body keeps the labels its diagram parser reads, not the + * format's syntax. Arrows and keywords (`-->`, `participant`, `classDiagram`) + * name no code and crowd the prose out of the 500-byte cap, while the labels + * are what name the code. Judged on held-out sections that embed diagrams, + * labels gave more sections a correct code candidate (56 vs 42 of 147) at a + * higher precision (0.54 vs 0.45) than the fence as written; dropping the + * fence lost candidates (29). The diagram itself keeps its own nodes. */ +static const char *extract_markdown_section_body(CBMExtractCtx *ctx, TSNode heading) { + CBMArena *a = ctx->arena; + const char *source = ctx->source; uint32_t start = ts_node_end_byte(heading); uint32_t end = start; + int fences = 0; for (TSNode sib = ts_node_next_sibling(heading); !ts_node_is_null(sib); sib = ts_node_next_sibling(sib)) { const char *sk = ts_node_type(sib); @@ -6717,11 +6863,57 @@ static const char *extract_markdown_section_body(CBMArena *a, TSNode heading, co break; } end = ts_node_end_byte(sib); + fences += cbm_diagram_fence_is_diagram(source, sib); } if (end <= start) { return NULL; } - return collapse_prose(a, source + start, (size_t)(end - start)); + if (fences == 0) { + return collapse_prose(a, source + start, (size_t)(end - start)); + } + /* The body with each diagram fence replaced by its labels. */ + const char **labels = (const char **)cbm_arena_alloc(a, (size_t)fences * sizeof(char *)); + if (!labels) { + return NULL; + } + size_t total = 0; + int k = 0; + for (TSNode sib = ts_node_next_sibling(heading); !ts_node_is_null(sib) && k < fences; + sib = ts_node_next_sibling(sib)) { + if (cbm_diagram_fence_is_diagram(source, sib)) { + labels[k] = cbm_diagram_fence_labels(ctx, sib); + total += labels[k] ? strlen(labels[k]) + SKIP_ONE : 0; + k++; + } + } + char *buf = (char *)cbm_arena_alloc(a, (size_t)(end - start) + total + SKIP_ONE); + if (!buf) { + return NULL; + } + size_t w = 0; + uint32_t prev = start; + k = 0; + for (TSNode sib = ts_node_next_sibling(heading); !ts_node_is_null(sib) && k < fences; + sib = ts_node_next_sibling(sib)) { + if (!cbm_diagram_fence_is_diagram(source, sib)) { + continue; + } + uint32_t sb = ts_node_start_byte(sib); + memcpy(buf + w, source + prev, sb - prev); + w += sb - prev; + if (labels[k]) { + size_t n = strlen(labels[k]); + memcpy(buf + w, labels[k], n); + w += n; + buf[w++] = ' '; + } + k++; + prev = ts_node_end_byte(sib); + } + memcpy(buf + w, source + prev, end - prev); + w += end - prev; + buf[w] = '\0'; + return collapse_prose(a, buf, w); } /* Push a config-language definition that carries prose. push_simple_class_def @@ -6921,7 +7113,7 @@ static bool extract_config_class_def(CBMExtractCtx *ctx, TSNode node, const char // "Class"; that assertion is the inaccurate side and is flagged for review. label = "Section"; // #518: index what the section SAYS, not just its title. - docstring = extract_markdown_section_body(a, node, ctx->source); + docstring = extract_markdown_section_body(ctx, node); } else if (ctx->language == CBM_LANG_HCL && strcmp(kind, "block") == 0) { name = find_hcl_block_name(a, node, ctx->source); } else { @@ -12065,6 +12257,10 @@ void cbm_extract_definitions(CBMExtractCtx *ctx) { ctx->result->module_doc = extract_module_doc(ctx); cbm_extract_definitions_without_module(ctx); + if (ctx->language == CBM_LANG_PERL) { + /* after the subs: the POD sections they hold are not the module's */ + ctx->result->module_doc = doc_perl_module(ctx); + } if (ctx->language == CBM_LANG_JAVASCRIPT || ctx->language == CBM_LANG_TYPESCRIPT || ctx->language == CBM_LANG_TSX || ctx->language == CBM_LANG_ARKTS) { diff --git a/internal/cbm/lang_specs.c b/internal/cbm/lang_specs.c index 6672011d0..11e8a6bec 100644 --- a/internal/cbm/lang_specs.c +++ b/internal/cbm/lang_specs.c @@ -2713,6 +2713,14 @@ static const CBMLangSpec lang_specs[CBM_LANG_COUNT] = { empty_types, NULL, NULL, tree_sitter_objectscript_routine, NULL}, + // CBM_LANG_OPENAPI — openapi.json / swagger.json: the JSON grammar without + // JSON's node per key (thousands in a generated spec); cbm_extract_openapi + // (openapi.c) reads the operations from the tree. + [CBM_LANG_OPENAPI] = {CBM_LANG_OPENAPI, empty_types, empty_types, empty_types, + json_module_types, empty_types, empty_types, empty_types, empty_types, + empty_types, empty_types, empty_types, NULL, empty_types, NULL, NULL, + tree_sitter_json, NULL}, + // CBM_LANG_PDF — PDF documents. No grammar row: extraction reads the text // layer itself (cbm_pdf_extract_document, doc_pdf.c) before any grammar is // looked up, so this language never reaches cbm_lang_spec()/cbm_ts_language(). @@ -2721,6 +2729,9 @@ static const CBMLangSpec lang_specs[CBM_LANG_COUNT] = { // CBM_LANG_ASCIIDOC — AsciiDoc documents. No grammar row, as PDF: extraction // reads the file itself (cbm_adoc_extract_document, doc_adoc.c). Zero spec. + // CBM_LANG_DIAGRAM — diagram files. No grammar row, as PDF: extraction reads + // the file itself (cbm_extract_diagram_file, diagram.c). Zero spec. + // CBM_LANG_OBJECTSCRIPT_EXPORT — Studio Export XML. No grammar row: the // pipeline transcodes Export XML to UDL (iris_export_xml.c) and re-extracts // each class as CBM_LANG_OBJECTSCRIPT_UDL, so this language never reaches diff --git a/internal/cbm/openapi.c b/internal/cbm/openapi.c new file mode 100644 index 000000000..adf8bac7e --- /dev/null +++ b/internal/cbm/openapi.c @@ -0,0 +1,239 @@ +/* + * openapi.c -- OpenAPI and Swagger documents: each operation a Route, named by + * the same QN as the route its handler registers in code (route_path.c), and + * its `operationId` a reference to that handler. + * + * A Route is a definition of the document (its file DEFINES it). The + * operationId is a doc-link record (CBM_DOCLINK_OPENAPI_OPERATION_ID) the + * pipeline resolves as a qualified code name: Connexion's dotted paths + * (`app.api.users.get_user`, or `x-openapi-router-controller` plus the id) + * name one function; a bare id (`getUser`) names none for sure and stays + * unresolved. + * + * The documents: `openapi.json` / `swagger.json` (CBM_LANG_OPENAPI, read with + * the JSON grammar but no node per key), and any YAML or JSON file whose root + * says `openapi:` or `swagger:` and has `paths`. + */ +#include "cbm.h" +#include "doclink.h" + +#include "arena.h" +#include "foundation/constants.h" +#include "tree_sitter/api.h" + +#include +#include +#include +#include +#include + +enum { + OA_SCALAR_MAX = 1024, /* a key, a path, an operationId */ + OA_UNWRAP_MAX = 8, /* stream > document > block_node > mapping */ + OA_METHOD_MAX = 8, +}; + +static bool oa_is(TSNode n, const char *type) { + return !ts_node_is_null(n) && strcmp(ts_node_type(n), type) == 0; +} + +/* The mapping a node holds (a YAML block or flow mapping, a JSON object), + * through the wrappers around it; a null node for none. */ +static TSNode oa_mapping(TSNode n) { + for (int k = 0; k < OA_UNWRAP_MAX && !ts_node_is_null(n); k++) { + if (oa_is(n, "block_mapping") || oa_is(n, "flow_mapping") || oa_is(n, "object")) { + return n; + } + if (!oa_is(n, "stream") && !oa_is(n, "document") && !oa_is(n, "block_node") && + !oa_is(n, "flow_node")) { + break; + } + TSNode next = {0}; + uint32_t nc = ts_node_named_child_count(n); + for (uint32_t i = 0; i < nc && ts_node_is_null(next); i++) { + TSNode c = ts_node_named_child(n, i); + if (!oa_is(c, "comment") && !oa_is(c, "anchor") && !oa_is(c, "tag")) { + next = c; + } + } + n = next; + } + return (TSNode){0}; +} + +static bool oa_pair(TSNode n) { + return oa_is(n, "block_mapping_pair") || oa_is(n, "flow_pair") || oa_is(n, "pair"); +} + +/* A scalar's text (quotes dropped) into out: its length, 0 for none. */ +static size_t oa_scalar(const CBMExtractCtx *ctx, TSNode n, char *out, size_t cap) { + for (int k = 0; k < OA_UNWRAP_MAX && (oa_is(n, "flow_node") || oa_is(n, "block_node")); k++) { + n = ts_node_named_child(n, 0); + } + if (ts_node_is_null(n) || !ts_node_is_null(oa_mapping(n)) || oa_is(n, "block_sequence") || + oa_is(n, "flow_sequence") || oa_is(n, "array")) { + out[0] = '\0'; + return 0; + } + uint32_t b = ts_node_start_byte(n); + uint32_t e = ts_node_end_byte(n); + const char *s = ctx->source + b; + size_t len = e - b; + while (len && isspace((unsigned char)s[len - SKIP_ONE])) { + len--; + } + if (len >= PAIR_LEN && (s[0] == '"' || s[0] == '\'') && s[len - SKIP_ONE] == s[0]) { + s++; + len -= PAIR_LEN; + } + if (len >= cap) { + out[0] = '\0'; + return 0; /* longer than any key or reference read here */ + } + memcpy(out, s, len); + out[len] = '\0'; + return len; +} + +/* The value of `key` in a mapping; a null node for none. */ +static TSNode oa_get(const CBMExtractCtx *ctx, TSNode map, const char *key) { + char k[OA_SCALAR_MAX]; + uint32_t nc = ts_node_is_null(map) ? 0 : ts_node_named_child_count(map); + for (uint32_t i = 0; i < nc; i++) { + TSNode p = ts_node_named_child(map, i); + if (oa_pair(p) && + oa_scalar(ctx, ts_node_child_by_field_name(p, "key", CBM_SZ_3), k, sizeof(k)) && + strcmp(k, key) == 0) { + return ts_node_child_by_field_name(p, "value", CBM_SZ_5); + } + } + return (TSNode){0}; +} + +static const char *oa_dup_scalar(const CBMExtractCtx *ctx, TSNode n) { + char v[OA_SCALAR_MAX]; + size_t len = oa_scalar(ctx, n, v, sizeof(v)); + return len ? cbm_arena_strndup(ctx->arena, v, len) : NULL; +} + +/* Connexion's controller: on the operation, else its path. */ +static const char *oa_controller(const CBMExtractCtx *ctx, TSNode op, TSNode path_item) { + static const char *const keys[] = {"x-openapi-router-controller", "x-swagger-router-controller", + NULL}; + for (int k = 0; keys[k]; k++) { + const char *c = oa_dup_scalar(ctx, oa_get(ctx, op, keys[k])); + c = c ? c : oa_dup_scalar(ctx, oa_get(ctx, path_item, keys[k])); + if (c) { + return c; + } + } + return NULL; +} + +/* One operation: its Route, and its operationId's reference. */ +static void oa_operation(CBMExtractCtx *ctx, const char *path, const char *method, TSNode op, + TSNode path_item, TSNode pair) { + CBMArena *a = ctx->arena; + char upper[OA_METHOD_MAX]; + size_t ml = strlen(method); + for (size_t i = 0; i < ml && i + SKIP_ONE < sizeof(upper); i++) { + upper[i] = (char)toupper((unsigned char)method[i]); + upper[i + SKIP_ONE] = '\0'; + } + size_t pl = strlen(path); + char *canon = (char *)cbm_arena_alloc(a, pl + SKIP_ONE); + const char *qn = canon ? cbm_arena_sprintf(a, "__route__%s__%s", upper, + cbm_route_canon_path(path, canon, pl + SKIP_ONE)) + : NULL; + const char *op_id = oa_dup_scalar(ctx, oa_get(ctx, op, "operationId")); + const char **kv = (const char **)cbm_arena_alloc(a, CBM_SZ_7 * sizeof(char *)); + const char *m = cbm_arena_strdup(a, upper); + if (!qn || !kv || !m) { + return; + } + kv[0] = "method"; + kv[SKIP_ONE] = m; + kv[PAIR_LEN] = "source"; + kv[CBM_SZ_3] = "openapi"; + kv[CBM_SZ_4] = op_id ? "operation_id" : NULL; + kv[CBM_SZ_5] = op_id; + kv[CBM_SZ_6] = NULL; + CBMDefinition def; + memset(&def, 0, sizeof(def)); + def.name = cbm_arena_strdup(a, path); + def.qualified_name = qn; + def.label = "Route"; + def.file_path = ctx->rel_path; + def.start_line = ts_node_start_point(pair).row + SKIP_ONE; + def.end_line = ts_node_end_point(pair).row + SKIP_ONE; + def.extra_props = kv; + if (!def.name) { + return; + } + cbm_defs_push(&ctx->result->defs, a, def); + if (!op_id) { + return; + } + const char *controller = oa_controller(ctx, op, path_item); + CBMDocLink link = { + .source_qn = qn, + .raw = controller ? cbm_arena_sprintf(a, "%s.%s", controller, op_id) : op_id, + .line = def.start_line, + .def_line = def.start_line, + .syntax = CBM_DOCLINK_OPENAPI_OPERATION_ID, + }; + if (link.raw) { + cbm_doclinks_push(&ctx->result->doc_links, a, link); + } +} + +static bool oa_method(const char *k) { + static const char *const methods[] = {"get", "put", "post", "delete", "options", + "head", "patch", "trace", NULL}; + for (int i = 0; methods[i]; i++) { + if (strcmp(k, methods[i]) == 0) { + return true; + } + } + return false; +} + +void cbm_extract_openapi(CBMExtractCtx *ctx) { + if (!ctx || !ctx->result || !ctx->source || ts_node_is_null(ctx->root) || + (ctx->language != CBM_LANG_OPENAPI && ctx->language != CBM_LANG_YAML && + ctx->language != CBM_LANG_JSON)) { + return; + } + TSNode root = oa_mapping(ctx->root); + if (ts_node_is_null(oa_get(ctx, root, "openapi")) && + ts_node_is_null(oa_get(ctx, root, "swagger"))) { + return; /* no API description */ + } + TSNode paths = oa_mapping(oa_get(ctx, root, "paths")); + char path[OA_SCALAR_MAX]; + char key[OA_SCALAR_MAX]; + uint32_t np = ts_node_is_null(paths) ? 0 : ts_node_named_child_count(paths); + for (uint32_t i = 0; i < np; i++) { + TSNode pp = ts_node_named_child(paths, i); + if (!oa_pair(pp) || + !oa_scalar(ctx, ts_node_child_by_field_name(pp, "key", CBM_SZ_3), path, sizeof(path)) || + path[0] != '/') { + continue; + } + TSNode item = oa_mapping(ts_node_child_by_field_name(pp, "value", CBM_SZ_5)); + uint32_t nm = ts_node_is_null(item) ? 0 : ts_node_named_child_count(item); + for (uint32_t j = 0; j < nm; j++) { + TSNode mp = ts_node_named_child(item, j); + if (!oa_pair(mp) || + !oa_scalar(ctx, ts_node_child_by_field_name(mp, "key", CBM_SZ_3), key, + sizeof(key)) || + !oa_method(key)) { + continue; + } + TSNode op = oa_mapping(ts_node_child_by_field_name(mp, "value", CBM_SZ_5)); + if (!ts_node_is_null(op)) { + oa_operation(ctx, path, key, op, item, mp); + } + } + } +} diff --git a/internal/cbm/result_compact.c b/internal/cbm/result_compact.c index 7fcc6158d..b9e4d2be6 100644 --- a/internal/cbm/result_compact.c +++ b/internal/cbm/result_compact.c @@ -375,6 +375,14 @@ static void cr_walk(cr_ctx_t *c, CBMFileResult *r) { cr_str(c, &r->infra_bindings.items[i].target_url); cr_str(c, &r->infra_bindings.items[i].broker); } + cr_array(c, (void **)&r->diagram_arrows.items, r->diagram_arrows.count, + sizeof(CBMDiagramArrow)); + for (int i = 0; i < r->diagram_arrows.count && r->diagram_arrows.items; i++) { + cr_str(c, &r->diagram_arrows.items[i].from_qn); + cr_str(c, &r->diagram_arrows.items[i].to_qn); + cr_str(c, &r->diagram_arrows.items[i].label); + cr_str(c, &r->diagram_arrows.items[i].style); + } cr_array(c, (void **)&r->channels.items, r->channels.count, sizeof(CBMChannel)); for (int i = 0; i < r->channels.count && r->channels.items; i++) { cr_str(c, &r->channels.items[i].channel_name); @@ -540,6 +548,7 @@ void cbm_result_compact(CBMFileResult *result) { tmp.string_refs.cap = tmp.string_refs.count; tmp.infra_bindings.cap = tmp.infra_bindings.count; tmp.channels.cap = tmp.channels.count; + tmp.diagram_arrows.cap = tmp.diagram_arrows.count; tmp.field_types.cap = tmp.field_types.count; tmp.doc_links.cap = tmp.doc_links.count; diff --git a/internal/cbm/route_path.c b/internal/cbm/route_path.c new file mode 100644 index 000000000..0efd5c838 --- /dev/null +++ b/internal/cbm/route_path.c @@ -0,0 +1,98 @@ +/* + * route_path.c -- one Route QN per endpoint, whoever names it: a client call + * site, a handler's decorator or an OpenAPI operation (moved here from the + * route pass so extraction can name a Route as the pipeline does). + */ +#include "cbm.h" + +#include +#include + +/* True for characters that may appear in a ":name" route parameter. */ +static inline bool is_route_ident_char(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; +} + +/* Canonicalize route-path parameter placeholders to a single "{}" token so that + * client call sites and server handlers rendezvous on the same Route QN + * regardless of framework syntax. Each parameter token collapses to "{}": + * + * :name Express / React-Router / Rails / typical JS API clients + * {name} Axum / Spring / OpenAPI / ASP.NET + * Flask / Rocket (incl. typed "") + * ${...} JS template interpolation captured into the path + * + * Parameter names are intentionally discarded so the same logical endpoint + * matches across services that name the path variable differently. Static path + * text is copied verbatim; the result never exceeds the input length. */ +const char *cbm_route_canon_path(const char *in, char *out, size_t out_sz) { + if (out == NULL || out_sz == 0) { + /* WHY: cppcheck value-flow follows a caller that passes an as-yet + * uninitialized output buffer (e.g. `char cpath[256];`) and flags this + * `return out` as uninitvar. False positive: we only return the pointer + * here and never read the buffer; returning it (NULL, or a buffer too + * small to write into) is the intended early-out contract. */ + // cppcheck-suppress uninitvar + return out; + } + if (in == NULL) { + out[0] = '\0'; + return out; + } + const size_t last = out_sz - 1; + size_t oi = 0; + size_t i = 0; + while (in[i] != '\0' && oi < last) { + char c = in[i]; + bool at_seg_start = (oi == 0) || (out[oi - 1] == '/'); + bool is_param = false; + + if (c == ':' && at_seg_start && is_route_ident_char(in[i + 1])) { + i++; + while (in[i] != '\0' && is_route_ident_char(in[i])) { + i++; + } + is_param = true; + } else if (c == '{') { + i++; + while (in[i] != '\0' && in[i] != '}' && in[i] != '/') { + i++; + } + if (in[i] == '}') { + i++; + } + is_param = true; + } else if (c == '<') { + i++; + while (in[i] != '\0' && in[i] != '>' && in[i] != '/') { + i++; + } + if (in[i] == '>') { + i++; + } + is_param = true; + } else if (c == '$' && in[i + 1] == '{') { + i += 2; + while (in[i] != '\0' && in[i] != '}' && in[i] != '/') { + i++; + } + if (in[i] == '}') { + i++; + } + is_param = true; + } + + if (is_param) { + if (oi + 2 > last) { + break; + } + out[oi++] = '{'; + out[oi++] = '}'; + continue; + } + out[oi++] = c; + i++; + } + out[oi] = '\0'; + return out; +} diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index 251920e2f..7095384a0 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -75,7 +75,7 @@ src/pipeline/pass_pkgmap.c 82 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/path_alias.c 40 src/pipeline/pipeline.c 134 src/pipeline/pipeline_delta.c 19 src/pipeline/pipeline_incremental.c 143 diff --git a/src/discover/discover.c b/src/discover/discover.c index 26a5ab4d4..3bf4f034c 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -83,6 +83,9 @@ static const char *ALWAYS_IGNORED_SUFFIXES[] = { ".webp", ".svg", ".wasm", ".node", ".exe", ".bin", ".dat", ".db", ".sqlite", ".sqlite3", ".woff", ".woff2", ".ttf", ".eot", ".otf", NULL}; +/* SVG files that carry a diagram (diagram.c): read, not ignored as images. */ +static const char *DIAGRAM_SVG_SUFFIXES[] = {".drawio.svg", ".excalidraw.svg", NULL}; + static const char *FAST_IGNORED_SUFFIXES[] = { ".zip", ".tar", ".gz", ".bz2", ".xz", ".rar", ".7z", ".jar", ".war", ".ear", ".mp3", ".mp4", ".avi", ".mov", ".wav", ".flac", @@ -381,6 +384,11 @@ bool cbm_has_ignored_suffix(const char *filename, cbm_index_mode_t mode) { } size_t filename_length = strlen(filename); + for (int i = 0; DIAGRAM_SVG_SUFFIXES[i]; i++) { + if (ends_with(filename, filename_length, DIAGRAM_SVG_SUFFIXES[i])) { + return false; + } + } for (int i = 0; ALWAYS_IGNORED_SUFFIXES[i]; i++) { if (ends_with(filename, filename_length, ALWAYS_IGNORED_SUFFIXES[i])) { return true; diff --git a/src/discover/language.c b/src/discover/language.c index b1cd7d938..eb1284c06 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -621,6 +621,17 @@ static const ext_entry_t EXT_TABLE[] = { {".pdf", CBM_LANG_PDF}, /* AsciiDoc; `.asc` is left out: it is as often an ASCII-armored signature */ {".adoc", CBM_LANG_ASCIIDOC}, + /* Diagram files, read without a grammar (diagram.c) */ + {".puml", CBM_LANG_DIAGRAM}, + {".plantuml", CBM_LANG_DIAGRAM}, + {".iuml", CBM_LANG_DIAGRAM}, + {".wsd", CBM_LANG_DIAGRAM}, + {".dot", CBM_LANG_DIAGRAM}, + {".gv", CBM_LANG_DIAGRAM}, + {".d2", CBM_LANG_DIAGRAM}, + {".drawio", CBM_LANG_DIAGRAM}, + {".dio", CBM_LANG_DIAGRAM}, + {".excalidraw", CBM_LANG_DIAGRAM}, {".asciidoc", CBM_LANG_ASCIIDOC}, /* Slang */ @@ -712,6 +723,8 @@ typedef struct { static const filename_entry_t FILENAME_TABLE[] = { {"CMakeLists.txt", CBM_LANG_CMAKE}, {"Dockerfile", CBM_LANG_DOCKERFILE}, + {"openapi.json", CBM_LANG_OPENAPI}, /* API descriptions: their operations, not each key */ + {"swagger.json", CBM_LANG_OPENAPI}, {"GNUmakefile", CBM_LANG_MAKEFILE}, {"Makefile", CBM_LANG_MAKEFILE}, {"makefile", CBM_LANG_MAKEFILE}, @@ -835,6 +848,8 @@ static const char *LANG_NAMES[CBM_LANG_COUNT] = { [CBM_LANG_CHIALISP] = "Chialisp", [CBM_LANG_PDF] = "PDF", [CBM_LANG_ASCIIDOC] = "AsciiDoc", + [CBM_LANG_DIAGRAM] = "Diagram", + [CBM_LANG_OPENAPI] = "OpenAPI", [CBM_LANG_FENNEL] = "Fennel", [CBM_LANG_FISH] = "Fish", [CBM_LANG_AWK] = "AWK", @@ -993,6 +1008,9 @@ static CBMLanguage lang_for_filename(const cbm_userconfig_t *ucfg, const char *f CBMLanguage lang; } COMPOUND_EXT_TABLE[] = { {".blade.php", CBM_LANG_BLADE}, + {".drawio.svg", CBM_LANG_DIAGRAM}, /* draw.io's editable SVG: its diagram, not XML */ + {".excalidraw.svg", CBM_LANG_DIAGRAM}, /* an Excalidraw export with its scene */ + {".excalidraw.json", CBM_LANG_DIAGRAM}, }; const char *p = strchr(filename, '.'); while (p && p < last_dot) { @@ -1528,16 +1546,13 @@ CBMLanguage cbm_disambiguate_cfc(const char *path) { /* JSON files that are configuration, not source worth indexing. */ static const char *const IGNORED_JSON_FILES[] = { - "package.json", "package-lock.json", "tsconfig.json", - "jsconfig.json", "composer.json", "composer.lock", - "yarn.lock", "openapi.json", "swagger.json", - "jest.config.json", ".eslintrc.json", ".prettierrc.json", - ".babelrc.json", "tslint.json", "angular.json", - "firebase.json", "renovate.json", "lerna.json", - "turbo.json", ".stylelintrc.json", "pnpm-lock.json", - "deno.json", "biome.json", "devcontainer.json", - ".devcontainer.json", "launch.json", "settings.json", - "extensions.json", "tasks.json", NULL}; + "package.json", "package-lock.json", "tsconfig.json", "jsconfig.json", + "composer.json", "composer.lock", "yarn.lock", "jest.config.json", + ".eslintrc.json", ".prettierrc.json", ".babelrc.json", "tslint.json", + "angular.json", "firebase.json", "renovate.json", "lerna.json", + "turbo.json", ".stylelintrc.json", "pnpm-lock.json", "deno.json", + "biome.json", "devcontainer.json", ".devcontainer.json", "launch.json", + "settings.json", "extensions.json", "tasks.json", NULL}; static bool lang_name_in(const char *name, const char *const *list) { for (int i = 0; list[i]; i++) { diff --git a/src/discover/userconfig.c b/src/discover/userconfig.c index a0d306008..7e14734a2 100644 --- a/src/discover/userconfig.c +++ b/src/discover/userconfig.c @@ -162,6 +162,8 @@ static const lang_name_entry_t LANG_NAME_TABLE[] = { {"chialisp", CBM_LANG_CHIALISP}, {"pdf", CBM_LANG_PDF}, {"asciidoc", CBM_LANG_ASCIIDOC}, + {"diagram", CBM_LANG_DIAGRAM}, + {"openapi", CBM_LANG_OPENAPI}, {"lean", CBM_LANG_LEAN}, {"form", CBM_LANG_FORM}, {"magma", CBM_LANG_MAGMA}, diff --git a/src/pipeline/doc_links.c b/src/pipeline/doc_links.c index c5023658d..e50303dcc 100644 --- a/src/pipeline/doc_links.c +++ b/src/pipeline/doc_links.c @@ -46,7 +46,9 @@ const char *cbm_doclink_reason_name(int reason) { * to this file. */ static const cbm_doclink_resolver_t *const DOCLINK_RESOLVERS[] = { &cbm_doclink_cs_resolver, &cbm_doclink_md_resolver, &cbm_doclink_pdf_resolver, - &cbm_doclink_rst_resolver, &cbm_doclink_adoc_resolver, + &cbm_doclink_rst_resolver, &cbm_doclink_adoc_resolver, &cbm_doclink_go_resolver, + &cbm_doclink_jvm_resolver, &cbm_doclink_rs_resolver, &cbm_doclink_perl_resolver, + &cbm_doclink_ts_resolver, &cbm_doclink_php_resolver, &cbm_doclink_c_resolver, }; enum { DOCLINK_RESOLVER_COUNT = sizeof(DOCLINK_RESOLVERS) / sizeof(DOCLINK_RESOLVERS[0]) }; @@ -118,6 +120,64 @@ int cbm_doclinks_scope_delta(const char *stored, const char *fresh, cbm_doclink_ return R->scope_delta(stored, fresh, removed, ud); } +/* One line of a scope blob at *p, without its newline; false at the end. */ +static bool scope_next_line(const char **p, const char **line, size_t *len) { + if (!*p || !**p) { + return false; + } + const char *nl = strchr(*p, '\n'); + *line = *p; + *len = nl ? (size_t)(nl - *p) : strlen(*p); + *p = nl ? nl + SKIP_ONE : *p + *len; + return true; +} + +int cbm_doclinks_scope_delta_lines(const char *stored, const char *fresh, + cbm_doclink_removable_fn removable, cbm_doclink_name_fn removed, + void *ud) { + const char *sp = stored; + const char *fp = fresh; + const char *sl = NULL; + const char *fl = NULL; + size_t slen = 0; + size_t flen = 0; + bool hs = scope_next_line(&sp, &sl, &slen); + bool hf = scope_next_line(&fp, &fl, &flen); + while (hs || hf) { + if (hs && hf && slen == flen && memcmp(sl, fl, slen) == 0) { + hs = scope_next_line(&sp, &sl, &slen); + hf = scope_next_line(&fp, &fl, &flen); + continue; + } + const char *name = NULL; + size_t nlen = 0; + if (!hs || !removable(sl, slen, &name, &nlen) || nlen == 0) { + return CBM_DOCLINK_DELTA_GLOBAL; /* new, changed, or resolved through */ + } + if (!removed || !removed(ud, name, nlen)) { + return CBM_NOT_FOUND; + } + hs = scope_next_line(&sp, &sl, &slen); + } + return CBM_DOCLINK_DELTA_LOCAL; +} + +bool cbm_doclinks_scope_field(const char *line, size_t len, int k, const char **field, + size_t *field_len) { + size_t s = 0; + for (int f = 0; f < k; f++) { + const char *tab = memchr(line + s, '\t', len - s); + if (!tab) { + return false; + } + s = (size_t)(tab - line) + SKIP_ONE; + } + const char *tab = memchr(line + s, '\t', len - s); + *field = line + s; + *field_len = tab ? (size_t)(tab - line) - s : len - s; + return true; +} + const char *cbm_doclinks_storable_scope(const char *scope, char **owned) { *owned = NULL; const cbm_doclink_resolver_t *R = resolver_of_scope(scope); @@ -510,14 +570,18 @@ void cbm_doclinks_resolve_file(cbm_doclinks_t *dl, int file_idx, const CBMFileRe } if (cbm_doclink_syntax_ships(link->syntax)) { atomic_fetch_add_explicit(&dl->mentions, 1, memory_order_relaxed); - mentions[nm++] = (doclink_mention_t){.src = src->id, - .tgt = out.target->id, - .line = link->line, - .target_first = out.target_first, - .target_last = out.target_last, - .via = R->via ? R->via : "doc_comment", - .syntax = link->syntax, - .exact = out.exact}; + mentions[nm++] = + (doclink_mention_t){.src = src->id, + .tgt = out.target->id, + .line = link->line, + .target_first = out.target_first, + .target_last = out.target_last, + .via = cbm_doclink_syntax_via(link->syntax) + ? cbm_doclink_syntax_via(link->syntax) + : R->via ? R->via + : "doc_comment", + .syntax = link->syntax, + .exact = out.exact}; continue; } /* the ship gate: resolved, but this link family is below the diff --git a/src/pipeline/doc_links.h b/src/pipeline/doc_links.h index 77a5dfb67..2bfedc56b 100644 --- a/src/pipeline/doc_links.h +++ b/src/pipeline/doc_links.h @@ -178,7 +178,7 @@ typedef enum { * file by file */ } cbm_doclink_delta_t; -enum { CBM_DOCLINK_RESOLVER_LANGS = 4 }; +enum { CBM_DOCLINK_RESOLVER_LANGS = 6 }; /* Everything the resolving half needs from a language leg: one of these, and * its pointer in doc_links.c's resolver table. */ @@ -295,6 +295,13 @@ void cbm_doclink_cs_test_unit_memo(bool on); * names but can move what those lines hold, so an incremental run * re-resolves such documents with the edited file. */ bool cbm_doclinks_binds_lines(CBMLanguage lang); +extern const cbm_doclink_resolver_t cbm_doclink_go_resolver; +extern const cbm_doclink_resolver_t cbm_doclink_jvm_resolver; +extern const cbm_doclink_resolver_t cbm_doclink_rs_resolver; +extern const cbm_doclink_resolver_t cbm_doclink_perl_resolver; +extern const cbm_doclink_resolver_t cbm_doclink_ts_resolver; +extern const cbm_doclink_resolver_t cbm_doclink_php_resolver; +extern const cbm_doclink_resolver_t cbm_doclink_c_resolver; /* True when `rel_path` is a scope input of some language (scope_input). */ bool cbm_doclinks_is_scope_input(const char *rel_path); @@ -306,6 +313,25 @@ bool cbm_doclinks_is_scope_input(const char *rel_path); int cbm_doclinks_scope_delta(const char *stored, const char *fresh, cbm_doclink_name_fn removed, void *ud); +/* A record of a scope blob that declares one name other files may reference + * and nothing others resolve THROUGH: true, with that name. */ +typedef bool (*cbm_doclink_removable_fn)(const char *line, size_t len, const char **name, + size_t *name_len); + +/* A scope_delta over the lines of two blobs, in order (a record may belong to + * the one above it): the same line in both is unchanged; a stored line the + * fresh blob dropped is LOCAL when `removable` names it -- the name goes to + * `removed` -- and GLOBAL otherwise; a line only the fresh blob has, new or + * changed, is GLOBAL. A cbm_doclink_delta_t, or CBM_NOT_FOUND when a name + * could not be recorded. */ +int cbm_doclinks_scope_delta_lines(const char *stored, const char *fresh, + cbm_doclink_removable_fn removable, cbm_doclink_name_fn removed, + void *ud); + +/* Field k (0: the record's kind) of a tab-separated scope line. */ +bool cbm_doclinks_scope_field(const char *line, size_t len, int k, const char **field, + size_t *field_len); + /* What is stored for a scope blob written in this run (the surface row's * `dl`, lsp_surface.c): the blob itself, or its language's rejected-scope * marker when that language's reader refuses it (scope_accepted), made into diff --git a/src/pipeline/doc_links_adoc.c b/src/pipeline/doc_links_adoc.c index 863462e41..9f495a471 100644 --- a/src/pipeline/doc_links_adoc.c +++ b/src/pipeline/doc_links_adoc.c @@ -887,6 +887,11 @@ static void ra_resolve(const void *index, void *state, int run_file, const CBMDo ra_unresolved(out, CBM_DOCLINK_REASON_UNPARSEABLE); return; } + if (cbm_doclink_md_twin(link->syntax) != CBM_DOCLINK_NONE) { /* a diagram's link, an + * API's operationId */ + cbm_doclink_md_resolver.resolve(x->md, NULL, run_file, link, graph, out); + return; + } if (link->syntax == CBM_DOCLINK_ADOC_CODE_PATH || link->syntax == CBM_DOCLINK_ADOC_CODE_NAME) { CBMDocLink md = *link; md.syntax = link->syntax == CBM_DOCLINK_ADOC_CODE_PATH ? CBM_DOCLINK_MD_CODE_PATH diff --git a/src/pipeline/doc_links_c.c b/src/pipeline/doc_links_c.c new file mode 100644 index 000000000..b33883926 --- /dev/null +++ b/src/pipeline/doc_links_c.c @@ -0,0 +1,503 @@ +/* + * doc_links_c.c — resolving C doc references (doclink_c.c): Doxygen @ref and + * @see names, with the lookup Doxygen itself has, which knows no imports. + * + * A name (`name`, `name()`, `::name`) is looked up + * 1. among the Doxygen pages, groups, anchors and sections of the + * repository's docs: such a name is no code entity (a graph gap), and + * never binds to a symbol of the same name; + * 2. among the definitions of the documented file, static ones included; + * 3. among the definitions every file sees: external linkage, and the types, + * enum constants and macros of headers. A static definition of another + * file is not visible. + * A field of the documented file (`@ref field`) comes after its other + * definitions and before what every file sees. `Type::member` names a field + * of the struct or union Type, or a constant of the enum Type, Type found by + * the same levels. + * The first level with a candidate decides. Its candidates are counted by + * qualified name: a prototype and its definition, and the #if variants of + * one definition, are one node, the one the graph keeps for that name. A + * definition (function, variable, type, enum constant) wins over a macro of + * the same name; with `()` written, a function or function-like macro wins + * over the others. One candidate binds (exact in the documented file, + * unique elsewhere), several are ambiguous, none is missing; but a word of an + * @see paragraph that names nothing and is a plain lowercase word (no `_`, + * digit, capital, `()` or `::`) is prose, which Doxygen prints as text: no + * row. Test code binds only from test code. + */ +#include "pipeline/doc_links.h" +#include "cbm.h" +#include "doclink.h" +#include "helpers.h" /* cbm_is_test_file */ +#include "foundation/arena.h" +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/log.h" +#include "foundation/mem_core.h" +#include "graph_buffer/graph_buffer.h" + +#include +#include +#include +#include +#include +#include +#include + +enum { CR_FIELDS = 6, CR_NAME_MAX = 256, CR_CANDS = 64, CR_QN_MAX = 2048 }; + +/* ── The index ───────────────────────────────────────────────────── */ + +typedef struct { + int file; + char kind; /* f v t e r m M */ + bool global; + const char *name; + const char *qn; + const char *parent; /* the enum of a constant, the struct of a field; NULL */ + int next; /* the next definition of the same name, or -1 */ +} cr_def_t; + +typedef struct { + const char *path; + bool test; +} cr_file_t; + +typedef struct { + CBMArena arena; + cr_file_t *files; + int nfiles; + int *run_to_file; + int run_count; + cr_def_t *defs; + int ndefs; + int cap_defs; + CBMHashTable *by_name; /* name -> the first definition's index + 1 */ + CBMHashTable *anchors; /* a Doxygen page, group, anchor or section name */ + CBMHashTable *by_path; /* rel_path -> file index + 1 */ + bool oom; +} cr_index_t; + +static const char *cr_copy(cr_index_t *ix, const char *s, size_t n) { + char *c = cbm_arena_strndup(&ix->arena, s, n); + ix->oom = ix->oom || !c; + return c; +} + +static bool cr_grow(cr_index_t *ix) { + if (ix->ndefs < ix->cap_defs) { + return true; + } + int ncap = ix->cap_defs ? ix->cap_defs * PAIR_LEN : CBM_SZ_64; + cr_def_t *g = (cr_def_t *)cbm_arena_alloc(&ix->arena, (size_t)ncap * sizeof(cr_def_t)); + if (!g) { + ix->oom = true; + return false; + } + if (ix->ndefs) { + memcpy(g, ix->defs, (size_t)ix->ndefs * sizeof(cr_def_t)); + } + ix->defs = g; + ix->cap_defs = ncap; + return true; +} + +static void cr_read_record(cr_index_t *ix, int fi, const char **fl, const size_t *ln, int n) { + if (fl[0][0] == 'D' && n >= CR_FIELDS - SKIP_ONE && cr_grow(ix)) { /* parent optional */ + cr_def_t *d = &ix->defs[ix->ndefs++]; + d->file = fi; + d->kind = fl[1][0]; + d->global = fl[PAIR_LEN][0] == 'x'; + d->name = cr_copy(ix, fl[CBM_SZ_3], ln[CBM_SZ_3]); + d->qn = cr_copy(ix, fl[CBM_SZ_4], ln[CBM_SZ_4]); + bool none = n <= CBM_SZ_5 || (ln[CBM_SZ_5] == SKIP_ONE && fl[CBM_SZ_5][0] == '-'); + d->parent = none ? NULL : cr_copy(ix, fl[CBM_SZ_5], ln[CBM_SZ_5]); + d->next = CBM_NOT_FOUND; + } else if (fl[0][0] == 'A' && n >= PAIR_LEN) { + const char *name = cr_copy(ix, fl[1], ln[1]); + if (name && !cbm_ht_get(ix->anchors, name)) { + cbm_ht_set(ix->anchors, name, (void *)(uintptr_t)SKIP_ONE); + } + } +} + +static void cr_read_blob(cr_index_t *ix, int fi, const char *blob) { + const char *line = strchr(blob, '\n'); /* the tag line */ + while (line && !ix->oom) { + const char *b = line + SKIP_ONE; + const char *e = strchr(b, '\n'); + if (!e) { + e = b + strlen(b); + } + const char *fl[CR_FIELDS]; + size_t ln[CR_FIELDS]; + int n = 0; + const char *s = b; + for (const char *p = b; p <= e && n < CR_FIELDS; p++) { + if (p == e || *p == '\t') { + fl[n] = s; + ln[n] = (size_t)(p - s); + n++; + s = p + SKIP_ONE; + } + } + if (n > 0 && ln[0] == SKIP_ONE) { + cr_read_record(ix, fi, fl, ln, n); + } + line = *e ? e : NULL; + } +} + +static void cr_destroy(void *index) { + cr_index_t *ix = (cr_index_t *)index; + if (!ix) { + return; + } + cbm_ht_free(ix->by_name); + cbm_ht_free(ix->anchors); + cbm_ht_free(ix->by_path); + cbm_free(CBM_MEM_CLASS_OTHER, ix->run_to_file); + cbm_arena_destroy(&ix->arena); + cbm_free(CBM_MEM_CLASS_OTHER, ix); +} + +/* Chain every definition into its name's list, in index order. */ +static bool cr_link(cr_index_t *ix) { + ix->by_name = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(ix->ndefs + SKIP_ONE)); + if (!ix->by_name) { + return false; + } + for (int i = ix->ndefs - SKIP_ONE; i >= 0; i--) { + cr_def_t *d = &ix->defs[i]; + if (!d->name || !d->qn) { + continue; + } + void *prev = cbm_ht_get(ix->by_name, d->name); + d->next = prev ? (int)((uintptr_t)prev - SKIP_ONE) : CBM_NOT_FOUND; + cbm_ht_set(ix->by_name, d->name, (void *)(uintptr_t)(i + SKIP_ONE)); + } + return true; +} + +static void *cr_build(const cbm_doclink_build_in_t *in) { + cr_index_t *ix = (cr_index_t *)cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*ix)); + if (!ix) { + return NULL; + } + cbm_arena_init(&ix->arena); + ix->nfiles = in->file_count; + ix->run_count = in->run_file_count; + ix->files = + in->file_count + ? (cr_file_t *)cbm_arena_calloc(&ix->arena, (size_t)in->file_count * sizeof(cr_file_t)) + : NULL; + ix->run_to_file = (int *)cbm_calloc(CBM_MEM_CLASS_OTHER, + (size_t)(in->run_file_count + SKIP_ONE) * sizeof(int)); + ix->by_path = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(in->file_count + SKIP_ONE)); + ix->anchors = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_64); + bool ok = (!in->file_count || ix->files) && ix->run_to_file && ix->by_path && ix->anchors; + for (int r = 0; ok && r < in->run_file_count; r++) { + ix->run_to_file[r] = CBM_NOT_FOUND; + } + for (int i = 0; ok && i < in->file_count && !ix->oom; i++) { + const cbm_doclink_file_t *src = &in->files[i]; + cr_file_t *f = &ix->files[i]; + f->path = src->rel_path; + f->test = cbm_is_test_file(src->rel_path, CBM_LANG_C); + cbm_ht_set(ix->by_path, src->rel_path, (void *)(uintptr_t)(i + SKIP_ONE)); + if (src->run_file >= 0 && src->run_file < in->run_file_count) { + ix->run_to_file[src->run_file] = i; + } + if (src->scope) { + cr_read_blob(ix, i, src->scope); + } + } + ok = ok && !ix->oom && cr_link(ix); + if (!ok) { + cbm_log_error("doc_links.c.error", "step", "index", "reason", "alloc"); + cr_destroy(ix); + return NULL; + } + char b[CBM_SZ_3][CBM_SZ_32]; + snprintf(b[0], sizeof(b[0]), "%d", ix->nfiles); + snprintf(b[SKIP_ONE], sizeof(b[SKIP_ONE]), "%d", ix->ndefs); + snprintf(b[PAIR_LEN], sizeof(b[PAIR_LEN]), "%u", cbm_ht_count(ix->anchors)); + cbm_log_info("doc_links.c.index", "files", b[0], "definitions", b[SKIP_ONE], "anchors", + b[PAIR_LEN]); + return ix; +} + +/* ── Resolution ──────────────────────────────────────────────────── */ + +/* The candidates of one level, one per qualified name. */ +typedef struct { + const cr_def_t *items[CR_CANDS]; + int n; + bool full; /* more than CR_CANDS: ambiguous */ +} cr_cands_t; + +static void cr_add(cr_cands_t *c, const cr_def_t *d) { + for (int i = 0; i < c->n; i++) { + if (strcmp(c->items[i]->qn, d->qn) == 0) { + return; + } + } + if (c->n == CR_CANDS) { + c->full = true; + return; + } + c->items[c->n++] = d; +} + +static bool cr_macro(const cr_def_t *d) { + return d->kind == 'm' || d->kind == 'M'; +} + +static bool cr_callable(const cr_def_t *d) { + return d->kind == 'f' || d->kind == 'M'; +} + +/* Keep the candidates `keep` accepts, when there is one. */ +static void cr_prefer(cr_cands_t *c, bool (*keep)(const cr_def_t *), bool want) { + int k = 0; + for (int i = 0; i < c->n; i++) { + k += keep(c->items[i]) == want; + } + if (k == 0 || k == c->n) { + return; + } + int w = 0; + for (int i = 0; i < c->n; i++) { + if (keep(c->items[i]) == want) { + c->items[w++] = c->items[i]; + } + } + c->n = w; +} + +enum { CR_LEVEL_OWN = 0, CR_LEVEL_FIELD = 1, CR_LEVEL_GLOBAL = 2, CR_LEVELS = 3 }; + +/* The definitions of `name` one level sees: the documented file's own (any + * linkage, fields apart), its fields, or those every file sees. */ +static void cr_level(const cr_index_t *ix, const char *name, int src, int level, cr_cands_t *c) { + void *v = cbm_ht_get(ix->by_name, name); + for (int i = v ? (int)((uintptr_t)v - SKIP_ONE) : CBM_NOT_FOUND; i >= 0; i = ix->defs[i].next) { + const cr_def_t *d = &ix->defs[i]; + bool field = d->kind == 'r'; + bool in = level == CR_LEVEL_GLOBAL ? d->global + : d->file == src && field == (level == CR_LEVEL_FIELD); + if (in) { + cr_add(c, d); + } + } +} + +static void cr_out_node(const cr_index_t *ix, int src, const cr_def_t *d, bool exact, + const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out) { + const cbm_gbuf_node_t *node = cbm_gbuf_find_by_qn(graph, d->qn); + if (!node || !node->label) { + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; + return; + } + if (ix->files[d->file].test && !ix->files[src].test) { + out->reason = CBM_DOCLINK_REASON_TEST_ONLY; + return; + } + out->kind = CBM_DOCLINK_EDGE; + out->target = node; + out->exact = exact; +} + +static bool cr_ident(const char *s, size_t n) { + if (n == 0 || !(isalpha((unsigned char)s[0]) || s[0] == '_')) { + return false; + } + for (size_t i = 0; i < n; i++) { + if (!isalnum((unsigned char)s[i]) && s[i] != '_') { + return false; + } + } + return true; +} + +/* The name of a reference, `::` in front and `()` behind dropped, and the + * type of `Type::member` (scope, "" for none); false when it is no C name. */ +static bool cr_name(const char *raw, char *out, char *scope, size_t cap, bool *call) { + size_t n = strlen(raw); + if (n >= PAIR_LEN && raw[0] == ':' && raw[SKIP_ONE] == ':') { + raw += PAIR_LEN; + n -= PAIR_LEN; + } + *call = n >= PAIR_LEN && raw[n - PAIR_LEN] == '(' && raw[n - SKIP_ONE] == ')'; + if (*call) { + n -= PAIR_LEN; + } + scope[0] = '\0'; + const char *sep = NULL; + for (size_t i = 0; i + SKIP_ONE < n && !sep; i++) { + sep = raw[i] == ':' && raw[i + SKIP_ONE] == ':' ? raw + i : NULL; + } + if (sep) { + size_t sl = (size_t)(sep - raw); + if (sl >= cap || !cr_ident(raw, sl)) { + return false; + } + memcpy(scope, raw, sl); + scope[sl] = '\0'; + n -= sl + PAIR_LEN; + raw = sep + PAIR_LEN; + } + if (n >= cap || !cr_ident(raw, n)) { + return false; + } + memcpy(out, raw, n); + out[n] = '\0'; + return true; +} + +static bool cr_type(const cr_def_t *d) { + return d->kind == 't'; +} + +/* `Type::member`: the type by the usual levels, then its field (a node at + * the type's qualified name + `.` + member) or its enum constant. */ +static void cr_member(const cr_index_t *ix, int src, const char *scope, const char *name, + const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out) { + const cr_def_t *type = NULL; + for (int level = 0; level < CR_LEVELS && !type; level++) { + cr_cands_t c = {.n = 0}; + cr_level(ix, scope, src, level, &c); + cr_prefer(&c, cr_type, true); + if (c.n == 0 || !cr_type(c.items[0])) { + continue; + } + if (c.full || c.n > SKIP_ONE) { + out->reason = CBM_DOCLINK_REASON_AMBIGUOUS; + return; + } + type = c.items[0]; + } + if (!type) { + return; /* missing */ + } + char qn[CR_QN_MAX]; + int n = snprintf(qn, sizeof(qn), "%s.%s", type->qn, name); + cr_def_t field = {.file = type->file, .kind = 'r', .name = name, .qn = qn}; + if (n > 0 && (size_t)n < sizeof(qn) && cbm_gbuf_find_by_qn(graph, qn)) { + cr_out_node(ix, src, &field, true, graph, out); + return; + } + void *v = cbm_ht_get(ix->by_name, name); + for (int i = v ? (int)((uintptr_t)v - SKIP_ONE) : CBM_NOT_FOUND; i >= 0; i = ix->defs[i].next) { + const cr_def_t *d = &ix->defs[i]; + if (d->kind == 'e' && d->parent && strcmp(d->parent, type->qn) == 0) { + cr_out_node(ix, src, d, true, graph, out); + return; + } + } +} + +/* A plain lowercase word: what an @see paragraph's prose is made of. */ +static bool cr_plain_word(const char *s) { + for (; *s; s++) { + if (*s < 'a' || *s > 'z') { + return false; + } + } + return true; +} + +static void cr_resolve_name(const cr_index_t *ix, int src, const CBMDocLink *link, + const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out); + +static void cr_resolve(const void *index, void *state, int run_file, const CBMDocLink *link, + const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out) { + (void)state; /* C keeps no per-file working state */ + const cr_index_t *ix = (const cr_index_t *)index; + out->kind = CBM_DOCLINK_UNRESOLVED; + out->reason = CBM_DOCLINK_REASON_MISSING; + out->target = NULL; + out->exact = false; + if (!ix || !link->raw || run_file < 0 || run_file >= ix->run_count || + ix->run_to_file[run_file] < 0) { + return; + } + int src = ix->run_to_file[run_file]; + cr_resolve_name(ix, src, link, graph, out); + if (link->syntax == CBM_DOCLINK_C_SEE && out->kind == CBM_DOCLINK_UNRESOLVED && + out->reason == CBM_DOCLINK_REASON_MISSING && cr_plain_word(link->raw)) { + out->kind = CBM_DOCLINK_LOCAL; /* prose: Doxygen prints it as text */ + } +} + +static void cr_resolve_name(const cr_index_t *ix, int src, const CBMDocLink *link, + const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out) { + if (cbm_ht_get(ix->anchors, link->raw)) { + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; /* a page, group or anchor */ + return; + } + char name[CR_NAME_MAX]; + char scope[CR_NAME_MAX]; + bool call = false; + if (strlen(link->raw) > CBM_DOCLINK_C_REF_MAX || + !cr_name(link->raw, name, scope, sizeof(name), &call)) { + out->reason = CBM_DOCLINK_REASON_UNPARSEABLE; + return; + } + if (scope[0]) { + cr_member(ix, src, scope, name, graph, out); + return; + } + if (cbm_ht_get(ix->anchors, name)) { + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; + return; + } + for (int level = 0; level < CR_LEVELS; level++) { + cr_cands_t c = {.n = 0}; + cr_level(ix, name, src, level, &c); + if (c.n == 0) { + continue; + } + if (call) { + cr_prefer(&c, cr_callable, true); /* `()` written: what can be called */ + } + cr_prefer(&c, cr_macro, false); /* then a definition before a macro */ + if (c.full || c.n > SKIP_ONE) { + out->reason = CBM_DOCLINK_REASON_AMBIGUOUS; + return; + } + cr_out_node(ix, src, c.items[0], level != CR_LEVEL_GLOBAL, graph, out); + return; + } +} + +/* A function, variable, enum constant, field or macro (D kind link name qn + * parent, kind f v e r m M) and a Doxygen anchor (A name) each declare one + * name. A type (t) is what members and Type::member are found through: + * GLOBAL, as is every new or changed record. */ +static bool cr_removable(const char *line, size_t len, const char **name, size_t *nlen) { + if (len <= PAIR_LEN || line[1] != '\t') { + return false; + } + if (line[0] == 'A') { + return cbm_doclinks_scope_field(line, len, 1, name, nlen); + } + return line[0] == 'D' && strchr("fverMm", line[PAIR_LEN]) && line[PAIR_LEN] && + line[PAIR_LEN + SKIP_ONE] == '\t' && + cbm_doclinks_scope_field(line, len, CBM_SZ_3, name, nlen); +} + +static int cr_scope_delta(const char *stored, const char *fresh, cbm_doclink_name_fn removed, + void *ud) { + return cbm_doclinks_scope_delta_lines(stored, fresh, cr_removable, removed, ud); +} + +const cbm_doclink_resolver_t cbm_doclink_c_resolver = { + .langs = {CBM_LANG_C, CBM_LANG_CPP}, + .lang_count = PAIR_LEN, + .scope_tag = CBM_DOCLINK_C_SCOPE_TAG, + .build = cr_build, + .destroy = cr_destroy, + .resolve = cr_resolve, + .scope_delta = cr_scope_delta, +}; diff --git a/src/pipeline/doc_links_go.c b/src/pipeline/doc_links_go.c new file mode 100644 index 000000000..047a7654e --- /dev/null +++ b/src/pipeline/doc_links_go.c @@ -0,0 +1,1905 @@ +/* + * doc_links_go.c — Go doc-link resolution for doc_links.h. + * + * Extraction (internal/cbm/doclink_go.c) emits every bracketed text of a doc + * comment that go/doc/comment COULD take for a doc link. This file makes the + * rest of go/doc's decision, which needs the whole package, and then binds: + * + * [Name], [Recv.Name] the documented file's package. A name the + * package does not declare is plain text for + * go/doc: CBM_DOCLINK_LOCAL, neither edge nor row. + * So is the documented declaration's own type + * parameter. Recv.Name is a method declared with + * that receiver, a method of that interface, or + * a field of that struct (the field is a + * deliberate difference from go/doc: see + * go_bind_member). + * [pkg], [pkg.Name], pkg with a '/' is an import path. Otherwise, in + * [pkg.Recv.Name] order: the documented FILE's import of that + * name; the package's own name; a standard-library + * package with that one-element path. None of + * them: plain text (see go_lookup_qualifier for + * the one place this is stricter than go/doc). + * import path a module of the repository (every go.mod, the + * longest module path first) -> its directory's + * package; no package there: missing. No module: + * external. An import without a name is called by + * the real package name of an in-repo directory, + * else by go/doc's assumed name. + * member through an alias `type X = pkg.Y`: [p.X.Member] is looked up on Y. + * + * The members of a type are the methods declared with that receiver, the + * fields of its own struct declaration and the methods of its own interface + * declaration: nothing promoted through embedding. + * + * Binding. The graph keeps one node per qualified name, Go method nodes have + * a FLAT name (.), and is the directory, not the package + * clause: functions, methods of every receiver, types and values of one name, + * in every file of the directory, share one node, and the node is ONE of those + * declarations (cbm_gbuf_upsert_node keeps the smallest file path, then the + * last line). So a reference binds only when the node IS the entity it names: + * the node's file is a file of the named package, the declaration that keeps + * the name in that file (the w field of its scope record) is the named entity + * (its kind; for a method, its receiver), and the node has that kind's label. + * Every other declared entity is a graph_gap. A receiver-qualified method node + * (..) is tried first, so a graph that names methods that + * way binds them all. Only a node's qualified name, label and file_path are + * read: the proxy nodes of an incremental run carry no lines and no + * properties (the one exception is the SOURCE node, see go_source_owns_node). + * + * One name, several declarations. Files whose build constraints exclude each + * other declare variants of ONE entity, and any of them may hold the node. Two + * files built unconditionally that declare the same name are no package (a + * directory of stand-alone programs, test data): two entities share the name. + * Such a name binds only from a file that declares it itself, to that file's + * own declaration; from anywhere else it is ambiguous. A node kept by a test + * file is no target for product code. + * + * Reasons: external (standard library, module dependencies, vendored or + * not), missing (a repository package that does not declare the name; an + * import path inside a module without a package there), graph_gap (declared, + * but the node is another declaration's or absent: block variables, later + * names of a declaration, embedded fields, methods that lost the flat name; + * also an undeclared name in a package with a parse error), test_only_target + * (product code naming a declaration that only test files have), ambiguous + * (several modules or packages fit; a name declared as two kinds; a name two + * unconstrained files declare), + * unparseable (a reference longer than CBM_DOCLINK_GO_REF_MAX, an import path + * the scan could not record, an alias chain longer than GO_ALIAS_DEPTH). + */ +#include "pipeline/doc_links.h" + +#include "cbm.h" +#include "doclink.h" +#include "helpers.h" /* cbm_fqn_module_source_lang, cbm_is_test_file */ +#include "foundation/arena.h" +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/log.h" +#include "foundation/mem_core.h" +#include "graph_buffer/graph_buffer.h" + +#include +#include +#include +#include +#include +#include + +enum { + GO_KEY_BUF = 2304, /* id + type + name, or id + an import path */ + GO_STACK_BUF = 1024, /* paths and qualified names shorter than this need no heap */ + GO_MAX_FIELDS = 6, /* one more than the fields of the longest scope record */ + GO_ALIAS_DEPTH = 8, /* alias-of-alias hops followed */ + GO_KEY_SEP = 0x1F, /* between the parts of a composite key */ + GO_NAME_MAX = CBM_DOCLINK_GO_REF_MAX, + GO_PATH_MAX = 1024, /* longest import or module path a scope records */ + GO_VERSION_MIN = 2, /* "v" + at least one digit */ + GO_GO_PREFIX = 3, /* strlen("go-") */ + GO_ASCII_END = 0x80, + GO_KIND_FUNC = 1, /* what a name is in a package (go_sym_t) or a file (go_flat_t) */ + GO_KIND_TYPE = 2, + GO_KIND_VALUE = 4, + GO_KIND_METHOD = 8, /* go_flat_t only: a method keeps the file's flat node name */ + GO_MEM_METHOD = 1, /* what Type.Name is in a package (go_mem_t) or a file (go_fmem_t) */ + GO_MEM_FIELD = 2, + GO_MEM_IFACE = 4, +}; + +/* ── Index data ──────────────────────────────────────────────────── */ + +typedef struct { + const char *local; /* the qualifier this import gives the file */ + const char *path; /* "?": the scan could not record it */ + bool conflict; /* the file has two imports called `local` */ +} go_import_t; + +typedef struct { + uint32_t line; /* first line of the generic declaration */ + uint32_t last; /* ... and its last */ + const char *names; /* ','-joined type parameters */ +} go_generic_t; + +struct go_pkg; + +typedef struct go_dir { + int idx; + const char *path; /* "" for the repository root */ + const char *qn; /* .: the prefix of its nodes */ + struct go_pkg **pkgs; + int npkgs; + int pkgs_cap; + bool has_module; /* a go.mod sits here */ + bool is_std; /* ... and it says `module std` */ + const struct go_dir *module_root; /* the nearest directory at or above with a go.mod */ +} go_dir_t; + +typedef struct go_pkg { + int idx; + const go_dir_t *dir; + const char *name; + bool importable; /* has a file that is neither *_test.go nor build-ignored */ + bool has_prod; /* has a file that is not test code */ + bool incomplete; /* a file of it has a parse error: declarations may hide */ +} go_pkg_t; + +typedef struct { + int idx; + const char *rel_path; /* borrowed: valid until destroy */ + const char *scope; /* borrowed: read while the index is built */ + go_dir_t *dir; + go_pkg_t *pkg; /* NULL: no package clause, or a go.mod file */ + bool is_test; /* test code by the indexer's rule: never a product target */ + bool go_test; /* *_test.go: invisible to importers */ + bool ignored; /* //go:build ignore: in no package */ + bool constrained; /* built only under a constraint (a build line, or its name) */ + go_import_t *imports; + int nimports; + int imports_cap; + go_generic_t *generics; /* fresh scopes only */ + int ngenerics; + int generics_cap; +} go_file_t; + +typedef struct { + const char *path; + const go_dir_t *dir; +} go_module_t; + +/* A standard-library package of a `module std` tree in the repository. */ +typedef struct { + const go_dir_t *dir; + int count; /* trees that have it */ +} go_stdpkg_t; + +/* How many files that are built unconditionally declare one name. A package + * that compiles has at most one: two declarations of a name can only live in + * files whose build constraints exclude each other, and those are variants of + * ONE entity. Two unconstrained files that declare the same name are no + * package at all (a directory of stand-alone programs, test data): their + * declarations are different entities that share a name. */ +typedef struct { + const go_file_t *last; /* the unconstrained file counted last */ + int files; /* 0, 1, or GO_TWINS for two and more */ +} go_free_t; + +enum { GO_TWINS = 2 }; + +/* A package-level name. */ +typedef struct { + int kinds; /* GO_KIND_* of its declarations */ + bool prod; /* declared by product code */ + const char *alias; /* every declaration is `type X = alias`, else NULL */ + const go_file_t *alias_file; /* the file whose imports name the alias target's package */ + bool alias_mixed; + go_free_t free; +} go_sym_t; + +/* Type.Name in a package. */ +typedef struct { + int kinds; /* GO_MEM_* */ + bool prod; + go_free_t free; +} go_mem_t; + +/* What ONE FILE declares under the flat node name ., and which of + * its declarations keeps that name (the w field of its scope records): the + * file's last declaration of that name. */ +typedef struct { + int kinds; /* GO_KIND_* the file declares under that name */ + int owner; /* the GO_KIND_* that keeps the name; 0: none */ + const char *method_recv; /* owner GO_KIND_METHOD: that method's receiver */ + bool contested; /* two declarations of that name start on the last line */ +} go_flat_t; + +/* What ONE FILE declares under ... */ +typedef struct { + int kinds; /* GO_MEM_* it declares */ + int node_kinds; /* ... and which of them get a node (not every field does) */ +} go_fmem_t; + +typedef struct { + CBMArena arena; + bool oom; + const char *project; + go_file_t *files; + int nfiles; + go_file_t **run_to_file; + int run_count; + go_dir_t **dirs; + int ndirs; + int dirs_cap; + int npkgs; + go_module_t *modules; /* sorted by (path, directory) */ + int nmodules; + int modules_cap; + int nstd; + CBMHashTable *dir_by_path; /* path -> go_dir_t */ + CBMHashTable *pkg_by_key; /* dir SEP name -> go_pkg_t */ + CBMHashTable *file_by_path; /* rel_path -> go_file_t */ + CBMHashTable *module_first; /* module path -> the first go_module_t of that path */ + CBMHashTable *module_at; /* dir SEP module path -> go_module_t */ + CBMHashTable *std_pkgs; /* path below a `module std` root -> go_stdpkg_t */ + CBMHashTable *syms; /* pkg SEP name -> go_sym_t */ + CBMHashTable *mems; /* pkg SEP type SEP name -> go_mem_t */ + CBMHashTable *flat; /* file SEP name -> go_flat_t */ + CBMHashTable *fmem; /* file SEP type SEP name -> go_fmem_t */ +} go_index_t; + +/* ── Small helpers ───────────────────────────────────────────────── */ + +static void *ix_alloc(go_index_t *ix, size_t n) { + void *p = cbm_arena_alloc(&ix->arena, n ? n : SKIP_ONE); + ix->oom = ix->oom || !p; + return p; +} + +static char *ix_strndup(go_index_t *ix, const char *s, size_t n) { + char *p = cbm_arena_strndup(&ix->arena, s, n); + ix->oom = ix->oom || !p; + return p; +} + +/* Room for one more element of an index array; NULL when memory ran out. */ +static void *ix_grow(go_index_t *ix, void *items, int count, int *cap, size_t elem) { + if (count < *cap) { + return items; + } + int ncap = *cap ? *cap * PAIR_LEN : CBM_SZ_8; + void *grown = ix_alloc(ix, (size_t)ncap * elem); + if (!grown) { + return NULL; + } + if (count > 0) { + memcpy(grown, items, (size_t)count * elem); + } + *cap = ncap; + return grown; +} + +/* A slice of text. */ +typedef struct { + const char *s; + size_t n; +} go_str_t; + +static const go_str_t GO_NO_STR = {NULL, 0}; + +static go_str_t go_str(const char *s) { + return (go_str_t){s, s ? strlen(s) : 0}; +} + +static bool go_str_eq(go_str_t a, const char *b) { + return strlen(b) == a.n && memcmp(a.s, b, a.n) == 0; +} + +/* A text buffer that lives on the stack unless the text is long. */ +typedef struct { + char stack[GO_STACK_BUF]; + char *heap; +} go_buf_t; + +/* At least `need` bytes; NULL when memory ran out. Released with go_buf_free. */ +static char *go_buf_get(go_buf_t *b, size_t need) { + b->heap = NULL; + if (need <= sizeof(b->stack)) { + return b->stack; + } + b->heap = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, need); + return b->heap; +} + +static void go_buf_free(go_buf_t *b) { + cbm_free(CBM_MEM_CLASS_OTHER, b->heap); + b->heap = NULL; +} + +/* " SEP a [SEP b]" into buf; false when it does not fit (the parts are + * names and paths a scope records, which are bounded). */ +static bool go_key(char *buf, size_t cap, int id, go_str_t a, go_str_t b) { + int n = snprintf(buf, cap, "%d%c", id, GO_KEY_SEP); + if (n < 0 || (size_t)n + a.n + b.n + PAIR_LEN >= cap) { + return false; + } + size_t w = (size_t)n; + memcpy(buf + w, a.s, a.n); + w += a.n; + if (b.s) { + buf[w++] = GO_KEY_SEP; + memcpy(buf + w, b.s, b.n); + w += b.n; + } + buf[w] = '\0'; + return true; +} + +/* The value stored under `key`, or a new zeroed one of `size` bytes. */ +static void *ix_entry(go_index_t *ix, CBMHashTable *ht, const char *key, size_t size) { + void *v = cbm_ht_get(ht, key); + if (v) { + return v; + } + v = ix_alloc(ix, size); + char *k = ix_strndup(ix, key, strlen(key)); + if (!v || !k) { + return NULL; + } + memset(v, 0, size); + cbm_ht_set(ht, k, v); + return v; +} + +/* The entry of a composite key, created when absent; NULL when the key does + * not fit or memory ran out. */ +static void *ix_keyed(go_index_t *ix, CBMHashTable *ht, int id, go_str_t a, go_str_t b, + size_t size) { + char key[GO_KEY_BUF]; + return go_key(key, sizeof(key), id, a, b) ? ix_entry(ix, ht, key, size) : NULL; +} + +static const void *go_lookup(const CBMHashTable *ht, int id, go_str_t a, go_str_t b) { + char key[GO_KEY_BUF]; + return go_key(key, sizeof(key), id, a, b) ? cbm_ht_get(ht, key) : NULL; +} + +/* The value stored under the text s[0..n). */ +static void *go_lookup_text(const CBMHashTable *ht, const char *s, size_t n) { + go_buf_t b; + char *key = go_buf_get(&b, n + SKIP_ONE); + if (!key) { + return NULL; + } + memcpy(key, s, n); + key[n] = '\0'; + void *hit = cbm_ht_get(ht, key); + go_buf_free(&b); + return hit; +} + +static bool go_has_suffix(const char *s, const char *suffix) { + size_t n = strlen(s); + size_t sl = strlen(suffix); + return n >= sl && memcmp(s + n - sl, suffix, sl) == 0; +} + +static bool go_has_non_ascii(go_str_t s) { + for (size_t i = 0; i < s.n; i++) { + if ((unsigned char)s.s[i] >= GO_ASCII_END) { + return true; + } + } + return false; +} + +/* ── Directories and packages ────────────────────────────────────── */ + +static go_dir_t *go_dir_find(const go_index_t *ix, const char *path, size_t len) { + return (go_dir_t *)go_lookup_text(ix->dir_by_path, path, len); +} + +static go_dir_t *go_dir_get(go_index_t *ix, const char *path, size_t len, const char *rel_path) { + go_dir_t *d = go_dir_find(ix, path, len); + if (d) { + return d; + } + go_dir_t **grown = + (go_dir_t **)ix_grow(ix, ix->dirs, ix->ndirs, &ix->dirs_cap, sizeof(go_dir_t *)); + d = (go_dir_t *)ix_alloc(ix, sizeof(*d)); + char *own = ix_strndup(ix, path, len); + if (!grown || !d || !own) { + return NULL; + } + memset(d, 0, sizeof(*d)); + d->idx = ix->ndirs; + d->path = own; + /* The same helper that names the directory's definitions. */ + d->qn = cbm_fqn_module_source_lang(&ix->arena, ix->project, rel_path, CBM_LANG_GO); + ix->oom = ix->oom || !d->qn; + ix->dirs = grown; + ix->dirs[ix->ndirs++] = d; + cbm_ht_set(ix->dir_by_path, own, d); + return d; +} + +static go_pkg_t *go_pkg_get(go_index_t *ix, go_dir_t *dir, go_str_t name) { + char key[GO_KEY_BUF]; + if (!go_key(key, sizeof(key), dir->idx, name, GO_NO_STR)) { + return NULL; + } + go_pkg_t *p = (go_pkg_t *)cbm_ht_get(ix->pkg_by_key, key); + if (p) { + return p; + } + go_pkg_t **dir_pkgs = + (go_pkg_t **)ix_grow(ix, dir->pkgs, dir->npkgs, &dir->pkgs_cap, sizeof(go_pkg_t *)); + p = (go_pkg_t *)ix_alloc(ix, sizeof(*p)); + char *own_key = ix_strndup(ix, key, strlen(key)); + char *own_name = ix_strndup(ix, name.s, name.n); + if (!dir_pkgs || !p || !own_key || !own_name) { + return NULL; + } + memset(p, 0, sizeof(*p)); + p->idx = ix->npkgs++; + p->dir = dir; + p->name = own_name; + dir->pkgs = dir_pkgs; + dir->pkgs[dir->npkgs++] = p; + cbm_ht_set(ix->pkg_by_key, own_key, p); + return p; +} + +/* ── Scope records ───────────────────────────────────────────────── */ + +/* Split a scope line (a copy the caller owns) at its tabs. */ +static int go_fields(char *line, char **out, int max) { + int n = 0; + out[n++] = line; + for (char *p = line; *p && n < max; p++) { + if (*p == '\t') { + *p = '\0'; + out[n++] = p + SKIP_ONE; + } + } + return n; +} + +/* A name field as a scan writes it: not empty, not longer than a name. */ +static bool go_name_ok(const char *s) { + size_t n = strlen(s); + return n > 0 && n <= GO_NAME_MAX; +} + +static bool go_path_ok(const char *s) { + size_t n = strlen(s); + return n > 0 && n <= GO_PATH_MAX; +} + +typedef void (*go_record_fn)(go_index_t *ix, go_file_t *file, char **f, int nf); + +/* Calls `fn` for every record line of a scope blob (the tag line is skipped); + * each line is handed over split into its fields. False when memory ran out. */ +static bool go_each_record(go_index_t *ix, go_file_t *file, const char *blob, go_record_fn fn) { + const char *p = strchr(blob, '\n'); + while (p && p[SKIP_ONE]) { + p++; + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + go_buf_t b; + char *line = go_buf_get(&b, len + SKIP_ONE); + if (!line) { + ix->oom = true; + return false; + } + memcpy(line, p, len); + line[len] = '\0'; + char *f[GO_MAX_FIELDS]; + int nf = go_fields(line, f, GO_MAX_FIELDS); + if (f[0][0] && !f[0][SKIP_ONE]) { + fn(ix, file, f, nf); + } + go_buf_free(&b); + p = nl; + } + return !ix->oom; +} + +static void go_add_module(go_index_t *ix, go_file_t *file, const char *path) { + file->dir->has_module = true; + if (strcmp(path, "std") == 0) { + /* the standard library's own module: its packages are imported by + * their bare directory path */ + file->dir->is_std = true; + ix->nstd++; + return; + } + go_module_t *grown = (go_module_t *)ix_grow(ix, ix->modules, ix->nmodules, &ix->modules_cap, + sizeof(go_module_t)); + char *own = ix_strndup(ix, path, strlen(path)); + if (!grown || !own) { + return; + } + ix->modules = grown; + ix->modules[ix->nmodules++] = (go_module_t){.path = own, .dir = file->dir}; +} + +static void go_add_import(go_index_t *ix, go_file_t *f, const char *local, const char *path) { + go_import_t *grown = + (go_import_t *)ix_grow(ix, f->imports, f->nimports, &f->imports_cap, sizeof(go_import_t)); + char *own_path = ix_strndup(ix, path, strlen(path)); + /* "-": not renamed; the name is derived once every package is known */ + char *own_local = strcmp(local, "-") == 0 ? NULL : ix_strndup(ix, local, strlen(local)); + if (!grown || !own_path || ix->oom) { + return; + } + f->imports = grown; + f->imports[f->nimports++] = (go_import_t){.local = own_local, .path = own_path}; +} + +/* A line number field of a scope record; false when it is none. */ +static bool go_line_field(const char *s, uint32_t *out) { + char *end = NULL; + unsigned long long value = strtoull(s, &end, CBM_DECIMAL_BASE); + if (end == s || *end != '\0' || value > UINT32_MAX) { + return false; + } + *out = (uint32_t)value; + return true; +} + +/* `g first last names`. */ +static void go_add_generic(go_index_t *ix, go_file_t *f, const char *first, const char *last, + const char *names) { + go_generic_t g = {.names = ix_strndup(ix, names, strlen(names))}; + go_generic_t *grown = (go_generic_t *)ix_grow(ix, f->generics, f->ngenerics, &f->generics_cap, + sizeof(go_generic_t)); + if (!grown || !g.names || !go_line_field(first, &g.line) || !go_line_field(last, &g.last)) { + return; + } + f->generics = grown; + f->generics[f->ngenerics++] = g; +} + +static bool go_word_in(go_str_t word, const char *const *list, size_t count) { + for (size_t i = 0; i < count; i++) { + if (go_str_eq(word, list[i])) { + return true; + } + } + return false; +} + +/* go/build goodOSArchFile: a file named *_GOOS.go, *_GOARCH.go or + * *_GOOS_GOARCH.go (before an optional _test) is built only for that target. + * The two lists are the toolchain's own closed lists (go/build syslist); a + * name they do not know leaves the file unconstrained, which can only make + * this resolver more careful (see go_free_t). */ +static bool go_name_constrained(const char *rel_path) { + static const char *const known_os[] = { + "aix", "android", "darwin", "dragonfly", "freebsd", "hurd", + "illumos", "ios", "js", "linux", "nacl", "netbsd", + "openbsd", "plan9", "solaris", "wasip1", "windows", "zos", + }; + static const char *const known_arch[] = { + "386", "amd64", "amd64p32", "arm", "armbe", "arm64", + "arm64be", "loong64", "mips", "mipsle", "mips64", "mips64le", + "mips64p32", "mips64p32le", "ppc", "ppc64", "ppc64le", "riscv", + "riscv64", "s390", "s390x", "sparc", "sparc64", "wasm", + }; + static const char ext[] = ".go"; + static const char test[] = "_test"; + const char *slash = strrchr(rel_path, '/'); + go_str_t base = go_str(slash ? slash + SKIP_ONE : rel_path); + if (base.n >= sizeof(ext) - SKIP_ONE && go_has_suffix(base.s, ext)) { + base.n -= sizeof(ext) - SKIP_ONE; + } + size_t tl = sizeof(test) - SKIP_ONE; + if (base.n >= tl && memcmp(base.s + base.n - tl, test, tl) == 0) { + base.n -= tl; + } + size_t last = base.n; + while (last > 0 && base.s[last - SKIP_ONE] != '_') { + last--; + } + if (last == 0) { + return false; /* no underscore: `linux.go` is an ordinary file */ + } + go_str_t tag = {base.s + last, base.n - last}; + return go_word_in(tag, known_os, sizeof(known_os) / sizeof(known_os[0])) || + go_word_in(tag, known_arch, sizeof(known_arch) / sizeof(known_arch[0])); +} + +static void go_set_package(go_index_t *ix, go_file_t *f, const char *name, const char *flags) { + if (f->pkg) { + return; + } + f->ignored = strchr(flags, 'i') != NULL; + f->constrained = strchr(flags, 'c') != NULL || go_name_constrained(f->rel_path); + f->pkg = go_pkg_get(ix, f->dir, go_str(name)); + if (!f->pkg) { + ix->oom = true; + return; + } + go_pkg_t *p = f->pkg; + p->importable = p->importable || (!f->go_test && !f->ignored); + p->has_prod = p->has_prod || (!f->is_test && !f->ignored); + p->incomplete = p->incomplete || strchr(flags, 'e') != NULL; +} + +/* First pass over a file's scope: package, module, imports, generics. */ +static void go_record_head(go_index_t *ix, go_file_t *file, char **f, int nf) { + enum { P_FIELDS = 3, I_FIELDS = 3, G_FIELDS = 2, GEN_FIELDS = 4, GEN_NAMES = 3 }; + char kind = f[0][0]; + if (kind == 'P' && nf == P_FIELDS && go_name_ok(f[SKIP_ONE])) { + go_set_package(ix, file, f[SKIP_ONE], f[PAIR_LEN]); + } else if (kind == 'G' && nf == G_FIELDS && go_path_ok(f[SKIP_ONE])) { + go_add_module(ix, file, f[SKIP_ONE]); + } else if (kind == 'I' && nf == I_FIELDS && go_name_ok(f[SKIP_ONE]) && + go_path_ok(f[PAIR_LEN])) { + go_add_import(ix, file, f[SKIP_ONE], f[PAIR_LEN]); + } else if (kind == 'g' && nf == GEN_FIELDS) { + go_add_generic(ix, file, f[SKIP_ONE], f[PAIR_LEN], f[GEN_NAMES]); + } +} + +/* ── Modules ─────────────────────────────────────────────────────── */ + +static int go_module_cmp(const void *a, const void *b) { + const go_module_t *x = (const go_module_t *)a; + const go_module_t *y = (const go_module_t *)b; + int c = strcmp(x->path, y->path); + return c ? c : strcmp(x->dir->path, y->dir->path); +} + +/* Sort the modules; index the first entry of every module path, and every + * (directory, module path) pair. */ +static void go_finish_modules(go_index_t *ix) { + if (ix->nmodules > SKIP_ONE) { + qsort(ix->modules, (size_t)ix->nmodules, sizeof(ix->modules[0]), go_module_cmp); + } + for (int i = 0; i < ix->nmodules; i++) { + go_module_t *m = &ix->modules[i]; + if (i == 0 || strcmp(m->path, ix->modules[i - SKIP_ONE].path) != 0) { + cbm_ht_set(ix->module_first, m->path, m); + } + char key[GO_KEY_BUF]; + char *own = go_key(key, sizeof(key), m->dir->idx, go_str(m->path), GO_NO_STR) + ? ix_strndup(ix, key, strlen(key)) + : NULL; + if (own) { + cbm_ht_set(ix->module_at, own, m); + } + } +} + +/* The nearest directory at or above `d` that holds a go.mod. */ +static const go_dir_t *go_module_root(const go_index_t *ix, const go_dir_t *d) { + size_t len = strlen(d->path); + for (;;) { + const go_dir_t *up = go_dir_find(ix, d->path, len); + if (up && up->has_module) { + return up; + } + if (len == 0) { + return NULL; + } + while (len > 0 && d->path[len - SKIP_ONE] != '/') { + len--; + } + if (len > 0) { + len--; /* the slash */ + } + } +} + +/* Give every directory its module root, and list the packages of the + * repository's `module std` trees under their bare import paths. */ +static void go_finish_dirs(go_index_t *ix) { + for (int i = 0; i < ix->ndirs; i++) { + go_dir_t *d = ix->dirs[i]; + d->module_root = go_module_root(ix, d); + const go_dir_t *root = d->module_root; + if (!root || !root->is_std || root == d || d->npkgs == 0) { + continue; + } + size_t rl = strlen(root->path); + const char *rel = d->path + rl + (rl > 0 ? SKIP_ONE : 0); + go_stdpkg_t *sp = (go_stdpkg_t *)ix_entry(ix, ix->std_pkgs, rel, sizeof(go_stdpkg_t)); + if (sp) { + sp->dir = sp->count == 0 ? d : sp->dir; + sp->count++; + } + } +} + +/* ── Import paths ────────────────────────────────────────────────── */ + +typedef enum { + GO_PATH_EXTERNAL = 0, /* no module of the repository holds it */ + GO_PATH_PACKAGE, /* a directory of the repository with Go files */ + GO_PATH_NO_PACKAGE, /* inside a module, but no Go package there */ + GO_PATH_AMBIGUOUS, /* several modules of that path, none of them the file's own */ + GO_PATH_UNKNOWN, /* not decidable: an import the scan could not record, no memory */ +} go_path_kind_t; + +/* `/` when it is a directory of the repository with Go files that + * belongs to the module rooted at `root` (no nested go.mod on the way). */ +static go_path_kind_t go_module_pkg_dir(const go_index_t *ix, const go_dir_t *root, go_str_t rest, + const go_dir_t **dir_out) { + size_t rl = strlen(root->path); + go_buf_t b; + char *buf = go_buf_get(&b, rl + rest.n + PAIR_LEN); + if (!buf) { + return GO_PATH_UNKNOWN; + } + size_t w = rl; + memcpy(buf, root->path, rl); + if (rest.n > 0) { + if (w > 0) { + buf[w++] = '/'; + } + memcpy(buf + w, rest.s, rest.n); + w += rest.n; + } + const go_dir_t *dir = go_dir_find(ix, buf, w); + go_buf_free(&b); + if (!dir || dir->npkgs == 0 || dir->module_root != root) { + return GO_PATH_NO_PACKAGE; + } + *dir_out = dir; + return GO_PATH_PACKAGE; +} + +/* The module a module path means for file `src`: the only module of that + * path, else the one the file itself lives in. NULL: nothing picks one. */ +static const go_module_t *go_pick_module(const go_index_t *ix, const go_module_t *first, + const go_file_t *src) { + const go_module_t *next = first + SKIP_ONE; + if (next >= ix->modules + ix->nmodules || strcmp(next->path, first->path) != 0) { + return first; + } + const go_dir_t *own = src ? src->dir->module_root : NULL; + return own ? (const go_module_t *)go_lookup(ix->module_at, own->idx, go_str(first->path), + GO_NO_STR) + : NULL; +} + +/* A path no module prefixes: a package of the repository's own standard + * library, when it has one. */ +static go_path_kind_t go_classify_std(const go_index_t *ix, const go_file_t *src, go_str_t path, + const go_dir_t **dir_out) { + if (ix->nstd == 0) { + return GO_PATH_EXTERNAL; + } + const go_stdpkg_t *sp = (const go_stdpkg_t *)go_lookup_text(ix->std_pkgs, path.s, path.n); + if (!sp) { + return GO_PATH_EXTERNAL; + } + if (sp->count == SKIP_ONE) { + *dir_out = sp->dir; + return GO_PATH_PACKAGE; + } + const go_dir_t *own = src ? src->dir->module_root : NULL; + if (own && own->is_std && go_module_pkg_dir(ix, own, path, dir_out) == GO_PATH_PACKAGE) { + return GO_PATH_PACKAGE; + } + return GO_PATH_AMBIGUOUS; +} + +/* Where an import path leads: the longest module path of the repository that + * prefixes it decides. */ +static go_path_kind_t go_classify_path(const go_index_t *ix, const go_file_t *src, go_str_t path, + const go_dir_t **dir_out) { + if (path.n == 0 || go_str_eq(path, "?")) { + return GO_PATH_UNKNOWN; + } + go_buf_t b; + char *buf = go_buf_get(&b, path.n + SKIP_ONE); + if (!buf) { + return GO_PATH_UNKNOWN; + } + memcpy(buf, path.s, path.n); + buf[path.n] = '\0'; + const go_module_t *first = NULL; + size_t cut = path.n; + for (; cut > 0 && !first; cut--) { + if (cut != path.n && buf[cut] != '/') { + continue; + } + char saved = buf[cut]; + buf[cut] = '\0'; + first = (const go_module_t *)cbm_ht_get(ix->module_first, buf); + buf[cut] = saved; + } + go_buf_free(&b); + if (!first) { + return go_classify_std(ix, src, path, dir_out); + } + const go_module_t *m = go_pick_module(ix, first, src); + if (!m) { + return GO_PATH_AMBIGUOUS; + } + size_t used = strlen(m->path); + go_str_t rest = {path.s + used, path.n - used}; + if (rest.n > 0) { + rest.s++; /* the slash after the module path */ + rest.n--; + } + return go_module_pkg_dir(ix, m->dir, rest, dir_out); +} + +/* The package an import path means in `dir`: its only importable package. With + * several, the one called `hint` (the qualifier, or the path's last element). + * NULL for none; *ambiguous when several fit and nothing picks one. */ +static const go_pkg_t *go_dir_package(const go_dir_t *dir, go_str_t hint, bool *ambiguous) { + const go_pkg_t *only = NULL; + const go_pkg_t *named = NULL; + int count = 0; + for (int i = 0; i < dir->npkgs; i++) { + const go_pkg_t *p = dir->pkgs[i]; + if (!p->importable) { + continue; + } + count++; + only = p; + if (hint.s && go_str_eq(hint, p->name)) { + named = p; + } + } + *ambiguous = count > SKIP_ONE && !named; + return count > SKIP_ONE ? named : only; +} + +static bool go_name_byte(unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || + c >= GO_ASCII_END; +} + +/* The last element of a slash-separated path. */ +static go_str_t go_path_base(go_str_t path) { + size_t start = path.n; + while (start > 0 && path.s[start - SKIP_ONE] != '/') { + start--; + } + return (go_str_t){path.s + start, path.n - start}; +} + +/* go/doc assumedPackageName: the last path element; for a major-version + * element (`v2`) the one before it; without a `go-` prefix; cut at the first + * byte that cannot be in an identifier. */ +static go_str_t go_assumed_name(go_str_t path) { + go_str_t base = go_path_base(path); + bool version = base.n >= GO_VERSION_MIN && base.s[0] == 'v'; + for (size_t i = SKIP_ONE; version && i < base.n; i++) { + version = base.s[i] >= '0' && base.s[i] <= '9'; + } + if (version && base.n < path.n) { + base = go_path_base((go_str_t){path.s, path.n - base.n - SKIP_ONE}); + } + if (base.n >= GO_GO_PREFIX && memcmp(base.s, "go-", GO_GO_PREFIX) == 0) { + base.s += GO_GO_PREFIX; + base.n -= GO_GO_PREFIX; + } + size_t n = 0; + while (n < base.n && go_name_byte((unsigned char)base.s[n])) { + n++; + } + return (go_str_t){base.s, n}; +} + +static int go_import_cmp(const void *a, const void *b) { + const go_import_t *x = (const go_import_t *)a; + const go_import_t *y = (const go_import_t *)b; + int c = strcmp(x->local, y->local); + return c ? c : strcmp(x->path, y->path); +} + +static int go_generic_cmp(const void *a, const void *b) { + const go_generic_t *x = (const go_generic_t *)a; + const go_generic_t *y = (const go_generic_t *)b; + return (x->line > y->line) - (x->line < y->line); +} + +/* The name an import without one is called by: the real package name of an + * in-repo directory, else go/doc's assumed name. */ +static go_str_t go_unnamed_import_name(const go_index_t *ix, const go_file_t *f, + const go_import_t *im) { + go_str_t path = go_str(im->path); + go_str_t name = go_assumed_name(path); + const go_dir_t *dir = NULL; + if (go_classify_path(ix, f, path, &dir) == GO_PATH_PACKAGE) { + bool ambiguous = false; + const go_pkg_t *pkg = go_dir_package(dir, name, &ambiguous); + if (pkg) { + return go_str(pkg->name); + } + } + return name; +} + +/* Name the unnamed imports of a file, sort its imports by name and mark the + * names two different paths claim. Its type-parameter records are sorted by + * line on the way. */ +static void go_finish_file(go_index_t *ix, go_file_t *f) { + if (f->ngenerics > SKIP_ONE) { + qsort(f->generics, (size_t)f->ngenerics, sizeof(f->generics[0]), go_generic_cmp); + } + for (int i = 0; i < f->nimports; i++) { + go_import_t *im = &f->imports[i]; + if (!im->local) { + go_str_t name = go_unnamed_import_name(ix, f, im); + im->local = ix_strndup(ix, name.s, name.n); + } + if (!im->local) { + f->nimports = 0; + return; + } + } + if (f->nimports > SKIP_ONE) { + qsort(f->imports, (size_t)f->nimports, sizeof(f->imports[0]), go_import_cmp); + } + int i = 0; + while (i < f->nimports) { + int end = i + SKIP_ONE; /* [i, end): the imports called alike, sorted by path */ + while (end < f->nimports && strcmp(f->imports[end].local, f->imports[i].local) == 0) { + end++; + } + bool conflict = strcmp(f->imports[i].path, f->imports[end - SKIP_ONE].path) != 0; + for (int k = i; k < end; k++) { + f->imports[k].conflict = conflict; + } + i = end; + } +} + +/* The file's import called `name`; NULL when it has none. */ +static const go_import_t *go_file_import(const go_file_t *f, go_str_t name) { + int lo = 0; + int hi = f->nimports - SKIP_ONE; + while (lo <= hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + const char *local = f->imports[mid].local; + size_t ll = strlen(local); + int c = memcmp(name.s, local, name.n < ll ? name.n : ll); + if (c == 0 && name.n != ll) { + c = name.n < ll ? -SKIP_ONE : SKIP_ONE; + } + if (c == 0) { + return &f->imports[mid]; + } + if (c < 0) { + hi = mid - SKIP_ONE; + } else { + lo = mid + SKIP_ONE; + } + } + return NULL; +} + +/* ── Declarations ────────────────────────────────────────────────── */ + +/* Count file `f` as a declarer of one name when it is built unconditionally. A + * file's records are read in one run, so its declarations of one name arrive + * together. */ +static void go_count_free(go_free_t *fr, const go_file_t *f) { + if (f->constrained || fr->last == f) { + return; + } + fr->last = f; + if (fr->files < GO_TWINS) { + fr->files++; + } +} + +static void go_add_sym(go_index_t *ix, go_file_t *f, int kind_bit, const char *name, + const char *alias) { + if (!f->pkg || f->ignored) { + return; + } + bool fresh = !go_lookup(ix->syms, f->pkg->idx, go_str(name), GO_NO_STR); + go_sym_t *s = + (go_sym_t *)ix_keyed(ix, ix->syms, f->pkg->idx, go_str(name), GO_NO_STR, sizeof(go_sym_t)); + if (!s) { + return; + } + s->kinds |= kind_bit; + s->prod = s->prod || !f->is_test; + go_count_free(&s->free, f); + bool is_alias = alias && strcmp(alias, "-") != 0; + if (fresh && is_alias) { + s->alias = ix_strndup(ix, alias, strlen(alias)); + s->alias_file = f; + } else if (!is_alias || !s->alias || strcmp(s->alias, alias) != 0) { + s->alias_mixed = true; /* declarations disagree: nothing to follow */ + } +} + +static void go_add_member(go_index_t *ix, go_file_t *f, int kind_bit, const char *type, + const char *name, bool has_node) { + go_fmem_t *fm = + (go_fmem_t *)ix_keyed(ix, ix->fmem, f->idx, go_str(type), go_str(name), sizeof(go_fmem_t)); + if (fm) { + fm->kinds |= kind_bit; + fm->node_kinds |= has_node ? kind_bit : 0; + } + if (!f->pkg || f->ignored) { + return; + } + go_mem_t *m = (go_mem_t *)ix_keyed(ix, ix->mems, f->pkg->idx, go_str(type), go_str(name), + sizeof(go_mem_t)); + if (m) { + m->kinds |= kind_bit; + m->prod = m->prod || !f->is_test; + go_count_free(&m->free, f); + } +} + +/* Record that file `f` declares `name` as `kind`, and what the declaration's + * w field says: `1` it keeps the file's flat node name ., `x` two + * declarations contest it, `0` a later one keeps it, `-` it has no node. + * `recv` is set for a method. */ +static void go_add_claim(go_index_t *ix, const go_file_t *f, int kind, const char *name, + const char *recv, const char *w) { + go_flat_t *fl = + (go_flat_t *)ix_keyed(ix, ix->flat, f->idx, go_str(name), GO_NO_STR, sizeof(go_flat_t)); + if (!fl) { + return; + } + fl->kinds |= kind; + if (w[0] == 'x') { + fl->contested = true; + } else if (w[0] == '1') { + fl->owner = kind; + fl->method_recv = recv ? ix_strndup(ix, recv, strlen(recv)) : NULL; + } +} + +/* Second pass over a file's scope: its declarations. */ +static void go_record_decl(go_index_t *ix, go_file_t *file, char **f, int nf) { + enum { + F_FIELDS = 3, + M_FIELDS = 4, + T_FIELDS = 5, + V_FIELDS = 3, + FIELD_FIELDS = 4, + IM_FIELDS = 3 + }; + enum { F_OWNER = 2, T_ALIAS = 3, T_OWNER = 4, V_OWNER = 2, M_OWNER = 3, FIELD_NODE = 3 }; + char kind = f[0][0]; + if (nf < IM_FIELDS || !go_name_ok(f[SKIP_ONE])) { + return; + } + if (kind == 'F' && nf == F_FIELDS) { + go_add_sym(ix, file, GO_KIND_FUNC, f[SKIP_ONE], NULL); + go_add_claim(ix, file, GO_KIND_FUNC, f[SKIP_ONE], NULL, f[F_OWNER]); + } else if (kind == 'T' && nf == T_FIELDS) { + go_add_sym(ix, file, GO_KIND_TYPE, f[SKIP_ONE], f[T_ALIAS]); + go_add_claim(ix, file, GO_KIND_TYPE, f[SKIP_ONE], NULL, f[T_OWNER]); + } else if (kind == 'V' && nf == V_FIELDS) { + go_add_sym(ix, file, GO_KIND_VALUE, f[SKIP_ONE], NULL); + go_add_claim(ix, file, GO_KIND_VALUE, f[SKIP_ONE], NULL, f[V_OWNER]); + } else if (kind == 'M' && nf == M_FIELDS && go_name_ok(f[PAIR_LEN])) { + if (strcmp(f[SKIP_ONE], "?") != 0) { + go_add_member(ix, file, GO_MEM_METHOD, f[SKIP_ONE], f[PAIR_LEN], true); + } + go_add_claim(ix, file, GO_KIND_METHOD, f[PAIR_LEN], f[SKIP_ONE], f[M_OWNER]); + } else if (kind == 'f' && nf == FIELD_FIELDS && go_name_ok(f[PAIR_LEN])) { + go_add_member(ix, file, GO_MEM_FIELD, f[SKIP_ONE], f[PAIR_LEN], f[FIELD_NODE][0] == '1'); + } else if (kind == 'm' && nf == IM_FIELDS && go_name_ok(f[PAIR_LEN])) { + go_add_member(ix, file, GO_MEM_IFACE, f[SKIP_ONE], f[PAIR_LEN], true); + } +} + +/* ── Index lifecycle ─────────────────────────────────────────────── */ + +static void go_destroy(void *index) { + go_index_t *ix = (go_index_t *)index; + if (!ix) { + return; + } + cbm_ht_free(ix->dir_by_path); + cbm_ht_free(ix->pkg_by_key); + cbm_ht_free(ix->file_by_path); + cbm_ht_free(ix->module_first); + cbm_ht_free(ix->module_at); + cbm_ht_free(ix->std_pkgs); + cbm_ht_free(ix->syms); + cbm_ht_free(ix->mems); + cbm_ht_free(ix->flat); + cbm_ht_free(ix->fmem); + cbm_free(CBM_MEM_CLASS_OTHER, ix->run_to_file); + cbm_arena_destroy(&ix->arena); + cbm_free(CBM_MEM_CLASS_OTHER, ix); +} + +static bool go_index_tables(go_index_t *ix, int file_count, int run_file_count) { + ix->dir_by_path = cbm_ht_create(CBM_SZ_1K); + ix->pkg_by_key = cbm_ht_create(CBM_SZ_1K); + ix->file_by_path = cbm_ht_create(CBM_SZ_4K); + ix->module_first = cbm_ht_create(CBM_SZ_64); + ix->module_at = cbm_ht_create(CBM_SZ_64); + ix->std_pkgs = cbm_ht_create(CBM_SZ_64); + ix->syms = cbm_ht_create(CBM_SZ_4K); + ix->mems = cbm_ht_create(CBM_SZ_4K); + ix->flat = cbm_ht_create(CBM_SZ_4K); + ix->fmem = cbm_ht_create(CBM_SZ_4K); + ix->nfiles = file_count; + ix->files = + (go_file_t *)ix_alloc(ix, (size_t)(file_count ? file_count : SKIP_ONE) * sizeof(go_file_t)); + ix->run_count = run_file_count; + ix->run_to_file = (go_file_t **)cbm_calloc( + CBM_MEM_CLASS_OTHER, + (size_t)(run_file_count ? run_file_count : SKIP_ONE) * sizeof(go_file_t *)); + return ix->dir_by_path && ix->pkg_by_key && ix->file_by_path && ix->module_first && + ix->module_at && ix->std_pkgs && ix->syms && ix->mems && ix->flat && ix->fmem && + ix->files && ix->run_to_file; +} + +/* True for a file below a directory called `testdata`: the go tool ignores + * such directories, so what they hold is fixture data, never product code. */ +static bool go_in_testdata(const char *rel_path) { + static const char dir[] = "testdata"; + const size_t dl = sizeof(dir) - SKIP_ONE; + for (const char *p = rel_path;;) { + const char *slash = strchr(p, '/'); + if (!slash) { + return false; /* the last element is the file's name */ + } + if ((size_t)(slash - p) == dl && memcmp(p, dir, dl) == 0) { + return true; + } + p = slash + SKIP_ONE; + } +} + +/* File i of the index: its directory, its test flags, the head of its scope. */ +static bool go_index_file(go_index_t *ix, int i, const cbm_doclink_file_t *src) { + go_file_t *f = &ix->files[i]; + memset(f, 0, sizeof(*f)); + f->idx = i; + f->rel_path = src->rel_path; + f->scope = src->scope; + f->is_test = cbm_is_test_file(src->rel_path, CBM_LANG_GO) || go_in_testdata(src->rel_path); + f->go_test = go_has_suffix(src->rel_path, "_test.go"); + const char *slash = strrchr(src->rel_path, '/'); + f->dir = + go_dir_get(ix, src->rel_path, slash ? (size_t)(slash - src->rel_path) : 0, src->rel_path); + if (!f->dir) { + ix->oom = true; + return false; + } + cbm_ht_set(ix->file_by_path, f->rel_path, f); + if (src->run_file >= 0 && src->run_file < ix->run_count) { + ix->run_to_file[src->run_file] = f; + } + return !f->scope || go_each_record(ix, f, f->scope, go_record_head); +} + +static void *go_build(const cbm_doclink_build_in_t *in) { + go_index_t *ix = (go_index_t *)cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*ix)); + if (!ix) { + return NULL; + } + cbm_arena_init(&ix->arena); + ix->project = in->ctx->project_name; + bool ok = go_index_tables(ix, in->file_count, in->run_file_count); + for (int i = 0; ok && i < in->file_count; i++) { + ok = go_index_file(ix, i, &in->files[i]); + } + if (ok) { + go_finish_modules(ix); + go_finish_dirs(ix); + } + for (int i = 0; ok && i < ix->nfiles; i++) { + go_finish_file(ix, &ix->files[i]); + ok = !ix->oom; + } + for (int i = 0; ok && i < ix->nfiles; i++) { + go_file_t *f = &ix->files[i]; + ok = !f->scope || go_each_record(ix, f, f->scope, go_record_decl); + f->scope = NULL; /* read: the index keeps no pointer into a blob */ + } + if (!ok || ix->oom) { + cbm_log_error("doc_links.go.error", "step", "index", "reason", "alloc"); + go_destroy(ix); + return NULL; + } + char b[CBM_SZ_4][CBM_SZ_32]; + snprintf(b[0], sizeof(b[0]), "%d", ix->nfiles); + snprintf(b[SKIP_ONE], sizeof(b[SKIP_ONE]), "%d", ix->npkgs); + snprintf(b[PAIR_LEN], sizeof(b[PAIR_LEN]), "%d", ix->nmodules + ix->nstd); + snprintf(b[CBM_SZ_3], sizeof(b[CBM_SZ_3]), "%u", cbm_ht_count(ix->syms)); + cbm_log_info("doc_links.go.index", "files", b[0], "packages", b[SKIP_ONE], "modules", + b[PAIR_LEN], "symbols", b[CBM_SZ_3]); + return ix; +} + +/* ── Resolution ──────────────────────────────────────────────────── */ + +typedef struct { + const go_index_t *ix; + const cbm_gbuf_t *g; + const go_file_t *src; + cbm_doclink_outcome_t *out; +} go_ctx_t; + +static void go_row(const go_ctx_t *c, int reason) { + c->out->kind = CBM_DOCLINK_UNRESOLVED; + c->out->reason = reason; +} + +/* Plain text for go/doc: neither an edge nor a row. */ +static void go_not_a_link(const go_ctx_t *c) { + c->out->kind = CBM_DOCLINK_LOCAL; +} + +static void go_edge(const go_ctx_t *c, const cbm_gbuf_node_t *node, bool exact) { + if (!node) { + go_row(c, CBM_DOCLINK_REASON_GRAPH_GAP); + return; + } + c->out->kind = CBM_DOCLINK_EDGE; + c->out->target = node; + c->out->exact = exact; +} + +/* A name the target package does not declare. Written bare it is plain text. + * Qualified it is a broken link; but in a package with a parse error the + * declaration may be hidden from the scan (a graph gap, not doc rot), and + * text with a non-ASCII byte may be no identifier at all, which this scan + * cannot tell (go/doc asks the Unicode letter classes): plain text. */ +static void go_undeclared(const go_ctx_t *c, const go_pkg_t *pkg, bool qualified, go_str_t recv, + go_str_t name) { + if (!qualified || go_has_non_ascii(recv) || go_has_non_ascii(name)) { + go_not_a_link(c); + } else { + go_row(c, pkg->incomplete ? CBM_DOCLINK_REASON_GRAPH_GAP : CBM_DOCLINK_REASON_MISSING); + } +} + +/* The node .[.] of the package's directory, or NULL. */ +static const cbm_gbuf_node_t *go_find_node(const go_ctx_t *c, const go_pkg_t *pkg, go_str_t a, + go_str_t b) { + size_t ql = strlen(pkg->dir->qn); + go_buf_t buf; + char *qn = go_buf_get(&buf, ql + a.n + b.n + CBM_SZ_3); + if (!qn) { + return NULL; + } + size_t w = ql; + memcpy(qn, pkg->dir->qn, ql); + qn[w++] = '.'; + memcpy(qn + w, a.s, a.n); + w += a.n; + if (b.s) { + qn[w++] = '.'; + memcpy(qn + w, b.s, b.n); + w += b.n; + } + qn[w] = '\0'; + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(c->g, qn); + go_buf_free(&buf); + return n; +} + +/* The file a node belongs to, when that is a file of package `pkg` whose + * declarations the reference may bind: not build-ignored, and no test code + * unless the reference itself is written in test code (a node kept by a test + * file's twin of a product declaration is no target for product code). */ +static const go_file_t *go_node_file(const go_ctx_t *c, const cbm_gbuf_node_t *n, + const go_pkg_t *pkg) { + if (!n || !n->file_path || !n->label) { + return NULL; + } + const go_file_t *f = (const go_file_t *)cbm_ht_get(c->ix->file_by_path, n->file_path); + if (!f || f->pkg != pkg || f->ignored || (f->is_test && !c->src->is_test)) { + return NULL; + } + return f; +} + +static bool go_label_is(const cbm_gbuf_node_t *n, const char *label) { + return strcmp(n->label, label) == 0; +} + +/* The declaration of the node's own file that keeps the flat name .; + * NULL when the node is no file's of package `pkg`, or two declarations + * contest the name there. */ +static const go_flat_t *go_flat_owner(const go_ctx_t *c, const cbm_gbuf_node_t *n, + const go_pkg_t *pkg, go_str_t name) { + const go_file_t *f = go_node_file(c, n, pkg); + const go_flat_t *fl = + f ? (const go_flat_t *)go_lookup(c->ix->flat, f->idx, name, GO_NO_STR) : NULL; + return (fl && !fl->contested) ? fl : NULL; +} + +/* The node of . when it IS the package's function, type or value + * of that name: the node's file keeps the name for a declaration of that + * kind, and the node has its label. */ +static const cbm_gbuf_node_t *go_symbol_node(const go_ctx_t *c, const go_pkg_t *pkg, int kind, + go_str_t name) { + const cbm_gbuf_node_t *n = go_find_node(c, pkg, name, GO_NO_STR); + const go_flat_t *fl = go_flat_owner(c, n, pkg, name); + if (!fl || fl->owner != kind) { + return NULL; + } + bool label_ok = (kind == GO_KIND_FUNC && go_label_is(n, "Function")) || + (kind == GO_KIND_TYPE && cbm_label_is_type_like(n->label)) || + (kind == GO_KIND_VALUE && go_label_is(n, "Variable")); + return label_ok ? n : NULL; +} + +/* The node of .. when it is that member of the package: a + * field or an interface method, or a method in a graph that names methods by + * their receiver. */ +static const cbm_gbuf_node_t *go_member_node(const go_ctx_t *c, const go_pkg_t *pkg, int kind, + go_str_t type, go_str_t name) { + const cbm_gbuf_node_t *n = go_find_node(c, pkg, type, name); + const go_file_t *f = go_node_file(c, n, pkg); + if (!f || !go_label_is(n, kind == GO_MEM_FIELD ? "Field" : "Method")) { + return NULL; + } + const go_fmem_t *fm = (const go_fmem_t *)go_lookup(c->ix->fmem, f->idx, type, name); + return (fm && (fm->node_kinds & kind)) ? n : NULL; +} + +/* The node of the method `recv.name`: its receiver-qualified node, else the + * flat node . when the method is the one that kept it (the last + * declaration of that name in the node's file). */ +static const cbm_gbuf_node_t *go_method_node(const go_ctx_t *c, const go_pkg_t *pkg, go_str_t recv, + go_str_t name) { + const cbm_gbuf_node_t *qualified = go_member_node(c, pkg, GO_MEM_METHOD, recv, name); + if (qualified) { + return qualified; + } + const cbm_gbuf_node_t *n = go_find_node(c, pkg, name, GO_NO_STR); + const go_flat_t *fl = go_flat_owner(c, n, pkg, name); + bool owns = fl && fl->owner == GO_KIND_METHOD && fl->method_recv && + go_str_eq(recv, fl->method_recv) && go_label_is(n, "Method"); + return owns ? n : NULL; +} + +static bool go_one_bit(int bits) { + return bits != 0 && (bits & (bits - SKIP_ONE)) == 0; +} + +/* Bind `n`, the node of a name declared once (build variants count as once). + * A name that two unconstrained files of the package declare (go_free_t) is + * two entities: written in a file that declares it itself (`declared_here`), + * the reference means that file's own declaration, which binds only when it + * is the one that has the node; written anywhere else, nothing picks one. */ +static void go_bind_node(const go_ctx_t *c, const cbm_gbuf_node_t *n, bool twins, + bool declared_here, bool exact) { + if (twins && !declared_here) { + go_row(c, CBM_DOCLINK_REASON_AMBIGUOUS); + return; + } + bool other_file = n && twins && strcmp(n->file_path, c->src->rel_path) != 0; + go_edge(c, other_file ? NULL : n, exact); +} + +/* [Name] / [pkg.Name] in package `pkg`. */ +static void go_bind_symbol(const go_ctx_t *c, const go_pkg_t *pkg, go_str_t name, bool qualified) { + const go_sym_t *s = (const go_sym_t *)go_lookup(c->ix->syms, pkg->idx, name, GO_NO_STR); + if (!s) { + go_undeclared(c, pkg, qualified, GO_NO_STR, name); + } else if (!go_one_bit(s->kinds)) { + go_row(c, CBM_DOCLINK_REASON_AMBIGUOUS); /* a function here, a type there */ + } else if (!s->prod && !c->src->is_test) { + go_row(c, CBM_DOCLINK_REASON_TEST_ONLY); + } else { + const go_flat_t *own = + pkg == c->src->pkg + ? (const go_flat_t *)go_lookup(c->ix->flat, c->src->idx, name, GO_NO_STR) + : NULL; + go_bind_node(c, go_symbol_node(c, pkg, s->kinds, name), s->free.files >= GO_TWINS, + own && (own->kinds & s->kinds), qualified); + } +} + +/* The package a qualifier of file `file` names through its imports. */ +static go_path_kind_t go_import_package(const go_index_t *ix, const go_file_t *file, + go_str_t qualifier, const go_pkg_t **pkg_out) { + const go_import_t *im = go_file_import(file, qualifier); + if (!im || im->conflict) { + return GO_PATH_NO_PACKAGE; + } + const go_dir_t *dir = NULL; + go_path_kind_t kind = go_classify_path(ix, file, go_str(im->path), &dir); + if (kind != GO_PATH_PACKAGE) { + return kind; + } + bool ambiguous = false; + *pkg_out = go_dir_package(dir, qualifier, &ambiguous); + if (ambiguous) { + return GO_PATH_AMBIGUOUS; + } + return *pkg_out ? GO_PATH_PACKAGE : GO_PATH_NO_PACKAGE; +} + +enum { GO_ALIAS_NONE = 0, GO_ALIAS_FOUND, GO_ALIAS_EXTERNAL, GO_ALIAS_TOO_DEEP }; + +/* [X.Member] where X is `type X = Y` or `= pkg.Y`: follow the alias (and an + * alias of an alias) to the type that declares Member; *pkg and *recv then + * name that type. */ +static int go_follow_alias(const go_ctx_t *c, const go_pkg_t **pkg, go_str_t *recv, go_str_t name) { + for (int depth = 0; depth < GO_ALIAS_DEPTH; depth++) { + const go_sym_t *s = (const go_sym_t *)go_lookup(c->ix->syms, (*pkg)->idx, *recv, GO_NO_STR); + if (!s || !s->alias || s->alias_mixed || s->kinds != GO_KIND_TYPE) { + return GO_ALIAS_NONE; + } + const char *dot = strchr(s->alias, '.'); + if (dot) { + go_str_t q = {s->alias, (size_t)(dot - s->alias)}; + const go_pkg_t *next = NULL; + go_path_kind_t kind = go_import_package(c->ix, s->alias_file, q, &next); + if (kind == GO_PATH_EXTERNAL) { + return GO_ALIAS_EXTERNAL; + } + if (kind != GO_PATH_PACKAGE) { + return GO_ALIAS_NONE; + } + *pkg = next; + } + *recv = dot ? go_str(dot + SKIP_ONE) : go_str(s->alias); + if (go_lookup(c->ix->mems, (*pkg)->idx, *recv, name)) { + return GO_ALIAS_FOUND; + } + } + return GO_ALIAS_TOO_DEEP; /* never an answer taken from half the chain */ +} + +/* The member `recv.name` of package `*pkg`, looked up through an alias when + * the type does not declare it itself (*pkg and *recv then name the type that + * does). NULL with *row false: not declared. NULL with *row true: a row was + * set (the alias leads out of the repository, or too deep). */ +static const go_mem_t *go_find_member(const go_ctx_t *c, const go_pkg_t **pkg, go_str_t *recv, + go_str_t name, bool *row) { + *row = false; + const go_mem_t *m = (const go_mem_t *)go_lookup(c->ix->mems, (*pkg)->idx, *recv, name); + if (m) { + return m; + } + int followed = go_follow_alias(c, pkg, recv, name); + if (followed == GO_ALIAS_EXTERNAL || followed == GO_ALIAS_TOO_DEEP) { + go_row(c, followed == GO_ALIAS_EXTERNAL ? CBM_DOCLINK_REASON_EXTERNAL + : CBM_DOCLINK_REASON_UNPARSEABLE); + *row = true; + return NULL; + } + return followed == GO_ALIAS_FOUND + ? (const go_mem_t *)go_lookup(c->ix->mems, (*pkg)->idx, *recv, name) + : NULL; +} + +/* [Recv.Name] / [pkg.Recv.Name] in package `pkg`. + * + * What binds: a member the named type declares ITSELF. That is a method + * declared with that receiver, a method of that interface type, or a field + * written in that type's own struct declaration; nothing promoted through an + * embedded type. A name the type does not declare is plain text when it is + * written without a package and a broken link (`missing`) when it is written + * with one. `type X = Y` is followed to the type that declares the member + * only for a reference written with a package. + * + * What go/doc does, as far as it is known here (no Go toolchain was at hand + * to check it; gopls sets its doc-comment parser up the same way): without a + * package, `Recv.Name` is a link only for a METHOD, so `[Type.Field]` inside + * its own package renders as plain text; with a package, go/doc links + * whatever follows it without looking. + * + * Binding the same-package field is a deliberate difference from go/doc. The + * brackets are doc-link syntax by the doc-comment specification ("[Name1. + * Name2] refers to exported identifiers in the current package"), and the + * field named is one declared entity, so nothing is guessed: its tier is + * `exact`. */ +static void go_bind_member(const go_ctx_t *c, const go_pkg_t *pkg, go_str_t recv, go_str_t name, + bool qualified) { + const go_pkg_t *named_pkg = pkg; + go_str_t named_recv = recv; + bool row = false; + const go_mem_t *m = qualified ? go_find_member(c, &pkg, &recv, name, &row) + : (const go_mem_t *)go_lookup(c->ix->mems, pkg->idx, recv, name); + if (row) { + return; + } + if (!m) { + go_undeclared(c, named_pkg, qualified, named_recv, name); + } else if (!go_one_bit(m->kinds)) { + go_row(c, CBM_DOCLINK_REASON_AMBIGUOUS); /* a method here, a field there */ + } else if (!m->prod && !c->src->is_test) { + go_row(c, CBM_DOCLINK_REASON_TEST_ONLY); + } else { + /* the member of one type: twins of the TYPE split its members too */ + const go_sym_t *ts = (const go_sym_t *)go_lookup(c->ix->syms, pkg->idx, recv, GO_NO_STR); + bool twins = m->free.files >= GO_TWINS || (ts && ts->free.files >= GO_TWINS); + const go_fmem_t *own = + pkg == c->src->pkg ? (const go_fmem_t *)go_lookup(c->ix->fmem, c->src->idx, recv, name) + : NULL; + const cbm_gbuf_node_t *n = m->kinds == GO_MEM_METHOD + ? go_method_node(c, pkg, recv, name) + : go_member_node(c, pkg, m->kinds, recv, name); + bool exact = qualified || m->kinds == GO_MEM_FIELD; + go_bind_node(c, n, twins, own && (own->kinds & m->kinds), exact); + } +} + +/* [pkg]: the package's directory node. The graph has a Folder node per + * directory and no node per Go package; the repository root is the Project + * node, which is no package. */ +static void go_bind_package(const go_ctx_t *c, const go_pkg_t *pkg) { + if (!pkg->has_prod && !c->src->is_test) { + go_row(c, CBM_DOCLINK_REASON_TEST_ONLY); + return; + } + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(c->g, pkg->dir->qn); + bool folder = n && n->label && strcmp(n->label, "Folder") == 0 && n->file_path && + strcmp(n->file_path, pkg->dir->path) == 0; + go_edge(c, folder ? n : NULL, true); +} + +/* Standard-library packages with a one-element import path (go/doc/comment + * stdPkgs): go/doc's own closed list, the only names it links without an + * import. A name of a later Go release that is missing here stays plain text; + * neither way is it an edge, unless the repository is the standard library + * itself (a `module std` tree), where the package is found by its path. */ +static bool go_is_std_single(go_str_t name) { + static const char *const std[] = { + "bufio", "bytes", "cmp", "context", "crypto", "embed", "encoding", + "errors", "expvar", "flag", "fmt", "hash", "html", "image", + "io", "iter", "log", "maps", "math", "mime", "net", + "os", "path", "plugin", "reflect", "regexp", "runtime", "slices", + "sort", "strconv", "strings", "structs", "sync", "syscall", "testing", + "time", "unicode", "unique", "unsafe", "uuid", "weak", + }; + return go_word_in(name, std, sizeof(std) / sizeof(std[0])); +} + +typedef enum { + GO_QUAL_NONE = 0, /* names no package: plain text */ + GO_QUAL_SELF, /* the documented file's own package */ + GO_QUAL_PATH, /* an import path */ +} go_qual_t; + +/* go/doc lookupPkg for a qualifier without a slash. + * + * go/doc itself knows only the UNION of the imports of all files of the + * package (one name with two paths is no name), then the package's own name, + * then its list of one-element standard-library paths. This resolver asks the + * documented FILE's own imports instead of the union: a strict subset of what + * go/doc links (a file can only disagree with the union by importing nothing + * under that name), so nothing is guessed, and a file's imports stay the + * file's own business -- with the union, every import edit would change its + * siblings' links and could not be repaired file by file. A qualifier that + * only a sibling file imports is therefore plain text here. + * + * A name two imports claim is no import name for go/doc, and it does not ask + * the package's own name then either: only the standard-library list is left. */ +static go_qual_t go_lookup_qualifier(const go_ctx_t *c, go_str_t q, go_str_t *path) { + const go_import_t *im = go_file_import(c->src, q); + if (im && !im->conflict) { + *path = go_str(im->path); + return GO_QUAL_PATH; + } + if (!im && go_str_eq(q, c->src->pkg->name)) { + return GO_QUAL_SELF; + } + if (go_is_std_single(q)) { + *path = q; + return GO_QUAL_PATH; + } + return GO_QUAL_NONE; +} + +/* True when the bare name is a type parameter in scope of the documented + * declaration: its own, or that of the generic type it is declared in (a + * field, an interface method). Generic declarations are top-level and do not + * overlap, so the one that starts last at or before the line is the only + * candidate. */ +static bool go_own_type_param(const go_file_t *f, uint32_t def_line, go_str_t name) { + int lo = 0; + int hi = f->ngenerics - SKIP_ONE; + const go_generic_t *hit = NULL; + while (lo <= hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (f->generics[mid].line <= def_line) { + hit = &f->generics[mid]; + lo = mid + SKIP_ONE; + } else { + hi = mid - SKIP_ONE; + } + } + if (hit && def_line > hit->last) { + hit = NULL; + } + for (const char *p = hit ? hit->names : ""; *p;) { + size_t n = strcspn(p, ","); + if (n == name.n && memcmp(p, name.s, n) == 0) { + return true; + } + p += n + (p[n] == ','); + } + return false; +} + +/* The package an import path means for the documented file; a row or the + * plain-text outcome is set, and NULL returned, when it means none. */ +static const go_pkg_t *go_path_package(const go_ctx_t *c, go_str_t path, go_str_t hint) { + const go_dir_t *dir = NULL; + go_path_kind_t kind = go_classify_path(c->ix, c->src, path, &dir); + bool ambiguous = kind == GO_PATH_AMBIGUOUS; + const go_pkg_t *pkg = kind == GO_PATH_PACKAGE ? go_dir_package(dir, hint, &ambiguous) : NULL; + if (kind == GO_PATH_EXTERNAL) { + go_row(c, CBM_DOCLINK_REASON_EXTERNAL); + } else if (kind == GO_PATH_UNKNOWN) { + go_row(c, CBM_DOCLINK_REASON_UNPARSEABLE); + } else if (ambiguous) { + go_row(c, CBM_DOCLINK_REASON_AMBIGUOUS); + } else if (!pkg) { + go_row(c, CBM_DOCLINK_REASON_MISSING); /* a module path without a package there */ + } + return ambiguous ? NULL : pkg; +} + +/* A reference qualified by `pkg` (an import path, or a name to look up). */ +static void go_resolve_qualified(const go_ctx_t *c, const CBMDocLinkGoRef *ref) { + go_str_t q = {ref->pkg, ref->pkg_len}; + go_str_t path = q; + go_str_t hint = q; + const go_pkg_t *pkg = c->src->pkg; + if (memchr(q.s, '/', q.n)) { + hint = go_path_base(q); + } else { + go_qual_t kind = go_lookup_qualifier(c, q, &path); + if (kind == GO_QUAL_NONE) { + go_not_a_link(c); + return; + } + if (kind == GO_QUAL_SELF) { + path.n = 0; + } + } + if (path.n > 0) { + pkg = go_path_package(c, path, hint); + if (!pkg) { + return; + } + } + go_str_t name = {ref->name, ref->name_len}; + go_str_t recv = {ref->recv, ref->recv_len}; + if (name.n == 0) { + go_bind_package(c, pkg); + } else if (recv.n == 0) { + go_bind_symbol(c, pkg, name, true); + } else { + go_bind_member(c, pkg, recv, name, true); + } +} + +/* True when the node the core takes for the edge's source IS the documented + * declaration: it sits in the file being resolved, at the declaration's line. + * Go method nodes share a flat name, so without this the doc of a method that + * lost the name (or of a build-tag twin) would be attributed to another + * declaration's node, in another file or on another line. That node's lines + * are known here: the file is one this run extracted. */ +static bool go_source_owns_node(const go_ctx_t *c, const CBMDocLink *link) { + if ((link->flags & CBM_DOCLINK_FLAG_FILE) || !link->source_qn) { + return true; /* the file's own doc: its File node is the source */ + } + const cbm_gbuf_node_t *src = cbm_gbuf_find_by_qn(c->g, link->source_qn); + if (!src) { + return true; /* no node at all: the core drops the reference */ + } + return src->file_path && strcmp(src->file_path, c->src->rel_path) == 0 && + src->start_line == (int)link->def_line; +} + +/* A well-formed reference of a file that has a package: by its shape. */ +static void go_resolve_ref(const go_ctx_t *c, const CBMDocLink *link, const CBMDocLinkGoRef *ref) { + go_str_t name = {ref->name, ref->name_len}; + go_str_t recv = {ref->recv, ref->recv_len}; + bool of_declaration = !(link->flags & CBM_DOCLINK_FLAG_FILE); + if (ref->pkg_len > 0) { + go_resolve_qualified(c, ref); + } else if (recv.n > 0) { + go_bind_member(c, c->src->pkg, recv, name, false); + } else if (of_declaration && go_own_type_param(c->src, link->def_line, name)) { + go_not_a_link(c); /* the declaration's own type parameter */ + } else { + go_bind_symbol(c, c->src->pkg, name, false); + } +} + +static void go_resolve(const void *index, void *state, int run_file, const CBMDocLink *link, + const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out) { + (void)state; /* Go keeps no per-file working state */ + const go_index_t *ix = (const go_index_t *)index; + out->kind = CBM_DOCLINK_UNRESOLVED; + out->reason = CBM_DOCLINK_REASON_MISSING; + out->target = NULL; + out->exact = false; + if (!ix || !link->raw || run_file < 0 || run_file >= ix->run_count || + !ix->run_to_file[run_file]) { + return; + } + go_ctx_t c = {.ix = ix, .g = graph, .src = ix->run_to_file[run_file], .out = out}; + size_t raw_len = strlen(link->raw); + CBMDocLinkGoRef ref; + if (raw_len > CBM_DOCLINK_GO_REF_MAX) { + go_row(&c, CBM_DOCLINK_REASON_UNPARSEABLE); /* clipped at extraction: never resolved */ + return; + } + if (!cbm_doclink_go_parse_ref(link->raw, raw_len, &ref)) { + go_not_a_link(&c); /* go/doc knows no malformed link, only text */ + return; + } + if (!c.src->pkg) { + go_row(&c, CBM_DOCLINK_REASON_GRAPH_GAP); /* no package clause: nothing can be placed */ + return; + } + go_resolve_ref(&c, link, &ref); + if (out->kind == CBM_DOCLINK_EDGE && !go_source_owns_node(&c, link)) { + go_row(&c, CBM_DOCLINK_REASON_GRAPH_GAP); /* the documented declaration has no node */ + } +} + +/* ── Incremental scope rules ─────────────────────────────────────── */ + +/* The next record line of a blob; false at the end. */ +static bool go_delta_next(const char **cursor, go_str_t *line) { + const char *p = *cursor; + if (!p || !*p) { + return false; + } + const char *nl = strchr(p, '\n'); + line->s = p; + line->n = nl ? (size_t)(nl - p) : strlen(p); + *cursor = nl ? nl + SKIP_ONE : p + line->n; + return true; +} + +/* The first record line of a blob: what follows its tag line. */ +static const char *go_delta_start(const char *blob) { + const char *nl = strchr(blob, '\n'); + return nl ? nl + SKIP_ONE : NULL; +} + +static int go_line_cmp(go_str_t a, go_str_t b) { + int c = memcmp(a.s, b.s, a.n < b.n ? a.n : b.n); + if (c == 0 && a.n != b.n) { + c = a.n < b.n ? -SKIP_ONE : SKIP_ONE; + } + return c; +} + +/* Field `want` (0-based) of a record line; GO_NO_STR when it has fewer. */ +static go_str_t go_record_field(go_str_t line, int want) { + size_t start = 0; + int field = 0; + for (size_t i = 0; i <= line.n; i++) { + if (i < line.n && line.s[i] != '\t') { + continue; + } + if (field == want) { + return (go_str_t){line.s + start, i - start}; + } + field++; + start = i + SKIP_ONE; + } + return GO_NO_STR; +} + +/* The name a record declares: field 1 of F and V, field 2 of M, f and m. */ +static go_str_t go_record_name(go_str_t line) { + return go_record_field(line, (line.s[0] == 'F' || line.s[0] == 'V') ? SKIP_ONE : PAIR_LEN); +} + +/* `T name a pkg.Y w`: an alias whose target is read through the file's + * imports. */ +static bool go_is_qualified_alias(go_str_t line) { + enum { T_ALIAS_FIELD = 3 }; + if (line.n == 0 || line.s[0] != 'T') { + return false; + } + go_str_t alias = go_record_field(line, T_ALIAS_FIELD); + return alias.s && memchr(alias.s, '.', alias.n) != NULL; +} + +typedef struct { + cbm_doclink_name_fn removed; + void *ud; + bool imports_changed; + bool qualified_alias; + bool global; + bool failed; +} go_delta_t; + +/* A record only one of the two scopes has. */ +static void go_delta_record(go_delta_t *d, go_str_t line, bool stored_only) { + char kind = line.s[0]; + if (kind == 'I') { + d->imports_changed = true; + return; + } + if (kind == 'g') { + return; /* never persisted; a fresh blob may still carry them */ + } + bool removable = kind == 'F' || kind == 'M' || kind == 'V' || kind == 'f' || kind == 'm'; + if (!stored_only || !removable) { + d->global = true; /* a new or changed record, a package, type or module line */ + return; + } + go_str_t name = go_record_name(line); + if (!name.s || name.n == 0 || !d->removed || !d->removed(d->ud, name.s, name.n)) { + d->failed = true; + } +} + +/* One step of the merge of two sorted scopes: -1 a record only the stored + * scope has, 1 one only the fresh scope has, 0 the same record in both. */ +static int go_delta_step(go_delta_t *d, bool has_stored, go_str_t stored, bool has_fresh, + go_str_t fresh) { + int c = 0; + if (!has_stored) { + c = SKIP_ONE; + } else if (!has_fresh) { + c = -SKIP_ONE; + } else { + c = go_line_cmp(stored, fresh); + } + d->qualified_alias = d->qualified_alias || (c <= 0 && go_is_qualified_alias(stored)) || + (c >= 0 && go_is_qualified_alias(fresh)); + if (c < 0) { + go_delta_record(d, stored, true); + } else if (c > 0) { + go_delta_record(d, fresh, false); + } + return c; +} + +/* Both scopes are sorted, so one merge finds what each has alone. + * LOCAL nothing differs; or only the file's imports differ (they scope + * the file itself, which is re-extracted anyway); or declarations + * only LEFT: a function, method, value, field or interface method. + * Their names are reported: files with an edge into the changed + * file are dependents anyway, and files whose unresolved rows name + * one re-resolve. + * GLOBAL anything that appeared or changed (a name appearing can turn + * another file's plain text into a link or a graph gap; a w field + * flipping hands a node to another declaration), the package + * clause, a type (an alias may be followed through it), a module + * path, and the imports of a file with a qualified alias (other + * files follow `type X = pkg.Y` through them). */ +static int go_scope_delta(const char *stored, const char *fresh, cbm_doclink_name_fn removed, + void *ud) { + go_delta_t d = {.removed = removed, .ud = ud}; + const char *sp = go_delta_start(stored); + const char *fp = go_delta_start(fresh); + go_str_t sl = GO_NO_STR; + go_str_t fl = GO_NO_STR; + bool hs = go_delta_next(&sp, &sl); + bool hf = go_delta_next(&fp, &fl); + while ((hs || hf) && !d.global && !d.failed) { + int c = go_delta_step(&d, hs, sl, hf, fl); + if (c <= 0) { + hs = go_delta_next(&sp, &sl); + } + if (c >= 0) { + hf = go_delta_next(&fp, &fl); + } + } + if (d.failed) { + return CBM_NOT_FOUND; + } + bool global = d.global || (d.imports_changed && d.qualified_alias); + return global ? CBM_DOCLINK_DELTA_GLOBAL : CBM_DOCLINK_DELTA_LOCAL; +} + +const cbm_doclink_resolver_t cbm_doclink_go_resolver = { + .langs = {CBM_LANG_GO, CBM_LANG_GOMOD}, + .lang_count = 2, + .scope_tag = CBM_DOCLINK_GO_SCOPE_TAG, + .build = go_build, + .destroy = go_destroy, + .resolve = go_resolve, + .scope_delta = go_scope_delta, +}; diff --git a/src/pipeline/doc_links_jvm.c b/src/pipeline/doc_links_jvm.c new file mode 100644 index 000000000..e01343298 --- /dev/null +++ b/src/pipeline/doc_links_jvm.c @@ -0,0 +1,4599 @@ +/* + * doc_links_jvm.c — Javadoc and KDoc reference resolution for doc_links.h. + * + * One index over the Java and the Kotlin files of a repository, built from + * their doc-link scopes (internal/cbm/doclink_jvm.c): on the JVM a package is + * one namespace whatever language declares into it, so a Kotlin import of a + * Java class and a Javadoc link to a Kotlin class must meet in one type table. + * The language of the SOURCE file decides the lookup order; where a target's + * node is comes from the target's own scope record (slot, own), never from a + * naming scheme assumed here. Graph nodes are looked up by qualified name + * only, and only their label and file path are read, so the closure-repair + * route (whose unchanged nodes are line-less proxies) resolves exactly as a + * full build does. + * + * Entities. A type entity is its binary-style name: package + '.' + the + * dotted logical path (com.acme.Outer.Inner). Several declarations of one + * name (source sets, expect/actual) are one entity. A member belongs to the + * declaration that holds it; overloads of one type share a node. + * + * Lookup (the first level with a visible candidate decides; more than one + * entity there is ambiguous, never a pick): + * Java type name (javac's order) + * the method's own type variables (local) -> the enclosing types, + * innermost first (the type and its member types, its type variables, the + * member types it inherits) -> the file's own top-level types and its + * single imports (a static import imports a member type too) -> the + * file's package -> on-demand imports, with java.lang among them. + * A qualified name: the head through those levels, the rest as member + * types; a head no level has is read from the root (packages, then a + * type). `Type.member` is no reference: Javadoc wants a '#'. + * Java METHOD, `#m` and `Type#m`: javadoc's own search (jr_walk, read in + * the JDK source), from the documented definition's type or the type + * named: the type's own methods -> its superclass, by the whole search + * (its superclass, its interfaces, the type around IT) -> its + * interfaces in the order written, likewise -> the type around it, + * likewise; each type once, the first method found. A type outside the + * repository on the way, or a method the language gives (Object's, + * Enum's, Annotation's, an enum's values/valueOf, a record's + * equals/hashCode/toString), ends it as `external`. Static imports are + * no step: javadoc does not search them for a member. + * Java FIELD, `#x` and `Type#x` without a parameter list: javadoc looks in + * the type, then the types around it, then the supertypes; an inherited + * field binds only when no type further out has one of that name, else + * it is ambiguous. ({@value Type#X}: the type and its supertypes.) + * Java kinds + * `#x` is the field when javadoc takes it over the method its search + * finds: the field's class is the method's class or below it, also for + * every type outside the repository the search passes (jr_java_field); + * else that method, when it is one of the repository with nothing + * unseen before it (javadoc links it), or external. + * A parameter list selects callables: + * the first type whose overloads have that signature, never another + * overload. A declared type variable matches its own name or its erasure. + * A method name without a list that has several signatures is ambiguous. + * A record's component declares an accessor of its name (no node). + * `#Type(..)` names the constructors of that type only. A bare + * identifier that is no type is javadoc's `#identifier`. + * Kotlin (the order KDoc resolves in) + * the definition's own parameters, type parameters and `this` (local) -> + * each enclosing type, innermost first: what it holds itself together + * with its companion (one scope), its type parameters and constructor + * parameters (local), then what it inherits and what its companion + * inherits -> explicit imports -> the file's package (types and top-level + * declarations) -> star imports -> default imports. + * A FUNCTION or PROPERTY found further out than an enclosing type binds + * only when that type is known not to have a member of the name: a type + * that has it from the language (kotlin.Any's members, an enum class's), + * or whose supertypes leave the repository, makes it `external` + * (jr_kotlin_simple). A type found further out binds. + * A dotted name: the qualifier is a type (through those levels, or from + * the root) and the last name what that type holds, inherits, or gets + * from an extension in scope whose receiver it is (an extension only + * where the type's supertypes all lie in the repository: a member comes + * before an extension); or the qualifier is a + * package and the last name one of its types or top-level declarations. + * Kotlin kinds + * a type and a function of one name at one level (a class and its factory + * function) are the type: KDoc resolves a classifier before a function + * (jr_kl_result). A type without a node of its own is then a graph gap, + * never the function. A property and a function of one name are + * ambiguous, and so is an overload group. + * A `[name]` that resolves to nothing is plain text to KDoc (Markdown): + * no reference at all. @see, @throws and @sample name their subject and + * keep a row. + * Visibility: product code never binds a declaration that only test code + * holds (test_only_target, no fallback to it); `@sample` is exempt. + * External: a name a single import brings from a package the repository does + * not declare; a qualified `pkg.Type` whose package it does not declare; a + * name only an on-demand import of such a package can supply; java.lang and + * the Kotlin default imports (the languages' own implicit imports, the only + * name lists here, and consulted after the repository's own declarations); an + * all-lower-case path under `java`, `javax` or `kotlin` (the platforms' own + * namespaces; no library family is one), or under a package the file imports + * from and the repository does not declare; a + * member that a supertype outside the repository may declare; the members + * every type has without declaring them (Object's, an enum's). + * Not indexed: a package below one the repository owns, in a directory the + * indexer skips by name (discovery's own rule, no file system access here). + * Graph gaps: a declaration without a definition (slot '-'), one whose + * qualified name another declaration took (own 0), a node of another kind or + * file, a package (it has no node), a member a parse error hides, a name the + * scan could not place, a definition in a part of its file it could not + * place -- and a reference that resolves, written on a definition that has + * no node of its own to leave from. + */ +#include "pipeline/doc_links.h" + +#include "doclink.h" +#include "helpers.h" /* cbm_fqn_module_source_lang */ +#include "discover/discover.h" /* cbm_should_skip_dir */ +#include "foundation/arena.h" +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/log.h" +#include "foundation/mem_core.h" + +#include +#include +#include +#include +#include + +enum { + JR_KEY_BUF = 2048, + JR_MAX_SEGS = 32, /* dotted segments of one reference; more is unparseable */ + JR_MAX_PARAMS = 32, /* written parameters of one reference; more is unparseable */ + JR_PARAM_BUF = 160, + JR_T_FIELDS = 10, + JR_M_FIELDS = 12, + JR_NONE = -1, +}; + +/* ── Index data ──────────────────────────────────────────────────── */ + +typedef struct { + char kind; /* s single, w on-demand, m static single, n static on-demand */ + const char *alias; + const char *target; +} jr_import_t; + +typedef struct { + uint32_t from; + uint32_t to; +} jr_lines_t; + +/* Line numbers are meaningful only in a file re-extracted by this run: the + * persisted scope of every other file carries zeros. They are read for the + * SOURCE file's own context, never for a target. */ +typedef struct { + uint32_t start; + uint32_t end; + char kind; /* c i e r a o C t */ + bool incomplete; + bool own; /* its definition is the one the graph keeps for the slot */ + const char *path; /* logical path */ + const char *name; + const char *tparams; + const char *supers; + const char + *slot; /* NULL: no definition; "=": the logical path; else the name below the module */ + const char *pnames; /* run files only */ + int outer; /* the enclosing type of this file, or JR_NONE */ + int entity; +} jr_type_t; + +typedef struct { + uint32_t start; + char kind; /* c callable, v value */ + bool ctor; /* K */ + bool component; /* p: a record component or constructor property */ + bool own; + int owner; /* its type in this file; JR_NONE: a Kotlin top-level declaration */ + int order; /* position in the scope */ + int shares; /* the member of this file whose node it shares; JR_NONE: it has none */ + int recv_entity; /* a Kotlin extension's receiver type, when the repository declares it */ + const char *name; + const char *tparams; + const char *sig; + const char *recv; + const char *slot; + const char *pnames; +} jr_member_t; + +typedef struct { + const char *rel_path; + const char *module_qn; + const char *package; + bool kotlin; + bool is_test; + bool scoped; /* its scope was read: without one nothing in it resolves */ + jr_import_t *imports; /* single imports, sorted by alias then target */ + int nimports; + jr_import_t *wild; /* on-demand imports, scope order */ + int nwild; + jr_type_t *types; /* scope order: an outer type before its nested ones */ + int ntypes; + jr_member_t *members; /* sorted by (owner, name, order) */ + int nmembers; + int *by_start; /* member indices sorted by (start, order) */ + jr_lines_t *unplaced; + int nunplaced; +} jr_file_t; + +typedef struct { + int file; + int type; +} jr_decl_t; + +/* A step of javadoc's search for a method (jr_walk) that is no type of the + * repository. */ +enum { + JR_W_NONE = JR_NONE, /* no step: an interface has no superclass */ + JR_W_OBJECT = -2, /* java.lang.Object */ + JR_W_ENUM = -3, /* java.lang.Enum, then Object */ + JR_W_ANNOTATION = -4, /* java.lang.annotation.Annotation */ + JR_W_OUTSIDE = -5, /* a type outside the repository, or a step whose place + in the order the index cannot tell */ +}; + +typedef struct { + const char *fqn; + const char *name; + char kind; + jr_decl_t *decls; /* sorted by file (path order), then scope order */ + int ndecls; + int dcap; + int *bases; /* the supertypes its declarations name, as far as the repository declares them */ + int nbases; + int *closure; /* every supertype the repository declares, nearest first */ + int nclosure; + int companion; /* Kotlin: its companion object, or JR_NONE */ + bool open; /* a supertype outside the repository, or one that could not be bound */ + bool incomplete; /* a declaration of it has members a parse error hides */ + bool any_prod; + /* javadoc's search for a method at this type (jr_walk): the superclass + * step (a type, or JR_W_*), the interface steps in the order written, + * and the type around it */ + int wsuper; + int *wifaces; + int nwifaces; + int encl; +} jr_entity_t; + +typedef struct { + int *items; + int count; + int cap; +} jr_ilist_t; + +/* What the resolve phase counts for the run's log line. The index itself is + * read-only then; these are the only words it writes. */ +typedef struct { + _Atomic int64_t source_gaps; /* resolved, but the documented definition has no node */ + _Atomic int64_t plain_text; /* KDoc `[name]` that names nothing: plain text */ + _Atomic int64_t type_and_function; /* KDoc name that is a type and a function at one + * level: resolved as the type */ + _Atomic int64_t type_and_function_node; /* ... where the type has a node (an edge) */ + /* Members further on that a lookup did not take, because something + * before them may have the member unseen (javadoc's search, jr_walk; the + * guards of jr_kotlin_simple and jr_kotlin_in_type): `external`. */ + _Atomic int64_t java_given; /* Javadoc method: the language gives one first */ + _Atomic int64_t java_open; /* ... or a type outside the repository comes first */ + _Atomic int64_t kotlin_given; /* KDoc name: the same two cases */ + _Atomic int64_t kotlin_open; + _Atomic int64_t kotlin_extension; /* KDoc `Type.name`: an extension, the type's + * hierarchy leaving the repository */ + /* Javadoc fields not taken, because a method that javadoc's search may + * reach first, outside the repository, may be what it links + * (jr_java_field). */ + _Atomic int64_t java_field; /* `#x` */ + _Atomic int64_t java_field_named; /* `Type#x` */ +} jr_stats_t; + +typedef struct { + CBMArena arena; + bool oom; + jr_stats_t *stats; + int mode; /* the run's index mode: which directories discovery skips */ + const char *project; + jr_file_t *files; + int nfiles; + int *run_to_file; + int run_count; + jr_entity_t *ents; + int nents; + int ecap; + CBMHashTable *ent_by_fqn; /* fqn -> entity + 1 */ + CBMHashTable *pkg_types; /* "package\x1f name" -> entity + 1 (top-level types) */ + CBMHashTable *packages; /* every package a file declares */ + CBMHashTable *pkg_prefixes; /* ... and every prefix of one */ + CBMHashTable *pkg_members; /* "package\x1f name" -> jr_ilist_t* of files (Kotlin top level) */ + CBMHashTable *extensions; /* "\x1f name" -> jr_ilist_t* of (file, member) pairs */ + CBMHashTable *quarantine; /* names of types declared where no scope is known */ + CBMHashTable *quarantine_test; /* the same, declared by test code only */ +} jr_index_t; + +/* ── Small helpers ───────────────────────────────────────────────── */ + +/* Index memory. A failed allocation is remembered: whatever a caller does + * with its NULL, the build as a whole reports failure instead of handing out + * an index that silently lacks a declaration. */ +static void *jr_alloc(jr_index_t *ix, size_t n) { + void *p = cbm_arena_alloc(&ix->arena, n ? n : SKIP_ONE); + ix->oom = ix->oom || !p; + return p; +} + +static char *jr_strdup(jr_index_t *ix, const char *s) { + char *p = cbm_arena_strdup(&ix->arena, s ? s : ""); + ix->oom = ix->oom || !p; + return p; +} + +static bool jr_ilist_push(jr_index_t *ix, jr_ilist_t *l, int v) { + if (l->count >= l->cap) { + int ncap = l->cap ? l->cap * PAIR_LEN : CBM_SZ_4; + int *grown = (int *)jr_alloc(ix, (size_t)ncap * sizeof(int)); + if (!grown) { + return false; + } + if (l->count > 0) { + memcpy(grown, l->items, (size_t)l->count * sizeof(int)); + } + l->items = grown; + l->cap = ncap; + } + l->items[l->count++] = v; + return true; +} + +static jr_ilist_t *jr_ht_ilist(jr_index_t *ix, CBMHashTable *ht, const char *key, bool create) { + jr_ilist_t *l = (jr_ilist_t *)cbm_ht_get(ht, key); + if (l || !create) { + return l; + } + l = (jr_ilist_t *)jr_alloc(ix, sizeof(*l)); + char *k = jr_strdup(ix, key); + if (!l || !k) { + return NULL; + } + memset(l, 0, sizeof(*l)); + cbm_ht_set(ht, k, l); + return l; +} + +/* Add `key` to a name set; false when memory ran out. */ +static bool jr_mark(jr_index_t *ix, CBMHashTable *ht, const char *key) { + if (cbm_ht_get(ht, key)) { + return true; + } + char *k = jr_strdup(ix, key); + if (!k) { + return false; + } + cbm_ht_set(ht, k, (void *)k); + return true; +} + +static bool jr_ci_equal(const char *s, size_t n, const char *word) { + if (strlen(word) != n) { + return false; + } + for (size_t i = 0; i < n; i++) { + if (tolower((unsigned char)s[i]) != word[i]) { + return false; + } + } + return true; +} + +static bool jr_ci_contains(const char *s, size_t n, const char *word) { + size_t wl = strlen(word); + for (size_t i = 0; i + wl <= n; i++) { + size_t k = 0; + while (k < wl && tolower((unsigned char)s[i + k]) == word[k]) { + k++; + } + if (k == wl) { + return true; + } + } + return false; +} + +/* True when one word of the directory name s[0, n) says test code: the name + * is split at '-', '_', '.' and at a lower-to-upper case step (`commonTest`, + * `json-tests`, `testFixtures`, `smoke-test-http`), and a word is compared + * whole, so `latest` and `attestation` say nothing. */ +static bool jr_test_dir_name(const char *s, size_t n) { + static const char *const words[] = {"test", "tests", "fixture", "fixtures", + "sample", "samples", "example", "examples"}; + size_t start = 0; + for (size_t i = 0; i <= n; i++) { + bool sep = i == n || s[i] == '-' || s[i] == '_' || s[i] == '.'; + bool step = !sep && i > start && isupper((unsigned char)s[i]) && + !isupper((unsigned char)s[i - SKIP_ONE]); + if (!sep && !step) { + continue; + } + for (size_t w = 0; w < sizeof(words) / sizeof(words[0]); w++) { + if (jr_ci_equal(s + start, i - start, words[w])) { + return true; + } + } + start = sep ? i + SKIP_ONE : i; + } + return false; +} + +/* Test code, by where a build puts it: + * - the source set after the last `src` directory holds "test" (`src/test`, + * `src/jvmTest`, `src/testFixtures`, `src/internalClusterTest`), or + * - a directory above that source root (any directory, when there is no + * `src`) has a word test, fixture, sample or example in its name: a test + * project, a `commonTest/src` source set, a samples tree. + * Directories below a source set are packages: `src/main/java/org/acme/test/ + * Tool.java` is product code. A file name decides nothing. */ +static bool jr_is_test_path(const char *rel) { + const char *src_set = NULL; /* the segment after the last `src` directory */ + size_t src_set_len = 0; + const char *last_src = NULL; + for (const char *p = rel;;) { + const char *slash = strchr(p, '/'); + if (!slash) { + break; + } + if (slash - p == 3 && memcmp(p, "src", 3) == 0) { + const char *next = slash + SKIP_ONE; + const char *end = strchr(next, '/'); + last_src = p; + src_set = end ? next : NULL; /* a file right under src is no source set */ + src_set_len = end ? (size_t)(end - next) : 0; + } + p = slash + SKIP_ONE; + } + if (src_set && jr_ci_contains(src_set, src_set_len, "test")) { + return true; + } + for (const char *p = rel; !last_src || p < last_src;) { + const char *slash = strchr(p, '/'); + if (!slash) { + break; + } + if (jr_test_dir_name(p, (size_t)(slash - p))) { + return true; + } + p = slash + SKIP_ONE; + } + return false; +} + +/* Split `s` in place at tabs into at most `max` fields; returns the count. */ +static int jr_split(char *s, char **out, int max) { + int n = 0; + out[n++] = s; + for (char *p = s; *p && n < max; p++) { + if (*p == '\t') { + *p = '\0'; + out[n++] = p + SKIP_ONE; + } + } + return n; +} + +/* True when name[0, nl) is one of the ','-joined names of `list`. An entry may + * carry a bound (`T:Comparable`, a Java type variable's): *bound / *blen are + * set to it (length 0 when it has none). */ +static bool jr_listed_n(const char *list, const char *name, size_t nl, const char **bound, + size_t *blen) { + for (const char *p = list; p && *p;) { + const char *e = strchr(p, ','); + size_t n = e ? (size_t)(e - p) : strlen(p); + const char *colon = (const char *)memchr(p, ':', n); + size_t name_len = colon ? (size_t)(colon - p) : n; + if (name_len == nl && memcmp(p, name, nl) == 0) { + if (bound) { + *bound = colon ? colon + SKIP_ONE : p + n; + *blen = colon ? n - name_len - SKIP_ONE : 0; + } + return true; + } + p = e ? e + SKIP_ONE : NULL; + } + return false; +} + +static bool jr_listed(const char *list, const char *name) { + return jr_listed_n(list, name, strlen(name), NULL, NULL); +} + +/* ── Scope parsing ───────────────────────────────────────────────── */ + +static int jr_import_cmp(const void *a, const void *b) { + const jr_import_t *x = (const jr_import_t *)a; + const jr_import_t *y = (const jr_import_t *)b; + int c = strcmp(x->alias, y->alias); + return c ? c : strcmp(x->target, y->target); +} + +static int jr_member_cmp(const void *a, const void *b) { + const jr_member_t *x = (const jr_member_t *)a; + const jr_member_t *y = (const jr_member_t *)b; + if (x->owner != y->owner) { + return x->owner < y->owner ? -1 : 1; + } + int c = strcmp(x->name, y->name); + if (c) { + return c; + } + return (x->order > y->order) - (x->order < y->order); +} + +typedef struct { + uint32_t start; + int order; + int idx; +} jr_start_key_t; + +static int jr_start_cmp(const void *a, const void *b) { + const jr_start_key_t *x = (const jr_start_key_t *)a; + const jr_start_key_t *y = (const jr_start_key_t *)b; + if (x->start != y->start) { + return x->start < y->start ? -1 : 1; + } + return (x->order > y->order) - (x->order < y->order); +} + +static int jr_lines_cmp(const void *a, const void *b) { + const jr_lines_t *x = (const jr_lines_t *)a; + const jr_lines_t *y = (const jr_lines_t *)b; + if (x->from != y->from) { + return x->from < y->from ? -1 : 1; + } + return (x->to > y->to) - (x->to < y->to); +} + +static void jr_parse_type(jr_file_t *f, char **fld, int *stack, int *sp) { + jr_type_t *t = &f->types[f->ntypes]; + memset(t, 0, sizeof(*t)); + t->start = (uint32_t)strtoul(fld[1], NULL, 10); + t->end = (uint32_t)strtoul(fld[2], NULL, 10); + t->kind = fld[3][0]; + t->incomplete = fld[3][0] && fld[3][1] == '!'; + t->path = fld[4]; + const char *dot = strrchr(t->path, '.'); + t->name = dot ? dot + SKIP_ONE : t->path; + t->tparams = fld[5]; + t->supers = fld[6]; + t->slot = strcmp(fld[7], "-") == 0 ? NULL : fld[7]; + t->own = fld[8][0] == '1'; + t->pnames = fld[9]; + t->entity = JR_NONE; + /* the enclosing type: the nearest earlier type whose path is this path + * without its last name (a nested type follows its outer type) */ + size_t outer_len = dot ? (size_t)(dot - t->path) : 0; + while (*sp > 0) { + const char *op = f->types[stack[*sp - SKIP_ONE]].path; + if (dot && strlen(op) == outer_len && memcmp(op, t->path, outer_len) == 0) { + break; + } + (*sp)--; + } + t->outer = (dot && *sp > 0) ? stack[*sp - SKIP_ONE] : JR_NONE; + stack[(*sp)++] = f->ntypes; + f->ntypes++; +} + +static bool jr_parse_member(jr_file_t *f, char **fld) { + jr_member_t *m = &f->members[f->nmembers]; + memset(m, 0, sizeof(*m)); + m->start = (uint32_t)strtoul(fld[1], NULL, 10); + m->kind = fld[2][0]; + m->ctor = strchr(fld[3], 'K') != NULL; + m->component = strchr(fld[3], 'p') != NULL; + m->owner = strcmp(fld[4], "-") == 0 ? JR_NONE : atoi(fld[4]); + if (m->owner >= f->ntypes || (m->owner < 0 && m->owner != JR_NONE) || !fld[5][0] || + (m->kind != 'c' && m->kind != 'v')) { + return false; /* not a record this code wrote */ + } + m->name = fld[5]; + m->tparams = fld[6]; + m->sig = fld[7]; + m->recv = fld[8]; + m->slot = strcmp(fld[9], "-") == 0 ? NULL : fld[9]; + m->own = fld[10][0] == '1'; + m->pnames = fld[11]; + m->order = f->nmembers; + m->shares = JR_NONE; + m->recv_entity = JR_NONE; + f->nmembers++; + return true; +} + +/* Which member's node each member shares. A value has its own or none. + * Callables of one name in one type (overloads) share the node of the one + * that owns their slot. */ +static void jr_link_shared_nodes(jr_file_t *f) { + for (int i = 0; i < f->nmembers;) { + int j = i; + int owner = JR_NONE; /* the callable of this run that owns the node */ + while (j < f->nmembers && f->members[j].owner == f->members[i].owner && + strcmp(f->members[j].name, f->members[i].name) == 0) { + jr_member_t *m = &f->members[j]; + if (m->kind == 'c' && m->slot && m->own) { + owner = j; + } + j++; + } + for (int k = i; k < j; k++) { + jr_member_t *m = &f->members[k]; + if (!m->slot) { + continue; + } + if (m->own) { + m->shares = k; + } else if (m->kind == 'c' && owner != JR_NONE && + strcmp(f->members[owner].slot, m->slot) == 0 && + f->members[owner].ctor == m->ctor) { + m->shares = owner; + } + } + i = j; + } +} + +/* Parse one file's scope. false when it is not a scope this code wrote, or + * memory ran out. */ +static bool jr_parse_scope(jr_index_t *ix, jr_file_t *f, const char *blob) { + char *buf = jr_strdup(ix, blob); + if (!buf) { + return false; + } + int ni = 0; + int nt = 0; + int nm = 0; + int nx = 0; + for (const char *p = buf; *p;) { + ni += *p == 'I'; + nt += *p == 'T'; + nm += *p == 'M'; + nx += *p == 'X'; + const char *nl = strchr(p, '\n'); + if (!nl) { + break; + } + p = nl + SKIP_ONE; + } + f->imports = (jr_import_t *)jr_alloc(ix, (size_t)ni * sizeof(jr_import_t)); + f->wild = (jr_import_t *)jr_alloc(ix, (size_t)ni * sizeof(jr_import_t)); + f->types = (jr_type_t *)jr_alloc(ix, (size_t)nt * sizeof(jr_type_t)); + f->members = (jr_member_t *)jr_alloc(ix, (size_t)nm * sizeof(jr_member_t)); + f->unplaced = (jr_lines_t *)jr_alloc(ix, (size_t)nx * sizeof(jr_lines_t)); + int *stack = (int *)cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)(nt + SKIP_ONE) * sizeof(int)); + if (!f->imports || !f->wild || !f->types || !f->members || !f->unplaced || !stack) { + cbm_free(CBM_MEM_CLASS_OTHER, stack); + ix->oom = true; + return false; + } + int sp = 0; + bool first = true; + bool ok = true; + for (char *line = buf; ok && line && *line;) { + char *nl = strchr(line, '\n'); + if (nl) { + *nl = '\0'; + } + char *fld[JR_M_FIELDS]; + if (first) { + first = false; + ok = strcmp(line, CBM_DOCLINK_JVM_SCOPE_TAG) == 0; + } else if (line[0] == 'L') { + f->kotlin = jr_split(line, fld, PAIR_LEN) == PAIR_LEN && fld[1][0] == 'k'; + } else if (line[0] == 'P') { + if (jr_split(line, fld, PAIR_LEN) == PAIR_LEN) { + f->package = fld[1]; + } + } else if (line[0] == 'I') { + if (jr_split(line, fld, CBM_SZ_4) == CBM_SZ_4 && fld[1][0] && fld[3][0]) { + jr_import_t imp = {.kind = fld[1][0], .alias = fld[2], .target = fld[3]}; + if (imp.kind == 'w' || imp.kind == 'n') { + f->wild[f->nwild++] = imp; + } else { + f->imports[f->nimports++] = imp; + } + } + } else if (line[0] == 'T') { + /* a member names its type by position: a type record that cannot + * be read spoils the whole scope */ + ok = jr_split(line, fld, JR_T_FIELDS) == JR_T_FIELDS && fld[4][0]; + if (ok) { + jr_parse_type(f, fld, stack, &sp); + } + } else if (line[0] == 'M') { + ok = jr_split(line, fld, JR_M_FIELDS) == JR_M_FIELDS && jr_parse_member(f, fld); + } else if (line[0] == 'X') { + if (jr_split(line, fld, 3) == 3) { + f->unplaced[f->nunplaced++] = + (jr_lines_t){.from = (uint32_t)strtoul(fld[1], NULL, 10), + .to = (uint32_t)strtoul(fld[2], NULL, 10)}; + } + } else if (line[0] == 'Q') { + if (jr_split(line, fld, PAIR_LEN) == PAIR_LEN && fld[1][0]) { + ok = jr_mark(ix, f->is_test ? ix->quarantine_test : ix->quarantine, fld[1]); + } + } + line = nl ? nl + SKIP_ONE : NULL; + } + cbm_free(CBM_MEM_CLASS_OTHER, stack); + if (!ok || first) { + return false; + } + if (f->nimports > 1) { + qsort(f->imports, (size_t)f->nimports, sizeof(jr_import_t), jr_import_cmp); + } + if (f->nmembers > 1) { + qsort(f->members, (size_t)f->nmembers, sizeof(jr_member_t), jr_member_cmp); + } + jr_link_shared_nodes(f); + /* the unplaced line ranges: sorted and disjoint, for a binary search */ + if (f->nunplaced > 1) { + qsort(f->unplaced, (size_t)f->nunplaced, sizeof(jr_lines_t), jr_lines_cmp); + int w = 0; + for (int i = SKIP_ONE; i < f->nunplaced; i++) { + if (f->unplaced[i].from <= f->unplaced[w].to) { + if (f->unplaced[i].to > f->unplaced[w].to) { + f->unplaced[w].to = f->unplaced[i].to; + } + } else { + f->unplaced[++w] = f->unplaced[i]; + } + } + f->nunplaced = w + SKIP_ONE; + } + f->by_start = (int *)jr_alloc(ix, (size_t)f->nmembers * sizeof(int)); + jr_start_key_t *keys = + f->nmembers > 0 + ? (jr_start_key_t *)cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)f->nmembers * sizeof(*keys)) + : NULL; + if (f->nmembers > 0 && (!f->by_start || !keys)) { + cbm_free(CBM_MEM_CLASS_OTHER, keys); + ix->oom = true; + return false; + } + for (int i = 0; i < f->nmembers; i++) { + keys[i] = + (jr_start_key_t){.start = f->members[i].start, .order = f->members[i].order, .idx = i}; + } + if (f->nmembers > 1) { + qsort(keys, (size_t)f->nmembers, sizeof(*keys), jr_start_cmp); + } + for (int i = 0; i < f->nmembers; i++) { + f->by_start[i] = keys[i].idx; + } + cbm_free(CBM_MEM_CLASS_OTHER, keys); + return true; +} + +/* ── Entities ────────────────────────────────────────────────────── */ + +enum { JR_ENT_FAIL = -1, JR_ENT_SKIP = -2 }; + +/* "package.path", or "path" in the default package. false when it does not + * fit (a name longer than any real one). */ +static bool jr_fqn(const char *package, const char *path, char *out, size_t cap) { + int n = (package && package[0]) ? snprintf(out, cap, "%s.%s", package, path) + : snprintf(out, cap, "%s", path); + return n >= 0 && (size_t)n < cap; +} + +static bool jr_pair_key(const char *a, const char *b, char *out, size_t cap) { + int n = snprintf(out, cap, "%s\x1f%s", a ? a : "", b); + return n >= 0 && (size_t)n < cap; +} + +static int jr_ent(const jr_index_t *ix, const char *fqn) { + intptr_t v = (intptr_t)cbm_ht_get(ix->ent_by_fqn, fqn); + return v > 0 ? (int)(v - SKIP_ONE) : JR_NONE; +} + +/* The entity `fqn`, created on first sight. */ +static int jr_entity_get(jr_index_t *ix, const char *fqn, const char *name, char kind) { + int found = jr_ent(ix, fqn); + if (found != JR_NONE) { + return found; + } + if (ix->nents >= ix->ecap) { + int ncap = ix->ecap ? ix->ecap * PAIR_LEN : CBM_SZ_1K; + jr_entity_t *grown = + (jr_entity_t *)cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)ncap * sizeof(jr_entity_t)); + if (!grown) { + return JR_ENT_FAIL; + } + if (ix->nents > 0) { + memcpy(grown, ix->ents, (size_t)ix->nents * sizeof(jr_entity_t)); + } + cbm_free(CBM_MEM_CLASS_OTHER, ix->ents); + ix->ents = grown; + ix->ecap = ncap; + } + int id = ix->nents; + jr_entity_t *e = &ix->ents[id]; + memset(e, 0, sizeof(*e)); + e->fqn = jr_strdup(ix, fqn); + e->name = name; + e->kind = kind; + e->companion = JR_NONE; + e->wsuper = JR_W_OUTSIDE; /* until jr_resolve_supers has read its declarations */ + e->encl = JR_NONE; + if (!e->fqn) { + return JR_ENT_FAIL; + } + ix->nents++; + cbm_ht_set(ix->ent_by_fqn, e->fqn, (void *)(intptr_t)(id + SKIP_ONE)); + return id; +} + +static bool jr_entity_add_decl(jr_index_t *ix, jr_entity_t *e, int file, int type) { + if (e->ndecls >= e->dcap) { + int ncap = e->dcap ? e->dcap * PAIR_LEN : CBM_SZ_2; + jr_decl_t *grown = (jr_decl_t *)jr_alloc(ix, (size_t)ncap * sizeof(jr_decl_t)); + if (!grown) { + return false; + } + if (e->ndecls > 0) { + memcpy(grown, e->decls, (size_t)e->ndecls * sizeof(jr_decl_t)); + } + e->decls = grown; + e->dcap = ncap; + } + e->decls[e->ndecls++] = (jr_decl_t){.file = file, .type = type}; + e->any_prod = e->any_prod || !ix->files[file].is_test; + e->incomplete = e->incomplete || ix->files[file].types[type].incomplete; + return true; +} + +static bool jr_mark_package(jr_index_t *ix, const char *package) { + if (!package || !package[0]) { + return true; + } + if (!jr_mark(ix, ix->packages, package)) { + return false; + } + char buf[JR_KEY_BUF]; + if (snprintf(buf, sizeof(buf), "%s", package) >= (int)sizeof(buf)) { + return true; + } + for (char *dot = strrchr(buf, '.'); dot; dot = strrchr(buf, '.')) { + *dot = '\0'; + if (!jr_mark(ix, ix->pkg_prefixes, buf)) { + return false; + } + } + return true; +} + +/* The entities of every declared type and the package tables. Files come in + * path order and types in scope order, so every declaration list is in that + * order too. *skipped counts declarations whose name is too long to index; + * their names are quarantined, so a reference to one binds nothing else. */ +static bool jr_build_entities(jr_index_t *ix, int *skipped) { + for (int fi = 0; fi < ix->nfiles; fi++) { + jr_file_t *f = &ix->files[fi]; + if (!jr_mark_package(ix, f->package)) { + return false; + } + for (int ti = 0; ti < f->ntypes; ti++) { + jr_type_t *t = &f->types[ti]; + char fqn[JR_KEY_BUF]; + char key[JR_KEY_BUF]; + int id = jr_fqn(f->package, t->path, fqn, sizeof(fqn)) && + jr_pair_key(f->package, t->name, key, sizeof(key)) + ? jr_entity_get(ix, fqn, t->name, t->kind) + : JR_ENT_SKIP; + if (id == JR_ENT_SKIP) { + (*skipped)++; + if (!jr_mark(ix, f->is_test ? ix->quarantine_test : ix->quarantine, t->name)) { + return false; + } + continue; + } + if (id < 0 || !jr_entity_add_decl(ix, &ix->ents[id], fi, ti)) { + return false; + } + t->entity = id; + if (t->kind == 'C' && t->outer != JR_NONE && f->types[t->outer].entity >= 0) { + ix->ents[f->types[t->outer].entity].companion = id; + } + if (t->outer == JR_NONE && !cbm_ht_get(ix->pkg_types, key)) { + char *k = jr_strdup(ix, key); + if (!k) { + return false; + } + cbm_ht_set(ix->pkg_types, k, (void *)(intptr_t)(id + SKIP_ONE)); + } + } + /* Kotlin top-level declarations: the files of a package that declare + * a name (each file once: its members of one name are one run) */ + for (int mi = 0; mi < f->nmembers && f->members[mi].owner == JR_NONE; mi++) { + const jr_member_t *m = &f->members[mi]; + if (mi > 0 && strcmp(f->members[mi - SKIP_ONE].name, m->name) == 0) { + continue; + } + char key[JR_KEY_BUF]; + if (!jr_pair_key(f->package, m->name, key, sizeof(key))) { + continue; + } + jr_ilist_t *l = jr_ht_ilist(ix, ix->pkg_members, key, true); + if (!l || !jr_ilist_push(ix, l, fi)) { + return false; + } + } + } + return true; +} + +/* ── The languages' own implicit imports ───────────────────────────── + * + * java.lang (Java and Kotlin/JVM) and the Kotlin default imports are part of + * the lookup order of every file. They are consulted last, after everything + * the repository declares, and only say "this simple name is the language's": + * external. Nothing else here is a list of names. */ + +static const char *const JR_JAVA_LANG[] = { + "AbstractMethodError", + "Appendable", + "ArithmeticException", + "ArrayIndexOutOfBoundsException", + "ArrayStoreException", + "AssertionError", + "AutoCloseable", + "Boolean", + "BootstrapMethodError", + "Byte", + "CharSequence", + "Character", + "Class", + "ClassCastException", + "ClassCircularityError", + "ClassFormatError", + "ClassLoader", + "ClassNotFoundException", + "ClassValue", + "CloneNotSupportedException", + "Cloneable", + "Comparable", + "Compiler", + "Deprecated", + "Double", + "Enum", + "EnumConstantNotPresentException", + "Error", + "Exception", + "ExceptionInInitializerError", + "Float", + "FunctionalInterface", + "IllegalAccessError", + "IllegalAccessException", + "IllegalArgumentException", + "IllegalCallerException", + "IllegalMonitorStateException", + "IllegalStateException", + "IllegalThreadStateException", + "IncompatibleClassChangeError", + "IndexOutOfBoundsException", + "InheritableThreadLocal", + "InstantiationError", + "InstantiationException", + "Integer", + "InternalError", + "InterruptedException", + "Iterable", + "LayerInstantiationException", + "LinkageError", + "Long", + "MatchException", + "Math", + "Module", + "ModuleLayer", + "NegativeArraySizeException", + "NoClassDefFoundError", + "NoSuchFieldError", + "NoSuchFieldException", + "NoSuchMethodError", + "NoSuchMethodException", + "NullPointerException", + "Number", + "NumberFormatException", + "Object", + "OutOfMemoryError", + "Override", + "Package", + "Process", + "ProcessBuilder", + "ProcessHandle", + "Readable", + "Record", + "ReflectiveOperationException", + "Runnable", + "Runtime", + "RuntimeException", + "RuntimePermission", + "SafeVarargs", + "ScopedValue", + "SecurityException", + "SecurityManager", + "Short", + "StackOverflowError", + "StackTraceElement", + "StackWalker", + "StrictMath", + "String", + "StringBuffer", + "StringBuilder", + "StringIndexOutOfBoundsException", + "StringTemplate", + "SuppressWarnings", + "System", + "Thread", + "ThreadDeath", + "ThreadGroup", + "ThreadLocal", + "Throwable", + "TypeNotPresentException", + "UnknownError", + "UnsatisfiedLinkError", + "UnsupportedClassVersionError", + "UnsupportedOperationException", + "VerifyError", + "VirtualMachineError", + "Void", + "WrongThreadException", +}; + +/* kotlin.*, kotlin.annotation.*, kotlin.collections.*, kotlin.comparisons.*, + * kotlin.io.*, kotlin.ranges.*, kotlin.sequences.*, kotlin.text.* and + * kotlin.jvm.*: their types and the standard scope functions. */ +static const char *const JR_KOTLIN_DEFAULT[] = { + "AbstractCollection", + "AbstractIterator", + "AbstractList", + "AbstractMap", + "AbstractMutableCollection", + "AbstractMutableList", + "AbstractMutableMap", + "AbstractMutableSet", + "AbstractSet", + "Annotation", + "AnnotationRetention", + "AnnotationTarget", + "Any", + "Array", + "ArrayDeque", + "ArrayList", + "Boolean", + "BooleanArray", + "BuilderInference", + "Byte", + "ByteArray", + "Char", + "CharArray", + "CharCategory", + "CharProgression", + "CharRange", + "CharSequence", + "Charsets", + "ClosedFloatingPointRange", + "ClosedRange", + "Collection", + "Comparable", + "Comparator", + "ConcurrentModificationException", + "DeepRecursiveFunction", + "Deprecated", + "DeprecationLevel", + "Double", + "DoubleArray", + "DslMarker", + "Enum", + "Float", + "FloatArray", + "Function", + "Grouping", + "HashMap", + "HashSet", + "IndexedValue", + "Int", + "IntArray", + "IntProgression", + "IntRange", + "Iterable", + "Iterator", + "JvmField", + "JvmInline", + "JvmName", + "JvmOverloads", + "JvmRecord", + "JvmStatic", + "JvmSuppressWildcards", + "JvmSynthetic", + "JvmWildcard", + "KotlinVersion", + "Lazy", + "LazyThreadSafetyMode", + "LinkedHashMap", + "LinkedHashSet", + "List", + "ListIterator", + "Long", + "LongArray", + "LongProgression", + "LongRange", + "Map", + "MatchGroup", + "MatchGroupCollection", + "MatchResult", + "Metadata", + "MustBeDocumented", + "MutableCollection", + "MutableIterable", + "MutableIterator", + "MutableList", + "MutableListIterator", + "MutableMap", + "MutableSet", + "NoSuchElementException", + "NotImplementedError", + "Nothing", + "Number", + "OpenEndRange", + "OptIn", + "Pair", + "ParameterName", + "PublishedApi", + "RandomAccess", + "Regex", + "RegexOption", + "Repeatable", + "ReplaceWith", + "RequiresOptIn", + "Result", + "Retention", + "Sequence", + "SequenceScope", + "Set", + "Short", + "ShortArray", + "SinceKotlin", + "String", + "StringBuilder", + "Strictfp", + "Suppress", + "Synchronized", + "Target", + "Throws", + "Transient", + "Triple", + "UByte", + "UByteArray", + "UInt", + "UIntArray", + "UIntRange", + "ULong", + "ULongArray", + "ULongRange", + "UShort", + "UShortArray", + "UninitializedPropertyAccessException", + "Unit", + "Volatile", + /* functions */ + "TODO", + "also", + "apply", + "arrayListOf", + "arrayOf", + "arrayOfNulls", + "assert", + "buildList", + "buildMap", + "buildSet", + "buildString", + "check", + "checkNotNull", + "compareBy", + "compareValues", + "emptyArray", + "emptyList", + "emptyMap", + "emptySequence", + "emptySet", + "enumValueOf", + "enumValues", + "error", + "generateSequence", + "hashMapOf", + "hashSetOf", + "lazy", + "lazyOf", + "let", + "linkedMapOf", + "linkedSetOf", + "listOf", + "listOfNotNull", + "mapOf", + "maxOf", + "minOf", + "mutableListOf", + "mutableMapOf", + "mutableSetOf", + "print", + "println", + "readLine", + "readln", + "repeat", + "require", + "requireNotNull", + "run", + "runCatching", + "sequence", + "sequenceOf", + "setOf", + "synchronized", + "takeIf", + "takeUnless", + "to", + "use", + "with", +}; + +static const char *const JR_JAVA_PRIMITIVES[] = {"boolean", "byte", "char", "short", "int", + "long", "float", "double", "void"}; + +/* The members a type has without declaring them. These lists are defined by + * the two languages, not collected from a library: the methods of + * java.lang.Object with their parameter lists ('|'-joined, as a declared + * signature is normalized), the three members of kotlin.Any, and what an enum + * has in either language (the methods of java.lang.Enum with the two static + * ones the language declares; the Kotlin counterparts). Written from knowledge + * of the two language specifications; not checked online. */ +typedef struct { + const char *name; + const char *sig; +} jr_given_t; + +static const jr_given_t JR_OBJECT_METHODS[] = { + {"equals", "Object"}, {"hashCode", ""}, {"toString", ""}, {"clone", ""}, + {"finalize", ""}, {"getClass", ""}, {"notify", ""}, {"notifyAll", ""}, + {"wait", ""}, {"wait", "long"}, {"wait", "long|int"}}; +static const char *const JR_ANY_MEMBERS[] = {"equals", "hashCode", "toString"}; +static const char *const JR_JAVA_ENUM_MEMBERS[] = { + "values", "valueOf", "name", "ordinal", "compareTo", "getDeclaringClass", "describeConstable"}; +static const char *const JR_KOTLIN_ENUM_MEMBERS[] = {"values", "valueOf", "entries", + "name", "ordinal", "compareTo"}; + +static bool jr_named(const char *const *table, size_t n, const char *name) { + for (size_t i = 0; i < n; i++) { + if (strcmp(table[i], name) == 0) { + return true; + } + } + return false; +} + +#define JR_NAMED(table, name) jr_named(table, sizeof(table) / sizeof((table)[0]), name) + +/* ── Resolution context ──────────────────────────────────────────── */ + +/* The scan nests types JV_MAX_DEPTH (128) deep at most; a chain is never + * longer. */ +enum { JR_CHAIN_MAX = 136 }; + +enum { JR_OK = 0, JR_UNRES, JR_LOCAL }; + +typedef struct { + int st; + const cbm_gbuf_node_t *node; + bool exact; + int reason; +} jr_res_t; + +typedef struct { + const jr_index_t *ix; + const cbm_gbuf_t *g; + int file; + const jr_file_t *f; + bool prod; /* test-only declarations are invisible */ + bool sample; /* a `@sample` subject: it names a function */ + bool inherit; /* false while supertypes are being bound */ + int chain[JR_CHAIN_MAX]; /* enclosing types, innermost first */ + const jr_type_t *chain_decl[JR_CHAIN_MAX]; /* ... and their declarations in this file */ + int nchain; + const jr_member_t *member; /* the documented member, when the definition is one */ + const jr_type_t *type; /* the documented type, when the definition is one */ +} jr_ctx_t; + +static jr_res_t jr_edge(const cbm_gbuf_node_t *n, bool exact) { + return (jr_res_t){.st = JR_OK, .node = n, .exact = exact}; +} + +static jr_res_t jr_unres(int reason) { + return (jr_res_t){.st = JR_UNRES, .reason = reason}; +} + +static bool jr_visible(const jr_ctx_t *c, int ent) { + return !c->prod || c->ix->ents[ent].any_prod; +} + +/* The enclosing types of type `t` of the context's file, `t` first. */ +static void jr_chain_from(jr_ctx_t *c, int t) { + while (t != JR_NONE && c->nchain < JR_CHAIN_MAX) { + const jr_type_t *ty = &c->f->types[t]; + if (ty->entity >= 0) { + c->chain[c->nchain] = ty->entity; + c->chain_decl[c->nchain] = ty; + c->nchain++; + } + t = ty->outer; + } +} + +/* ── Type candidates of one scope level ──────────────────────────── */ + +typedef struct { + char kind; /* 0 none; T a type, X a name outside the repository, G a name the + repository declares where no scope shows it, N a name in a + package of the repository that the indexer does not read */ + int ent; /* T: the type */ + int count; /* distinct candidates; 2 stands for "several" */ + bool invisible; /* a candidate product code may not bind was passed over */ + bool exact; /* named by an explicit import */ +} jr_cands_t; + +static void jr_cand(jr_cands_t *out, char kind, int ent) { + if (out->count > 0 && out->kind == kind && out->ent == ent) { + return; + } + if (out->count == 0) { + out->kind = kind; + out->ent = ent; + } + out->count += out->count < PAIR_LEN; +} + +/* A type, unless the source may not name it: product code does not see what + * only test code declares, and Java does not see a Kotlin type alias. */ +static void jr_cand_type(const jr_ctx_t *c, jr_cands_t *out, int ent) { + if (ent < 0 || (!c->f->kotlin && c->ix->ents[ent].kind == 't')) { + return; + } + if (!jr_visible(c, ent)) { + out->invisible = true; + return; + } + jr_cand(out, 'T', ent); +} + +/* ── Types: lookup tables ────────────────────────────────────────── */ + +static int jr_pkg_type(const jr_index_t *ix, const char *package, const char *name) { + char key[JR_KEY_BUF]; + if (!jr_pair_key(package, name, key, sizeof(key))) { + return JR_NONE; + } + intptr_t v = (intptr_t)cbm_ht_get(ix->pkg_types, key); + return v > 0 ? (int)(v - SKIP_ONE) : JR_NONE; +} + +/* The type `name` nested directly in entity `ent`. */ +static int jr_nested(const jr_index_t *ix, int ent, const char *name) { + char fqn[JR_KEY_BUF]; + if (!jr_fqn(ix->ents[ent].fqn, name, fqn, sizeof(fqn))) { + return JR_NONE; + } + return jr_ent(ix, fqn); +} + +/* The member type `name` of `ent`: its own, else the one of the nearest + * supertype that declares one. */ +static int jr_member_type(const jr_ctx_t *c, int ent, const char *name) { + int own = jr_nested(c->ix, ent, name); + if (own != JR_NONE || !c->inherit) { + return own; + } + const jr_entity_t *e = &c->ix->ents[ent]; + for (int i = 0; i < e->nclosure; i++) { + int n = jr_nested(c->ix, e->closure[i], name); + if (n != JR_NONE) { + return n; + } + } + return JR_NONE; +} + +static bool jr_open(const jr_index_t *ix, int ent) { + const jr_entity_t *e = &ix->ents[ent]; + if (e->open) { + return true; + } + for (int i = 0; i < e->nclosure; i++) { + if (ix->ents[e->closure[i]].open) { + return true; + } + } + return false; +} + +/* True when `sup` is `ent` or one of its supertypes. */ +static bool jr_is_or_extends(const jr_index_t *ix, int ent, int sup) { + if (ent == sup) { + return true; + } + const jr_entity_t *e = &ix->ents[ent]; + for (int i = 0; i < e->nclosure; i++) { + if (e->closure[i] == sup) { + return true; + } + } + return false; +} + +/* ── Node binding ────────────────────────────────────────────────── */ + +/* Representative ordering of the declarations of one entity, without node + * ids or lines: the source file itself, the longest common directory prefix + * with it, then path and declaration order. */ +static int jr_common_dirs(const char *a, const char *b) { + int n = 0; + for (;;) { + const char *sa = strchr(a, '/'); + const char *sb = strchr(b, '/'); + if (!sa || !sb) { + return n; + } + size_t la = (size_t)(sa - a); + if (la != (size_t)(sb - b) || memcmp(a, b, la) != 0) { + return n; + } + n++; + a = sa + SKIP_ONE; + b = sb + SKIP_ONE; + } +} + +static bool jr_decl_better(const jr_index_t *ix, int fa, int oa, int fb, int ob, const char *src) { + const char *ra = ix->files[fa].rel_path; + const char *rb = ix->files[fb].rel_path; + bool sa = strcmp(ra, src) == 0; + bool sb = strcmp(rb, src) == 0; + if (sa != sb) { + return sa; + } + int pa = jr_common_dirs(ra, src); + int pb = jr_common_dirs(rb, src); + if (pa != pb) { + return pa > pb; + } + int cmp = strcmp(ra, rb); + if (cmp != 0) { + return cmp < 0; + } + return oa < ob; +} + +static bool jr_label_callable(const char *l) { + return l && (strcmp(l, "Method") == 0 || strcmp(l, "Function") == 0); +} + +static bool jr_label_value(const char *l) { + return l && (strcmp(l, "Field") == 0 || strcmp(l, "Variable") == 0); +} + +/* The node at "." when it is this file's own: one node per + * qualified name, and across files the graph keeps one file's. */ +static const cbm_gbuf_node_t *jr_node_at(const jr_ctx_t *c, const jr_file_t *f, const char *tail) { + char qn[JR_KEY_BUF]; + if (snprintf(qn, sizeof(qn), "%s.%s", f->module_qn, tail) >= (int)sizeof(qn)) { + return NULL; + } + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(c->g, qn); + return (n && n->file_path && strcmp(n->file_path, f->rel_path) == 0) ? n : NULL; +} + +/* The node of a type declaration; NULL when it has none of its own. */ +static const cbm_gbuf_node_t *jr_type_node(const jr_ctx_t *c, jr_decl_t d) { + const jr_file_t *f = &c->ix->files[d.file]; + const jr_type_t *t = &f->types[d.type]; + if (!t->slot || !t->own) { + return NULL; + } + const cbm_gbuf_node_t *n = jr_node_at(c, f, strcmp(t->slot, "=") == 0 ? t->path : t->slot); + return (n && cbm_label_is_type_like(n->label)) ? n : NULL; +} + +/* The node of a member: its own, or the one the overloads of its name share. + * NULL when it has none, or when the slot holds a node of the other kind. */ +static const cbm_gbuf_node_t *jr_member_node(const jr_ctx_t *c, int file, const jr_member_t *m) { + const jr_file_t *f = &c->ix->files[file]; + if (m->shares == JR_NONE) { + return NULL; + } + const jr_member_t *o = &f->members[m->shares]; + char tail[JR_KEY_BUF]; + const char *slot = o->slot; + if (strcmp(slot, "=") == 0) { + int n = o->owner == JR_NONE + ? snprintf(tail, sizeof(tail), "%s", o->name) + : snprintf(tail, sizeof(tail), "%s.%s", f->types[o->owner].path, o->name); + if (n < 0 || n >= (int)sizeof(tail)) { + return NULL; + } + slot = tail; + } + const cbm_gbuf_node_t *n = jr_node_at(c, f, slot); + if (!n) { + return NULL; + } + return (o->kind == 'c' ? jr_label_callable(n->label) : jr_label_value(n->label)) ? n : NULL; +} + +/* The entity's representative node: never a test declaration for product + * code. An entity none of whose eligible declarations has a node is a graph + * gap. */ +static jr_res_t jr_type_result(const jr_ctx_t *c, int ent, bool exact) { + const jr_index_t *ix = c->ix; + const jr_entity_t *e = &ix->ents[ent]; + const cbm_gbuf_node_t *best = NULL; + int bf = 0; + int bo = 0; + for (int d = 0; d < e->ndecls; d++) { + int file = e->decls[d].file; + if (c->prod && ix->files[file].is_test) { + continue; + } + const cbm_gbuf_node_t *n = jr_type_node(c, e->decls[d]); + if (n && (!best || jr_decl_better(ix, file, e->decls[d].type, bf, bo, c->f->rel_path))) { + best = n; + bf = file; + bo = e->decls[d].type; + } + } + return best ? jr_edge(best, exact) : jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP); +} + +/* ── Reference syntax ────────────────────────────────────────────── */ + +typedef struct { + char text[CBM_DOCLINK_JVM_REF_MAX + CBM_SZ_8]; /* the segments point in here */ + char *segs[JR_MAX_SEGS]; + int nsegs; + char *member; /* Java: the name after '#' (or of a bare `name(..)`); NULL when none */ + bool has_params; + char params[JR_MAX_PARAMS][JR_PARAM_BUF]; + int nparams; +} jr_ref_t; + +static bool jr_ident(const char *s) { + unsigned char first = (unsigned char)s[0]; + if (!(isalpha(first) || first == '_' || first == '$' || first >= 0x80)) { + return false; + } + for (const char *p = s + SKIP_ONE; *p; p++) { + unsigned char u = (unsigned char)*p; + if (!(isalnum(u) || u == '_' || u == '$' || u >= 0x80)) { + return false; + } + } + return true; +} + +/* Split a dotted path in place into identifiers; false when a part is none + * or there are more parts than a name has. */ +static bool jr_split_path(char *s, jr_ref_t *r) { + r->nsegs = 0; + if (!s[0]) { + return true; + } + for (char *p = s;;) { + char *dot = strchr(p, '.'); + if (dot) { + *dot = '\0'; + } + if (r->nsegs >= JR_MAX_SEGS || !jr_ident(p)) { + return false; + } + r->segs[r->nsegs++] = p; + if (!dot) { + return true; + } + p = dot + SKIP_ONE; + } +} + +/* The written parameter list s[0, n) of a Javadoc member reference: + * normalized like the declared signatures. false when it has more + * parameters than any method. */ +static bool jr_parse_params(const char *s, size_t n, jr_ref_t *r) { + r->has_params = true; + r->nparams = 0; + bool any = false; + for (size_t i = 0; i < n; i++) { + any = any || !isspace((unsigned char)s[i]); + } + if (!any) { + return true; + } + size_t start = 0; + int depth = 0; + for (size_t i = 0; i <= n; i++) { + char ch = i < n ? s[i] : ','; + if (ch == '<' || ch == '(' || ch == '[') { + depth++; + } else if (ch == '>' || ch == ')' || ch == ']') { + depth--; + } else if (ch == ',' && depth <= 0) { + if (r->nparams >= JR_MAX_PARAMS) { + return false; + } + char *p = r->params[r->nparams]; + cbm_doclink_java_norm_type(s + start, i - start, p, sizeof(r->params[0])); + if (p[0] == '?' && p[SKIP_ONE] == '\0') { + return false; /* no type, or one longer than any */ + } + r->nparams++; + start = i + SKIP_ONE; + } + } + return true; +} + +/* `[module/]Type[#member[(params)]]`, `#member[(params)]`, `member(params)`. */ +static bool jr_parse_java(const char *raw, jr_ref_t *r) { + memset(r, 0, sizeof(*r)); + size_t len = strlen(raw); + if (len == 0 || len > CBM_DOCLINK_JVM_REF_MAX) { + return false; /* over-long: never resolve a truncated prefix */ + } + memcpy(r->text, raw, len + SKIP_ONE); + char *s = r->text; + char *hash = strchr(s, '#'); + char *paren = strchr(s, '('); + char *slash = strchr(s, '/'); + if (slash && (!hash || slash < hash) && (!paren || slash < paren)) { + s = slash + SKIP_ONE; /* a module prefix */ + } + char *type = s; + char *member = NULL; + if (hash) { + *hash = '\0'; + member = hash + SKIP_ONE; + } else if (paren) { + member = s; /* `name(..)`: a member of the documented type */ + type = NULL; + } + if (member) { + char *mp = strchr(member, '('); + if (mp) { + size_t ml = strlen(mp); + if (mp[ml - SKIP_ONE] != ')' || !jr_parse_params(mp + SKIP_ONE, ml - PAIR_LEN, r)) { + return false; /* a list that does not close, or is longer than any */ + } + *mp = '\0'; + } + if (!jr_ident(member)) { + return false; + } + r->member = member; + } + if (type) { + /* without type arguments and array brackets */ + char *lt = strchr(type, '<'); + if (lt) { + *lt = '\0'; + } + size_t tl = strlen(type); + while (tl >= PAIR_LEN && type[tl - PAIR_LEN] == '[' && type[tl - SKIP_ONE] == ']') { + tl -= PAIR_LEN; + type[tl] = '\0'; + } + if (!jr_split_path(type, r)) { + return false; + } + } + return r->nsegs > 0 || r->member != NULL; +} + +/* A KDoc name: dotted, a segment may be backticked. */ +static bool jr_parse_kotlin(const char *raw, jr_ref_t *r) { + memset(r, 0, sizeof(*r)); + size_t len = strlen(raw); + if (len == 0 || len > CBM_DOCLINK_JVM_REF_MAX) { + return false; + } + /* copy without backticks; a dot inside backticks is no separator, and a + * name that holds one cannot be a path segment here */ + size_t w = 0; + bool quoted = false; + for (size_t i = 0; i < len; i++) { + if (raw[i] == '`') { + quoted = !quoted; + } else if (quoted && raw[i] == '.') { + return false; + } else { + r->text[w++] = raw[i]; + } + } + r->text[w] = '\0'; + if (quoted) { + return false; + } + if (w > PAIR_LEN && r->text[w - PAIR_LEN] == '(' && r->text[w - SKIP_ONE] == ')') { + r->text[w - PAIR_LEN] = '\0'; /* `@see name()` */ + } + r->nsegs = 0; + for (char *p = r->text;;) { + char *dot = strchr(p, '.'); + if (dot) { + *dot = '\0'; + } + if (r->nsegs >= JR_MAX_SEGS || !p[0] || strpbrk(p, "\t()#<>[]{}/\\:;,\"'")) { + return false; + } + r->segs[r->nsegs++] = p; + if (!dot) { + break; + } + p = dot + SKIP_ONE; + } + return r->nsegs > 0; +} + +/* ── Members of a type ───────────────────────────────────────────── */ + +enum { + JR_SEL_VALUES = 1, /* fields, properties, enum constants, components */ + JR_SEL_CALLABLES = 2, /* methods and functions, no constructors */ + JR_SEL_CTORS = 4, /* constructors only */ + JR_SEL_SIG = 8, /* of the callables, those with the written signature */ +}; + +typedef struct { + int count; /* the selected members */ + bool several; /* ... are more than one thing: different signatures, + or members of different owners */ + bool component; /* one is a record component or constructor property */ + bool invisible; /* members left out because product code may not bind + what only test code declares */ + bool uncompared; /* callables left out because a Javadoc signature cannot + be compared with a Kotlin declaration's */ + const char *sig; /* the first one's */ + const cbm_gbuf_node_t *node; /* the representative node of those that have one */ + int node_file; + int node_order; +} jr_found_t; + +/* Index of the first member of type `type` in `f` named `name` (the members + * are sorted by owner, name, order); f->nmembers when there is none before + * the end. */ +static int jr_member_lower(const jr_file_t *f, int type, const char *name) { + int lo = 0; + int hi = f->nmembers; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + const jr_member_t *m = &f->members[mid]; + bool below = m->owner != type ? m->owner < type : strcmp(m->name, name) < 0; + if (below) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return lo; +} + +/* The suffix of a normalized type that says how many array levels it has. */ +static size_t jr_array_suffix(const char *s, size_t n) { + size_t e = n; + while (e >= PAIR_LEN && s[e - PAIR_LEN] == '[' && s[e - SKIP_ONE] == ']') { + e -= PAIR_LEN; + } + return e; +} + +/* True when `name[0, n)` is a type variable in scope of a member of type + * `owner` whose own type parameters are `mtparams`: the member's, its type's + * or an enclosing type's. *bound / *blen: its first bound. */ +static bool jr_is_tvar(const jr_file_t *f, const char *mtparams, int owner, const char *name, + size_t n, const char **bound, size_t *blen) { + if (n == 0) { + return false; + } + if (jr_listed_n(mtparams, name, n, bound, blen)) { + return true; + } + for (int t = owner; t != JR_NONE; t = f->types[t].outer) { + if (jr_listed_n(f->types[t].tparams, name, n, bound, blen)) { + return true; + } + } + return false; +} + +/* One written parameter type against one declared type: the same name, or + * the erasure of a declared type variable (its first bound, Object when it + * has none) with the same array rank. Nothing else: javadoc matches a + * signature against the declared types and against their erasures. */ +static bool jr_param_match(const jr_file_t *f, const char *mtparams, int owner, const char *written, + const char *declared, size_t dn) { + static const char object[] = "Object"; + size_t wn = strlen(written); + if (wn == SKIP_ONE && written[0] == '?') { + return false; /* a written type that is no type */ + } + if (wn == dn && memcmp(written, declared, wn) == 0) { + return true; + } + size_t wb = jr_array_suffix(written, wn); + size_t db = jr_array_suffix(declared, dn); + const char *bound = NULL; + size_t blen = 0; + if ((wn - wb) != (dn - db) || !jr_is_tvar(f, mtparams, owner, declared, db, &bound, &blen)) { + return false; + } + if (blen == 0) { + bound = object; + blen = sizeof(object) - SKIP_ONE; + } + return wb == blen && memcmp(written, bound, blen) == 0; +} + +/* True when the written parameter list is the canonical one of the record + * `owner` declares: its components' types, in declaration order. The written + * list is at most JR_MAX_PARAMS long, which bounds the passes over the + * record's members. */ +static bool jr_canonical_match(const jr_file_t *f, int owner, const jr_ref_t *r) { + int first = jr_member_lower(f, owner, ""); + int components = 0; + for (int i = first; i < f->nmembers && f->members[i].owner == owner; i++) { + components += f->members[i].component; + } + if (components != r->nparams) { + return false; + } + int last_order = JR_NONE; + for (int k = 0; k < r->nparams; k++) { + const jr_member_t *next = NULL; /* the component with the next larger order */ + for (int i = first; i < f->nmembers && f->members[i].owner == owner; i++) { + const jr_member_t *x = &f->members[i]; + if (x->component && x->order > last_order && (!next || x->order < next->order)) { + next = x; + } + } + if (!next || !jr_param_match(f, "", owner, r->params[k], next->sig, strlen(next->sig))) { + return false; + } + last_order = next->order; + } + return true; +} + +/* The written parameter types against a declared signature. */ +static bool jr_sig_match(const jr_file_t *f, const jr_member_t *m, const jr_ref_t *r) { + if (m->ctor && strcmp(m->sig, "*") == 0) { + return jr_canonical_match(f, m->owner, r); + } + const char *p = m->sig; + for (int i = 0; i < r->nparams; i++) { + if (!p || !*p) { + return false; /* fewer declared than written */ + } + const char *bar = strchr(p, '|'); + size_t dn = bar ? (size_t)(bar - p) : strlen(p); + if (!jr_param_match(f, m->tparams, m->owner, r->params[i], p, dn)) { + return false; + } + p = bar ? bar + SKIP_ONE : NULL; + } + return r->nparams == 0 ? m->sig[0] == '\0' : p == NULL; +} + +/* Count member `m` of `file` among the selected ones. */ +static void jr_found_take(const jr_ctx_t *c, jr_found_t *out, int file, const jr_member_t *m) { + if (out->count == 0) { + out->sig = m->sig; + } else if (strcmp(out->sig, m->sig) != 0) { + out->several = true; + } + out->count++; + out->component = out->component || m->component; + const cbm_gbuf_node_t *n = jr_member_node(c, file, m); + if (n && (!out->node || jr_decl_better(c->ix, file, m->order, out->node_file, out->node_order, + c->f->rel_path))) { + out->node = n; + out->node_file = file; + out->node_order = m->order; + } +} + +/* Add to `out` the members named `name` that `sel` selects among those `type` + * of `file` declares (JR_NONE: the file's top-level declarations). */ +static void jr_members_in(const jr_ctx_t *c, int file, int type, const char *name, int sel, + const jr_ref_t *r, jr_found_t *out) { + const jr_file_t *f = &c->ix->files[file]; + for (int i = jr_member_lower(f, type, name); i < f->nmembers; i++) { + const jr_member_t *m = &f->members[i]; + if (m->owner != type || strcmp(m->name, name) != 0) { + break; + } + bool take = m->kind == 'v' ? (sel & JR_SEL_VALUES) != 0 + : (sel & (m->ctor ? JR_SEL_CTORS : JR_SEL_CALLABLES)) != 0; + if (!take) { + continue; + } + if (c->prod && f->is_test) { + out->invisible = true; + continue; + } + if ((sel & JR_SEL_SIG) && m->kind == 'c') { + if (f->kotlin) { + out->uncompared = true; /* Kotlin writes its types another way */ + continue; + } + if (!jr_sig_match(f, m, r)) { + continue; + } + } + jr_found_take(c, out, file, m); + } +} + +/* The members of entity `ent` named `name` that `sel` selects, across its + * declarations. */ +static jr_found_t jr_members(const jr_ctx_t *c, int ent, const char *name, int sel, + const jr_ref_t *r) { + const jr_entity_t *e = &c->ix->ents[ent]; + jr_found_t out = {0}; + for (int d = 0; d < e->ndecls; d++) { + jr_members_in(c, e->decls[d].file, e->decls[d].type, name, sel, r, &out); + } + return out; +} + +/* The top-level declarations named `name` of package `pkg`, across its + * files. */ +static jr_found_t jr_top_members(const jr_ctx_t *c, const char *pkg, const char *name, int sel) { + jr_found_t out = {0}; + char key[JR_KEY_BUF]; + if (!jr_pair_key(pkg, name, key, sizeof(key))) { + return out; + } + const jr_ilist_t *l = (const jr_ilist_t *)cbm_ht_get(c->ix->pkg_members, key); + for (int i = 0; l && i < l->count; i++) { + jr_members_in(c, l->items[i], JR_NONE, name, sel, NULL, &out); + } + return out; +} + +/* Join the members found under one name in two places. Members of different + * owners are different things, whatever their signatures. */ +static void jr_found_add(const jr_ctx_t *c, jr_found_t *into, const jr_found_t *add, + bool same_owner) { + into->invisible = into->invisible || add->invisible; + into->uncompared = into->uncompared || add->uncompared; + if (add->count == 0) { + return; + } + if (into->count == 0) { + bool invisible = into->invisible; + bool uncompared = into->uncompared; + *into = *add; + into->invisible = invisible; + into->uncompared = uncompared; + return; + } + into->several = + into->several || add->several || !same_owner || strcmp(into->sig, add->sig) != 0; + into->count += add->count; + into->component = into->component || add->component; + if (add->node && + (!into->node || jr_decl_better(c->ix, add->node_file, add->node_order, into->node_file, + into->node_order, c->f->rel_path))) { + into->node = add->node; + into->node_file = add->node_file; + into->node_order = add->node_order; + } +} + +/* The selected members as a result: their node, or a graph gap when the + * source declares them and the graph has no node for them. */ +static jr_res_t jr_found_result(const jr_found_t *found, bool exact) { + return found->node ? jr_edge(found->node, exact) : jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP); +} + +/* ── Type paths ──────────────────────────────────────────────────── */ + +typedef enum { JR_TP_OK = 0, JR_TP_PARTIAL, JR_TP_RES } jr_tp_kind_t; + +typedef struct { + jr_tp_kind_t st; + int ent; /* OK: the type; PARTIAL: the last type reached */ + int failed_seg; /* PARTIAL: index of the segment that is no member type */ + bool exact; + bool unfound; /* RES: no scope level has the head (the result says why not) */ + jr_res_t res; /* RES */ +} jr_tp_t; + +static jr_tp_t jr_tp_res(jr_res_t r) { + return (jr_tp_t){.st = JR_TP_RES, .res = r}; +} + +static bool jr_join(char *const *segs, int from, int to, char *out, size_t cap) { + size_t w = 0; + out[0] = '\0'; + for (int i = from; i < to; i++) { + int k = snprintf(out + w, cap - w, "%s%s", w ? "." : "", segs[i]); + if (k < 0 || (size_t)k >= cap - w) { + return false; + } + w += (size_t)k; + } + return true; +} + +/* Walk segs[from, to) as member types of `cur` (own, then inherited). */ +static jr_tp_t jr_walk_nested(const jr_ctx_t *c, int cur, char *const *segs, int from, int to, + bool exact) { + for (int i = from; i < to; i++) { + int next = jr_member_type(c, cur, segs[i]); + if (next == JR_NONE) { + return (jr_tp_t){.st = JR_TP_PARTIAL, .ent = cur, .failed_seg = i, .exact = exact}; + } + if (!jr_visible(c, next)) { + return jr_tp_res(jr_unres(CBM_DOCLINK_REASON_TEST_ONLY)); + } + cur = next; + } + return (jr_tp_t){.st = JR_TP_OK, .ent = cur, .exact = exact}; +} + +/* The file's single import of the simple name `name` with one of `kinds`; + * the next one after `prev` (the imports are sorted by alias). */ +static const jr_import_t *jr_import_next(const jr_file_t *f, const char *name, + const jr_import_t *prev) { + int i; + if (prev) { + i = (int)(prev - f->imports) + SKIP_ONE; + } else { + int lo = 0; + int hi = f->nimports; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (strcmp(f->imports[mid].alias, name) < 0) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + i = lo; + } + return (i < f->nimports && strcmp(f->imports[i].alias, name) == 0) ? &f->imports[i] : NULL; +} + +/* True when the repository declares package `p`. */ +static bool jr_is_package(const jr_index_t *ix, const char *p) { + return p[0] && cbm_ht_get(ix->packages, p) != NULL; +} + +/* The package part of a qualified name: everything before its last name. + * false for a simple name. */ +static bool jr_package_of(const char *fqn, char *out, size_t cap) { + const char *dot = strrchr(fqn, '.'); + if (!dot || (size_t)(dot - fqn) >= cap) { + return false; + } + memcpy(out, fqn, (size_t)(dot - fqn)); + out[dot - fqn] = '\0'; + return true; +} + +/* True when the file's scope is open: an on-demand import of a package the + * repository does not declare (or of a type it does not declare) can supply + * any simple name. */ +static bool jr_scope_open(const jr_ctx_t *c) { + for (int i = 0; i < c->f->nwild; i++) { + const char *target = c->f->wild[i].target; + if (!jr_is_package(c->ix, target) && jr_ent(c->ix, target) == JR_NONE) { + return true; + } + } + return false; +} + +/* True when package `pkg` lies in a directory the indexer never reads, below + * a package the repository owns. A JVM package is a directory path, and + * discovery skips some directory names wherever they occur (`env`, `target`, + * `tmp` ...): what is declared there is on disk, but not in the graph. */ +static bool jr_not_indexed(const jr_index_t *ix, const char *pkg) { + char buf[JR_KEY_BUF]; + size_t n = strlen(pkg); + if (n == 0 || n >= sizeof(buf)) { + return false; + } + memcpy(buf, pkg, n + SKIP_ONE); + bool owned = false; /* a prefix before this segment is the repository's */ + size_t seg = 0; + for (size_t i = 0; i <= n; i++) { + if (buf[i] != '.' && buf[i] != '\0') { + continue; + } + char keep = buf[i]; + buf[i] = '\0'; /* buf: the prefix through this segment; buf + seg: the segment */ + if (owned && cbm_should_skip_dir(buf + seg, (cbm_index_mode_t)ix->mode)) { + return true; + } + owned = owned || cbm_ht_get(ix->packages, buf) || cbm_ht_get(ix->pkg_prefixes, buf); + buf[i] = keep; + seg = i + SKIP_ONE; + } + return false; +} + +/* What a name that no scope shows is, by its package: declared by the + * repository without a scope record (G: generated code, a declaration the + * scan does not see), in a part of it the indexer does not read (N), or + * outside the repository (X). `pkg`: the package, or the type, the name is + * said to be in. */ +static char jr_unseen_kind(const jr_index_t *ix, const char *pkg) { + if (pkg[0] && (jr_is_package(ix, pkg) || jr_ent(ix, pkg) != JR_NONE)) { + return 'G'; + } + return jr_not_indexed(ix, pkg) ? 'N' : 'X'; +} + +static int jr_kind_reason(char kind) { + return kind == 'G' ? CBM_DOCLINK_REASON_GRAPH_GAP + : kind == 'N' ? CBM_DOCLINK_REASON_NOT_INDEXED + : CBM_DOCLINK_REASON_EXTERNAL; +} + +/* What an explicit import of `fqn` names: the type, or what jr_unseen_kind + * says of its package. */ +static void jr_cand_import_type(const jr_ctx_t *c, jr_cands_t *out, const char *fqn) { + int ent = jr_ent(c->ix, fqn); + if (ent != JR_NONE) { + jr_cand_type(c, out, ent); + return; + } + char pkg[JR_KEY_BUF]; + if (!jr_package_of(fqn, pkg, sizeof(pkg))) { + pkg[0] = '\0'; + } + jr_cand(out, jr_unseen_kind(c->ix, pkg), JR_NONE); +} + +/* ── Why a name was not found ────────────────────────────────────── */ + +/* A simple name no scope level has: the scope can still explain it. */ +static jr_res_t jr_unfound_simple(const jr_ctx_t *c, const char *name) { + if (!c->f->kotlin && JR_NAMED(JR_JAVA_PRIMITIVES, name)) { + return jr_unres(CBM_DOCLINK_REASON_EXTERNAL); + } + if (jr_scope_open(c)) { + return jr_unres(CBM_DOCLINK_REASON_EXTERNAL); + } + for (int i = 0; c->inherit && i < c->nchain; i++) { + if (jr_open(c->ix, c->chain[i])) { + return jr_unres(CBM_DOCLINK_REASON_EXTERNAL); /* a supertype outside may declare it */ + } + } + return jr_unres(CBM_DOCLINK_REASON_MISSING); +} + +/* True when the file imports something of package `pkg` (or the package + * itself on demand) and the repository does not declare that package. */ +static bool jr_imports_outside(const jr_ctx_t *c, const char *pkg) { + if (jr_is_package(c->ix, pkg)) { + return false; + } + size_t pl = strlen(pkg); + for (int i = 0; i < c->f->nwild; i++) { + if (strcmp(c->f->wild[i].target, pkg) == 0) { + return true; + } + } + for (int i = 0; i < c->f->nimports; i++) { + const char *t = c->f->imports[i].target; + if (strncmp(t, pkg, pl) == 0 && t[pl] == '.') { + return true; + } + } + return false; +} + +/* A qualified name that is no type, no member and no top-level declaration. + * - a package of the repository (or the start of one): packages have no node + * - `pkg.Type..` (a capitalized name after the package part): the type is + * missing when the repository declares that package, else it is outside + * - all lower case: the languages' own namespaces and what the file imports + * from outside are external; anything else names nothing + * - `Type.x` with an unknown head: as the simple name + * The roots are the namespaces the two platforms define themselves (`java`, + * `javax`, `kotlin`). A library family is none, however common: a path under + * `kotlinx` or `org` is external only where the file imports from it. */ +static jr_res_t jr_unfound_path(const jr_ctx_t *c, char *const *segs, int n) { + static const char *const roots[] = {"java", "javax", "kotlin"}; + const jr_index_t *ix = c->ix; + char buf[JR_KEY_BUF]; + if (!jr_join(segs, 0, n, buf, sizeof(buf))) { + return jr_unres(CBM_DOCLINK_REASON_UNPARSEABLE); + } + if (cbm_ht_get(ix->packages, buf) || cbm_ht_get(ix->pkg_prefixes, buf)) { + return jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP); + } + if (n == SKIP_ONE) { + return jr_unfound_simple(c, segs[0]); + } + int k = 0; + while (k < n && !isupper((unsigned char)segs[k][0])) { + k++; + } + if (k == 0) { + return jr_unfound_simple(c, segs[0]); + } + if (k < n) { + (void)jr_join(segs, 0, k, buf, sizeof(buf)); + if (jr_is_package(ix, buf)) { + return jr_unres(CBM_DOCLINK_REASON_MISSING); + } + return jr_unres(jr_kind_reason(jr_unseen_kind(ix, buf))); + } + if (JR_NAMED(roots, segs[0])) { + return jr_unres(CBM_DOCLINK_REASON_EXTERNAL); + } + if (jr_not_indexed(ix, buf)) { + return jr_unres(CBM_DOCLINK_REASON_NOT_INDEXED); /* a package the indexer skips */ + } + for (int i = n; i >= SKIP_ONE; i--) { + (void)jr_join(segs, 0, i, buf, sizeof(buf)); + if (jr_imports_outside(c, buf)) { + return jr_unres(CBM_DOCLINK_REASON_EXTERNAL); + } + } + return jr_unres(CBM_DOCLINK_REASON_MISSING); +} + +/* ── Members across supertypes ───────────────────────────────────── */ + +/* How many supertypes that declare a name are compared with the nearest one. + * Past that the name is ambiguous: an unresolved outcome with its reason, + * never a pick. */ +enum { JR_CLASH_CHECKS = 16 }; + +typedef struct { + jr_found_t found; /* the fields of the first type that has any */ + int ent; /* that type; JR_NONE when none has */ + bool gap; /* a type with members a parse error hides was reached before + any was found: one of those may be the field */ + bool open; /* nothing found, and a supertype outside the repository */ + bool clash; /* a second type declares the name, and neither comes first */ + bool invisible; +} jr_msearch_t; + +/* The fields `name` of `ent` (enum constants and record components among + * them): its own, else those of the nearest supertype that declares any. A + * second supertype with the name must be one the first extends (hidden); + * else neither comes first: a clash. Methods are javadoc's search (jr_walk). */ +static void jr_fields_in(const jr_ctx_t *c, int ent, const char *name, jr_msearch_t *out) { + const jr_index_t *ix = c->ix; + const jr_entity_t *e = &ix->ents[ent]; + memset(out, 0, sizeof(*out)); + out->ent = JR_NONE; + jr_found_t own = jr_members(c, ent, name, JR_SEL_VALUES, NULL); + out->invisible = own.invisible; + if (own.count > 0) { + out->found = own; + out->ent = ent; + return; + } + if (e->incomplete) { + out->gap = true; + return; + } + int checks = 0; + for (int k = 0; k < e->nclosure; k++) { + int sup = e->closure[k]; + jr_found_t inh = jr_members(c, sup, name, JR_SEL_VALUES, NULL); + out->invisible = out->invisible || inh.invisible; + if (inh.count == 0) { + if (out->ent == JR_NONE && ix->ents[sup].incomplete) { + out->gap = true; + return; + } + continue; + } + if (out->ent == JR_NONE) { + out->found = inh; + out->ent = sup; + } else if (!out->clash && + (++checks > JR_CLASH_CHECKS || !jr_is_or_extends(ix, out->ent, sup))) { + out->clash = true; + } + } + out->open = out->ent == JR_NONE && jr_open(ix, ent); +} + +/* True when java.lang.Object has a method `name`. With `r`, a reference that + * writes a parameter list: one with exactly that list. */ +static bool jr_object_method(const char *name, const jr_ref_t *r) { + for (size_t i = 0; i < sizeof(JR_OBJECT_METHODS) / sizeof(JR_OBJECT_METHODS[0]); i++) { + const char *p = JR_OBJECT_METHODS[i].sig; + bool same = strcmp(JR_OBJECT_METHODS[i].name, name) == 0; + for (int k = 0; same && r && k < r->nparams; k++) { + size_t n = strlen(r->params[k]); + same = n > 0 && strncmp(p, r->params[k], n) == 0 && (p[n] == '|' || p[n] == '\0'); + p += same ? n + (p[n] == '|' ? SKIP_ONE : 0) : 0; + } + if (same && (!r || *p == '\0')) { + return true; + } + } + return false; +} + +/* The members every type has without declaring them, in either language: + * Object's, an enum's, an annotation's. By name alone, and wider than any one + * language: it only tells `external` from `missing` once nothing was found. */ +static bool jr_implicit_member(const jr_index_t *ix, int ent, const char *name) { + char kind = ix->ents[ent].kind; + if (jr_object_method(name, NULL)) { + return true; + } + if (kind == 'e') { + return JR_NAMED(JR_JAVA_ENUM_MEMBERS, name) || JR_NAMED(JR_KOTLIN_ENUM_MEMBERS, name); + } + return kind == 'a' && strcmp(name, "annotationType") == 0; +} + +/* True when the type `ent`, a type around the documented definition of a + * Kotlin file, has a member `name` without declaring it: a member of + * kotlin.Any, at an enum class what an enum class has (the name alone + * decides). Java's counterpart is javadoc's own search (jr_walk). */ +static bool jr_given_member(const jr_ctx_t *c, int ent, const char *name) { + char kind = c->ix->ents[ent].kind; + return JR_NAMED(JR_ANY_MEMBERS, name) || + (kind == 'e' && JR_NAMED(JR_KOTLIN_ENUM_MEMBERS, name)); +} + +/* The fields `name` through the context's chain of types, innermost first: a + * type and what it inherits before the type around it. *level is the chain + * index of the hit. javadoc looks for a field in a type, then in the types + * around it, then in its supertypes; where the two orders find different + * fields, jr_java_member says ambiguous. */ +static void jr_fields_around(const jr_ctx_t *c, const char *name, jr_msearch_t *out, int *level) { + bool open = false; + bool invisible = false; + memset(out, 0, sizeof(*out)); + out->ent = JR_NONE; + *level = 0; + for (int i = 0; i < c->nchain; i++) { + jr_msearch_t one; + jr_fields_in(c, c->chain[i], name, &one); + open = open || one.open; + invisible = invisible || one.invisible; + if (one.ent != JR_NONE || one.gap) { + *out = one; + *level = i; + break; + } + } + out->open = out->ent == JR_NONE && !out->gap && open; + out->invisible = out->invisible || invisible; +} + +/* ── Java: javadoc's search for a method ─────────────────────────── */ + +/* javadoc links `#m` and `Type#m` to the first method its own search finds + * (JavacTrees.searchMethod; read in the JDK 21 source on 2026-10-04, stated + * here in our words). It is not javac's scoping. From a type it looks at + * 1. the type's own methods of the name; with a written parameter list, + * one whose parameters match it; + * 2. the superclass, by this whole search: the superclass's own methods, + * its superclass, its interfaces and the type around the SUPERCLASS + * all come before step 3; + * 3. the interfaces, in the order written, each by this whole search; + * 4. the type around it, by this whole search; + * and at each type once. `#m` starts at the type of the documented + * definition, `Type#m` at the type named. The steps of each type come from + * its declarations (jr_walk_steps). java.lang.Object, java.lang.Enum and + * java.lang.annotation.Annotation are steps whose methods the language + * defines; an enum has values() and valueOf(String), a record equals, + * hashCode and toString without declaring them. A type outside the + * repository is a step the index cannot look into: a method there would be + * the one linked. These lists are the language's, written from knowledge of + * the Java SE API. */ +static const char *const JR_ENUM_OWN_METHODS[] = {"values", "valueOf"}; +static const char *const JR_RECORD_OWN_METHODS[] = {"equals", "hashCode", "toString"}; +static const char *const JR_ENUM_CLASS_METHODS[] = { + "name", "ordinal", "compareTo", "getDeclaringClass", "describeConstable", + "valueOf", "equals", "hashCode", "toString", "clone", + "finalize"}; +static const char *const JR_ANNOTATION_METHODS[] = {"annotationType", "equals", "hashCode", + "toString"}; + +/* How many types one search looks at, at most. A search that would look at + * more ends as ambiguous (JR_WK_LIMIT): never cut short into a result. */ +enum { JR_WALK_MAX = 512, JR_WALK_SLOTS = 1024 /* a power of two above twice the max */ }; +static const unsigned JR_WALK_MIX = 2654435761U; /* multiplicative hashing */ + +typedef enum { + JR_WK_NONE = 0, /* no type on the way has the method */ + JR_WK_FOUND, /* a type of the repository declares it */ + JR_WK_GIVEN, /* the language gives it */ + JR_WK_OUTSIDE, /* a type outside the repository comes first */ + JR_WK_GAP, /* a type whose members a parse error hides comes first */ + JR_WK_UNCOMPARED, /* a Kotlin declaration of the name comes first, which a written + parameter list cannot be compared with: it may be the one */ + JR_WK_LIMIT, /* more than JR_WALK_MAX types */ +} jr_wk_t; + +typedef struct { + jr_wk_t kind; + /* FOUND, GAP, UNCOMPARED: the type; OUTSIDE: the type whose supertype + * lies outside; GIVEN: the type that has the method without declaring + * it, or JR_W_OBJECT, JR_W_ENUM, JR_W_ANNOTATION */ + int ent; + jr_found_t found; /* FOUND */ + bool invisible; /* a declaration only test code holds was passed over */ + bool passed_outside; /* for a field: a step outside was passed (jr_walk_ends) */ + bool passed_gap; /* ... a type whose members a parse error hides */ +} jr_walk_t; + +enum { JR_WS_OWN = 0, JR_WS_SUPER, JR_WS_IFACES, JR_WS_DONE }; + +typedef struct { + int ent; + int stage; /* JR_WS_* */ + int next; /* JR_WS_IFACES: the next interface step */ +} jr_wframe_t; + +/* Marks type `ent` as looked at (`slots` holds ent + 1); false when it was. + * The caller marks at most JR_WALK_MAX + 1 types, so a free slot is found. */ +static bool jr_walk_mark(int *slots, int ent) { + unsigned mask = (unsigned)JR_WALK_SLOTS - SKIP_ONE; + unsigned h = ((unsigned)ent * JR_WALK_MIX) & mask; + for (int i = 0; i < JR_WALK_SLOTS; i++) { + if (slots[h] == ent + SKIP_ONE) { + return false; + } + if (slots[h] == 0) { + slots[h] = ent + SKIP_ONE; + return true; + } + h = (h + SKIP_ONE) & mask; + } + return false; +} + +/* Step 1 at a type of the repository: what the type itself has under the + * name. True when that ends the step, as *out says. */ +static bool jr_walk_own(const jr_ctx_t *c, int ent, const char *name, int sel, const jr_ref_t *r, + jr_walk_t *out) { + const jr_entity_t *e = &c->ix->ents[ent]; + jr_found_t own = jr_members(c, ent, name, sel, r); + out->invisible = out->invisible || own.invisible; + out->ent = ent; + if (own.count > 0) { + out->kind = JR_WK_FOUND; + out->found = own; + return true; + } + if (own.uncompared) { + out->kind = JR_WK_UNCOMPARED; + return true; + } + if (e->kind == 'r' && (!(sel & JR_SEL_SIG) || r->nparams == 0)) { + jr_found_t comp = jr_members(c, ent, name, JR_SEL_VALUES, NULL); + if (comp.count > 0 && comp.component) { + /* a component's accessor: the record's own method, without a node */ + memset(&out->found, 0, sizeof(out->found)); + out->found.count = SKIP_ONE; + out->kind = JR_WK_FOUND; + return true; + } + } + if ((e->kind == 'e' && JR_NAMED(JR_ENUM_OWN_METHODS, name)) || + (e->kind == 'r' && JR_NAMED(JR_RECORD_OWN_METHODS, name))) { + out->kind = JR_WK_GIVEN; + return true; + } + if (e->incomplete) { + out->kind = JR_WK_GAP; + return true; + } + out->ent = JR_NONE; + return false; +} + +/* A step that is no type of the repository, taken from type `owner`. True + * when it ends the search, as *out says. With a written parameter list + * Object's methods are matched by it; an enum's and an annotation's by name. */ +static bool jr_walk_marker(int step, int owner, const char *name, int sel, const jr_ref_t *r, + jr_walk_t *out) { + const jr_ref_t *sig = (sel & JR_SEL_SIG) ? r : NULL; + int given = JR_NONE; + if (step == JR_W_OUTSIDE) { + out->kind = JR_WK_OUTSIDE; + out->ent = owner; + return true; + } + if (step == JR_W_OBJECT && jr_object_method(name, sig)) { + given = JR_W_OBJECT; + } else if (step == JR_W_ENUM && JR_NAMED(JR_ENUM_CLASS_METHODS, name)) { + given = JR_W_ENUM; + } else if (step == JR_W_ENUM && jr_object_method(name, sig)) { + given = JR_W_OBJECT; + } else if (step == JR_W_ANNOTATION && JR_NAMED(JR_ANNOTATION_METHODS, name)) { + given = JR_W_ANNOTATION; + } + if (given == JR_NONE) { + return false; + } + out->kind = JR_WK_GIVEN; + out->ent = given; + return true; +} + +/* Whether the search ends where *out says. A method is linked where it is + * found. For a field of class `cf` (jr_java_field) a step outside the + * repository, or a type whose members a parse error hides, does not end it + * when `cf` is below the type the step belongs to: whatever method is there, + * the field comes first, so the search goes on to what may come after it. + * `peek`, for a count only, passes everything but a method of the + * repository. */ +static bool jr_walk_ends(const jr_index_t *ix, jr_walk_t *out, int cf, bool peek) { + bool pass = false; + if (peek) { + pass = out->kind != JR_WK_FOUND; + } else if (cf != JR_NONE && (out->kind == JR_WK_OUTSIDE || out->kind == JR_WK_GAP) && + jr_is_or_extends(ix, cf, out->ent)) { + pass = true; + if (out->kind == JR_WK_OUTSIDE) { + out->passed_outside = true; + } else { + out->passed_gap = true; + } + } + if (pass) { + out->kind = JR_WK_NONE; + out->ent = JR_NONE; + } + return !pass; +} + +/* javadoc's search for a method `name` (`sel`, `r`: a written parameter + * list) from type `start`, without recursion: an explicit stack, each type + * once. `cf` and `peek`: jr_walk_ends. */ +static void jr_walk(const jr_ctx_t *c, int start, const char *name, int sel, const jr_ref_t *r, + int cf, bool peek, jr_walk_t *out) { + const jr_index_t *ix = c->ix; + int slots[JR_WALK_SLOTS]; + jr_wframe_t stack[JR_WALK_MAX]; + memset(slots, 0, sizeof(slots)); + memset(out, 0, sizeof(*out)); + out->ent = JR_NONE; + (void)jr_walk_mark(slots, start); + int marked = SKIP_ONE; + int depth = 0; + stack[depth++] = (jr_wframe_t){.ent = start, .stage = JR_WS_OWN, .next = 0}; + while (depth > 0) { + jr_wframe_t *fr = &stack[depth - SKIP_ONE]; + const jr_entity_t *e = &ix->ents[fr->ent]; + int step = JR_W_NONE; + if (fr->stage == JR_WS_OWN) { + fr->stage = JR_WS_SUPER; + if (jr_walk_own(c, fr->ent, name, sel, r, out) && jr_walk_ends(ix, out, cf, peek)) { + return; + } + continue; + } + if (fr->stage == JR_WS_SUPER) { + fr->stage = JR_WS_IFACES; + step = e->wsuper; + } else if (fr->stage == JR_WS_IFACES && fr->next < e->nwifaces) { + step = e->wifaces[fr->next++]; + } else if (fr->stage == JR_WS_IFACES) { + fr->stage = JR_WS_DONE; + step = e->encl; + } else { + depth--; + continue; + } + if (step < 0) { + if (jr_walk_marker(step, fr->ent, name, sel, r, out) && + jr_walk_ends(ix, out, cf, peek)) { + return; + } + continue; + } + if (!jr_walk_mark(slots, step)) { + continue; + } + if (++marked > JR_WALK_MAX) { + out->kind = JR_WK_LIMIT; + out->ent = JR_NONE; + return; + } + stack[depth++] = (jr_wframe_t){.ent = step, .stage = JR_WS_OWN, .next = 0}; + } +} + +/* ── Java ────────────────────────────────────────────────────────── */ + +typedef enum { JR_LK_NONE = 0, JR_LK_FOUND, JR_LK_INVISIBLE, JR_LK_LOCAL } jr_lk_t; + +/* Closes one scope level: true when it has a candidate, which then decides + * the lookup. */ +static bool jr_level_done(jr_cands_t *lv, jr_cands_t *out, bool *invisible) { + if (lv->count > 0) { + *out = *lv; + return true; + } + *invisible = *invisible || lv->invisible; + memset(lv, 0, sizeof(*lv)); + return false; +} + +/* True when the type `ent` has a declaration in file `file`. */ +static bool jr_declared_in(const jr_index_t *ix, int ent, int file) { + const jr_entity_t *e = &ix->ents[ent]; + for (int d = 0; d < e->ndecls; d++) { + if (e->decls[d].file == file) { + return true; + } + } + return false; +} + +/* The member types an on-demand import of `target` offers under `name`. */ +static void jr_java_on_demand(const jr_ctx_t *c, jr_cands_t *lv, const jr_import_t *imp, + const char *name) { + const jr_index_t *ix = c->ix; + if (imp->kind == 'w' && jr_is_package(ix, imp->target)) { + jr_cand_type(c, lv, jr_pkg_type(ix, imp->target, name)); + } + int type = jr_ent(ix, imp->target); /* `import a.Outer.*;`, `import static a.B.*;` */ + if (type != JR_NONE) { + jr_cand_type(c, lv, jr_member_type(c, type, name)); + } +} + +/* A simple type name in a Java file, in javac's order. */ +static jr_lk_t jr_java_type_lookup(const jr_ctx_t *c, const char *name, jr_cands_t *out) { + const jr_index_t *ix = c->ix; + const jr_file_t *f = c->f; + jr_cands_t lv = {0}; + bool invisible = false; + memset(out, 0, sizeof(*out)); + if (c->member && jr_listed(c->member->tparams, name)) { + return JR_LK_LOCAL; /* the method's own type variable */ + } + /* enclosing types, innermost first: the type and its member types, its + * type variables, the member types it inherits */ + for (int i = 0; i < c->nchain; i++) { + int ent = c->chain[i]; + if (strcmp(ix->ents[ent].name, name) == 0) { + jr_cand_type(c, &lv, ent); + } + jr_cand_type(c, &lv, jr_nested(ix, ent, name)); + if (jr_level_done(&lv, out, &invisible)) { + return JR_LK_FOUND; + } + if (jr_listed(c->chain_decl[i]->tparams, name)) { + return JR_LK_LOCAL; + } + const jr_entity_t *e = &ix->ents[ent]; + for (int k = 0; c->inherit && k < e->nclosure && lv.count == 0; k++) { + jr_cand_type(c, &lv, jr_nested(ix, e->closure[k], name)); + } + if (jr_level_done(&lv, out, &invisible)) { + return JR_LK_FOUND; + } + } + /* the file's own top-level types and its single imports */ + int same = jr_pkg_type(ix, f->package, name); + if (same != JR_NONE && jr_declared_in(ix, same, c->file)) { + jr_cand_type(c, &lv, same); + } + for (const jr_import_t *imp = jr_import_next(f, name, NULL); imp; + imp = jr_import_next(f, name, imp)) { + if (imp->kind == 's') { + jr_cand_import_type(c, &lv, imp->target); + continue; + } + /* `import static a.B.name;` imports the member TYPE of that name too */ + int owner = jr_ent(ix, imp->target); + if (owner != JR_NONE) { + jr_cand_type(c, &lv, jr_member_type(c, owner, name)); + } else { + /* a member of a type no scope shows: whatever it is, it is not + * one of the repository's known declarations */ + char pkg[JR_KEY_BUF]; + if (!jr_package_of(imp->target, pkg, sizeof(pkg))) { + pkg[0] = '\0'; + } + jr_cand(&lv, jr_unseen_kind(ix, pkg), JR_NONE); + } + } + if (lv.count > 0) { + lv.exact = true; + } + if (jr_level_done(&lv, out, &invisible)) { + return JR_LK_FOUND; + } + /* the file's package */ + jr_cand_type(c, &lv, same); + if (jr_level_done(&lv, out, &invisible)) { + return JR_LK_FOUND; + } + /* on-demand imports; java.lang is one of them */ + for (int i = 0; i < f->nwild; i++) { + jr_java_on_demand(c, &lv, &f->wild[i], name); + } + int lang = jr_pkg_type(ix, "java.lang", name); + if (lang != JR_NONE) { + jr_cand_type(c, &lv, lang); + } else if (JR_NAMED(JR_JAVA_LANG, name)) { + jr_cand(&lv, 'X', JR_NONE); + } + if (jr_level_done(&lv, out, &invisible)) { + return JR_LK_FOUND; + } + return invisible ? JR_LK_INVISIBLE : JR_LK_NONE; +} + +/* A (qualified) type name in a Java file: the head through the scope, the + * rest as member types; a head no scope level has is read from the root, as + * packages and then a type. */ +static jr_tp_t jr_java_type_path(const jr_ctx_t *c, char *const *segs, int n) { + const jr_index_t *ix = c->ix; + jr_cands_t cands; + jr_lk_t lk = jr_java_type_lookup(c, segs[0], &cands); + if (lk == JR_LK_LOCAL) { + return jr_tp_res((jr_res_t){.st = JR_LOCAL}); + } + if (lk == JR_LK_INVISIBLE) { + return jr_tp_res(jr_unres(CBM_DOCLINK_REASON_TEST_ONLY)); + } + if (lk == JR_LK_FOUND) { + if (cands.count > SKIP_ONE) { + return jr_tp_res(jr_unres(CBM_DOCLINK_REASON_AMBIGUOUS)); + } + if (cands.kind != 'T') { + return jr_tp_res(jr_unres(jr_kind_reason(cands.kind))); + } + return jr_walk_nested(c, cands.ent, segs, SKIP_ONE, n, cands.exact); + } + for (int i = SKIP_ONE; i < n; i++) { + char pkg[JR_KEY_BUF]; + if (!jr_join(segs, 0, i, pkg, sizeof(pkg))) { + break; + } + int ent = jr_pkg_type(ix, pkg, segs[i]); + if (ent == JR_NONE || ix->ents[ent].kind == 't') { + continue; + } + if (!jr_visible(c, ent)) { + return jr_tp_res(jr_unres(CBM_DOCLINK_REASON_TEST_ONLY)); + } + return jr_walk_nested(c, ent, segs, i + SKIP_ONE, n, true); + } + return jr_tp_res(jr_unfound_path(c, segs, n)); +} + +/* True when a record declaration of `ent` has exactly the written parameter + * list as its components. */ +static bool jr_record_canonical(const jr_ctx_t *c, int ent, const jr_ref_t *r) { + const jr_index_t *ix = c->ix; + const jr_entity_t *e = &ix->ents[ent]; + for (int d = 0; d < e->ndecls; d++) { + const jr_file_t *f = &ix->files[e->decls[d].file]; + if (f->types[e->decls[d].type].kind == 'r' && !(c->prod && f->is_test) && + jr_canonical_match(f, e->decls[d].type, r)) { + return true; + } + } + return false; +} + +/* `Type#Type(..)` and `#Type(..)`: the constructors of that type alone; none + * is inherited. A constructor the type has without declaring it (the default + * one, a record's canonical one) has no node. */ +static jr_res_t jr_java_ctor(const jr_ctx_t *c, int ent, const jr_ref_t *r, bool exact) { + const jr_entity_t *e = &c->ix->ents[ent]; + bool params = r->has_params; + jr_found_t all = jr_members(c, ent, e->name, JR_SEL_CTORS, r); + if (!params && all.count > 0) { + return all.several ? jr_unres(CBM_DOCLINK_REASON_AMBIGUOUS) : jr_found_result(&all, exact); + } + if (params) { + jr_found_t sel = jr_members(c, ent, e->name, JR_SEL_CTORS | JR_SEL_SIG, r); + if (sel.count > 0) { + return jr_found_result(&sel, exact); + } + if (sel.uncompared) { + return jr_unres(CBM_DOCLINK_REASON_AMBIGUOUS); + } + } + if (e->incomplete) { + return jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP); + } + if (all.count == 0 && all.invisible) { + return jr_unres(CBM_DOCLINK_REASON_TEST_ONLY); + } + bool has_ctors = e->kind == 'c' || e->kind == 'e' || e->kind == 'r'; + if (has_ctors && params && jr_record_canonical(c, ent, r)) { + return jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP); + } + if (has_ctors && all.count == 0 && (!params || (r->nparams == 0 && e->kind != 'r'))) { + return jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP); + } + return jr_unres(CBM_DOCLINK_REASON_MISSING); +} + +/* A method reference's outcome from javadoc's search `w` from `base`; `vs`: + * the field search that came first, if any. A search that ended at a method + * javadoc would link that the repository does not show is external; when a + * method of the repository lies further on, the count says so. */ +static jr_res_t jr_java_method(const jr_ctx_t *c, int base, const char *name, int sel, + const jr_ref_t *r, const jr_walk_t *w, const jr_msearch_t *vs, + bool exact) { + if (w->kind == JR_WK_FOUND) { + /* without a list, several signatures are an overload group */ + return (!(sel & JR_SEL_SIG) && w->found.several) ? jr_unres(CBM_DOCLINK_REASON_AMBIGUOUS) + : jr_found_result(&w->found, exact); + } + if (w->kind == JR_WK_GAP) { + return jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP); + } + if (w->invisible || (vs && vs->invisible)) { + return jr_unres(CBM_DOCLINK_REASON_TEST_ONLY); + } + if (w->kind == JR_WK_UNCOMPARED || w->kind == JR_WK_LIMIT) { + return jr_unres(CBM_DOCLINK_REASON_AMBIGUOUS); + } + if (w->kind == JR_WK_GIVEN || w->kind == JR_WK_OUTSIDE) { + jr_walk_t further; + jr_walk(c, base, name, sel, r, JR_NONE, true, &further); + if (further.kind == JR_WK_FOUND) { + jr_stats_t *st = c->ix->stats; + atomic_fetch_add(w->kind == JR_WK_GIVEN ? &st->java_given : &st->java_open, 1); + } + return jr_unres(CBM_DOCLINK_REASON_EXTERNAL); + } + if (jr_implicit_member(c->ix, base, name) || (vs && vs->open)) { + return jr_unres(CBM_DOCLINK_REASON_EXTERNAL); + } + return jr_unres(CBM_DOCLINK_REASON_MISSING); +} + +/* A field `vs` that javadoc's field search found for a reference without a + * parameter list, weighed against javadoc's search for a METHOD of the name + * from `start` (jr_walk). javadoc takes the field when no method is found, or + * when the field's class is the method's class or below it + * (JavacTrees.attributeDocReference, read in the JDK 21 source on + * 2026-10-04). So the field binds only when that holds whatever the steps the + * index cannot see hold: + * - a step outside the repository may hold the method; the field's class + * must be below the type the step belongs to (then it is below the + * outside type as well), and the search goes on past it; + * - a method the language gives is weighed like a declared one: Object's + * never stops the field; Enum's or Annotation's only an enum's or an + * annotation type's; a record's or an enum's own implicit one, a field + * of that type or below it; + * - against a method of the repository the field binds when its class is + * below the method's class. Otherwise, with nothing the index cannot see + * before that method, javadoc links the method, and so does the reference + * (the lead's ruling of 2026-10-04: a certain binding), with the tier of + * a member the search finds; an overload group without a list stays + * ambiguous, as for any method. If a step outside was passed, the method + * there may be the one: external; + * - a type whose members a parse error hides may hold the method: a graph + * gap, unless the field's class is below it (then it is passed, and only + * a field outcome survives it). + * *yielded: the field was not taken because of what lies outside. */ +static jr_res_t jr_java_field(const jr_ctx_t *c, int start, const char *name, const jr_ref_t *r, + const jr_msearch_t *vs, bool exact, bool *yielded) { + const jr_index_t *ix = c->ix; + int cf = vs->ent; + char cf_kind = ix->ents[cf].kind; + jr_walk_t w; + jr_walk(c, start, name, JR_SEL_CALLABLES, r, cf, false, &w); + bool field = false; + int reason = CBM_DOCLINK_REASON_EXTERNAL; + switch (w.kind) { + case JR_WK_NONE: + field = true; + break; + case JR_WK_FOUND: + field = jr_is_or_extends(ix, cf, w.ent); + if (!field && !w.passed_outside && !w.passed_gap) { + return w.found.several ? jr_unres(CBM_DOCLINK_REASON_AMBIGUOUS) + : jr_found_result(&w.found, exact); + } + break; + case JR_WK_GIVEN: + field = w.ent == JR_W_OBJECT || (w.ent == JR_W_ENUM && cf_kind == 'e') || + (w.ent == JR_W_ANNOTATION && cf_kind == 'a') || + (w.ent >= 0 && jr_is_or_extends(ix, cf, w.ent)); + break; + case JR_WK_OUTSIDE: + break; + case JR_WK_GAP: + reason = CBM_DOCLINK_REASON_GRAPH_GAP; + break; + default: /* a Kotlin declaration (only with a list), or too many types */ + reason = CBM_DOCLINK_REASON_AMBIGUOUS; + break; + } + if (field) { + return jr_found_result(&vs->found, exact); + } + if (w.passed_gap) { + reason = CBM_DOCLINK_REASON_GRAPH_GAP; + } + *yielded = reason == CBM_DOCLINK_REASON_EXTERNAL; + return jr_unres(reason); +} + +/* The scope of `Type#x`: the type named and the types around it, innermost + * first. javadoc's field search goes through them as it does for `#x`. */ +static void jr_ctx_named(jr_ctx_t *tc, const jr_ctx_t *c, int type) { + *tc = *c; + tc->nchain = 0; + for (int t = type; t != JR_NONE && tc->nchain < JR_CHAIN_MAX; t = c->ix->ents[t].encl) { + tc->chain[tc->nchain] = t; + tc->chain_decl[tc->nchain] = NULL; + tc->nchain++; + } +} + +/* A Javadoc member reference. `type` is the type it names, or JR_NONE for + * `#member`, which starts at the type of the documented definition. + * - a METHOD is javadoc's search (jr_walk): the first method it finds is the + * one; a type outside the repository on the way, or a method the language + * gives, ends it as external; a parameter list selects the overloads with + * that signature, never another overload, and without one several + * signatures are ambiguous + * - without a list a FIELD comes first. javadoc looks for one in the type, + * then in the types around it, then in the supertypes; javac scoping + * takes the inherited one first: an inherited field binds only when no + * type further out has one of that name (else ambiguous). The field is + * then weighed against the method (jr_java_field) + * - {@value} names a field and nothing else; for `Type#x` it searches the + * type and its supertypes only, as before */ +static jr_res_t jr_java_member(const jr_ctx_t *c, const char *name, const jr_ref_t *r, int type, + bool params, bool exact, bool values_only) { + const jr_index_t *ix = c->ix; + bool in_scope = type == JR_NONE; + int base = in_scope ? (c->nchain > 0 ? c->chain[0] : JR_NONE) : type; + if (base == JR_NONE) { + return jr_unres(CBM_DOCLINK_REASON_MISSING); /* no type to be a member of */ + } + if (!values_only && strcmp(name, ix->ents[base].name) == 0) { + return jr_java_ctor(c, base, r, exact); + } + int sel = JR_SEL_CALLABLES | (params ? JR_SEL_SIG : 0); + jr_walk_t mw; /* the method, as javadoc searches for it */ + memset(&mw, 0, sizeof(mw)); + mw.ent = JR_NONE; + if (params) { + jr_walk(c, base, name, sel, r, JR_NONE, false, &mw); + return jr_java_method(c, base, name, sel, r, &mw, NULL, exact); + } + jr_ctx_t named; + const jr_ctx_t *fc = c; /* the chain the field is searched through */ + if (!in_scope && !values_only) { + jr_ctx_named(&named, c, base); + fc = &named; + } + bool chain = in_scope || !values_only; + jr_msearch_t vs; + int vlevel = 0; + if (chain) { + jr_fields_around(fc, name, &vs, &vlevel); + } else { + jr_fields_in(c, base, name, &vs); + } + if (vs.ent != JR_NONE) { + for (int j = vlevel + SKIP_ONE; + chain && vs.ent != fc->chain[vlevel] && !vs.clash && j < fc->nchain; j++) { + jr_msearch_t outer; + jr_fields_in(fc, fc->chain[j], name, &outer); + vs.clash = outer.ent != JR_NONE || outer.gap; + } + if (vs.clash) { + return jr_unres(CBM_DOCLINK_REASON_AMBIGUOUS); + } + if (values_only) { + return jr_found_result(&vs.found, exact); + } + bool yielded = false; + jr_res_t res = jr_java_field(c, base, name, r, &vs, exact, &yielded); + if (yielded) { + atomic_fetch_add(in_scope ? &ix->stats->java_field : &ix->stats->java_field_named, 1); + } + return res; + } + if (vs.gap) { + return jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP); /* a hidden field would come first */ + } + if (!values_only) { + jr_walk(c, base, name, sel, r, JR_NONE, false, &mw); + } + return jr_java_method(c, base, name, sel, r, &mw, &vs, exact); +} + +static jr_res_t jr_java_resolve(const jr_ctx_t *c, const jr_ref_t *r, bool values_only) { + if (r->nsegs == 0) { + return jr_java_member(c, r->member, r, JR_NONE, r->has_params, false, values_only); + } + jr_tp_t tp = jr_java_type_path(c, r->segs, r->nsegs); + if (r->member) { + if (tp.st == JR_TP_OK) { + return jr_java_member(c, r->member, r, tp.ent, r->has_params, tp.exact, values_only); + } + /* a member type the named type does not have: javadoc finds no type */ + return tp.st == JR_TP_PARTIAL ? jr_unres(CBM_DOCLINK_REASON_MISSING) : tp.res; + } + if (tp.st == JR_TP_OK) { + return values_only ? jr_unres(CBM_DOCLINK_REASON_MISSING) + : jr_type_result(c, tp.ent, tp.exact); + } + if (tp.st == JR_TP_PARTIAL) { + return jr_unres(CBM_DOCLINK_REASON_MISSING); /* `Type.member`: Javadoc wants a '#' */ + } + if (r->nsegs == SKIP_ONE && tp.res.st == JR_UNRES && + tp.res.reason == CBM_DOCLINK_REASON_MISSING) { + /* javadoc's own fixup: a bare identifier that is no type is read as + * `#identifier`, a member in the enclosing types. Only where the scope + * is closed: an open one may hold a type of that name, which comes + * first. */ + jr_res_t m = jr_java_member(c, r->segs[0], r, JR_NONE, false, false, values_only); + if (!(m.st == JR_UNRES && m.reason == CBM_DOCLINK_REASON_MISSING)) { + return m; + } + } + return tp.res; +} + +/* ── Kotlin ──────────────────────────────────────────────────────── */ + +/* The packages every Kotlin file imports on demand. */ +static const char *const JR_KOTLIN_DEFAULT_PKGS[] = { + "kotlin", "kotlin.annotation", "kotlin.collections", "kotlin.comparisons", "kotlin.io", + "kotlin.ranges", "kotlin.sequences", "kotlin.text", "java.lang", "kotlin.jvm"}; + +/* What one scope level holds under a name: types, properties, functions. */ +typedef struct { + jr_cands_t types; + jr_found_t vals; + jr_found_t calls; + bool clash; /* two supertypes declare the name and neither comes first */ +} jr_klevel_t; + +static bool jr_kl_any(const jr_klevel_t *lv) { + return lv->types.count > 0 || lv->vals.count > 0 || lv->calls.count > 0; +} + +static bool jr_kl_invisible(const jr_klevel_t *lv) { + return lv->types.invisible || lv->vals.invisible || lv->calls.invisible; +} + +/* The members `name` of type `ent`, joined to the level. */ +static void jr_kl_members(const jr_ctx_t *c, jr_klevel_t *lv, int ent, const char *name) { + jr_found_t v = jr_members(c, ent, name, JR_SEL_VALUES, NULL); + jr_found_t k = jr_members(c, ent, name, JR_SEL_CALLABLES, NULL); + jr_found_add(c, &lv->vals, &v, false); + jr_found_add(c, &lv->calls, &k, false); +} + +/* The top-level declarations `name` of package `pkg`, joined to the level. */ +static void jr_kl_top(const jr_ctx_t *c, jr_klevel_t *lv, const char *pkg, const char *name) { + jr_found_t v = jr_top_members(c, pkg, name, JR_SEL_VALUES); + jr_found_t k = jr_top_members(c, pkg, name, JR_SEL_CALLABLES); + jr_found_add(c, &lv->vals, &v, false); + jr_found_add(c, &lv->calls, &k, false); +} + +/* The type `name` nested in `ent`; through a type alias, in the type it + * names. */ +static int jr_kotlin_nested(const jr_index_t *ix, int ent, const char *name) { + int n = jr_nested(ix, ent, name); + const jr_entity_t *e = &ix->ents[ent]; + if (n == JR_NONE && e->kind == 't' && e->nclosure > 0) { + n = jr_nested(ix, e->closure[0], name); + } + return n; +} + +/* What type `ent` holds under `name` itself: a nested type, its members, and + * the same of its companion object. One level: a class's member scope and its + * companion's are one scope to a KDoc name. */ +static void jr_kl_type_scope(const jr_ctx_t *c, jr_klevel_t *lv, int ent, const char *name, + bool members) { + const jr_index_t *ix = c->ix; + jr_cand_type(c, &lv->types, jr_kotlin_nested(ix, ent, name)); + if (members) { + jr_kl_members(c, lv, ent, name); + } + int comp = ix->ents[ent].companion; + if (comp != JR_NONE) { + jr_cand_type(c, &lv->types, jr_nested(ix, comp, name)); + if (members) { + jr_kl_members(c, lv, comp, name); + } + } +} + +/* What the supertypes of `ent` hold under `name`: the nearest one that has + * anything (`types`: nested types, `members`: properties and functions). A + * further one that the nearest does not extend is a clash. *gap: a supertype + * with members a parse error hides came before any hit. */ +static void jr_kl_inherited(const jr_ctx_t *c, jr_klevel_t *lv, int ent, const char *name, + bool types, bool members, bool *gap) { + const jr_index_t *ix = c->ix; + const jr_entity_t *e = &ix->ents[ent]; + int first = JR_NONE; + int checks = 0; + bool invisible = false; + for (int k = 0; k < e->nclosure; k++) { + int sup = e->closure[k]; + jr_klevel_t one; + memset(&one, 0, sizeof(one)); + if (types) { + jr_cand_type(c, &one.types, jr_nested(ix, sup, name)); + } + if (members) { + jr_kl_members(c, &one, sup, name); + } + if (!jr_kl_any(&one)) { + invisible = invisible || jr_kl_invisible(&one); + if (first == JR_NONE && members && ix->ents[sup].incomplete) { + *gap = true; + break; + } + continue; + } + if (first == JR_NONE) { + *lv = one; + first = sup; + } else if (++checks > JR_CLASH_CHECKS || !jr_is_or_extends(ix, first, sup)) { + lv->clash = true; + break; + } + } + lv->types.invisible = lv->types.invisible || invisible; +} + +/* What an explicit import of `fqn` puts in scope: a type, the top-level + * declarations of that name, a member of an object or an enum. When no scope + * shows anything of that name it is either declared by the repository where + * no scope sees it (its package or type is the repository's), or outside. */ +static void jr_kl_import(const jr_ctx_t *c, jr_klevel_t *lv, const char *fqn, bool members) { + const jr_index_t *ix = c->ix; + const char *dot = strrchr(fqn, '.'); + const char *last = dot ? dot + SKIP_ONE : fqn; + char pkg[JR_KEY_BUF]; + bool has_pkg = jr_package_of(fqn, pkg, sizeof(pkg)); + if (!has_pkg) { + pkg[0] = '\0'; + } + jr_klevel_t one; + memset(&one, 0, sizeof(one)); + jr_cand_type(c, &one.types, jr_ent(ix, fqn)); + jr_kl_top(c, &one, pkg, last); + int owner = has_pkg ? jr_ent(ix, pkg) : JR_NONE; + if (owner != JR_NONE) { + jr_kl_members(c, &one, owner, last); + } + if (!jr_kl_any(&one)) { + if (jr_kl_invisible(&one)) { + lv->types.invisible = true; + return; + } + jr_cand(&lv->types, jr_unseen_kind(ix, pkg), JR_NONE); + return; + } + if (one.types.count > 0) { + jr_cand(&lv->types, one.types.kind, one.types.ent); + } + if (members) { + jr_found_add(c, &lv->vals, &one.vals, false); + jr_found_add(c, &lv->calls, &one.calls, false); + } +} + +/* True when the member scope of `ent` is not fully known: the type or its + * companion object has a supertype outside the repository, directly or + * further up. */ +static bool jr_scope_unknown(const jr_index_t *ix, int ent) { + int comp = ix->ents[ent].companion; + return jr_open(ix, ent) || (comp != JR_NONE && jr_open(ix, comp)); +} + +/* Why a Kotlin lookup passed an enclosing type that may have the member + * unseen (jr_kotlin_lookup). */ +enum { JR_STOP_NO = 0, JR_STOP_GIVEN, JR_STOP_OPEN }; + +/* A simple name in a Kotlin file, level by level: + * the documented definition's own parameters and type parameters, `this` + * each enclosing type, innermost first: what it holds itself (with its + * companion), its type parameters and constructor parameters, what it + * inherits, what its companion inherits + * explicit imports -> the file's package -> star imports -> default imports + * `members` false: types only (the head of a qualified name, a supertype). + * *gap: a type with members a parse error hides was passed without a hit; a + * member found after it may be shadowed by one of those. + * *stop (JR_STOP_*): an enclosing type was passed without a hit that may have + * a member of the name unseen (jr_kotlin_simple); what the lookup returns + * then lies further out than that type. */ +static jr_lk_t jr_kotlin_lookup(const jr_ctx_t *c, const char *name, bool members, jr_klevel_t *out, + bool *exact, bool *gap, int *stop) { + const jr_index_t *ix = c->ix; + const jr_file_t *f = c->f; + jr_klevel_t lv; + bool invisible = false; + memset(out, 0, sizeof(*out)); + *exact = false; + *gap = false; + *stop = JR_STOP_NO; + if (c->member && + (jr_listed(c->member->tparams, name) || (members && jr_listed(c->member->pnames, name)))) { + return JR_LK_LOCAL; + } + /* a documented class: every parameter of its primary constructor is its + * own, a `val` one included (seen from a member's doc that one is a + * property of the class, further down) */ + if (c->type && + (jr_listed(c->type->tparams, name) || (members && jr_listed(c->type->pnames, name)))) { + return JR_LK_LOCAL; + } + if (members && strcmp(name, "this") == 0) { + return JR_LK_LOCAL; + } + for (int i = 0; i < c->nchain; i++) { + int ent = c->chain[i]; + const jr_type_t *decl = c->chain_decl[i]; + memset(&lv, 0, sizeof(lv)); + if (strcmp(ix->ents[ent].name, name) == 0) { + jr_cand_type(c, &lv.types, ent); + } + jr_kl_type_scope(c, &lv, ent, name, members); + if (jr_kl_any(&lv)) { + *out = lv; + return JR_LK_FOUND; + } + invisible = invisible || jr_kl_invisible(&lv); + if (jr_listed(decl->tparams, name) || (members && jr_listed(decl->pnames, name))) { + return JR_LK_LOCAL; + } + if (members && ix->ents[ent].incomplete) { + *gap = true; + } + if (!c->inherit) { + continue; + } + memset(&lv, 0, sizeof(lv)); + jr_kl_inherited(c, &lv, ent, name, true, members, gap); + if (jr_kl_any(&lv)) { + *out = lv; + return JR_LK_FOUND; + } + invisible = invisible || jr_kl_invisible(&lv); + if (!members) { + continue; + } + /* the companion's member scope is part of the class's, with what the + * companion inherits */ + int comp = ix->ents[ent].companion; + if (comp != JR_NONE) { + memset(&lv, 0, sizeof(lv)); + jr_kl_inherited(c, &lv, comp, name, false, true, gap); + if (jr_kl_any(&lv)) { + *out = lv; + return JR_LK_FOUND; + } + invisible = invisible || jr_kl_invisible(&lv); + } + if (*stop == JR_STOP_NO) { + *stop = jr_given_member(c, ent, name) ? JR_STOP_GIVEN + : jr_scope_unknown(ix, ent) ? JR_STOP_OPEN + : JR_STOP_NO; + } + } + /* explicit imports */ + memset(&lv, 0, sizeof(lv)); + for (const jr_import_t *imp = jr_import_next(f, name, NULL); imp; + imp = jr_import_next(f, name, imp)) { + jr_kl_import(c, &lv, imp->target, members); + } + if (jr_kl_any(&lv)) { + *out = lv; + *exact = true; + return JR_LK_FOUND; + } + invisible = invisible || jr_kl_invisible(&lv); + /* the file's package */ + memset(&lv, 0, sizeof(lv)); + jr_cand_type(c, &lv.types, jr_pkg_type(ix, f->package, name)); + if (members) { + jr_kl_top(c, &lv, f->package, name); + } + if (jr_kl_any(&lv)) { + *out = lv; + return JR_LK_FOUND; + } + invisible = invisible || jr_kl_invisible(&lv); + /* star imports: of a package, or of a type (its nested types and members) */ + memset(&lv, 0, sizeof(lv)); + for (int i = 0; i < f->nwild; i++) { + const char *target = f->wild[i].target; + if (jr_is_package(ix, target)) { + jr_cand_type(c, &lv.types, jr_pkg_type(ix, target, name)); + if (members) { + jr_kl_top(c, &lv, target, name); + } + } + int type = jr_ent(ix, target); + if (type != JR_NONE) { + jr_cand_type(c, &lv.types, jr_kotlin_nested(ix, type, name)); + if (members) { + jr_kl_members(c, &lv, type, name); + } + } + } + if (jr_kl_any(&lv)) { + *out = lv; + return JR_LK_FOUND; + } + invisible = invisible || jr_kl_invisible(&lv); + /* default imports: the repository's own declarations there first */ + memset(&lv, 0, sizeof(lv)); + for (size_t i = 0; i < sizeof(JR_KOTLIN_DEFAULT_PKGS) / sizeof(JR_KOTLIN_DEFAULT_PKGS[0]); + i++) { + jr_cand_type(c, &lv.types, jr_pkg_type(ix, JR_KOTLIN_DEFAULT_PKGS[i], name)); + if (members) { + jr_kl_top(c, &lv, JR_KOTLIN_DEFAULT_PKGS[i], name); + } + } + if (!jr_kl_any(&lv) && (JR_NAMED(JR_KOTLIN_DEFAULT, name) || JR_NAMED(JR_JAVA_LANG, name))) { + jr_cand(&lv.types, 'X', JR_NONE); + } + if (jr_kl_any(&lv)) { + *out = lv; + return JR_LK_FOUND; + } + invisible = invisible || jr_kl_invisible(&lv); + return invisible ? JR_LK_INVISIBLE : JR_LK_NONE; +} + +/* What a level with candidates is: one type, one property or one function. + * + * A type and a function of one name at one level (the factory-function + * idiom, `class Json` beside `fun Json(..)`) are the TYPE. The order relied + * on: KDoc's link resolution asks a scope for the classifier of a name before + * it asks for functions and properties, and the documentation tool links the + * first answer. That is stated from knowledge of the Kotlin documentation + * tooling (the compiler's KDoc link resolver, which Dokka uses); it was not + * checked online. The type binds with the tier the lookup gave. A type whose + * node another declaration took (class and factory in one file share the + * file-based qualified name, and the later one owns it) is a graph gap: the + * function never stands in for it. However many overloads the function has + * makes no difference. `@sample` names a function, so that order says nothing + * about it: there a type and a function of one name stay ambiguous. + * + * Everything else with more than one candidate is ambiguous, never a pick: a + * property and a function, a type and a property, a second type, an overload + * group, members of two owners. */ +static jr_res_t jr_kl_result(const jr_ctx_t *c, const jr_klevel_t *lv, bool exact) { + int kinds = (lv->types.count > 0) + (lv->vals.count > 0) + (lv->calls.count > 0); + if (!c->sample && lv->types.count == SKIP_ONE && lv->types.kind == 'T' && lv->calls.count > 0 && + lv->vals.count == 0 && !lv->clash) { + jr_res_t type = jr_type_result(c, lv->types.ent, exact); + atomic_fetch_add(&c->ix->stats->type_and_function, 1); + if (type.st == JR_OK) { + atomic_fetch_add(&c->ix->stats->type_and_function_node, 1); + } + return type; + } + if (kinds > SKIP_ONE || lv->types.count > SKIP_ONE || lv->clash) { + return jr_unres(CBM_DOCLINK_REASON_AMBIGUOUS); + } + if (lv->types.count == SKIP_ONE) { + if (lv->types.kind == 'T') { + return jr_type_result(c, lv->types.ent, exact); + } + return jr_unres(jr_kind_reason(lv->types.kind)); + } + const jr_found_t *m = lv->vals.count > 0 ? &lv->vals : &lv->calls; + return m->several ? jr_unres(CBM_DOCLINK_REASON_AMBIGUOUS) : jr_found_result(m, exact); +} + +/* A simple KDoc name. + * + * A function or a property further out than an enclosing type binds only when + * that type is known not to have a member of the name. KDoc resolves a name + * in the member scope of each enclosing class, inherited members included, + * before it looks at the class around it, the file's imports or its package. + * So when the lookup passed a type + * - that has the name without declaring it (jr_given_member: a member of + * kotlin.Any, of an enum class), or + * - whose supertypes, or whose companion's, leave the repository + * (jr_scope_unknown): one of them may declare the name, + * a function or a property found further out is not taken: the reference is + * `external`, and so is a name found nowhere. The order relied on is stated + * from knowledge of the Kotlin documentation tooling (the compiler's KDoc + * link resolver); it was not checked online. + * A TYPE found further out binds as before: the guard is about the member + * functions and properties a type has, not about nested types a supertype + * outside the repository might lend. */ +static jr_res_t jr_kotlin_simple(const jr_ctx_t *c, const char *name) { + jr_klevel_t lv; + bool exact = false; + bool gap = false; + int stop = JR_STOP_NO; + jr_lk_t lk = jr_kotlin_lookup(c, name, true, &lv, &exact, &gap, &stop); + if (lk == JR_LK_LOCAL) { + return (jr_res_t){.st = JR_LOCAL}; + } + if (lk == JR_LK_INVISIBLE) { + return jr_unres(CBM_DOCLINK_REASON_TEST_ONLY); + } + if (lk == JR_LK_FOUND) { + /* a member found past a type with hidden members: one of those may + * be the one. A type is safe: a type declared where the scan lost its + * place is quarantined by name. */ + if (gap && lv.types.count == 0) { + return jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP); + } + if (stop != JR_STOP_NO && lv.types.count == 0) { + jr_stats_t *st = c->ix->stats; + atomic_fetch_add(stop == JR_STOP_GIVEN ? &st->kotlin_given : &st->kotlin_open, 1); + return jr_unres(CBM_DOCLINK_REASON_EXTERNAL); + } + return jr_kl_result(c, &lv, exact); + } + if (stop != JR_STOP_NO) { + return jr_unres(CBM_DOCLINK_REASON_EXTERNAL); /* the member that type may have */ + } + jr_res_t res = jr_unfound_simple(c, name); + return (gap && res.reason == CBM_DOCLINK_REASON_MISSING) + ? jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP) + : res; +} + +/* A (qualified) type name in a Kotlin file: the head through the scope or, + * when no level has it, from the root as packages and then a type; the rest + * as nested types. */ +static jr_tp_t jr_kotlin_type_path(const jr_ctx_t *c, char *const *segs, int n) { + const jr_index_t *ix = c->ix; + jr_klevel_t lv; + bool exact = false; + bool gap = false; + int stop = JR_STOP_NO; /* types only: nothing to hold back */ + jr_lk_t lk = jr_kotlin_lookup(c, segs[0], false, &lv, &exact, &gap, &stop); + if (lk == JR_LK_LOCAL) { + return jr_tp_res((jr_res_t){.st = JR_LOCAL}); + } + if (lk == JR_LK_INVISIBLE) { + return jr_tp_res(jr_unres(CBM_DOCLINK_REASON_TEST_ONLY)); + } + int cur = JR_NONE; + int from = SKIP_ONE; + if (lk == JR_LK_FOUND) { + if (lv.types.count > SKIP_ONE) { + return jr_tp_res(jr_unres(CBM_DOCLINK_REASON_AMBIGUOUS)); + } + if (lv.types.kind != 'T') { + return jr_tp_res(jr_unres(jr_kind_reason(lv.types.kind))); + } + cur = lv.types.ent; + } + for (int i = SKIP_ONE; cur == JR_NONE && i < n; i++) { + char pkg[JR_KEY_BUF]; + if (!jr_join(segs, 0, i, pkg, sizeof(pkg))) { + break; + } + int ent = jr_pkg_type(ix, pkg, segs[i]); + if (ent == JR_NONE) { + continue; + } + if (!jr_visible(c, ent)) { + return jr_tp_res(jr_unres(CBM_DOCLINK_REASON_TEST_ONLY)); + } + cur = ent; + from = i + SKIP_ONE; + exact = true; + } + if (cur == JR_NONE) { + jr_tp_t tp = jr_tp_res(jr_unfound_path(c, segs, n)); + tp.unfound = true; + return tp; + } + for (int i = from; i < n; i++) { + int next = jr_kotlin_nested(ix, cur, segs[i]); + if (next == JR_NONE) { + return (jr_tp_t){.st = JR_TP_PARTIAL, .ent = cur, .failed_seg = i, .exact = exact}; + } + if (!jr_visible(c, next)) { + return jr_tp_res(jr_unres(CBM_DOCLINK_REASON_TEST_ONLY)); + } + cur = next; + } + return (jr_tp_t){.st = JR_TP_OK, .ent = cur, .exact = exact}; +} + +static bool jr_ext_key(int recv, const char *name, char *out, size_t cap) { + int n = recv == JR_NONE ? snprintf(out, cap, "?\x1f%s", name) + : snprintf(out, cap, "%d\x1f%s", recv, name); + return n >= 0 && (size_t)n < cap; +} + +/* True when the extension `m` of `file` is in scope of the source under its + * simple name: declared in the source's package, imported, or a member of an + * enclosing type. KDoc finds an extension for `Type.name` among the callables + * in scope, nowhere else. */ +static bool jr_ext_in_scope(const jr_ctx_t *c, int file, const jr_member_t *m) { + const jr_file_t *xf = &c->ix->files[file]; + if (m->owner != JR_NONE) { + int owner = xf->types[m->owner].entity; + for (int i = 0; i < c->nchain; i++) { + if (c->chain[i] == owner) { + return true; + } + } + return false; + } + if (strcmp(xf->package, c->f->package) == 0) { + return true; + } + for (int i = 0; i < c->f->nwild; i++) { + if (strcmp(c->f->wild[i].target, xf->package) == 0) { + return true; + } + } + size_t pl = strlen(xf->package); + for (const jr_import_t *imp = jr_import_next(c->f, m->name, NULL); imp; + imp = jr_import_next(c->f, m->name, imp)) { + const char *t = imp->target; + if (pl == 0 ? strcmp(t, m->name) == 0 + : (strncmp(t, xf->package, pl) == 0 && t[pl] == '.' && + strcmp(t + pl + SKIP_ONE, m->name) == 0)) { + return true; + } + } + return false; +} + +/* The extensions `name` in scope whose receiver is `ent`, else one of its + * supertypes (nearest first), else any type. false when there is none. */ +static bool jr_kotlin_extension(const jr_ctx_t *c, int ent, const char *name, jr_res_t *res, + bool *invisible) { + const jr_index_t *ix = c->ix; + const jr_entity_t *e = &ix->ents[ent]; + for (int k = -1; k <= e->nclosure; k++) { + int recv = k < 0 ? ent : (k < e->nclosure ? e->closure[k] : JR_NONE); + char key[JR_KEY_BUF]; + if (!jr_ext_key(recv, name, key, sizeof(key))) { + return false; + } + const jr_ilist_t *l = (const jr_ilist_t *)cbm_ht_get(ix->extensions, key); + jr_found_t found = {0}; + const char *package = NULL; + for (int i = 0; l && i + SKIP_ONE < l->count; i += PAIR_LEN) { + int file = l->items[i]; + const jr_member_t *m = &ix->files[file].members[l->items[i + SKIP_ONE]]; + if (!jr_ext_in_scope(c, file, m)) { + continue; + } + if (c->prod && ix->files[file].is_test) { + *invisible = true; + continue; + } + /* the same name and signature in two packages are two functions */ + if (package && strcmp(package, ix->files[file].package) != 0) { + found.several = true; + } + package = ix->files[file].package; + jr_found_take(c, &found, file, m); + } + if (found.count > 0) { + *res = found.several ? jr_unres(CBM_DOCLINK_REASON_AMBIGUOUS) + : jr_found_result(&found, false); + return true; + } + } + return false; +} + +/* `name` in the type `ent`, named explicitly: what the type holds itself + * (with its companion), then what it inherits and what its companion + * inherits, the members every type has, and the extensions in scope whose + * receiver it is. + * + * An extension is taken only when the type is known not to have a member of + * the name: a member comes before an extension. Where the supertypes of the + * type, or of its companion, leave the repository (jr_scope_unknown), one of + * them may declare the name: `external`, never the extension. Stated from + * knowledge of the language and of the KDoc link resolver, which asks the + * type's member scope before the extensions in scope; not checked online. */ +static jr_res_t jr_kotlin_in_type(const jr_ctx_t *c, int ent, const char *name, bool exact) { + const jr_index_t *ix = c->ix; + jr_klevel_t lv; + bool gap = false; + memset(&lv, 0, sizeof(lv)); + jr_kl_type_scope(c, &lv, ent, name, true); + if (jr_kl_any(&lv)) { + return jr_kl_result(c, &lv, exact); + } + bool invisible = jr_kl_invisible(&lv); + if (ix->ents[ent].incomplete) { + return jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP); + } + memset(&lv, 0, sizeof(lv)); + jr_kl_inherited(c, &lv, ent, name, true, true, &gap); + if (jr_kl_any(&lv)) { + return jr_kl_result(c, &lv, exact); + } + invisible = invisible || jr_kl_invisible(&lv); + int comp = ix->ents[ent].companion; + if (comp != JR_NONE && !gap) { + memset(&lv, 0, sizeof(lv)); + jr_kl_inherited(c, &lv, comp, name, false, true, &gap); + if (jr_kl_any(&lv)) { + return jr_kl_result(c, &lv, exact); + } + invisible = invisible || jr_kl_invisible(&lv); + } + if (gap) { + return jr_unres(CBM_DOCLINK_REASON_GRAPH_GAP); + } + if (jr_implicit_member(ix, ent, name)) { + return jr_unres(CBM_DOCLINK_REASON_EXTERNAL); + } + jr_res_t ext; + bool has_ext = jr_kotlin_extension(c, ent, name, &ext, &invisible); + if (jr_scope_unknown(ix, ent)) { + if (has_ext) { + atomic_fetch_add(&ix->stats->kotlin_extension, 1); + return jr_unres(CBM_DOCLINK_REASON_EXTERNAL); + } + return jr_unres(invisible ? CBM_DOCLINK_REASON_TEST_ONLY : CBM_DOCLINK_REASON_EXTERNAL); + } + if (has_ext) { + return ext; + } + return jr_unres(invisible ? CBM_DOCLINK_REASON_TEST_ONLY : CBM_DOCLINK_REASON_MISSING); +} + +/* True when `name` is a parameter or type parameter of the documented + * definition or of a type around it. */ +static bool jr_kotlin_local(const jr_ctx_t *c, const char *name) { + if (c->member && (jr_listed(c->member->pnames, name) || jr_listed(c->member->tparams, name))) { + return true; + } + for (int i = 0; i < c->nchain; i++) { + if (jr_listed(c->chain_decl[i]->pnames, name) || + jr_listed(c->chain_decl[i]->tparams, name)) { + return true; + } + } + return strcmp(name, "this") == 0; +} + +/* A dotted KDoc name. The qualifier is a type (found through the scope, or + * from the root) and the last name what that type holds; or the qualifier is + * a package and the last name one of its types or top-level declarations. */ +static jr_res_t jr_kotlin_qualified(const jr_ctx_t *c, const jr_ref_t *r) { + const jr_index_t *ix = c->ix; + int n = r->nsegs; + const char *last = r->segs[n - SKIP_ONE]; + if (jr_kotlin_local(c, r->segs[0])) { + return (jr_res_t){.st = JR_LOCAL}; /* `param.member` names nothing elsewhere */ + } + jr_tp_t qual = jr_kotlin_type_path(c, r->segs, n - SKIP_ONE); + if (qual.st == JR_TP_OK) { + return jr_kotlin_in_type(c, qual.ent, last, qual.exact); + } + if (qual.st == JR_TP_PARTIAL) { + return jr_unres(CBM_DOCLINK_REASON_MISSING); /* a type without that nested type */ + } + if (!qual.unfound) { + return qual.res; /* the qualifier is known, and is no type of the repository */ + } + char pkg[JR_KEY_BUF]; + if (!jr_join(r->segs, 0, n - SKIP_ONE, pkg, sizeof(pkg))) { + return jr_unres(CBM_DOCLINK_REASON_UNPARSEABLE); + } + jr_klevel_t lv; + memset(&lv, 0, sizeof(lv)); + jr_cand_type(c, &lv.types, jr_pkg_type(ix, pkg, last)); + jr_kl_top(c, &lv, pkg, last); + if (jr_kl_any(&lv)) { + return jr_kl_result(c, &lv, true); + } + if (jr_kl_invisible(&lv)) { + return jr_unres(CBM_DOCLINK_REASON_TEST_ONLY); + } + return jr_unfound_path(c, r->segs, n); +} + +static jr_res_t jr_kotlin_resolve(const jr_ctx_t *c, const jr_ref_t *r) { + return r->nsegs == SKIP_ONE ? jr_kotlin_simple(c, r->segs[0]) : jr_kotlin_qualified(c, r); +} + +/* ── Context ─────────────────────────────────────────────────────── */ + +/* True when a declaration with `slot` (see jr_type_t) is the definition whose + * name below the module is `tail`. `path`: its logical path. */ +static bool jr_slot_is(const char *slot, const char *owner_path, const char *name, + const char *tail) { + if (!slot) { + return false; + } + if (strcmp(slot, "=") != 0) { + return strcmp(slot, tail) == 0; + } + if (!owner_path) { + return strcmp(name, tail) == 0; + } + size_t ol = strlen(owner_path); + return strncmp(tail, owner_path, ol) == 0 && tail[ol] == '.' && + strcmp(tail + ol + SKIP_ONE, name) == 0; +} + +/* The scope of the definition a reference is written on: the documented + * member or type (found by its line and its name below the module), and the + * types around it. */ +static void jr_ctx_init(jr_ctx_t *c, const jr_index_t *ix, const cbm_gbuf_t *g, int file, + const CBMDocLink *link) { + memset(c, 0, sizeof(*c)); + c->ix = ix; + c->g = g; + c->file = file; + c->f = &ix->files[file]; + c->prod = !c->f->is_test; + c->inherit = true; + if (link->flags & CBM_DOCLINK_FLAG_FILE) { + return; /* a file-level doc: the file's scope alone */ + } + const jr_file_t *f = c->f; + uint32_t line = link->def_line; + size_t ml = strlen(f->module_qn); + const char *qn = link->source_qn; + const char *tail = + (qn && strncmp(qn, f->module_qn, ml) == 0 && qn[ml] == '.') ? qn + ml + SKIP_ONE : NULL; + int lo = 0; + int hi = f->nmembers; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (f->members[f->by_start[mid]].start < line) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + for (int i = lo; tail && i < f->nmembers; i++) { + const jr_member_t *m = &f->members[f->by_start[i]]; + if (m->start != line) { + break; + } + if (jr_slot_is(m->slot, m->owner == JR_NONE ? NULL : f->types[m->owner].path, m->name, + tail)) { + c->member = m; + jr_chain_from(c, m->owner); + return; + } + } + /* types are in source order: the last one that starts at or before the line */ + lo = 0; + hi = f->ntypes; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (f->types[mid].start <= line) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + int t = lo - SKIP_ONE; + for (int k = t; tail && k >= 0 && f->types[k].start == line; k--) { + if (jr_slot_is(f->types[k].slot, NULL, f->types[k].path, tail)) { + c->type = &f->types[k]; + jr_chain_from(c, k); + return; + } + } + /* neither: a definition the scan has no record of. The innermost type + * around its line is its scope. */ + while (t >= 0 && f->types[t].end < line) { + t = f->types[t].outer; + } + if (t >= 0) { + jr_chain_from(c, t); + } +} + +/* The scope of a type DECLARATION, for the names its header writes + * (supertypes, an extension's receiver): the types around it. From structure + * alone, since a stored scope has no lines. */ +static void jr_ctx_decl(jr_ctx_t *c, const jr_index_t *ix, const cbm_gbuf_t *g, int file, int outer, + bool inherit) { + memset(c, 0, sizeof(*c)); + c->ix = ix; + c->g = g; + c->file = file; + c->f = &ix->files[file]; + c->prod = false; /* a declared supertype is whatever the compiler bound */ + c->inherit = inherit; + jr_chain_from(c, outer); +} + +/* ── Supertypes, extensions ──────────────────────────────────────── */ + +/* Split a dotted name in place. Returns the number of names, 0 when there + * are more than a reference may have. */ +static int jr_split_dots(char *s, char **segs) { + int n = 0; + for (char *p = s;;) { + char *dot = strchr(p, '.'); + if (dot) { + *dot = '\0'; + } + if (n >= JR_MAX_SEGS || !p[0]) { + return 0; + } + segs[n++] = p; + if (!dot) { + return n; + } + p = dot + SKIP_ONE; + } +} + +/* The type a declaration header of `file` names with `name[0, len)`, in the + * scope `c`; JR_NONE when the repository declares no such type. */ +static int jr_header_type(const jr_ctx_t *c, const char *name, size_t len) { + char buf[JR_KEY_BUF]; + char *segs[JR_MAX_SEGS]; + if (len == 0 || len >= sizeof(buf)) { + return JR_NONE; + } + memcpy(buf, name, len); + buf[len] = '\0'; + int n = jr_split_dots(buf, segs); + if (n == 0) { + return JR_NONE; + } + jr_tp_t tp = c->f->kotlin ? jr_kotlin_type_path(c, segs, n) : jr_java_type_path(c, segs, n); + return tp.st == JR_TP_OK ? tp.ent : JR_NONE; +} + +/* True when a declaration header of the context's file writes, with + * `name[0, len)`, the root type every type has anyway: java.lang.Object, + * kotlin.Any. Written out it adds no member (jr_given_member knows them), so + * it does not leave the hierarchy open. The simple name counts only where + * nothing else can supply it: no import of that name, no on-demand import of + * a package the repository does not declare. */ +static bool jr_root_super(const jr_ctx_t *c, const char *name, size_t len) { + const char *full = c->f->kotlin ? "kotlin.Any" : "java.lang.Object"; + const char *simple = strrchr(full, '.') + SKIP_ONE; + if (len == strlen(full) && memcmp(name, full, len) == 0) { + return true; + } + return len == strlen(simple) && memcmp(name, simple, len) == 0 && + !jr_import_next(c->f, simple, NULL) && !jr_scope_open(c); +} + +/* True when the repository's type `step` is an interface to javadoc (an + * annotation type is one). */ +static bool jr_walk_iface(const jr_index_t *ix, int step) { + return step >= 0 && (ix->ents[step].kind == 'i' || ix->ents[step].kind == 'a'); +} + +/* The steps of javadoc's search for a method (jr_walk) at one declaration of + * kind `kind`, from the supertypes its header writes, in the order written + * (`steps[0, nd)`: types of the repository, JR_W_OBJECT for Object or Any + * written out, JR_W_OUTSIDE for any other). Returns the superclass step and + * leaves the interface steps, in order, in steps[0, *n). + * - An interface has no superclass; an annotation type has the interface + * java.lang.annotation.Annotation, an enum the superclass java.lang.Enum, + * a record java.lang.Record (which adds nothing to what the record has + * itself and Object's), any other class Object unless its header names + * another. + * - Java writes the superclass first: a first supertype that is no + * interface of the repository is it. One outside the repository may be + * the superclass or the first interface; it is taken as the superclass, + * the earlier of the two places. + * - Kotlin writes the superclass anywhere in the list: the one class of the + * repository there is it. Without one, a supertype outside the + * repository may be the superclass, which javadoc searches before every + * interface: the superclass step is then outside. + * - A Kotlin type alias is outside to this search. */ +static int jr_walk_steps(const jr_index_t *ix, char kind, bool kotlin, int *steps, int nd, int *n) { + int sup = JR_W_OBJECT; + int skip = JR_NONE; /* the entry that is the superclass */ + for (int i = 0; i < nd; i++) { + if (steps[i] >= 0 && ix->ents[steps[i]].kind == 't') { + steps[i] = JR_W_OUTSIDE; + } + } + *n = 0; + if (kind == 'i') { + sup = JR_W_NONE; + } else if (kind == 'a') { + sup = JR_W_ANNOTATION; + } else if (kind == 'e') { + sup = JR_W_ENUM; + } else if (kind == 't') { + return JR_W_OUTSIDE; + } else if (kind != 'r' && !kotlin) { + if (nd > 0 && steps[0] != JR_W_OBJECT && !jr_walk_iface(ix, steps[0])) { + sup = steps[0]; + skip = 0; + } + } else if (kind != 'r') { + int classes = 0; + bool outside = false; + for (int i = 0; i < nd; i++) { + if (steps[i] >= 0 && !jr_walk_iface(ix, steps[i])) { + classes++; + skip = i; + } + outside = outside || steps[i] == JR_W_OUTSIDE; + } + if (classes > SKIP_ONE) { + return JR_W_OUTSIDE; /* two superclasses: no order to follow */ + } + if (classes == SKIP_ONE) { + sup = steps[skip]; + } else if (outside) { + sup = JR_W_OUTSIDE; + } + } + int w = 0; + for (int i = 0; i < nd; i++) { + if (i != skip && steps[i] != JR_W_OBJECT) { + steps[w++] = steps[i]; + } + } + *n = w; + return sup; +} + +/* One declaration's steps into its type's: the first declaration sets them, + * a later one that has other steps or another order leaves the type's place + * in javadoc's search unknown (its superclass step outside). */ +static bool jr_walk_decl(jr_index_t *ix, jr_entity_t *e, const jr_type_t *t, bool kotlin, + int *steps, int nd, bool merge) { + int n = 0; + int sup = jr_walk_steps(ix, t->kind, kotlin, steps, nd, &n); + if (!merge) { + e->wsuper = sup; + if (n > 0) { + e->wifaces = (int *)jr_alloc(ix, (size_t)n * sizeof(int)); + if (!e->wifaces) { + return false; + } + memcpy(e->wifaces, steps, (size_t)n * sizeof(int)); + e->nwifaces = n; + } + return true; + } + if (sup != e->wsuper || n != e->nwifaces || + (n > 0 && memcmp(steps, e->wifaces, (size_t)n * sizeof(int)) != 0)) { + e->wsuper = JR_W_OUTSIDE; + e->nwifaces = 0; + } + return true; +} + +/* The type around declaration `t` of `f`, as the scope chain has it. */ +static int jr_walk_encl(const jr_file_t *f, const jr_type_t *t) { + int o = t->outer; + while (o != JR_NONE && f->types[o].entity < 0) { + o = f->types[o].outer; + } + return o == JR_NONE ? JR_NONE : f->types[o].entity; +} + +/* The direct supertypes of every type (each declaration's list, bound in that + * declaration's own scope), then every type's closure: breadth-first, the + * nearest first, each supertype once. A supertype that is no type of the + * repository leaves the hierarchy open. Alongside, each type's steps of + * javadoc's search for a method (jr_walk_steps). */ +static bool jr_resolve_supers(jr_index_t *ix, const cbm_gbuf_t *g) { + size_t n = (size_t)(ix->nents ? ix->nents : SKIP_ONE); + int *seen = (int *)cbm_calloc(CBM_MEM_CLASS_OTHER, n * sizeof(int)); + int *queue = (int *)cbm_alloc(CBM_MEM_CLASS_OTHER, n * sizeof(int)); + int *steps = NULL; /* one declaration's supertypes, as javadoc's search sees them */ + int scap = 0; + bool ok = seen && queue; + for (int ei = 0; ok && ei < ix->nents; ei++) { + jr_entity_t *e = &ix->ents[ei]; + int stamp = ei + SKIP_ONE; + int nb = 0; + for (int d = 0; ok && d < e->ndecls; d++) { + int file = e->decls[d].file; + const jr_type_t *t = &ix->files[file].types[e->decls[d].type]; + jr_ctx_t c; + if (t->supers[0]) { + jr_ctx_decl(&c, ix, g, file, t->outer, false); + } + int nd = 0; + for (const char *p = t->supers; ok && p && *p;) { + const char *bar = strchr(p, '|'); + size_t len = bar ? (size_t)(bar - p) : strlen(p); + int base = jr_header_type(&c, p, len); + int step = base; + if (base == ei) { + e->open = true; + step = JR_W_OUTSIDE; + } else if (base == JR_NONE) { + bool root = jr_root_super(&c, p, len); + e->open = e->open || !root; + step = root ? JR_W_OBJECT : JR_W_OUTSIDE; + } else if (seen[base] != stamp) { + seen[base] = stamp; + queue[nb++] = base; + } + if (nd >= scap) { + int ncap = scap ? scap * PAIR_LEN : CBM_SZ_16; + int *grown = (int *)cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)ncap * sizeof(int)); + ok = grown != NULL; + if (ok && nd > 0) { + memcpy(grown, steps, (size_t)nd * sizeof(int)); + } + cbm_free(CBM_MEM_CLASS_OTHER, steps); + steps = grown; + scap = ok ? ncap : 0; + } + if (ok) { + steps[nd++] = step; + } + p = bar ? bar + SKIP_ONE : NULL; + } + ok = ok && jr_walk_decl(ix, e, t, ix->files[file].kotlin, steps, nd, d > 0); + if (d == 0) { + e->encl = jr_walk_encl(&ix->files[file], t); + } + } + if (ok && nb > 0) { + e->bases = (int *)jr_alloc(ix, (size_t)nb * sizeof(int)); + ok = e->bases != NULL; + if (ok) { + memcpy(e->bases, queue, (size_t)nb * sizeof(int)); + e->nbases = nb; + } + } + } + memset(seen, 0, ok ? n * sizeof(int) : 0); + for (int ei = 0; ok && ei < ix->nents; ei++) { + jr_entity_t *e = &ix->ents[ei]; + int stamp = ei + SKIP_ONE; + int head = 0; + int tail = 0; + seen[ei] = stamp; /* a cycle does not come back to the type itself */ + for (int b = 0; b < e->nbases; b++) { + if (seen[e->bases[b]] != stamp) { + seen[e->bases[b]] = stamp; + queue[tail++] = e->bases[b]; + } + } + while (head < tail) { + const jr_entity_t *s = &ix->ents[queue[head++]]; + for (int b = 0; b < s->nbases; b++) { + if (seen[s->bases[b]] != stamp) { + seen[s->bases[b]] = stamp; + queue[tail++] = s->bases[b]; + } + } + } + if (tail > 0) { + e->closure = (int *)jr_alloc(ix, (size_t)tail * sizeof(int)); + ok = e->closure != NULL; + if (ok) { + memcpy(e->closure, queue, (size_t)tail * sizeof(int)); + e->nclosure = tail; + } + } + } + cbm_free(CBM_MEM_CLASS_OTHER, seen); + cbm_free(CBM_MEM_CLASS_OTHER, queue); + cbm_free(CBM_MEM_CLASS_OTHER, steps); + return ok; +} + +/* The receiver of every Kotlin extension, bound in its file's scope, and the + * extensions of each receiver type by name. */ +static bool jr_build_extensions(jr_index_t *ix, const cbm_gbuf_t *g) { + for (int fi = 0; fi < ix->nfiles; fi++) { + jr_file_t *f = &ix->files[fi]; + for (int mi = 0; f->kotlin && mi < f->nmembers; mi++) { + jr_member_t *m = &f->members[mi]; + if (!m->recv[0]) { + continue; + } + if (strcmp(m->recv, "?") != 0) { + jr_ctx_t c; + jr_ctx_decl(&c, ix, g, fi, m->owner, true); + m->recv_entity = jr_header_type(&c, m->recv, strlen(m->recv)); + if (m->recv_entity == JR_NONE) { + continue; /* a receiver outside the repository: nobody names it */ + } + } + char key[JR_KEY_BUF]; + if (!jr_ext_key(m->recv_entity, m->name, key, sizeof(key))) { + continue; + } + jr_ilist_t *l = jr_ht_ilist(ix, ix->extensions, key, true); + if (!l || !jr_ilist_push(ix, l, fi) || !jr_ilist_push(ix, l, mi)) { + return false; + } + } + } + return true; +} + +/* ── Index lifecycle and the resolver hooks ──────────────────────── */ + +static void jr_destroy(void *index) { + jr_index_t *ix = (jr_index_t *)index; + if (!ix) { + return; + } + if (ix->stats) { + const jr_stats_t *st = ix->stats; + const _Atomic int64_t *vals[] = {&st->source_gaps, &st->plain_text, + &st->type_and_function, &st->type_and_function_node, + &st->java_given, &st->java_open, + &st->kotlin_given, &st->kotlin_open, + &st->kotlin_extension, &st->java_field, + &st->java_field_named}; + char b[sizeof(vals) / sizeof(vals[0])][CBM_SZ_32]; + for (size_t i = 0; i < sizeof(vals) / sizeof(vals[0]); i++) { + snprintf(b[i], sizeof(b[i]), "%lld", (long long)atomic_load(vals[i])); + } + cbm_log_info("doc_links.jvm.done", "source_without_node", b[0], "kdoc_plain_text", b[1], + "type_and_function", b[2], "type_and_function_with_node", b[3], + "not_taken_java_given", b[4], "not_taken_java_open", b[5], + "not_taken_kotlin_given", b[6], "not_taken_kotlin_open", b[7], + "not_taken_kotlin_extension", b[8], "not_taken_java_field", b[9], + "not_taken_java_field_named", b[10]); + cbm_free(CBM_MEM_CLASS_OTHER, ix->stats); + } + cbm_ht_free(ix->ent_by_fqn); + cbm_ht_free(ix->pkg_types); + cbm_ht_free(ix->packages); + cbm_ht_free(ix->pkg_prefixes); + cbm_ht_free(ix->pkg_members); + cbm_ht_free(ix->extensions); + cbm_ht_free(ix->quarantine); + cbm_ht_free(ix->quarantine_test); + cbm_free(CBM_MEM_CLASS_OTHER, ix->ents); + cbm_free(CBM_MEM_CLASS_OTHER, ix->run_to_file); + cbm_arena_destroy(&ix->arena); + cbm_free(CBM_MEM_CLASS_OTHER, ix); +} + +static void *jr_build_fail(jr_index_t *ix, const char *step) { + cbm_log_error("doc_links.jvm.error", "step", step, "reason", "alloc"); + jr_destroy(ix); + return NULL; +} + +static bool jr_has_suffix(const char *s, const char *sfx) { + size_t n = strlen(s); + size_t sl = strlen(sfx); + return n >= sl && strcmp(s + n - sl, sfx) == 0; +} + +static void *jr_build(const cbm_doclink_build_in_t *in) { + jr_index_t *ix = (jr_index_t *)cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*ix)); + if (!ix) { + return NULL; + } + cbm_arena_init(&ix->arena); + ix->project = in->ctx->project_name; + ix->mode = in->ctx->mode; + ix->ent_by_fqn = cbm_ht_create(CBM_SZ_4K); + ix->pkg_types = cbm_ht_create(CBM_SZ_4K); + ix->packages = cbm_ht_create(CBM_SZ_1K); + ix->pkg_prefixes = cbm_ht_create(CBM_SZ_1K); + ix->pkg_members = cbm_ht_create(CBM_SZ_1K); + ix->extensions = cbm_ht_create(CBM_SZ_256); + ix->quarantine = cbm_ht_create(CBM_SZ_64); + ix->quarantine_test = cbm_ht_create(CBM_SZ_64); + ix->nfiles = in->file_count; + ix->files = (jr_file_t *)jr_alloc(ix, (size_t)(in->file_count ? in->file_count : SKIP_ONE) * + sizeof(jr_file_t)); + ix->run_count = in->run_file_count; + ix->run_to_file = (int *)cbm_alloc( + CBM_MEM_CLASS_OTHER, + (size_t)(in->run_file_count ? in->run_file_count : SKIP_ONE) * sizeof(int)); + ix->stats = (jr_stats_t *)cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*ix->stats)); + if (!ix->ent_by_fqn || !ix->pkg_types || !ix->packages || !ix->pkg_prefixes || + !ix->pkg_members || !ix->extensions || !ix->quarantine || !ix->quarantine_test || + !ix->files || !ix->run_to_file || !ix->stats) { + return jr_build_fail(ix, "tables"); + } + for (int i = 0; i < in->run_file_count; i++) { + ix->run_to_file[i] = JR_NONE; + } + int bad_scopes = 0; + for (int i = 0; i < in->file_count; i++) { + const cbm_doclink_file_t *src = &in->files[i]; + jr_file_t *f = &ix->files[i]; + memset(f, 0, sizeof(*f)); + f->rel_path = src->rel_path; /* stays valid until destroy */ + f->is_test = jr_is_test_path(src->rel_path); + f->scoped = src->scope && jr_parse_scope(ix, f, src->scope); + if (!f->scoped) { + bad_scopes += src->scope != NULL; + f->nimports = f->nwild = f->ntypes = f->nmembers = f->nunplaced = 0; + } + f->kotlin = !jr_has_suffix(src->rel_path, ".java"); + if (!f->package) { + f->package = ""; + } + f->module_qn = cbm_fqn_module_source_lang(&ix->arena, ix->project, src->rel_path, + f->kotlin ? CBM_LANG_KOTLIN : CBM_LANG_JAVA); + if (!f->module_qn) { + ix->oom = true; + } + if (src->run_file >= 0 && src->run_file < in->run_file_count) { + ix->run_to_file[src->run_file] = i; + } + } + if (ix->oom) { + return jr_build_fail(ix, "scopes"); + } + if (bad_scopes > 0) { + char b[CBM_SZ_32]; + snprintf(b, sizeof(b), "%d", bad_scopes); + cbm_log_warn("doc_links.jvm.bad_scope", "files", b); + } + int skipped = 0; + if (!jr_build_entities(ix, &skipped) || ix->oom) { + return jr_build_fail(ix, "entities"); + } + if (!jr_resolve_supers(ix, in->graph) || ix->oom) { + return jr_build_fail(ix, "supertypes"); + } + if (!jr_build_extensions(ix, in->graph) || ix->oom) { + return jr_build_fail(ix, "extensions"); + } + int incomplete = 0; + for (int i = 0; i < ix->nents; i++) { + incomplete += ix->ents[i].incomplete; + } + char b[5][CBM_SZ_32]; + snprintf(b[0], sizeof(b[0]), "%d", ix->nfiles); + snprintf(b[1], sizeof(b[1]), "%d", ix->nents); + snprintf(b[2], sizeof(b[2]), "%d", skipped); + snprintf(b[3], sizeof(b[3]), "%d", incomplete); + snprintf(b[4], sizeof(b[4]), "%u", + (unsigned)(cbm_ht_count(ix->quarantine) + cbm_ht_count(ix->quarantine_test))); + cbm_log_info("doc_links.jvm.index", "files", b[0], "types", b[1], "skipped_types", b[2], + "incomplete_types", b[3], "quarantined_names", b[4]); + return ix; +} + +/* True when the documented definition has a node of its own (or shares the + * node of its overload group). One node per qualified name: a definition + * whose name another declaration took (a Kotlin class and the factory + * function named like it, two classes' properties of one name in one file) + * has none, and an edge written for it would leave from that other + * declaration. A definition the scan has no record of is checked against the + * node itself: the source file is extracted in this run, so its nodes carry + * their lines. */ +static bool jr_source_has_node(const jr_ctx_t *c, const CBMDocLink *link) { + if (link->flags & CBM_DOCLINK_FLAG_FILE) { + return true; /* the File node: the core finds it */ + } + if (c->member) { + return jr_member_node(c, c->file, c->member) != NULL; + } + if (c->type) { + jr_decl_t d = {.file = c->file, .type = (int)(c->type - c->f->types)}; + return jr_type_node(c, d) != NULL; + } + const cbm_gbuf_node_t *n = link->source_qn ? cbm_gbuf_find_by_qn(c->g, link->source_qn) : NULL; + return n && n->file_path && strcmp(n->file_path, c->f->rel_path) == 0 && + n->start_line == (int)link->def_line; +} + +/* True when `line` lies in a part of the file the scan could not place. */ +static bool jr_unplaced_line(const jr_file_t *f, uint32_t line) { + int lo = 0; + int hi = f->nunplaced; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (f->unplaced[mid].to < line) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return lo < f->nunplaced && f->unplaced[lo].from <= line; +} + +static void jr_resolve(const void *index, void *state, int run_file, const CBMDocLink *link, + const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out) { + (void)state; /* Java and Kotlin keep no per-file working state */ + const jr_index_t *ix = (const jr_index_t *)index; + out->kind = CBM_DOCLINK_UNRESOLVED; + out->reason = CBM_DOCLINK_REASON_MISSING; + out->target = NULL; + out->exact = false; + if (!ix || !link || run_file < 0 || run_file >= ix->run_count || + ix->run_to_file[run_file] < 0) { + return; + } + int file = ix->run_to_file[run_file]; + const jr_file_t *f = &ix->files[file]; + if (!f->scoped) { + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; /* no scope to resolve in: never a guess */ + return; + } + jr_ref_t r; + if (!link->raw || + !(f->kotlin ? jr_parse_kotlin(link->raw, &r) : jr_parse_java(link->raw, &r))) { + out->reason = CBM_DOCLINK_REASON_UNPARSEABLE; + return; + } + /* What could not be placed is not resolved around: a definition in a part + * of its file where the scan lost its place has no known scope, and a + * name some file declares at an unknown place could be that declaration. + * Both are declared-but-unplaced: graph gaps. */ + bool sample = link->syntax == CBM_DOCLINK_KT_SAMPLE; + bool unplaced = !(link->flags & CBM_DOCLINK_FLAG_FILE) && jr_unplaced_line(f, link->def_line); + for (int i = 0; !unplaced && i < r.nsegs; i++) { + unplaced = cbm_ht_get(ix->quarantine, r.segs[i]) != NULL || + ((f->is_test || sample) && cbm_ht_get(ix->quarantine_test, r.segs[i]) != NULL); + } + if (unplaced) { + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; + return; + } + jr_ctx_t c; + jr_ctx_init(&c, ix, graph, file, link); + if (sample) { + c.prod = false; /* samples live in test sources */ + c.sample = true; + } + jr_res_t res = f->kotlin ? jr_kotlin_resolve(&c, &r) + : jr_java_resolve(&c, &r, link->syntax == CBM_DOCLINK_JAVA_VALUE); + /* KDoc `[name]` is Markdown: a name that resolves to nothing is rendered + * as the plain text it is, so it is no reference. A block tag (@see, + * @throws, @sample) names its subject and keeps its row. */ + bool plain_text = link->syntax == CBM_DOCLINK_KT_KDOC_LINK && res.st == JR_UNRES && + res.reason == CBM_DOCLINK_REASON_MISSING; + if (res.st == JR_LOCAL || plain_text) { + if (plain_text) { + atomic_fetch_add(&ix->stats->plain_text, 1); + } + out->kind = CBM_DOCLINK_LOCAL; + return; + } + if (res.st == JR_OK && res.node) { + if (!jr_source_has_node(&c, link)) { + /* resolved, with nowhere to leave from: the documented definition + * is the one without a node */ + atomic_fetch_add(&ix->stats->source_gaps, 1); + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; + return; + } + out->kind = CBM_DOCLINK_EDGE; + out->target = res.node; + out->exact = res.exact; + return; + } + out->reason = res.reason; +} + +/* ── Incremental scope rules ─────────────────────────────────────── */ + +/* Scope record fields (0-based, the tag is field 0; internal/cbm/ + * doclink_jvm.c): `I kind alias target`, `T .. supers(6) ..` and + * `M start kind flags(3) owner name(5) tparams sig recv(8) ..`. */ +enum { + JR_F_I_KIND = 1, + JR_F_I_ALIAS = 2, + JR_F_T_SUPERS = 6, + JR_F_M_FLAGS = 3, + JR_F_M_NAME = 5, + JR_F_M_RECV = 8, + JR_DELTA_IMPORTS = 16, /* changed imports compared by name; more: any name */ +}; + +typedef struct { + const char *p; + size_t n; +} jr_span_t; + +static jr_span_t jr_delta_field(jr_span_t line, int idx) { + int f = 0; + size_t s = 0; + for (size_t i = 0; i <= line.n; i++) { + if (i == line.n || line.p[i] == '\t') { + if (f == idx) { + return (jr_span_t){.p = line.p + s, .n = i - s}; + } + f++; + s = i + SKIP_ONE; + } + } + return (jr_span_t){0}; +} + +/* The next record; with `imports` the next import record, without it the + * next record that is none. false at the end. */ +static bool jr_delta_next(const char **cursor, jr_span_t *line, bool imports) { + for (const char *p = *cursor; p && *p;) { + const char *nl = strchr(p, '\n'); + size_t n = nl ? (size_t)(nl - p) : strlen(p); + const char *next = nl ? nl + SKIP_ONE : p + n; + if (n > 0 && (p[0] == 'I') == imports) { + *line = (jr_span_t){.p = p, .n = n}; + *cursor = next; + return true; + } + p = next; + } + *cursor = NULL; + return false; +} + +static bool jr_span_eq(jr_span_t a, jr_span_t b) { + return a.n == b.n && memcmp(a.p, b.p, a.n) == 0; +} + +/* The import records of a scope, in order. Returns the count, -1 when memory + * ran out. */ +static int jr_delta_imports(const char *scope, jr_span_t **out) { + int n = 0; + jr_span_t line; + for (const char *p = scope; jr_delta_next(&p, &line, true);) { + n++; + } + *out = + (jr_span_t *)cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)(n ? n : SKIP_ONE) * sizeof(jr_span_t)); + if (!*out) { + return CBM_NOT_FOUND; + } + int w = 0; + for (const char *p = scope; jr_delta_next(&p, &line, true);) { + (*out)[w++] = line; + } + return n; +} + +/* True when a changed import can change what the '|'-joined type names in + * `names` bind to: an on-demand import can supply any name, a single import + * the one it is known by. */ +static bool jr_delta_names_hit(jr_span_t names, const jr_span_t *changed, int nchanged, + bool any_name) { + for (size_t i = 0; i < names.n;) { + size_t e = i; + while (e < names.n && names.p[e] != '|') { + e++; + } + size_t head = i; + while (head < e && names.p[head] != '.') { + head++; + } + bool named = head > i && !(head - i == SKIP_ONE && names.p[i] == '?'); + if (named && any_name) { + return true; + } + for (int k = 0; named && k < nchanged; k++) { + jr_span_t alias = jr_delta_field(changed[k], JR_F_I_ALIAS); + if (alias.n == head - i && memcmp(alias.p, names.p + i, alias.n) == 0) { + return true; + } + } + i = e + SKIP_ONE; + } + return false; +} + +/* Imports scope the file itself, which is re-extracted anyway -- except for + * the names its declaration headers write: a supertype and an extension's + * receiver are bound in the file's scope, and references in OTHER files + * resolve through them. A changed import that can re-bind one of those is not + * repairable file by file. */ +static int jr_delta_import_change(const char *stored, const char *fresh) { + jr_span_t *si = NULL; + jr_span_t *fi = NULL; + int ns = jr_delta_imports(stored, &si); + int nf = jr_delta_imports(fresh, &fi); + int result = CBM_DOCLINK_DELTA_LOCAL; + if (ns < 0 || nf < 0) { + result = CBM_NOT_FOUND; + } else { + /* what is left between the common start and the common end changed */ + int a = 0; + while (a < ns && a < nf && jr_span_eq(si[a], fi[a])) { + a++; + } + int es = ns; + int ef = nf; + while (es > a && ef > a && jr_span_eq(si[es - SKIP_ONE], fi[ef - SKIP_ONE])) { + es--; + ef--; + } + int nchanged = (es - a) + (ef - a); + bool any_name = nchanged > JR_DELTA_IMPORTS; + jr_span_t changed[JR_DELTA_IMPORTS]; + int w = 0; + for (int i = a; !any_name && i < es; i++) { + changed[w++] = si[i]; + } + for (int i = a; !any_name && i < ef; i++) { + changed[w++] = fi[i]; + } + for (int i = 0; i < w; i++) { + jr_span_t kind = jr_delta_field(changed[i], JR_F_I_KIND); + any_name = any_name || (kind.n == SKIP_ONE && (kind.p[0] == 'w' || kind.p[0] == 'n')); + } + jr_span_t line; + for (const char *p = fresh; nchanged > 0 && result == CBM_DOCLINK_DELTA_LOCAL && + jr_delta_next(&p, &line, false);) { + int field = line.p[0] == 'T' ? JR_F_T_SUPERS : (line.p[0] == 'M' ? JR_F_M_RECV : 0); + if (field && jr_delta_names_hit(jr_delta_field(line, field), changed, w, any_name)) { + result = CBM_DOCLINK_DELTA_GLOBAL; + } + } + } + cbm_free(CBM_MEM_CLASS_OTHER, si); + cbm_free(CBM_MEM_CLASS_OTHER, fi); + return result; +} + +/* Compare a changed file's stored and fresh scopes record by record, in + * order. Two differences leave every other file's resolution alone: + * - a member the fresh scope no longer has (a method, a constructor, a + * field, a property, an enum constant): a reference that depended on it + * either bound it (an edge into this file) or names it in its unresolved + * row, so the name is reported; + * - the file's own imports, unless one can re-bind a supertype or an + * extension receiver the file declares (jr_delta_import_change). + * Everything else is GLOBAL: a new or changed record (a type, a member, a + * signature, the package), a removed type (it may be another type's + * supertype), a removed record component or constructor property (a record's + * canonical constructor is matched against them without naming them), a + * quarantined name or unplaced range that came or went, and a changed order + * (declarations of one name own their node by order). */ +static int jr_scope_delta(const char *stored, const char *fresh, cbm_doclink_name_fn removed, + void *ud) { + const char *sp = stored; + const char *fp = fresh; + jr_span_t sl = {0}; + jr_span_t fl = {0}; + bool hs = jr_delta_next(&sp, &sl, false); + bool hf = jr_delta_next(&fp, &fl, false); + while (hs || hf) { + if (hs && hf && jr_span_eq(sl, fl)) { + hs = jr_delta_next(&sp, &sl, false); + hf = jr_delta_next(&fp, &fl, false); + continue; + } + if (!hs || sl.p[0] != 'M') { + return CBM_DOCLINK_DELTA_GLOBAL; + } + jr_span_t flags = jr_delta_field(sl, JR_F_M_FLAGS); + jr_span_t name = jr_delta_field(sl, JR_F_M_NAME); + if (!flags.p || !name.p || name.n == 0) { + return CBM_NOT_FOUND; /* not a member record this code wrote */ + } + if (memchr(flags.p, 'p', flags.n)) { + return CBM_DOCLINK_DELTA_GLOBAL; + } + if (!removed || !removed(ud, name.p, name.n)) { + return CBM_NOT_FOUND; + } + hs = jr_delta_next(&sp, &sl, false); + } + return jr_delta_import_change(stored, fresh); +} + +const cbm_doclink_resolver_t cbm_doclink_jvm_resolver = { + .langs = {CBM_LANG_JAVA, CBM_LANG_KOTLIN}, + .lang_count = 2, + .scope_tag = CBM_DOCLINK_JVM_SCOPE_TAG, + .build = jr_build, + .destroy = jr_destroy, + .resolve = jr_resolve, + .scope_delta = jr_scope_delta, +}; diff --git a/src/pipeline/doc_links_md.c b/src/pipeline/doc_links_md.c index 4eaee1ce8..9e76d41a4 100644 --- a/src/pipeline/doc_links_md.c +++ b/src/pipeline/doc_links_md.c @@ -110,8 +110,9 @@ static const char *mdr_base(const char *path) { static bool mdr_code_file(const char *path) { CBMLanguage lang = cbm_language_for_filename(mdr_base(path)); return lang != CBM_LANG_COUNT && lang != CBM_LANG_MARKDOWN && lang != CBM_LANG_RST && - lang != CBM_LANG_ASCIIDOC && lang != CBM_LANG_PDF && lang != CBM_LANG_HTML && - lang != CBM_LANG_CSS && lang != CBM_LANG_SCSS && !cbm_has_config_extension(path); + lang != CBM_LANG_ASCIIDOC && lang != CBM_LANG_PDF && lang != CBM_LANG_DIAGRAM && + lang != CBM_LANG_OPENAPI && lang != CBM_LANG_HTML && lang != CBM_LANG_CSS && + lang != CBM_LANG_SCSS && !cbm_has_config_extension(path); } /* The stems of a package directory's index file. */ @@ -660,6 +661,35 @@ static void mdr_edge(cbm_doclink_outcome_t *out, const cbm_gbuf_node_t *target, out->target_last = last; } +/* A bare OpenAPI operationId (`getUser`): the one function or method of the + * repository with exactly that name, outside the tests; several are + * ambiguous, none is missing. (A dotted one, Connexion's, reads as a + * qualified code name.) */ +static void mdr_resolve_unique_callable(const cbm_gbuf_t *graph, const char *name, + cbm_doclink_outcome_t *out) { + const cbm_gbuf_node_t **nodes = NULL; + int n = 0; + const cbm_gbuf_node_t *hit = NULL; + int hits = 0; + if (cbm_gbuf_find_by_name(graph, name, &nodes, &n) == 0) { + for (int i = 0; i < n; i++) { + const char *l = nodes[i]->label; + if (l && (strcmp(l, "Function") == 0 || strcmp(l, "Method") == 0) && + !(nodes[i]->file_path && mdr_test_path(nodes[i]->file_path))) { + hit = nodes[i]; + hits++; + } + } + } + if (hits == 1) { + mdr_edge(out, hit, 0, 0); + out->exact = false; /* unique by name, not named in full */ + return; + } + out->kind = CBM_DOCLINK_UNRESOLVED; + out->reason = hits > 1 ? CBM_DOCLINK_REASON_AMBIGUOUS : CBM_DOCLINK_REASON_MISSING; +} + enum { MDR_MAX_PIECES = 80 }; /* a code span is at most 150 bytes */ typedef struct { @@ -1286,7 +1316,13 @@ static void mdr_resolve(const void *index, void *state, int run_file, const CBMD } char buf[MDR_PATH_CAP]; mdr_ref_t r = {0}; - switch (link->syntax) { + if (link->syntax == CBM_DOCLINK_OPENAPI_OPERATION_ID && !strchr(link->raw, '.')) { + mdr_resolve_unique_callable(graph, link->raw, out); + return; + } + /* a diagram's link reads as a link, an operationId as a code name */ + int twin = cbm_doclink_md_twin(link->syntax); + switch (twin != CBM_DOCLINK_NONE ? twin : link->syntax) { case CBM_DOCLINK_MD_SUPERSEDES: case CBM_DOCLINK_MD_SUPERSEDES_PROSE: mdr_resolve_adr(x, graph, doc, link->raw, out); @@ -1586,8 +1622,10 @@ bool cbm_doclink_md_test_path(const char *path) { } const cbm_doclink_resolver_t cbm_doclink_md_resolver = { - .langs = {CBM_LANG_MARKDOWN}, - .lang_count = 1, + /* + the links of diagram files and the operationIds of API documents */ + .langs = {CBM_LANG_MARKDOWN, CBM_LANG_MERMAID, CBM_LANG_DIAGRAM, CBM_LANG_OPENAPI, + CBM_LANG_JSON}, + .lang_count = 5, .scope_tag = NULL, .build = mdr_build, .destroy = mdr_destroy, diff --git a/src/pipeline/doc_links_perl.c b/src/pipeline/doc_links_perl.c new file mode 100644 index 000000000..27ba5a9eb --- /dev/null +++ b/src/pipeline/doc_links_perl.c @@ -0,0 +1,504 @@ +/* + * doc_links_perl.c — resolving Perl POD L<> references (doclink_perl.c). + * + * A page is a module. L binds the module whose file declares + * `package Mojo::Base;` (the scope blob's P records). When several files + * declare it, the one the library layout names (…/Mojo/Base.pm) is the + * module; more than one such file, or none among several, is ambiguous. A page + * the repository does not declare (CPAN, the Perl core, a .pod page: + * discovery keeps no .pod file, so nothing can say it is in the repository) + * is external, and so is a man page (L). + * + * The target of a module is its File node: a POD page is the file it is + * written in (the field test's audited rule). Its Module node would do for + * most, but not where a same-named Folder holds the module's qualified name + * (lib/Mojo.pm next to lib/Mojo/). + * + * A section, L: when a heading of the module's file reads + * `section` (POD text as it renders, cbm_doclink_perl_plain_text) and names a + * sub that has a definition in that file, the target is the sub; headings + * that name two subs are ambiguous. A section that names no sub is a place in + * the module's documentation: the target is the module. L is the + * same page: a sub its heading names binds; a heading that names no sub is a + * place in the source's own page, no reference worth an edge or a row + * (local); a section the page has no heading for is missing (doc rot). + * + * Test code (t/, xt/, *.t, and cbm_is_test_file) is bound only from test code. + * Every edge is exact: a module name and a heading are spelled out in full. + * + * The scope blob carries no lines (doclink_perl.c). A dropped sub is repaired + * file by file (pl_scope_delta); a change that adds a sub, or adds or drops a + * package or a heading, is a full re-resolution; an edit inside a sub changes + * no blob. + */ +#include "pipeline/doc_links.h" +#include "pipeline/pipeline_internal.h" /* cbm_pipeline_file_node */ +#include "cbm.h" +#include "doclink.h" +#include "helpers.h" /* cbm_fqn_module_source_lang, cbm_is_test_file */ +#include "foundation/arena.h" +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/log.h" +#include "foundation/mem_core.h" +#include "graph_buffer/graph_buffer.h" + +#include +#include +#include +#include +#include +#include + +/* ── The index ───────────────────────────────────────────────────── */ + +typedef struct { + const char *sub; /* the sub the heading names; NULL: none */ + const char *key; /* the heading as it renders */ +} pr_head_t; + +typedef struct { + const char *path; /* rel_path */ + const char *module_qn; /* the file's module qualified name */ + bool test; + int nsubs; + const char **subs; /* subs with a definition in the file */ + int nheads; + pr_head_t *heads; +} pr_file_t; + +typedef struct { + const char *name; + int file; +} pr_decl_t; + +typedef struct { + int first; /* into decls */ + int count; +} pr_range_t; + +typedef struct { + CBMArena arena; + const char *project; + pr_file_t *files; + int nfiles; + int *run_to_file; /* run_file -> files index, -1: none */ + int run_count; + pr_decl_t *decls; /* every package declaration, by name, then path */ + int ndecls; + CBMHashTable *pkg; /* package name -> pr_range_t */ + bool oom; +} pr_index_t; + +static bool pr_test_path(const char *path) { + if (cbm_is_test_file(path, CBM_LANG_PERL)) { + return true; + } + size_t n = strlen(path); + if (n >= PAIR_LEN && strcmp(path + n - PAIR_LEN, ".t") == 0) { + return true; + } + return strncmp(path, "t/", PAIR_LEN) == 0 || strncmp(path, "xt/", CBM_SZ_3) == 0 || + strstr(path, "/t/") != NULL || strstr(path, "/xt/") != NULL; +} + +/* One record line of a blob: its fields, split at tabs (at most `cap`). */ +static int pr_fields(const char *b, const char *e, const char **f, size_t *fl, int cap) { + int n = 0; + const char *s = b; + for (const char *p = b; p <= e && n < cap; p++) { + if (p == e || *p == '\t') { + f[n] = s; + fl[n] = (size_t)(p - s); + n++; + s = p + SKIP_ONE; + } + } + return n; +} + +enum { PR_MAX_FIELDS = 4, PR_F_TAG = 0, PR_F_A = 1, PR_F_B = 2 }; + +static const char *pr_copy(pr_index_t *ix, const char *s, size_t n) { + char *c = cbm_arena_strndup(&ix->arena, s, n); + ix->oom = ix->oom || !c; + return c; +} + +/* Count, then fill: the records of one file's blob. */ +static void pr_read_blob(pr_index_t *ix, int fi, const char *blob, bool fill) { + pr_file_t *f = &ix->files[fi]; + int np = 0; + int ns = 0; + int nh = 0; + const char *line = strchr(blob, '\n'); /* the tag line */ + while (line && !ix->oom) { + const char *b = line + SKIP_ONE; + const char *e = strchr(b, '\n'); + if (!e) { + e = b + strlen(b); + } + const char *fld[PR_MAX_FIELDS]; + size_t fln[PR_MAX_FIELDS]; + int n = pr_fields(b, e, fld, fln, PR_MAX_FIELDS); + if (n >= PAIR_LEN && fln[PR_F_TAG] == SKIP_ONE) { + char tag = fld[PR_F_TAG][0]; + if (tag == 'P') { + if (fill) { + ix->decls[ix->ndecls++] = + (pr_decl_t){pr_copy(ix, fld[PR_F_A], fln[PR_F_A]), fi}; + } + np++; + } else if (tag == 'S' && n >= CBM_SZ_3) { + if (fill) { + f->subs[f->nsubs++] = pr_copy(ix, fld[PR_F_B], fln[PR_F_B]); + } + ns++; + } else if (tag == 'H' && n >= CBM_SZ_3) { + if (fill) { + bool none = fln[PR_F_A] == SKIP_ONE && fld[PR_F_A][0] == '-'; + f->heads[f->nheads++] = (pr_head_t){ + none ? NULL : pr_copy(ix, fld[PR_F_A], fln[PR_F_A]), + pr_copy(ix, fld[PR_F_B], fln[PR_F_B]), + }; + } + nh++; + } + } + line = *e ? e : NULL; + } + if (!fill) { + ix->ndecls += np; + f->subs = + ns ? (const char **)cbm_arena_alloc(&ix->arena, (size_t)ns * sizeof(char *)) : NULL; + f->heads = + nh ? (pr_head_t *)cbm_arena_alloc(&ix->arena, (size_t)nh * sizeof(pr_head_t)) : NULL; + ix->oom = ix->oom || (ns && !f->subs) || (nh && !f->heads); + } +} + +static int pr_decl_cmp(const void *a, const void *b) { + const pr_decl_t *x = (const pr_decl_t *)a; + const pr_decl_t *y = (const pr_decl_t *)b; + int c = strcmp(x->name, y->name); + return c != 0 ? c : (x->file > y->file) - (x->file < y->file); +} + +static void pr_destroy(void *index) { + pr_index_t *ix = (pr_index_t *)index; + if (!ix) { + return; + } + cbm_ht_free(ix->pkg); + cbm_free(CBM_MEM_CLASS_OTHER, ix->run_to_file); + cbm_arena_destroy(&ix->arena); + cbm_free(CBM_MEM_CLASS_OTHER, ix); +} + +static bool pr_index_files(pr_index_t *ix, const cbm_doclink_build_in_t *in) { + ix->nfiles = in->file_count; + ix->files = + in->file_count + ? (pr_file_t *)cbm_arena_calloc(&ix->arena, (size_t)in->file_count * sizeof(pr_file_t)) + : NULL; + ix->run_count = in->run_file_count; + ix->run_to_file = (int *)cbm_calloc(CBM_MEM_CLASS_OTHER, + (size_t)(in->run_file_count + SKIP_ONE) * sizeof(int)); + if ((in->file_count && !ix->files) || !ix->run_to_file) { + return false; + } + for (int r = 0; r < in->run_file_count; r++) { + ix->run_to_file[r] = CBM_NOT_FOUND; + } + for (int i = 0; i < in->file_count && !ix->oom; i++) { + const cbm_doclink_file_t *src = &in->files[i]; + pr_file_t *f = &ix->files[i]; + f->path = src->rel_path; /* valid until destroy */ + f->module_qn = + cbm_fqn_module_source_lang(&ix->arena, ix->project, src->rel_path, CBM_LANG_PERL); + f->test = pr_test_path(src->rel_path); + ix->oom = ix->oom || !f->module_qn; + if (src->run_file >= 0 && src->run_file < in->run_file_count) { + ix->run_to_file[src->run_file] = i; + } + if (src->scope) { + pr_read_blob(ix, i, src->scope, false); + } + } + return !ix->oom; +} + +static bool pr_index_packages(pr_index_t *ix, const cbm_doclink_build_in_t *in) { + int total = ix->ndecls; + ix->ndecls = 0; + ix->decls = + total ? (pr_decl_t *)cbm_arena_alloc(&ix->arena, (size_t)total * sizeof(pr_decl_t)) : NULL; + if (total && !ix->decls) { + return false; + } + for (int i = 0; i < in->file_count && !ix->oom; i++) { + if (in->files[i].scope) { + pr_read_blob(ix, i, in->files[i].scope, true); + } + } + if (ix->oom) { + return false; + } + qsort(ix->decls, (size_t)ix->ndecls, sizeof(pr_decl_t), pr_decl_cmp); + ix->pkg = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(ix->ndecls + SKIP_ONE)); + if (!ix->pkg) { + return false; + } + for (int i = 0; i < ix->ndecls;) { + int j = i; + while (j < ix->ndecls && strcmp(ix->decls[j].name, ix->decls[i].name) == 0) { + j++; + } + pr_range_t *r = (pr_range_t *)cbm_arena_alloc(&ix->arena, sizeof(pr_range_t)); + if (!r) { + return false; + } + *r = (pr_range_t){i, j - i}; + cbm_ht_set(ix->pkg, ix->decls[i].name, r); + i = j; + } + return true; +} + +static void *pr_build(const cbm_doclink_build_in_t *in) { + pr_index_t *ix = (pr_index_t *)cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*ix)); + if (!ix) { + return NULL; + } + cbm_arena_init(&ix->arena); + ix->project = in->ctx->project_name; + if (!pr_index_files(ix, in) || !pr_index_packages(ix, in)) { + cbm_log_error("doc_links.perl.error", "step", "index", "reason", "alloc"); + pr_destroy(ix); + return NULL; + } + char b[PAIR_LEN][CBM_SZ_32]; + snprintf(b[0], sizeof(b[0]), "%d", ix->nfiles); + snprintf(b[SKIP_ONE], sizeof(b[SKIP_ONE]), "%u", cbm_ht_count(ix->pkg)); + cbm_log_info("doc_links.perl.index", "files", b[0], "packages", b[SKIP_ONE]); + return ix; +} + +/* ── Resolution ──────────────────────────────────────────────────── */ + +typedef struct { + const pr_index_t *ix; + const cbm_gbuf_t *g; + const pr_file_t *src; + cbm_doclink_outcome_t *out; +} pr_ctx_t; + +static void pr_row(const pr_ctx_t *c, int reason) { + c->out->kind = CBM_DOCLINK_UNRESOLVED; + c->out->reason = reason; +} + +static void pr_edge(const pr_ctx_t *c, const pr_file_t *f, const cbm_gbuf_node_t *node) { + if (!node) { + pr_row(c, CBM_DOCLINK_REASON_GRAPH_GAP); + return; + } + if (f->test && !c->src->test) { + pr_row(c, CBM_DOCLINK_REASON_TEST_ONLY); + return; + } + c->out->kind = CBM_DOCLINK_EDGE; + c->out->target = node; + c->out->exact = true; +} + +static bool pr_node_is(const cbm_gbuf_node_t *n, const pr_file_t *f, const char *label) { + return n && n->label && n->file_path && strcmp(n->label, label) == 0 && + strcmp(n->file_path, f->path) == 0; +} + +/* The module's node: its File node. */ +static const cbm_gbuf_node_t *pr_module_node(const pr_ctx_t *c, const pr_file_t *f) { + return cbm_pipeline_file_node(c->g, c->ix->project, f->path); +} + +/* The node of sub `name` of file f; NULL when it has none of its own. */ +static const cbm_gbuf_node_t *pr_sub_node(const pr_ctx_t *c, const pr_file_t *f, const char *name) { + size_t ql = strlen(f->module_qn); + size_t nl = strlen(name); + char stack[CBM_SZ_512]; + char *qn = ql + nl + PAIR_LEN <= sizeof(stack) + ? stack + : (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, ql + nl + PAIR_LEN); + if (!qn) { + return NULL; + } + memcpy(qn, f->module_qn, ql); + qn[ql] = '.'; + memcpy(qn + ql + SKIP_ONE, name, nl + SKIP_ONE); + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(c->g, qn); + if (qn != stack) { + cbm_free(CBM_MEM_CLASS_OTHER, qn); + } + return (pr_node_is(n, f, "Function") || pr_node_is(n, f, "Method")) ? n : NULL; +} + +static bool pr_has_sub(const pr_file_t *f, const char *name) { + for (int i = 0; i < f->nsubs; i++) { + if (strcmp(f->subs[i], name) == 0) { + return true; + } + } + return false; +} + +/* The one sub a heading `key` of file f names (it has a definition there): + * 1 found, 0 none, 2 several. */ +static int pr_heading_sub(const pr_file_t *f, const char *key, const char **sub) { + *sub = NULL; + for (int i = 0; i < f->nheads; i++) { + const pr_head_t *h = &f->heads[i]; + if (!h->sub || strcmp(h->key, key) != 0 || !pr_has_sub(f, h->sub)) { + continue; + } + if (*sub && strcmp(*sub, h->sub) != 0) { + return PAIR_LEN; + } + *sub = h->sub; + } + return *sub ? SKIP_ONE : 0; +} + +static bool pr_has_heading(const pr_file_t *f, const char *key) { + for (int i = 0; i < f->nheads; i++) { + if (strcmp(f->heads[i].key, key) == 0) { + return true; + } + } + return false; +} + +static void pr_section(const pr_ctx_t *c, const pr_file_t *f, const char *section, bool same_page) { + char key[CBM_DOCLINK_PERL_KEY_MAX]; + cbm_doclink_perl_plain_text(section, strlen(section), key, sizeof(key)); + const char *sub = NULL; + int found = pr_heading_sub(f, key, &sub); + if (found > SKIP_ONE) { + pr_row(c, CBM_DOCLINK_REASON_AMBIGUOUS); + } else if (found == SKIP_ONE) { + pr_edge(c, f, pr_sub_node(c, f, sub)); + } else if (same_page && pr_has_heading(f, key)) { + c->out->kind = CBM_DOCLINK_LOCAL; /* a place in the source's own page */ + } else if (same_page) { + pr_row(c, CBM_DOCLINK_REASON_MISSING); /* a section the page does not have */ + } else { + pr_edge(c, f, pr_module_node(c, f)); + } +} + +/* True when `path` is the file the library layout gives module `page`. */ +static bool pr_layout_names(const char *path, const char *page) { + char want[CBM_DOCLINK_PERL_KEY_MAX + CBM_SZ_8]; + size_t w = 0; + for (const char *p = page; *p && w + CBM_SZ_4 < sizeof(want); p++) { + if (p[0] == ':' && p[SKIP_ONE] == ':') { + want[w++] = '/'; + p++; + } else { + want[w++] = *p; + } + } + memcpy(want + w, ".pm", CBM_SZ_4); + w += CBM_SZ_3; + size_t n = strlen(path); + return n >= w && strcmp(path + n - w, want) == 0 && (n == w || path[n - w - SKIP_ONE] == '/'); +} + +/* The file of module `page`: NULL with *ambiguous false when the repository + * declares no such package. */ +static const pr_file_t *pr_page_file(const pr_index_t *ix, const char *page, bool *ambiguous) { + *ambiguous = false; + const pr_range_t *r = (const pr_range_t *)cbm_ht_get(ix->pkg, page); + if (!r || r->count == 0) { + return NULL; + } + if (r->count == SKIP_ONE) { + return &ix->files[ix->decls[r->first].file]; + } + const pr_file_t *hit = NULL; + for (int i = r->first; i < r->first + r->count; i++) { + const pr_file_t *f = &ix->files[ix->decls[i].file]; + if (f == hit || !pr_layout_names(f->path, page)) { + continue; + } + if (hit) { + *ambiguous = true; + return NULL; + } + hit = f; + } + *ambiguous = hit == NULL; + return hit; +} + +static void pr_resolve(const void *index, void *state, int run_file, const CBMDocLink *link, + const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out) { + (void)state; /* Perl keeps no per-file working state */ + const pr_index_t *ix = (const pr_index_t *)index; + out->kind = CBM_DOCLINK_UNRESOLVED; + out->reason = CBM_DOCLINK_REASON_MISSING; + out->target = NULL; + out->exact = false; + if (!ix || !link->raw || run_file < 0 || run_file >= ix->run_count || + ix->run_to_file[run_file] < 0) { + return; + } + pr_ctx_t c = {.ix = ix, .g = graph, .src = &ix->files[ix->run_to_file[run_file]], .out = out}; + CBMPerlLink pl; + if (!cbm_doclink_perl_parse_link(link->raw, &pl)) { + pr_row(&c, CBM_DOCLINK_REASON_UNPARSEABLE); + return; + } + if (pl.kind == CBM_PERL_LINK_MAN) { + pr_row(&c, CBM_DOCLINK_REASON_EXTERNAL); + return; + } + if (pl.kind == CBM_PERL_LINK_SECTION) { + pr_section(&c, c.src, pl.section, true); + return; + } + bool ambiguous = false; + const pr_file_t *f = pr_page_file(ix, pl.page, &ambiguous); + if (!f) { + pr_row(&c, ambiguous ? CBM_DOCLINK_REASON_AMBIGUOUS : CBM_DOCLINK_REASON_EXTERNAL); + return; + } + if (pl.section[0]) { + pr_section(&c, f, pl.section, false); + return; + } + pr_edge(&c, f, pr_module_node(&c, f)); +} + +/* A sub (S pkg name) declares one name. A package (its index places every + * sub after it), a heading (a section is found by its text) and the + * parse-error mark change how others resolve: GLOBAL. */ +static bool pl_removable(const char *line, size_t len, const char **name, size_t *nlen) { + return len > PAIR_LEN && line[0] == 'S' && line[1] == '\t' && + cbm_doclinks_scope_field(line, len, PAIR_LEN, name, nlen); +} + +static int pl_scope_delta(const char *stored, const char *fresh, cbm_doclink_name_fn removed, + void *ud) { + return cbm_doclinks_scope_delta_lines(stored, fresh, pl_removable, removed, ud); +} + +const cbm_doclink_resolver_t cbm_doclink_perl_resolver = { + .langs = {CBM_LANG_PERL}, + .lang_count = 1, + .scope_tag = CBM_DOCLINK_PERL_SCOPE_TAG, + .build = pr_build, + .destroy = pr_destroy, + .resolve = pr_resolve, + .scope_delta = pl_scope_delta, +}; diff --git a/src/pipeline/doc_links_php.c b/src/pipeline/doc_links_php.c new file mode 100644 index 000000000..11e256c21 --- /dev/null +++ b/src/pipeline/doc_links_php.c @@ -0,0 +1,994 @@ +/* + * doc_links_php.c — resolving PHP doc references (doclink_php.c). A reference + * is a FQSEN as phpDocumentor reads it, resolved with PHP's own name rules. + * + * Forms: `\Ns\Type`, `Type`, `Type::method()`, `Type::$property`, + * `Type::CONSTANT` (an enum case too), `function()` and `\Ns\function()`; + * `self::`, `static::` and `parent::` with a member. A member written without + * `()` and without `$` is a constant or enum case, else a method. + * + * Names. A leading backslash is taken as written. Otherwise the first segment + * of a type name is replaced when it is the alias of a class `use` of the + * namespace block the reference is written in (aliases compare without case), + * else the block's namespace is put in front: phpDocumentor's rule. Two rules + * go beyond that tool, as PHP itself has them: an unqualified function name + * is taken from a `use function` import, else from the current namespace, + * else from the global namespace; and `self` and `static` name the type the + * doc sits in, `parent` that type's superclass. Beyond both, as authors + * write it: a bare `name()` in the doc of a type (or of its members) that + * declares or inherits a method of that name is that method; a `use + * function` import of the name still wins. Type, function + * and method names compare without case, properties and constants with it. + * The global fallback cannot see a function a dependency declares in the + * current namespace: a reference to one binds the global function of that + * name (a residual). + * + * Members are looked up on the type, then on the traits it uses, its + * superclass and its interfaces, each of them the same way (depth first; the + * first declaration found wins). Nothing found where a supertype is outside + * the repository is external; nothing found with every supertype in the + * repository is missing. + * + * Outcomes. A type, function or method binds to its graph node. A property, + * constant or enum case has none (a graph gap), nor has a type or function + * the graph folds into another one of the same name in its file (the scope + * flags those). A type or qualified function the repository does not declare + * is external when the first segment of its name is no namespace the + * repository declares (PHP's built-ins, Composer dependencies), else missing; + * an unqualified function nobody declares is external (PHP's built-in + * functions). Two declarations of one name are ambiguous unless one is in the + * reference's own file. Test code binds only from test code. Edges are exact + * for a name written fully qualified or bound by a use import, unique + * otherwise. + */ +#include "pipeline/doc_links.h" +#include "cbm.h" +#include "doclink.h" +#include "helpers.h" /* cbm_fqn_module_source_lang, cbm_is_test_file */ +#include "foundation/arena.h" +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/log.h" +#include "foundation/mem_core.h" +#include "graph_buffer/graph_buffer.h" + +#include +#include +#include +#include +#include +#include +#include + +enum { + PR_QN_MAX = 2048, + PR_NAME_MAX = 1024, + PR_FIELDS = 6, + PR_MAX_DEPTH = 16, + PR_VISIT_MAX = 64, + PR_USE_FIELDS = 5, /* U blk kind alias name */ + PR_LINE_FIELDS = 4, /* N blk name first last / T kind fqn flags first last: lines from here */ +}; + +/* ── The index ───────────────────────────────────────────────────── */ + +typedef struct { + char kind; /* m p c e */ + const char *name; +} pr_member_t; + +typedef struct { + char rel; /* e i t */ + const char *fqn; +} pr_base_t; + +typedef struct { + int file; + char kind; /* c i t e */ + bool dup; /* folded into another declaration of its file */ + const char *fqn; + const char *name; /* the tail of fqn */ + uint32_t first; /* lines: only in a fresh blob */ + uint32_t last; + pr_base_t *bases; + int nbases; + int cap_bases; + pr_member_t *members; + int nmembers; + int cap_members; + int next; /* the next type of the same name (without case), or -1 */ +} pr_type_t; + +typedef struct { + int file; + bool dup; + const char *fqn; + const char *name; + int next; +} pr_func_t; + +typedef struct { + int blk; + const char *name; + uint32_t first; + uint32_t last; +} pr_ns_t; + +typedef struct { + const char *path; + const char *module_qn; + bool test; + pr_ns_t *nss; + int nnss; + int cap_nss; + cbm_doclink_php_use_t *uses; /* only in a fresh blob; a block's are adjacent */ + int *use_blk; + int nuses; + int cap_uses; + int cap_use_blk; + int type_first; /* its types: types[type_first, type_first + type_count) */ + int type_count; +} pr_file_t; + +typedef struct { + CBMArena arena; + const char *project; + pr_file_t *files; + int nfiles; + int *run_to_file; + int run_count; + pr_type_t *types; + int ntypes; + int cap_types; + pr_func_t *funcs; + int nfuncs; + int cap_funcs; + CBMHashTable *type_at; /* lowercase fqn -> the first type's index + 1 */ + CBMHashTable *func_at; /* lowercase fqn -> the first function's index + 1 */ + CBMHashTable *roots; /* lowercase first segment of a declared namespace */ + CBMHashTable *by_path; /* rel_path -> file index + 1 */ + bool oom; +} pr_index_t; + +static const char *pr_copy(pr_index_t *ix, const char *s, size_t n) { + char *c = cbm_arena_strndup(&ix->arena, s, n); + ix->oom = ix->oom || !c; + return c; +} + +static const char *pr_lower(pr_index_t *ix, const char *s, size_t n) { + char *c = cbm_arena_strndup(&ix->arena, s, n); + if (!c) { + ix->oom = true; + return NULL; + } + for (size_t i = 0; i < n; i++) { + c[i] = (char)tolower((unsigned char)c[i]); + } + return c; +} + +/* Room for one more item in a growing arena array. */ +static bool pr_grow(pr_index_t *ix, void **items, int count, int *cap, size_t size) { + if (count < *cap) { + return true; + } + int ncap = *cap ? *cap * PAIR_LEN : CBM_SZ_8; + void *g = cbm_arena_alloc(&ix->arena, (size_t)ncap * size); + if (!g) { + ix->oom = true; + return false; + } + if (count) { + memcpy(g, *items, (size_t)count * size); + } + *items = g; + *cap = ncap; + return true; +} + +static bool pr_dash(const char *s, size_t n) { + return n == SKIP_ONE && s[0] == '-'; +} + +static uint32_t pr_num(const char *s) { + return (uint32_t)strtoul(s, NULL, CBM_DECIMAL_BASE); +} + +static pr_type_t *pr_last_type(pr_index_t *ix, const pr_file_t *f) { + return f->type_count ? &ix->types[f->type_first + f->type_count - SKIP_ONE] : NULL; +} + +static void pr_read_type(pr_index_t *ix, pr_file_t *f, int fi, const char **fl, const size_t *ln, + int n) { + if (!pr_grow(ix, (void **)&ix->types, ix->ntypes, &ix->cap_types, sizeof(pr_type_t))) { + return; + } + if (!f->type_count) { + f->type_first = ix->ntypes; + } + pr_type_t *t = &ix->types[ix->ntypes++]; + f->type_count++; + memset(t, 0, sizeof(*t)); + t->file = fi; + t->kind = fl[1][0]; + t->fqn = pr_copy(ix, fl[PAIR_LEN], ln[PAIR_LEN]); + t->dup = fl[CBM_SZ_3][0] == 'd'; + t->next = CBM_NOT_FOUND; + if (n > PR_LINE_FIELDS) { + t->first = pr_num(fl[PR_LINE_FIELDS]); + t->last = n > PR_LINE_FIELDS + SKIP_ONE ? pr_num(fl[PR_LINE_FIELDS + SKIP_ONE]) : 0; + } + if (t->fqn) { + const char *bs = strrchr(t->fqn, '\\'); + t->name = bs ? bs + SKIP_ONE : t->fqn; + } +} + +static void pr_read_record(pr_index_t *ix, pr_file_t *f, int fi, const char **fl, const size_t *ln, + int n) { + char tag = fl[0][0]; + pr_type_t *t = NULL; + if (tag == 'N' && n >= CBM_SZ_3 && + pr_grow(ix, (void **)&f->nss, f->nnss, &f->cap_nss, sizeof(pr_ns_t))) { + pr_ns_t *s = &f->nss[f->nnss++]; + s->blk = (int)pr_num(fl[1]); + s->name = + pr_dash(fl[PAIR_LEN], ln[PAIR_LEN]) ? "" : pr_copy(ix, fl[PAIR_LEN], ln[PAIR_LEN]); + s->first = n > CBM_SZ_3 ? pr_num(fl[CBM_SZ_3]) : 0; + s->last = n > CBM_SZ_4 ? pr_num(fl[CBM_SZ_4]) : 0; + } else if (tag == 'U' && n >= PR_USE_FIELDS && + pr_grow(ix, (void **)&f->uses, f->nuses, &f->cap_uses, + sizeof(cbm_doclink_php_use_t)) && + pr_grow(ix, (void **)&f->use_blk, f->nuses, &f->cap_use_blk, sizeof(int))) { + cbm_doclink_php_use_t *u = &f->uses[f->nuses]; + f->use_blk[f->nuses++] = (int)pr_num(fl[1]); + u->kind = fl[PAIR_LEN][0]; + u->alias = pr_copy(ix, fl[CBM_SZ_3], ln[CBM_SZ_3]); + u->alias_len = ln[CBM_SZ_3]; + u->name = pr_copy(ix, fl[CBM_SZ_4], ln[CBM_SZ_4]); + u->name_len = ln[CBM_SZ_4]; + } else if (tag == 'T' && n >= CBM_SZ_4) { + pr_read_type(ix, f, fi, fl, ln, n); + } else if (tag == 'B' && n >= CBM_SZ_3 && (t = pr_last_type(ix, f)) && + pr_grow(ix, (void **)&t->bases, t->nbases, &t->cap_bases, sizeof(pr_base_t))) { + t->bases[t->nbases++] = (pr_base_t){fl[1][0], pr_copy(ix, fl[PAIR_LEN], ln[PAIR_LEN])}; + } else if (tag == 'K' && n >= CBM_SZ_3 && (t = pr_last_type(ix, f)) && + pr_grow(ix, (void **)&t->members, t->nmembers, &t->cap_members, + sizeof(pr_member_t))) { + t->members[t->nmembers++] = + (pr_member_t){fl[1][0], pr_copy(ix, fl[PAIR_LEN], ln[PAIR_LEN])}; + } else if (tag == 'F' && n >= CBM_SZ_3 && + pr_grow(ix, (void **)&ix->funcs, ix->nfuncs, &ix->cap_funcs, sizeof(pr_func_t))) { + pr_func_t *fn = &ix->funcs[ix->nfuncs++]; + fn->file = fi; + fn->fqn = pr_copy(ix, fl[1], ln[1]); + fn->dup = fl[PAIR_LEN][0] == 'd'; + fn->next = CBM_NOT_FOUND; + const char *bs = fn->fqn ? strrchr(fn->fqn, '\\') : NULL; + fn->name = bs ? bs + SKIP_ONE : fn->fqn; + } +} + +static void pr_read_blob(pr_index_t *ix, pr_file_t *f, int fi, const char *blob) { + const char *line = strchr(blob, '\n'); /* the tag line */ + while (line && !ix->oom) { + const char *b = line + SKIP_ONE; + const char *e = strchr(b, '\n'); + if (!e) { + e = b + strlen(b); + } + const char *fl[PR_FIELDS]; + size_t ln[PR_FIELDS]; + int n = 0; + const char *s = b; + for (const char *p = b; p <= e && n < PR_FIELDS; p++) { + if (p == e || *p == '\t') { + fl[n] = s; + ln[n] = (size_t)(p - s); + n++; + s = p + SKIP_ONE; + } + } + if (n > 0 && ln[0] == SKIP_ONE) { + pr_read_record(ix, f, fi, fl, ln, n); + } + line = *e ? e : NULL; + } +} + +static void pr_destroy(void *index) { + pr_index_t *ix = (pr_index_t *)index; + if (!ix) { + return; + } + cbm_ht_free(ix->type_at); + cbm_ht_free(ix->func_at); + cbm_ht_free(ix->roots); + cbm_ht_free(ix->by_path); + cbm_free(CBM_MEM_CLASS_OTHER, ix->run_to_file); + cbm_arena_destroy(&ix->arena); + cbm_free(CBM_MEM_CLASS_OTHER, ix); +} + +/* The namespace root of a qualified name ("" for a global one). */ +static void pr_add_root(pr_index_t *ix, const char *name) { + const char *bs = name ? strchr(name, '\\') : NULL; + size_t n = bs ? (size_t)(bs - name) : (name ? strlen(name) : 0); + if (!n) { + return; + } + const char *key = pr_lower(ix, name, n); + if (key && !cbm_ht_get(ix->roots, key)) { + cbm_ht_set(ix->roots, key, (void *)(uintptr_t)SKIP_ONE); + } +} + +/* Chain every type and function into its name's list, and collect the + * namespace roots. */ +static bool pr_link(pr_index_t *ix) { + ix->type_at = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(ix->ntypes + SKIP_ONE)); + ix->func_at = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(ix->nfuncs + SKIP_ONE)); + ix->roots = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_64); + if (!ix->type_at || !ix->func_at || !ix->roots) { + return false; + } + for (int i = 0; i < ix->ntypes && !ix->oom; i++) { + pr_type_t *t = &ix->types[i]; + const char *key = t->fqn ? pr_lower(ix, t->fqn, strlen(t->fqn)) : NULL; + if (!key) { + continue; + } + void *prev = cbm_ht_get(ix->type_at, key); + t->next = prev ? (int)((uintptr_t)prev - SKIP_ONE) : CBM_NOT_FOUND; + cbm_ht_set(ix->type_at, key, (void *)(uintptr_t)(i + SKIP_ONE)); + if (strchr(t->fqn, '\\')) { + pr_add_root(ix, t->fqn); + } + } + for (int i = 0; i < ix->nfuncs && !ix->oom; i++) { + pr_func_t *fn = &ix->funcs[i]; + const char *key = fn->fqn ? pr_lower(ix, fn->fqn, strlen(fn->fqn)) : NULL; + if (!key) { + continue; + } + void *prev = cbm_ht_get(ix->func_at, key); + fn->next = prev ? (int)((uintptr_t)prev - SKIP_ONE) : CBM_NOT_FOUND; + cbm_ht_set(ix->func_at, key, (void *)(uintptr_t)(i + SKIP_ONE)); + if (strchr(fn->fqn, '\\')) { + pr_add_root(ix, fn->fqn); + } + } + for (int i = 0; i < ix->nfiles && !ix->oom; i++) { + for (int k = 0; k < ix->files[i].nnss; k++) { + pr_add_root(ix, ix->files[i].nss[k].name); + } + } + return !ix->oom; +} + +static void *pr_build(const cbm_doclink_build_in_t *in) { + pr_index_t *ix = (pr_index_t *)cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*ix)); + if (!ix) { + return NULL; + } + cbm_arena_init(&ix->arena); + ix->project = in->ctx->project_name; + ix->nfiles = in->file_count; + ix->run_count = in->run_file_count; + ix->files = + in->file_count + ? (pr_file_t *)cbm_arena_calloc(&ix->arena, (size_t)in->file_count * sizeof(pr_file_t)) + : NULL; + ix->run_to_file = (int *)cbm_calloc(CBM_MEM_CLASS_OTHER, + (size_t)(in->run_file_count + SKIP_ONE) * sizeof(int)); + ix->by_path = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(in->file_count + SKIP_ONE)); + bool ok = (!in->file_count || ix->files) && ix->run_to_file && ix->by_path; + for (int r = 0; ok && r < in->run_file_count; r++) { + ix->run_to_file[r] = CBM_NOT_FOUND; + } + for (int i = 0; ok && i < in->file_count && !ix->oom; i++) { + const cbm_doclink_file_t *src = &in->files[i]; + pr_file_t *f = &ix->files[i]; + f->path = src->rel_path; + f->module_qn = + cbm_fqn_module_source_lang(&ix->arena, ix->project, src->rel_path, CBM_LANG_PHP); + f->test = cbm_is_test_file(src->rel_path, CBM_LANG_PHP); + ix->oom = ix->oom || !f->module_qn; + cbm_ht_set(ix->by_path, src->rel_path, (void *)(uintptr_t)(i + SKIP_ONE)); + if (src->run_file >= 0 && src->run_file < in->run_file_count) { + ix->run_to_file[src->run_file] = i; + } + if (src->scope) { + pr_read_blob(ix, f, i, src->scope); + } + } + ok = ok && !ix->oom && pr_link(ix); + if (!ok) { + cbm_log_error("doc_links.php.error", "step", "index", "reason", "alloc"); + pr_destroy(ix); + return NULL; + } + char b[CBM_SZ_3][CBM_SZ_32]; + snprintf(b[0], sizeof(b[0]), "%d", ix->nfiles); + snprintf(b[SKIP_ONE], sizeof(b[SKIP_ONE]), "%d", ix->ntypes); + snprintf(b[PAIR_LEN], sizeof(b[PAIR_LEN]), "%d", ix->nfuncs); + cbm_log_info("doc_links.php.index", "files", b[0], "types", b[SKIP_ONE], "functions", + b[PAIR_LEN]); + return ix; +} + +/* ── References ──────────────────────────────────────────────────── */ + +typedef struct { + const char *cls; /* a type or function name, NULL for none */ + size_t cls_len; + const char *mem; /* a member name (no `$`), NULL for none */ + size_t mem_len; + bool prop; /* `::$name` */ + bool call; /* `()` at the end */ +} pr_ref_t; + +static bool pr_ident_start(char c) { + return isalpha((unsigned char)c) || c == '_' || (unsigned char)c >= 0x80; +} + +static bool pr_ident_char(char c) { + return isalnum((unsigned char)c) || c == '_' || (unsigned char)c >= 0x80; +} + +/* The end of an identifier at s[i] (i when there is none). */ +static size_t pr_ident_end(const char *s, size_t i, size_t n) { + if (i >= n || !pr_ident_start(s[i])) { + return i; + } + while (i < n && pr_ident_char(s[i])) { + i++; + } + return i; +} + +/* The end of a name (`\`-joined identifiers, a leading `\` allowed) at s[0]. */ +static size_t pr_name_end(const char *s, size_t n) { + size_t i = (n > 0 && s[0] == '\\') ? SKIP_ONE : 0; + size_t e = pr_ident_end(s, i, n); + if (e == i) { + return 0; + } + while (e + SKIP_ONE < n && s[e] == '\\' && pr_ident_start(s[e + SKIP_ONE])) { + e = pr_ident_end(s, e + SKIP_ONE, n); + } + return e; +} + +static bool pr_parse(const char *s, size_t n, pr_ref_t *r) { + memset(r, 0, sizeof(*r)); + size_t i = pr_name_end(s, n); + if (i == 0) { + return false; /* a member needs its type: `::name` is no FQSEN */ + } + r->cls = s; + r->cls_len = i; + if (i + PAIR_LEN <= n && s[i] == ':' && s[i + SKIP_ONE] == ':') { + i += PAIR_LEN; + if (i < n && s[i] == '$') { + r->prop = true; + i++; + } + size_t e = pr_ident_end(s, i, n); + if (e == i) { + return false; + } + r->mem = s + i; + r->mem_len = e - i; + i = e; + } + if (!r->prop && i + PAIR_LEN == n && s[i] == '(' && s[i + SKIP_ONE] == ')') { + r->call = true; + i = n; + } + return i == n; +} + +/* ── Resolution ──────────────────────────────────────────────────── */ + +typedef enum { + PR_NONE = 0, /* nothing found */ + PR_TYPE, /* a type of the index (`type`) */ + PR_NODE, + PR_GAP, + PR_EXTERNAL, + PR_MISSING, + PR_AMBIGUOUS, +} pr_kind_t; + +typedef struct { + pr_kind_t kind; + int type; + const cbm_gbuf_node_t *node; + bool exact; +} pr_res_t; + +typedef struct { + const pr_index_t *ix; + const cbm_gbuf_t *g; + int src; + const char *ns; /* the reference's namespace block */ + size_t ns_len; + const cbm_doclink_php_use_t *uses; /* that block's imports */ + int nuses; + int cur; /* the type the doc sits in, or -1 */ +} pr_ctx_t; + +static pr_res_t pr_mk(pr_kind_t k) { + return (pr_res_t){k, CBM_NOT_FOUND, NULL, false}; +} + +static bool pr_eq_nocase(const char *a, size_t an, const char *b, size_t bn) { + if (an != bn) { + return false; + } + for (size_t i = 0; i < an; i++) { + if (tolower((unsigned char)a[i]) != tolower((unsigned char)b[i])) { + return false; + } + } + return true; +} + +static void pr_lower_into(char *out, const char *s, size_t n) { + for (size_t i = 0; i < n; i++) { + out[i] = (char)tolower((unsigned char)s[i]); + } + out[n] = '\0'; +} + +/* The namespace block of `line` in file f and its imports. */ +static void pr_block_at(pr_ctx_t *c, const pr_file_t *f, uint32_t line) { + int blk = f->nnss ? CBM_NOT_FOUND : 0; + c->ns = ""; + c->ns_len = 0; + for (int i = 0; i < f->nnss; i++) { + if (line >= f->nss[i].first && line <= f->nss[i].last) { + blk = f->nss[i].blk; + c->ns = f->nss[i].name; + c->ns_len = strlen(c->ns); + break; + } + } + c->uses = NULL; + c->nuses = 0; + for (int i = 0; i < f->nuses; i++) { + if (f->use_blk[i] != blk) { + continue; + } + if (!c->uses) { + c->uses = &f->uses[i]; + } + c->nuses++; + } +} + +/* The type of file f whose lines hold `line`, or -1. */ +static int pr_type_at(const pr_index_t *ix, const pr_file_t *f, uint32_t line) { + for (int i = 0; i < f->type_count; i++) { + const pr_type_t *t = &ix->types[f->type_first + i]; + if (line >= t->first && line <= t->last) { + return f->type_first + i; + } + } + return CBM_NOT_FOUND; +} + +/* The node at `qn` in file `path`, or NULL. */ +static const cbm_gbuf_node_t *pr_node(const pr_ctx_t *c, const char *qn, const char *path) { + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(c->g, qn); + if (!n || !n->label || !n->file_path || strcmp(n->file_path, path) != 0) { + return NULL; + } + return n; +} + +/* A declared type's own node: a gap when the graph folded it or has none. */ +static pr_res_t pr_type_node(const pr_ctx_t *c, int ti) { + const pr_type_t *t = &c->ix->types[ti]; + const pr_file_t *f = &c->ix->files[t->file]; + char qn[PR_QN_MAX]; + int n = snprintf(qn, sizeof(qn), "%s.%s", f->module_qn, t->name); + const cbm_gbuf_node_t *node = + (!t->dup && n > 0 && (size_t)n < sizeof(qn)) ? pr_node(c, qn, f->path) : NULL; + pr_res_t r = pr_mk(node ? PR_NODE : PR_GAP); + r.node = node; + return r; +} + +/* The repository's type named `fqn`: one, several (ambiguous, unless one is + * in the reference's file) or none. */ +static pr_res_t pr_find_type(const pr_ctx_t *c, const char *fqn, size_t len) { + char key[PR_NAME_MAX]; + if (len >= sizeof(key)) { + return pr_mk(PR_NONE); + } + pr_lower_into(key, fqn, len); + void *v = cbm_ht_get(c->ix->type_at, key); + int first = v ? (int)((uintptr_t)v - SKIP_ONE) : CBM_NOT_FOUND; + int count = 0; + int own = CBM_NOT_FOUND; + for (int i = first; i >= 0; i = c->ix->types[i].next) { + count++; + own = c->ix->types[i].file == c->src ? i : own; + } + if (count == 0) { + return pr_mk(PR_NONE); + } + pr_res_t r = pr_mk(count == SKIP_ONE || own >= 0 ? PR_TYPE : PR_AMBIGUOUS); + r.type = count == SKIP_ONE ? first : own; + return r; +} + +/* A name the repository does not declare: missing inside one of its + * namespaces, else external. */ +static pr_res_t pr_absent(const pr_ctx_t *c, const char *fqn, size_t len) { + const char *bs = memchr(fqn, '\\', len); + char key[PR_NAME_MAX]; + size_t rl = bs ? (size_t)(bs - fqn) : 0; + if (!rl || rl >= sizeof(key)) { + return pr_mk(PR_EXTERNAL); + } + pr_lower_into(key, fqn, rl); + return pr_mk(cbm_ht_get(c->ix->roots, key) ? PR_MISSING : PR_EXTERNAL); +} + +/* True when the first segment of `name` is a class-use alias. */ +static bool pr_aliased(const pr_ctx_t *c, const char *name, size_t len) { + const char *bs = memchr(name, '\\', len); + size_t first = bs ? (size_t)(bs - name) : len; + for (int i = 0; i < c->nuses; i++) { + if (c->uses[i].kind == 'c' && + pr_eq_nocase(c->uses[i].alias, c->uses[i].alias_len, name, first)) { + return true; + } + } + return false; +} + +static bool pr_is(const char *s, size_t n, const char *word) { + return pr_eq_nocase(s, n, word, strlen(word)); +} + +/* The superclass of type ti, as a type of the index. */ +static pr_res_t pr_parent(const pr_ctx_t *c, int ti) { + const pr_type_t *t = &c->ix->types[ti]; + for (int i = 0; i < t->nbases; i++) { + if (t->bases[i].rel == 'e' && t->bases[i].fqn) { + pr_res_t r = pr_find_type(c, t->bases[i].fqn, strlen(t->bases[i].fqn)); + return r.kind == PR_NONE ? pr_mk(PR_EXTERNAL) : r; + } + } + return pr_mk(PR_MISSING); +} + +/* The type a reference's type part names. */ +static pr_res_t pr_type_of(const pr_ctx_t *c, const pr_ref_t *r) { + if (pr_is(r->cls, r->cls_len, "self") || pr_is(r->cls, r->cls_len, "static")) { + pr_res_t res = pr_mk(c->cur >= 0 ? PR_TYPE : PR_MISSING); + res.type = c->cur; + return res; + } + if (pr_is(r->cls, r->cls_len, "parent")) { + return c->cur >= 0 ? pr_parent(c, c->cur) : pr_mk(PR_MISSING); + } + char fqn[PR_NAME_MAX]; + size_t fl = cbm_doclink_php_qualify(fqn, sizeof(fqn), c->ns, c->ns_len, c->uses, c->nuses, + r->cls, r->cls_len); + if (!fl) { + return pr_mk(PR_MISSING); + } + pr_res_t res = pr_find_type(c, fqn, fl); + if (res.kind == PR_NONE) { + res = pr_absent(c, fqn, fl); + } + res.exact = r->cls[0] == '\\' || pr_aliased(c, r->cls, r->cls_len); + return res; +} + +/* ── Members across supertypes ───────────────────────────────────── */ + +typedef struct { + const pr_ctx_t *c; + char want; /* 'm' a method, 'p' a property, 'c' a constant or enum case */ + const char *name; + size_t len; + int visited[PR_VISIT_MAX]; + int nvisited; + bool open; /* a supertype outside the repository */ + bool amb; /* a supertype with several declarations */ + int owner; /* found: the declaring type and member */ + const pr_member_t *found; +} pr_search_t; + +static bool pr_member_is(const pr_search_t *s, const pr_member_t *m) { + size_t ml = strlen(m->name); + if (s->want == 'm') { + return m->kind == 'm' && pr_eq_nocase(m->name, ml, s->name, s->len); + } + bool kind = s->want == 'p' ? m->kind == 'p' : (m->kind == 'c' || m->kind == 'e'); + return kind && ml == s->len && memcmp(m->name, s->name, ml) == 0; +} + +static bool pr_seen(pr_search_t *s, int ti) { + for (int i = 0; i < s->nvisited; i++) { + if (s->visited[i] == ti) { + return true; + } + } + if (s->nvisited == PR_VISIT_MAX) { + s->open = true; /* too wide to follow: not every supertype was read */ + return true; + } + s->visited[s->nvisited++] = ti; + return false; +} + +static bool pr_search(pr_search_t *s, int ti, int depth) { + if (depth > PR_MAX_DEPTH) { + s->open = true; + return false; + } + if (pr_seen(s, ti)) { + return false; + } + const pr_type_t *t = &s->c->ix->types[ti]; + for (int i = 0; i < t->nmembers; i++) { + if (pr_member_is(s, &t->members[i])) { + s->owner = ti; + s->found = &t->members[i]; + return true; + } + } + static const char order[] = {'t', 'e', 'i'}; /* traits, superclass, interfaces */ + for (size_t o = 0; o < sizeof(order); o++) { + for (int i = 0; i < t->nbases; i++) { + if (t->bases[i].rel != order[o] || !t->bases[i].fqn) { + continue; + } + pr_res_t r = pr_find_type(s->c, t->bases[i].fqn, strlen(t->bases[i].fqn)); + if (r.kind == PR_TYPE && pr_search(s, r.type, depth + SKIP_ONE)) { + return true; + } + s->amb = s->amb || r.kind == PR_AMBIGUOUS; + s->open = s->open || r.kind == PR_NONE; + } + } + return false; +} + +/* The member's outcome: a method's node, else a graph gap. */ +static pr_res_t pr_member_node(const pr_ctx_t *c, const pr_search_t *s) { + const pr_type_t *t = &c->ix->types[s->owner]; + if (s->found->kind != 'm' || t->dup) { + return pr_mk(PR_GAP); + } + const pr_file_t *f = &c->ix->files[t->file]; + char qn[PR_QN_MAX]; + int n = snprintf(qn, sizeof(qn), "%s.%s.%s", f->module_qn, t->name, s->found->name); + const cbm_gbuf_node_t *node = + (n > 0 && (size_t)n < sizeof(qn)) ? pr_node(c, qn, f->path) : NULL; + pr_res_t r = pr_mk(node ? PR_NODE : PR_GAP); + r.node = node; + return r; +} + +static pr_res_t pr_member(const pr_ctx_t *c, int ti, const pr_ref_t *r) { + pr_search_t s = {.c = c, .name = r->mem, .len = r->mem_len, .owner = CBM_NOT_FOUND}; + s.want = r->prop ? 'p' : r->call ? 'm' : 'c'; + bool hit = pr_search(&s, ti, 0); + if (!hit && s.want == 'c') { + /* `Type::name` without parentheses names a method as often as a + * constant: no constant or case of that name, then a method */ + bool open = s.open; + bool amb = s.amb; + s.want = 'm'; + s.nvisited = 0; + hit = pr_search(&s, ti, 0); + s.open = s.open || open; + s.amb = s.amb || amb; + } + if (hit) { + return pr_member_node(c, &s); + } + return pr_mk(s.amb ? PR_AMBIGUOUS : s.open ? PR_EXTERNAL : PR_MISSING); +} + +/* ── Functions ───────────────────────────────────────────────────── */ + +static pr_res_t pr_find_func(const pr_ctx_t *c, const char *fqn, size_t len) { + char key[PR_NAME_MAX]; + if (len >= sizeof(key)) { + return pr_mk(PR_NONE); + } + pr_lower_into(key, fqn, len); + void *v = cbm_ht_get(c->ix->func_at, key); + int first = v ? (int)((uintptr_t)v - SKIP_ONE) : CBM_NOT_FOUND; + int count = 0; + int own = CBM_NOT_FOUND; + for (int i = first; i >= 0; i = c->ix->funcs[i].next) { + count++; + own = c->ix->funcs[i].file == c->src ? i : own; + } + if (count == 0) { + return pr_mk(PR_NONE); + } + if (count > SKIP_ONE && own < 0) { + return pr_mk(PR_AMBIGUOUS); + } + const pr_func_t *fn = &c->ix->funcs[count == SKIP_ONE ? first : own]; + const pr_file_t *f = &c->ix->files[fn->file]; + char qn[PR_QN_MAX]; + int n = snprintf(qn, sizeof(qn), "%s.%s", f->module_qn, fn->name); + const cbm_gbuf_node_t *node = + (!fn->dup && n > 0 && (size_t)n < sizeof(qn)) ? pr_node(c, qn, f->path) : NULL; + pr_res_t r = pr_mk(node ? PR_NODE : PR_GAP); + r.node = node; + return r; +} + +static pr_res_t pr_function(const pr_ctx_t *c, const pr_ref_t *r) { + char fqn[PR_NAME_MAX]; + if (r->cls[0] == '\\' || memchr(r->cls, '\\', r->cls_len)) { + /* qualified: the type rules (a namespace alias may start it) */ + size_t fl = cbm_doclink_php_qualify(fqn, sizeof(fqn), c->ns, c->ns_len, c->uses, c->nuses, + r->cls, r->cls_len); + pr_res_t res = fl ? pr_find_func(c, fqn, fl) : pr_mk(PR_MISSING); + if (res.kind == PR_NONE) { + res = pr_absent(c, fqn, fl); + } + res.exact = r->cls[0] == '\\' || pr_aliased(c, r->cls, r->cls_len); + return res; + } + for (int i = 0; i < c->nuses; i++) { + const cbm_doclink_php_use_t *u = &c->uses[i]; + if (u->kind == 'f' && pr_eq_nocase(u->alias, u->alias_len, r->cls, r->cls_len)) { + pr_res_t res = pr_find_func(c, u->name, u->name_len); + if (res.kind == PR_NONE) { + res = pr_absent(c, u->name, u->name_len); + } + res.exact = true; + return res; + } + } + if (c->cur >= 0) { + /* a method the documented type declares or inherits */ + pr_search_t s = { + .c = c, .want = 'm', .name = r->cls, .len = r->cls_len, .owner = CBM_NOT_FOUND}; + if (pr_search(&s, c->cur, 0)) { + return pr_member_node(c, &s); + } + } + pr_res_t res = pr_mk(PR_NONE); + if (c->ns_len) { + int n = snprintf(fqn, sizeof(fqn), "%.*s\\%.*s", (int)c->ns_len, c->ns, (int)r->cls_len, + r->cls); + res = (n > 0 && (size_t)n < sizeof(fqn)) ? pr_find_func(c, fqn, (size_t)n) : res; + } + if (res.kind == PR_NONE) { + res = pr_find_func(c, r->cls, r->cls_len); /* PHP's global fallback */ + } + return res.kind == PR_NONE ? pr_mk(PR_EXTERNAL) : res; /* none: a built-in */ +} + +/* ── Outcomes ────────────────────────────────────────────────────── */ + +static void pr_out(const pr_ctx_t *c, pr_res_t r, cbm_doclink_outcome_t *out) { + switch (r.kind) { + case PR_NODE: { + const pr_file_t *src = &c->ix->files[c->src]; + void *v = r.node->file_path ? cbm_ht_get(c->ix->by_path, r.node->file_path) : NULL; + int tf = v ? (int)((uintptr_t)v - SKIP_ONE) : CBM_NOT_FOUND; + if (tf >= 0 && c->ix->files[tf].test && !src->test) { + out->kind = CBM_DOCLINK_UNRESOLVED; + out->reason = CBM_DOCLINK_REASON_TEST_ONLY; + return; + } + out->kind = CBM_DOCLINK_EDGE; + out->target = r.node; + out->exact = r.exact; + return; + } + case PR_EXTERNAL: + out->reason = CBM_DOCLINK_REASON_EXTERNAL; + break; + case PR_AMBIGUOUS: + out->reason = CBM_DOCLINK_REASON_AMBIGUOUS; + break; + case PR_GAP: + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; + break; + default: + out->reason = CBM_DOCLINK_REASON_MISSING; + break; + } + out->kind = CBM_DOCLINK_UNRESOLVED; +} + +static void pr_resolve(const void *index, void *state, int run_file, const CBMDocLink *link, + const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out) { + (void)state; /* PHP keeps no per-file working state */ + const pr_index_t *ix = (const pr_index_t *)index; + out->kind = CBM_DOCLINK_UNRESOLVED; + out->reason = CBM_DOCLINK_REASON_MISSING; + out->target = NULL; + out->exact = false; + if (!ix || !link->raw || run_file < 0 || run_file >= ix->run_count || + ix->run_to_file[run_file] < 0) { + return; + } + pr_ctx_t c = {.ix = ix, .g = graph, .src = ix->run_to_file[run_file]}; + const pr_file_t *f = &ix->files[c.src]; + size_t rl = strlen(link->raw); + pr_ref_t r; + if (rl > CBM_DOCLINK_PHP_REF_MAX || !pr_parse(link->raw, rl, &r)) { + out->reason = CBM_DOCLINK_REASON_UNPARSEABLE; + return; + } + pr_block_at(&c, f, link->line); + c.cur = pr_type_at(ix, f, link->def_line); + pr_res_t res; + if (!r.mem && r.call) { + res = pr_function(&c, &r); + } else if (!r.mem) { + res = pr_type_of(&c, &r); + if (res.kind == PR_TYPE) { + bool exact = res.exact; + res = pr_type_node(&c, res.type); + res.exact = exact; + } + } else { + res = pr_type_of(&c, &r); + if (res.kind == PR_TYPE) { + bool exact = res.exact; + res = pr_member(&c, res.type, &r); + res.exact = exact; + } + } + pr_out(&c, res, out); +} + +/* A top-level function (F fqn flags) or a member of the type above it (K kind + * name) declares one name; reported as a reference spells it, the last + * segment of a qualified name. A type, a supertype, a namespace block or a + * use import changes how others resolve: GLOBAL. */ +static bool pr_removable(const char *line, size_t len, const char **name, size_t *nlen) { + if (len <= PAIR_LEN || line[1] != '\t') { + return false; + } + if (line[0] == 'K') { + return cbm_doclinks_scope_field(line, len, PAIR_LEN, name, nlen); + } + if (line[0] != 'F' || !cbm_doclinks_scope_field(line, len, 1, name, nlen)) { + return false; + } + for (size_t i = *nlen; i > 0; i--) { + if ((*name)[i - SKIP_ONE] == '\\') { + *nlen -= i; + *name += i; + break; + } + } + return true; +} + +static int pr_scope_delta(const char *stored, const char *fresh, cbm_doclink_name_fn removed, + void *ud) { + return cbm_doclinks_scope_delta_lines(stored, fresh, pr_removable, removed, ud); +} + +const cbm_doclink_resolver_t cbm_doclink_php_resolver = { + .langs = {CBM_LANG_PHP}, + .lang_count = SKIP_ONE, + .scope_tag = CBM_DOCLINK_PHP_SCOPE_TAG, + .build = pr_build, + .destroy = pr_destroy, + .resolve = pr_resolve, + .scope_delta = pr_scope_delta, +}; diff --git a/src/pipeline/doc_links_rs.c b/src/pipeline/doc_links_rs.c new file mode 100644 index 000000000..0f6c02460 --- /dev/null +++ b/src/pipeline/doc_links_rs.c @@ -0,0 +1,4447 @@ +/* + * doc_links_rs.c — Rust intra-doc link resolution for doc_links.h. + * + * The index is built from the scope blobs of the repository's Rust files and + * Cargo manifests (internal/cbm/doclink_rs.c); nothing is read from disk. + * Graph nodes are looked up by qualified name only, and a node is taken only + * when its label and file are the declaration's, so the closure-repair route + * (line-less proxy nodes) resolves exactly as a full build does. + * + * Model. + * package a Cargo manifest with [package]: its directory, edition and + * dependencies. A dependency names a crate of the repository + * only through `path` (its own, or the workspace table's) + * crate root a target root file of a package: the lib (src/lib.rs or + * [lib] path), src/main.rs, src/bin/, tests/, benches/, + * examples/, build.rs, and explicit target paths + * module a file's root module or an inline `mod x { }`. A `mod x;` + * attaches the file rustc would read: x.rs or x/mod.rs beside a + * crate root, a mod.rs or a `#[path]` file, under / beside + * any other file, or the `#[path]` file. A file can be attached + * more than once and belong to several crate roots; a file no + * root reaches is an orphan + * binding what a module declares under a name: an item, a child + * module, a named import, a name a macro invocation may declare + * + * Lookup follows rustc's name resolution as rustdoc applies it to intra-doc + * links, one namespace (type, value, macro) at a time: + * first segment `crate` (every crate root of the file), `self`, `super` + * (repeatable), `Self` (the impl's self type, the trait, or + * the documented type), `::name` (extern prelude), else + * 1. the module's own bindings + * 2. its glob imports, through re-exporting globs, only + * names visible to the importing module + * 3. the extern prelude: std, core, alloc, proc_macro, + * test, the package's dependencies, and the package's + * own lib from its other targets + * 4. the std prelude, primitive types, std macros + * The first level with a binding in the namespace decides. A + * level whose content is not known (a glob of an external + * module) is not looked past. + * later segments a module's levels 1 and 2; for a type: enum variant, items + * of its inherent impls, then items of trait impls whose + * trait is in scope of the documenting module, then (value + * namespace) fields; for a trait its own items; type aliases + * are followed + * The candidates of the namespaces are united. One entity binds. A trait and + * a derive macro of one name are the trait (rustdoc's rule). Anything else + * with two or more is ambiguous. A disambiguator or suffix restricts the + * namespace and the kind; an entity of another kind is no match. + * + * Nothing is resolved around what the scan could not place: in a file with a + * parse error (or constructs the blob cannot carry) a name its modules do not + * show is a graph gap, never looked up further out; so are a name a macro + * invocation may declare, an import that cannot be followed, a `mod` whose + * file is not indexed, and `crate::` or `super::` from an orphan file. + */ +#include "pipeline/doc_links.h" + +#include "doclink.h" +#include "helpers.h" /* cbm_fqn_compute, cbm_fqn_module_source_lang, cbm_is_test_file */ +#include "foundation/arena.h" +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/log.h" +#include "foundation/mem_core.h" + +#include +#include +#include +#include +#include + +enum { + RS_NS_TYPE = 1, + RS_NS_VALUE = 2, + RS_NS_MACRO = 4, + RS_NS_ALL = 7, + RS_NS_COUNT = 3, +}; + +static const int RS_NS_LIST[RS_NS_COUNT] = {RS_NS_TYPE, RS_NS_VALUE, RS_NS_MACRO}; + +/* What a path names. */ +enum { + RS_E_NONE = 0, + RS_E_ITEM, /* idx: item */ + RS_E_MOD, /* idx: module */ + RS_E_MEMBER, /* idx: member */ + RS_E_IMPL, /* idx: impl whose self type is no item of the repository (`Self` only) */ + RS_E_EXTERNAL, /* outside the repository */ + RS_E_GAP, /* declared in the repository, but not placed or not followed */ +}; + +enum { + RS_SET_CAP = 8, /* distinct candidates kept; more is ambiguous either way */ + RS_ROOTS_KEPT = 4, /* crate roots kept per file; a file of more has no single crate */ + RS_IMPORT_PASSES = 32, /* an import behind a longer chain of imports is not followed */ + RS_ALIAS_HOPS = 8, /* a chain of type aliases; longer is not followed */ + RS_UP_STEPS = 256, /* module ancestors walked for one question; more is unknown */ + RS_GLOB_VISIT = 64, /* modules reached through globs for one name; more is unknown */ + RS_IMPL_SCAN = 256, /* trait impls of one type searched for a default item */ + RS_QN_BUF = 2048, + RS_PATH_BUF = 1024, + RS_PATH_DEPTH = RS_PATH_BUF / 2, /* inline modules around a `mod x;` */ + RS_FIELDS_MAX = 9, + RS_TAIL = 4, /* path components read from the end */ + RS_EDITION_2015 = 2015, + RS_EDITION_2018 = 2018, + RS_EDITION_UNKNOWN = 0, + RS_EDITION_INHERITED = -1, + RS_EDITION_UNREADABLE = -2, + RS_PKG_NONE = -1, + RS_PKG_CONFLICT = -2, + RS_VIEW_A = 1, /* out-of-line children are read beside the file */ + RS_VIEW_B = 2, /* ... from the directory named like the file */ + RS_RS_EXT = 3, /* strlen(".rs") */ +}; + +enum { RS_FILE_OTHER = 0, RS_FILE_SOURCE, RS_FILE_MANIFEST }; +enum { RS_BIND_ITEM = 0, RS_BIND_CHILD, RS_BIND_IMPORT, RS_BIND_MAYBE }; +enum { RS_STATE_NEW = 0, RS_STATE_DONE }; +enum { + RS_TARGET_LIB = 0, + RS_TARGET_BIN, + RS_TARGET_EXAMPLE, + RS_TARGET_TEST, + RS_TARGET_BENCH, + RS_TARGET_BUILD +}; + +typedef struct { + uint8_t kind; /* RS_E_* */ + bool via_import; /* bound by a named import: an exact naming */ + bool weak; /* the name was not found, but is not known to be absent either (a gap + * or an external glob could hold it): a binding found in another + * namespace overrides it */ + int idx; +} rs_ent_t; + +typedef struct { + rs_ent_t v[RS_SET_CAP]; + int n; + bool overflow; +} rs_set_t; + +/* ── Index data ──────────────────────────────────────────────────── */ + +typedef struct { + int file; + int parent; /* the inline parent module; CBM_NOT_FOUND for a file's root module */ + const char *name; + uint32_t start; /* lines: a re-extracted file's only */ + uint32_t end; + char vis; + bool cfg_test; + bool path_attr; /* an inline module with `#[path]`: where its `mod x;` files are is not known */ + bool product; /* reachable as product code: not test-only */ + int bind_first; + int bind_count; + int glob_first; + int glob_count; + int named_first; + int named_count; + int attach_first; /* a file's root module: the `mod x;` declarations naming the file */ + int attach_count; +} rs_mod_t; + +typedef struct { + int mod; /* the declaring module */ + const char *name; + const char *path; /* the #[path] literal, NULL when there is none */ + char vis; + bool cfg_test; + bool cond_path; /* its file depends on cfg: not followed */ + int child; /* the declared file's root module, CBM_NOT_FOUND when not known */ +} rs_decl_t; + +typedef struct { + int child; + int decl; +} rs_attach_t; + +typedef struct { + int file; + int mod; + const char *name; + char kind; /* S E U R A F C G M V B P */ + char vis; + bool test; + bool macro_export; + const char *derives; /* a type's derive names, comma-joined; NULL when it has none */ + uint32_t start; + uint32_t end; + char label; /* node label code, 0 when the item has no node */ + const char *suffix; /* the node's qualified name after the file's item prefix */ + const char *alias; /* A: the aliased path, NULL when it is no path */ + rs_ent_t alias_ent; + int member_first; + int member_count; + int impl_first; /* into rs_index_t.item_impls */ + int impl_count; +} rs_item_t; + +typedef struct { + int file; + int mod; + int owner; /* the item it belongs to, or -(impl) - 1 */ + bool owner_test; + const char *name; + char kind; /* f c t d v */ + char label; + const char *suffix; + int seq; +} rs_member_t; + +typedef struct { + int file; + int mod; + uint32_t start; + uint32_t end; + const char *self_path; /* NULL when the self type is no path */ + const char *trait_path; /* NULL for an inherent impl, "?" when it is no path */ + rs_ent_t self_ent; + rs_ent_t trait_ent; + int member_first; + int member_count; +} rs_impl_t; + +typedef struct { + int item; + int impl; +} rs_item_impl_t; + +/* An impl's member, by the type the impl is for. */ +typedef struct { + int item; + const char *name; + int impl; + int member; +} rs_assoc_t; + +typedef struct { + int file; + int mod; + char vis; + char kind; /* b named, s `self` (type namespace only), g glob, x extern crate */ + const char *local; + const char **segs; + int nsegs; + bool global; + bool spread; /* what it names has no known namespace (external, or a gap): res[] holds + * it in all three, and a binding the module really has in one of them + * takes precedence there */ + int state; + int done_pass; + int seq; + rs_ent_t res[RS_NS_COUNT]; /* named: per namespace; the others: res[0] */ +} rs_import_t; + +typedef struct { + int mod; + const char *name; + int kind; /* RS_BIND_* */ + int ref; + int seq; + char vis; +} rs_bind_t; + +/* An index entry sorted by (mod, seq). */ +typedef struct { + int mod; + int seq; + int ref; +} rs_ref_t; + +/* A name the body of the `macro_rules!` macro `macro` declares (W records). */ +typedef struct { + const char *name; + const char *macro; +} rs_macdecl_t; + +/* Module `mod` invokes a macro of this name among its items (V records). */ +typedef struct { + int mod; + const char *macro; +} rs_macuse_t; + +/* A range of a file in which `Self` means one type: a type item's own + * extent, or an impl block. */ +typedef struct { + uint32_t start; + uint32_t end; + int item; /* CBM_NOT_FOUND for an impl */ + int impl; +} rs_scope_t; + +typedef struct { + uint32_t line; + const char *text; /* P: comma-joined names; Z: the unowned definition's QN suffix */ +} rs_note_t; + +/* From line `from` on, the innermost module is `mod`. */ +typedef struct { + uint32_t from; + int mod; +} rs_span_t; + +typedef struct { + const char *rel_path; + const char *item_prefix; /* qualified name of an item of the file, minus its name */ + const char *module_qn; + int kind; + bool test_path; + bool broken; /* its modules may declare names the blob does not show */ + bool fresh; /* re-extracted in this run: its blob has line numbers */ + bool crate_root; + bool queued; + bool roots_top; /* more crate roots than are kept */ + bool in_lib; /* some crate root of it is a package's lib target */ + uint8_t views; /* RS_VIEW_* its `mod x;` declarations are read under */ + uint8_t views_done; + uint32_t err_line; /* first line whose definitions have no scope; 0: none */ + int mod_base; /* its root module */ + int nmods; + int decl_first; + int decl_count; + int item_first; + int item_count; + int root_pkg; /* a crate root: its package (RS_PKG_*) */ + int pkg; /* the package of its crate roots (RS_PKG_*) */ + int roots[RS_ROOTS_KEPT]; + int nroots; + int scope_first; + int scope_count; + int local_first; /* P records */ + int local_count; + int unowned_first; /* Z records */ + int unowned_count; + int span_first; + int span_count; + int test_first; /* start lines of the items marked as tests */ + int test_count; +} rs_file_t; + +typedef struct { + char kind; /* d dependencies, v dev, b build, w workspace.dependencies */ + const char *name; + const char *attr; /* "-", "path", "package", "workspace" */ + const char *value; + int seq; +} rs_rawdep_t; + +typedef struct { + const char *key; /* the crate's name in scope */ + rs_ent_t target; +} rs_dep_t; + +typedef struct { + int file; + const char *dir; /* "" for the repository root */ + const char *name; + const char *lib_name; /* the lib crate's name; NULL when it has none that is known */ + const char *lib_path; + const char *build; + bool has_package; + bool is_workspace; + bool broken; + bool no_autobins; + bool no_autoexamples; + bool no_autotests; + bool no_autobenches; + int edition; + int ws_edition; + rs_rawdep_t *raw; + int nraw; + const char **targets; /* explicit target paths, as written */ + int ntargets; + rs_dep_t *deps; /* sorted by key */ + int ndeps; + int lib_root; /* the lib target's root module, CBM_NOT_FOUND when it has none */ + int ws; /* its workspace root's package, CBM_NOT_FOUND */ +} rs_pkg_t; + +typedef struct { + CBMArena arena; + bool oom; + const char *project; + rs_file_t *files; + int nfiles; + int nsources; + int *run_to_file; + int run_count; + rs_mod_t *mods; + int nmods; + rs_decl_t *decls; + int ndecls; + rs_attach_t *attach; + int nattach; + rs_item_t *items; + int nitems; + rs_member_t *members; + int nmembers; + rs_impl_t *impls; + int nimpls; + rs_item_impl_t *item_impls; + int nitem_impls; + rs_assoc_t *assoc; + int nassoc; + rs_import_t *imports; + int nimports; + rs_ref_t *globs; /* the glob imports, by module */ + int nglobs; + rs_ref_t *named; /* the named imports, by module */ + int nnamed; + rs_bind_t *binds; + int nbinds; + rs_ref_t *maybes; /* Q records: ref indexes maybe_names */ + const char **maybe_names; + int nmaybes; + rs_macdecl_t *macdecls; /* sorted by (name, macro) */ + int nmacdecls; + rs_macuse_t *macuses; /* sorted by (mod, macro) */ + int nmacuses; + rs_scope_t *scopes; + int nscopes; + rs_note_t *locals; + int nlocals; + rs_note_t *unowned; + int nunowned; + rs_span_t *spans; + int nspans; + uint32_t *test_lines; + int ntest_lines; + rs_pkg_t *pkgs; + int npkgs; + CBMHashTable *file_by_path; /* rel_path -> file + 1 */ + CBMHashTable *pkg_by_dir; /* directory -> package + 1 */ + CBMHashTable *blanket; /* names of members of impls for no named type */ + int crate_roots; + int orphans; + int gap_imports; +} rs_index_t; + +/* ── Small helpers ───────────────────────────────────────────────── */ + +static void *ix_alloc(rs_index_t *ix, size_t n) { + void *p = cbm_arena_alloc(&ix->arena, n ? n : SKIP_ONE); + ix->oom = ix->oom || !p; + return p; +} + +static void *ix_calloc(rs_index_t *ix, size_t count, size_t size) { + size_t n = count * size; + void *p = ix_alloc(ix, n); + if (p) { + memset(p, 0, n ? n : SKIP_ONE); + } + return p; +} + +static char *ix_strdup(rs_index_t *ix, const char *s) { + char *p = cbm_arena_strdup(&ix->arena, s ? s : ""); + ix->oom = ix->oom || !p; + return p; +} + +static char *ix_strndup(rs_index_t *ix, const char *s, size_t n) { + char *p = cbm_arena_strndup(&ix->arena, s, n); + ix->oom = ix->oom || !p; + return p; +} + +/* A name without the raw-identifier marker: `r#type` and `type` are one name. */ +static const char *rs_plain(const char *name) { + return (name[0] == 'r' && name[1] == '#' && name[PAIR_LEN]) ? name + PAIR_LEN : name; +} + +/* Split `line` in place at tabs into at most `max` fields; the count. */ +static int rs_fields(char *line, char **out, int max) { + int n = 0; + out[n++] = line; + for (char *p = line; *p && n < max; p++) { + if (*p == '\t') { + *p = '\0'; + out[n++] = p + SKIP_ONE; + } + } + return n; +} + +static bool rs_ends_with(const char *s, const char *suffix) { + size_t n = strlen(s); + size_t sl = strlen(suffix); + return n >= sl && strcmp(s + n - sl, suffix) == 0; +} + +static const char *rs_basename(const char *rel) { + const char *slash = strrchr(rel, '/'); + return slash ? slash + SKIP_ONE : rel; +} + +/* Append "/" and s[0..n) to the path in out[0..*w); nothing when n is 0. false + * when it does not fit. */ +static bool rs_path_put(char *out, size_t cap, size_t *w, const char *s, size_t n) { + if (n == 0) { + return true; + } + if (*w + n + PAIR_LEN >= cap) { + return false; + } + if (*w > 0) { + out[(*w)++] = '/'; + } + memcpy(out + *w, s, n); + *w += n; + out[*w] = '\0'; + return true; +} + +/* Normalize `dir`/`path` (repository-relative): "." and ".." segments, doubled + * and trailing slashes. false when it leaves the repository or does not fit. */ +static bool rs_join_path(const char *dir, const char *path, char *out, size_t cap) { + char tmp[RS_PATH_BUF]; + int k = (path[0] == '/') ? CBM_NOT_FOUND + : snprintf(tmp, sizeof(tmp), "%s%s%s", dir, dir[0] ? "/" : "", path); + if (k < 0 || (size_t)k >= sizeof(tmp)) { + return false; + } + size_t w = 0; + out[0] = '\0'; + for (const char *p = tmp; *p;) { + const char *slash = strchr(p, '/'); + size_t len = slash ? (size_t)(slash - p) : strlen(p); + const char *next = slash ? slash + SKIP_ONE : p + len; + if (len == PAIR_LEN && p[0] == '.' && p[1] == '.') { + if (w == 0) { + return false; + } + while (w > 0 && out[w - SKIP_ONE] != '/') { + w--; + } + w = w > 0 ? w - SKIP_ONE : 0; + out[w] = '\0'; + } else if (len > 0 && !(len == SKIP_ONE && p[0] == '.')) { + if (!rs_path_put(out, cap, &w, p, len)) { + return false; + } + } + p = next; + } + return true; +} + +static bool rs_is_test_dir(const char *seg, size_t n) { + static const char *const dirs[] = {"tests", "test", "benches", "examples", "fixtures"}; + for (size_t i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) { + if (strlen(dirs[i]) == n && memcmp(dirs[i], seg, n) == 0) { + return true; + } + } + return false; +} + +/* Test, bench, example and fixture code by its place in the tree: Cargo's + * target directories, plus the indexer's own test-file rule. */ +static bool rs_is_test_path(const char *rel) { + if (cbm_is_test_file(rel, CBM_LANG_RUST)) { + return true; + } + for (const char *p = rel;;) { + const char *slash = strchr(p, '/'); + if (!slash) { + return false; + } + if (rs_is_test_dir(p, (size_t)(slash - p))) { + return true; + } + p = slash + SKIP_ONE; + } +} + +static int rs_ns_index(int ns) { + return ns == RS_NS_TYPE ? 0 : (ns == RS_NS_VALUE ? SKIP_ONE : PAIR_LEN); +} + +static int rs_item_ns(char kind) { + switch (kind) { + case 'S': + case 'E': + case 'U': + case 'R': + case 'A': + return RS_NS_TYPE; + case 'F': + case 'C': + case 'G': + return RS_NS_VALUE; + default: + return RS_NS_MACRO; + } +} + +/* A variant is a type-namespace name here: its constructor in the value + * namespace is not what a link names (rustdoc drops it). */ +static int rs_member_ns(char kind) { + return (kind == 't' || kind == 'v') ? RS_NS_TYPE : RS_NS_VALUE; +} + +static bool rs_item_is_type(char kind) { + return kind == 'S' || kind == 'E' || kind == 'U' || kind == 'R' || kind == 'A'; +} + +static bool rs_in_list(const char *const *list, size_t n, const char *name) { + for (size_t i = 0; i < n; i++) { + if (strcmp(list[i], name) == 0) { + return true; + } + } + return false; +} + +#define RS_IN(list, name) rs_in_list((list), sizeof(list) / sizeof((list)[0]), (name)) + +/* ── Candidate sets ──────────────────────────────────────────────── */ + +static rs_ent_t rs_ent(uint8_t kind, int idx) { + return (rs_ent_t){.kind = kind, .idx = idx}; +} + +/* RS_E_GAP or RS_E_EXTERNAL for a name that was not found (rs_ent_t.weak). */ +static rs_ent_t rs_weak(uint8_t kind) { + return (rs_ent_t){.kind = kind, .weak = true}; +} + +static bool rs_ent_same(rs_ent_t a, rs_ent_t b) { + return a.kind == b.kind && (a.kind == RS_E_EXTERNAL || a.kind == RS_E_GAP || a.idx == b.idx); +} + +/* Add `e`. Every entity outside the repository counts as one candidate (the + * outcome is `external` whichever it is), and so does every gap. */ +static void rs_set_add(rs_set_t *s, rs_ent_t e) { + if (e.kind == RS_E_NONE) { + return; + } + for (int i = 0; i < s->n; i++) { + if (rs_ent_same(s->v[i], e)) { + s->v[i].via_import = s->v[i].via_import || e.via_import; + s->v[i].weak = s->v[i].weak && e.weak; + return; + } + } + if (s->n >= RS_SET_CAP) { + s->overflow = true; + return; + } + s->v[s->n++] = e; +} + +static void rs_set_merge(rs_set_t *dst, const rs_set_t *src) { + for (int i = 0; i < src->n; i++) { + rs_set_add(dst, src->v[i]); + } + dst->overflow = dst->overflow || src->overflow; +} + +static bool rs_set_empty(const rs_set_t *s) { + return s->n == 0 && !s->overflow; +} + +static bool rs_set_has(const rs_set_t *s, rs_ent_t e) { + for (int i = 0; i < s->n; i++) { + if (rs_ent_same(s->v[i], e)) { + return true; + } + } + return false; +} + +static bool rs_set_equal(const rs_set_t *a, const rs_set_t *b) { + if (a->n != b->n || a->overflow || b->overflow) { + return false; + } + for (int i = 0; i < a->n; i++) { + if (!rs_set_has(b, a->v[i])) { + return false; + } + } + return true; +} + +/* ── Scope blobs: counting ───────────────────────────────────────── */ + +typedef struct { + int mods; + int decls; + int items; + int members; + int impls; + int imports; + int maybes; + int locals; + int unowned; + int deps; + int targets; + int macuses; + int macdecls; +} rs_counts_t; + +/* The number of comma-separated names a W record carries. */ +static int rs_count_names(const char *line, const char *end) { + int n = SKIP_ONE; + for (const char *p = line; p < end; p++) { + n += *p == ','; + } + return n; +} + +static void rs_count_blob(const char *blob, rs_counts_t *c) { + for (const char *p = blob; p && *p;) { + const char *eol = strchr(p, '\n'); + switch (p[0]) { + case 'V': + c->macuses++; + break; + case 'W': + c->macdecls += rs_count_names(p, eol ? eol : p + strlen(p)); + break; + case 'N': + c->mods++; + break; + case 'D': + c->decls++; + break; + case 'T': + c->items++; + break; + case 'M': + c->members++; + break; + case 'I': + c->impls++; + break; + case 'U': + c->imports++; + break; + case 'Q': + c->maybes++; + break; + case 'P': + c->locals++; + break; + case 'Z': + c->unowned++; + break; + case 'X': + c->deps++; + break; + case 'a': + c->targets++; + break; + default: + break; + } + p = eol ? eol + SKIP_ONE : NULL; + } +} + +/* True when `scope` is a manifest's blob: the line after the tag is "C". */ +static bool rs_blob_is_manifest(const char *scope) { + const char *nl = scope ? strchr(scope, '\n') : NULL; + return nl && nl[SKIP_ONE] == 'C' && (nl[PAIR_LEN] == '\n' || nl[PAIR_LEN] == '\0'); +} + +/* ── Scope blobs: a source file ──────────────────────────────────── */ + +/* The node field of a T or M record: '!' for none, else a label code and '=' + * (the node's name is the record's name as written) or ':' and the node's + * name after the item prefix. */ +static void rs_parse_node(const char *field, const char *name, char *label, const char **suffix) { + *label = 0; + *suffix = NULL; + if (!field[0] || field[0] == '!') { + return; + } + if (field[SKIP_ONE] == '=') { + *label = field[0]; + *suffix = name; + } else if (field[SKIP_ONE] == ':' && field[PAIR_LEN]) { + *label = field[0]; + *suffix = field + PAIR_LEN; + } +} + +/* A module field of a record of file `f`: the module, or CBM_NOT_FOUND when + * the number is not one of the file's. */ +static int rs_mod_field(const rs_file_t *f, const char *field) { + char *end = NULL; + long id = strtol(field, &end, CBM_DECIMAL_BASE); + if (end == field || *end || id < 0 || id >= f->nmods) { + return CBM_NOT_FOUND; + } + return f->mod_base + (int)id; +} + +static uint32_t rs_u32_field(const char *field) { + return (uint32_t)strtoul(field, NULL, CBM_DECIMAL_BASE); +} + +/* What is being built while one source blob is read. */ +typedef struct { + rs_index_t *ix; + rs_file_t *f; + int file; + int next_mod; /* the id the next N record must carry */ + int owner_item; /* the T or I record members belong to */ + int owner_impl; + int owner_mod; + bool owner_test; +} rs_reader_t; + +static void rs_read_module(rs_reader_t *r, char **fld, int n) { + enum { F_ID = 1, F_PARENT, F_START, F_END, F_FLAGS, F_NAME, F_COUNT }; + rs_index_t *ix = r->ix; + int parent = n == F_COUNT ? rs_mod_field(r->f, fld[F_PARENT]) : CBM_NOT_FOUND; + if (parent < 0 || (int)rs_u32_field(fld[F_ID]) != r->next_mod || r->next_mod >= r->f->nmods || + parent >= r->f->mod_base + r->next_mod || !fld[F_FLAGS][0]) { + r->f->broken = true; + return; + } + rs_mod_t *m = &ix->mods[r->f->mod_base + r->next_mod]; + r->next_mod++; + m->parent = parent; + m->start = rs_u32_field(fld[F_START]); + m->end = rs_u32_field(fld[F_END]); + m->vis = fld[F_FLAGS][0]; + m->cfg_test = strchr(fld[F_FLAGS] + SKIP_ONE, 't') != NULL; + m->path_attr = strchr(fld[F_FLAGS] + SKIP_ONE, 'c') != NULL; + m->name = rs_plain(fld[F_NAME]); +} + +static void rs_read_decl(rs_reader_t *r, char **fld, int n) { + enum { F_MOD = 1, F_FLAGS, F_NAME, F_PATH, F_COUNT }; + int mod = n == F_COUNT ? rs_mod_field(r->f, fld[F_MOD]) : CBM_NOT_FOUND; + if (mod < 0 || !fld[F_FLAGS][0]) { + r->f->broken = true; + return; + } + rs_decl_t *d = &r->ix->decls[r->ix->ndecls++]; + r->f->decl_count++; + d->mod = mod; + d->vis = fld[F_FLAGS][0]; + d->cfg_test = strchr(fld[F_FLAGS] + SKIP_ONE, 't') != NULL; + d->cond_path = strchr(fld[F_FLAGS] + SKIP_ONE, 'c') != NULL; + d->name = rs_plain(fld[F_NAME]); + d->path = strcmp(fld[F_PATH], "-") == 0 ? NULL : fld[F_PATH]; + d->child = CBM_NOT_FOUND; +} + +static void rs_read_import(rs_reader_t *r, char **fld, int n) { + enum { F_MOD = 1, F_VIS, F_KIND, F_LOCAL, F_PATH, F_COUNT }; + rs_index_t *ix = r->ix; + int mod = n == F_COUNT ? rs_mod_field(r->f, fld[F_MOD]) : CBM_NOT_FOUND; + if (mod < 0) { + r->f->broken = true; + return; + } + rs_import_t *imp = &ix->imports[ix->nimports]; + imp->file = r->file; + imp->mod = mod; + imp->vis = fld[F_VIS][0]; + imp->kind = fld[F_KIND][0]; + imp->local = rs_plain(fld[F_LOCAL]); + imp->seq = ix->nimports++; + CBMDocLinkRsPath path; + if (cbm_doclink_rs_parse_path(fld[F_PATH], strlen(fld[F_PATH]), false, &path) != + CBM_DOCLINK_RS_PATH_OK) { + return; /* no segments: an import that is not followed */ + } + imp->segs = (const char **)ix_alloc(ix, (size_t)path.nsegs * sizeof(char *)); + imp->global = path.global; + for (int i = 0; imp->segs && i < path.nsegs; i++) { + imp->segs[i] = ix_strdup(ix, rs_plain(path.segs[i])); + } + imp->nsegs = imp->segs ? path.nsegs : 0; +} + +static void rs_read_item(rs_reader_t *r, char **fld, int n) { + enum { F_MOD = 1, F_START, F_END, F_FLAGS, F_KIND, F_NAME, F_NODE, F_EXTRA, F_COUNT }; + rs_index_t *ix = r->ix; + int mod = n == F_COUNT ? rs_mod_field(r->f, fld[F_MOD]) : CBM_NOT_FOUND; + r->owner_item = CBM_NOT_FOUND; + r->owner_impl = CBM_NOT_FOUND; + if (mod < 0 || !fld[F_FLAGS][0]) { + r->f->broken = true; + return; + } + int idx = ix->nitems++; + rs_item_t *it = &ix->items[idx]; + r->f->item_count++; + it->file = r->file; + it->mod = mod; + it->start = rs_u32_field(fld[F_START]); + it->end = rs_u32_field(fld[F_END]); + it->vis = fld[F_FLAGS][0]; + it->test = strchr(fld[F_FLAGS] + SKIP_ONE, 't') != NULL; + it->macro_export = strchr(fld[F_FLAGS] + SKIP_ONE, 'e') != NULL; + it->kind = fld[F_KIND][0]; + it->name = rs_plain(fld[F_NAME]); + rs_parse_node(fld[F_NODE], fld[F_NAME], &it->label, &it->suffix); + const char *extra = strcmp(fld[F_EXTRA], "-") != 0 ? fld[F_EXTRA] : NULL; + it->alias = it->kind == 'A' ? extra : NULL; + it->derives = (it->kind == 'S' || it->kind == 'E' || it->kind == 'U') ? extra : NULL; + it->member_first = ix->nmembers; + r->owner_item = idx; + r->owner_mod = mod; + r->owner_test = it->test; + if (r->f->fresh && rs_item_is_type(it->kind)) { + ix->scopes[ix->nscopes++] = + (rs_scope_t){.start = it->start, .end = it->end, .item = idx, .impl = CBM_NOT_FOUND}; + r->f->scope_count++; + } +} + +static void rs_read_impl(rs_reader_t *r, char **fld, int n) { + enum { F_MOD = 1, F_START, F_END, F_SELF, F_TRAIT, F_COUNT }; + rs_index_t *ix = r->ix; + int mod = n == F_COUNT ? rs_mod_field(r->f, fld[F_MOD]) : CBM_NOT_FOUND; + r->owner_item = CBM_NOT_FOUND; + r->owner_impl = CBM_NOT_FOUND; + if (mod < 0) { + r->f->broken = true; + return; + } + int idx = ix->nimpls++; + rs_impl_t *im = &ix->impls[idx]; + im->file = r->file; + im->mod = mod; + im->start = rs_u32_field(fld[F_START]); + im->end = rs_u32_field(fld[F_END]); + im->self_path = strcmp(fld[F_SELF], "?") == 0 ? NULL : fld[F_SELF]; + im->trait_path = strcmp(fld[F_TRAIT], "-") == 0 ? NULL : fld[F_TRAIT]; + im->member_first = ix->nmembers; + r->owner_impl = idx; + r->owner_mod = mod; + r->owner_test = false; + if (r->f->fresh) { + ix->scopes[ix->nscopes++] = + (rs_scope_t){.start = im->start, .end = im->end, .item = CBM_NOT_FOUND, .impl = idx}; + r->f->scope_count++; + } +} + +static void rs_read_member(rs_reader_t *r, char **fld, int n) { + enum { F_START = 1, F_KIND, F_NAME, F_NODE, F_COUNT }; + rs_index_t *ix = r->ix; + if (n != F_COUNT || (r->owner_item < 0 && r->owner_impl < 0)) { + r->f->broken = true; + return; + } + int idx = ix->nmembers++; + rs_member_t *m = &ix->members[idx]; + m->file = r->file; + m->mod = r->owner_mod; + m->owner_test = r->owner_test; + m->kind = fld[F_KIND][0]; + m->name = rs_plain(fld[F_NAME]); + m->seq = idx; + rs_parse_node(fld[F_NODE], fld[F_NAME], &m->label, &m->suffix); + if (r->owner_item >= 0) { + m->owner = r->owner_item; + ix->items[r->owner_item].member_count++; + } else { + m->owner = -r->owner_impl - SKIP_ONE; + ix->impls[r->owner_impl].member_count++; + } +} + +/* A name a macro invocation may declare. One that may be declared anywhere in + * the file ('*') follows a parse error, and such a file is `broken` anyway. */ +static void rs_read_maybe(rs_reader_t *r, char **fld, int n) { + enum { F_MOD = 1, F_NAME, F_COUNT }; + rs_index_t *ix = r->ix; + int mod = n == F_COUNT ? rs_mod_field(r->f, fld[F_MOD]) : CBM_NOT_FOUND; + if (mod < 0) { + r->f->broken = true; /* '*', or a record this code did not write */ + return; + } + ix->maybe_names[ix->nmaybes] = rs_plain(fld[F_NAME]); + ix->maybes[ix->nmaybes] = (rs_ref_t){.mod = mod, .seq = ix->nmaybes, .ref = ix->nmaybes}; + ix->nmaybes++; +} + +/* `V mod name`: the module invokes a macro of that name. */ +static void rs_read_macuse(rs_reader_t *r, char **fld, int n) { + enum { F_MOD = 1, F_NAME, F_COUNT }; + rs_index_t *ix = r->ix; + int mod = n == F_COUNT ? rs_mod_field(r->f, fld[F_MOD]) : CBM_NOT_FOUND; + if (mod < 0) { + r->f->broken = true; + return; + } + ix->macuses[ix->nmacuses++] = (rs_macuse_t){.mod = mod, .macro = rs_plain(fld[F_NAME])}; +} + +/* `W name names`: what the body of the macro `name` declares. */ +static void rs_read_macdecl(rs_reader_t *r, char **fld, int n) { + enum { F_MACRO = 1, F_NAMES, F_COUNT }; + rs_index_t *ix = r->ix; + if (n != F_COUNT) { + r->f->broken = true; + return; + } + const char *macro = rs_plain(fld[F_MACRO]); + for (char *p = fld[F_NAMES]; p && *p;) { + char *comma = strchr(p, ','); + if (comma) { + *comma = '\0'; + } + ix->macdecls[ix->nmacdecls++] = (rs_macdecl_t){.name = rs_plain(p), .macro = macro}; + p = comma ? comma + SKIP_ONE : NULL; + } +} + +static void rs_read_note(rs_reader_t *r, char **fld, int n, bool local) { + enum { F_LINE = 1, F_TEXT, F_COUNT }; + rs_index_t *ix = r->ix; + if (n != F_COUNT || !r->f->fresh) { + return; + } + rs_note_t note = {.line = rs_u32_field(fld[F_LINE]), .text = fld[F_TEXT]}; + if (local) { + ix->locals[ix->nlocals++] = note; + r->f->local_count++; + } else { + ix->unowned[ix->nunowned++] = note; + r->f->unowned_count++; + } +} + +static void rs_read_record(rs_reader_t *r, char *line) { + char *fld[RS_FIELDS_MAX]; + char tag = line[0]; + int n = rs_fields(line, fld, RS_FIELDS_MAX); + switch (tag) { + case 'B': + r->f->broken = true; + break; + case 'E': + if (n == PAIR_LEN && r->f->fresh) { + r->f->err_line = rs_u32_field(fld[SKIP_ONE]); + } + break; + case 'N': + rs_read_module(r, fld, n); + break; + case 'D': + rs_read_decl(r, fld, n); + break; + case 'U': + rs_read_import(r, fld, n); + break; + case 'T': + rs_read_item(r, fld, n); + break; + case 'I': + rs_read_impl(r, fld, n); + break; + case 'M': + rs_read_member(r, fld, n); + break; + case 'Q': + rs_read_maybe(r, fld, n); + break; + case 'V': + rs_read_macuse(r, fld, n); + break; + case 'W': + rs_read_macdecl(r, fld, n); + break; + case 'P': + rs_read_note(r, fld, n, true); + break; + case 'Z': + rs_read_note(r, fld, n, false); + break; + default: + break; + } +} + +/* Read the scope blob of source file `fi` (its modules are allocated). */ +static void rs_read_source(rs_index_t *ix, int fi, const char *blob) { + rs_file_t *f = &ix->files[fi]; + f->decl_first = ix->ndecls; + f->item_first = ix->nitems; + f->scope_first = ix->nscopes; + f->local_first = ix->nlocals; + f->unowned_first = ix->nunowned; + char *buf = blob ? ix_strdup(ix, blob) : NULL; + if (!buf) { + f->broken = true; /* no scope: what the file declares is not known */ + return; + } + rs_reader_t r = {.ix = ix, + .f = f, + .file = fi, + .next_mod = SKIP_ONE, + .owner_item = CBM_NOT_FOUND, + .owner_impl = CBM_NOT_FOUND}; + bool first = true; + for (char *line = buf; line && *line;) { + char *nl = strchr(line, '\n'); + if (nl) { + *nl = '\0'; + } + if (!first) { + rs_read_record(&r, line); + } + first = false; + line = nl ? nl + SKIP_ONE : NULL; + } + if (r.next_mod != f->nmods) { + f->broken = true; /* a module record was not read: its number names nothing */ + } +} + +/* ── Scope blobs: a Cargo manifest ───────────────────────────────── */ + +static int rs_parse_edition(const char *value) { + char *end = NULL; + long year = strtol(value, &end, CBM_DECIMAL_BASE); + if (end == value || *end || year < RS_EDITION_2015) { + return RS_EDITION_UNREADABLE; + } + return (int)year; +} + +static bool rs_is_false(const char *value) { + return strcmp(value, "false") == 0; +} + +static void rs_package_key(rs_pkg_t *p, const char *key, const char *value) { + if (strcmp(key, "name") == 0) { + p->name = strcmp(value, "?") != 0 ? value : NULL; + } else if (strcmp(key, "edition") == 0) { + p->edition = rs_parse_edition(value); + } else if (strcmp(key, "edition.workspace") == 0) { + p->edition = RS_EDITION_INHERITED; + } else if (strcmp(key, "build") == 0) { + p->build = value; + } else if (strcmp(key, "autobins") == 0) { + p->no_autobins = rs_is_false(value); + } else if (strcmp(key, "autoexamples") == 0) { + p->no_autoexamples = rs_is_false(value); + } else if (strcmp(key, "autotests") == 0) { + p->no_autotests = rs_is_false(value); + } else if (strcmp(key, "autobenches") == 0) { + p->no_autobenches = rs_is_false(value); + } +} + +static void rs_manifest_key(rs_pkg_t *p, const char *table, const char *key, const char *value) { + if (strcmp(table, "package") == 0) { + p->has_package = true; + rs_package_key(p, key, value); + } else if (strcmp(table, "lib") == 0) { + if (strcmp(key, "name") == 0) { + p->lib_name = value; + } else if (strcmp(key, "path") == 0) { + p->lib_path = value; + } + } else if (strcmp(table, "workspace.package") == 0 && strcmp(key, "edition") == 0) { + p->ws_edition = rs_parse_edition(value); + } +} + +static void rs_manifest_record(rs_pkg_t *p, char *line) { + enum { F_1 = 1, F_2, F_3, F_4, K_FIELDS = 4, A_FIELDS = 3, X_FIELDS = 5 }; + char *fld[X_FIELDS]; + char tag = line[0]; + int n = rs_fields(line, fld, X_FIELDS); + if (tag == 'K' && n == K_FIELDS) { + rs_manifest_key(p, fld[F_1], fld[F_2], fld[F_3]); + } else if (tag == 'a' && n == A_FIELDS && strcmp(fld[F_1], "path") == 0) { + p->targets[p->ntargets++] = fld[F_2]; + } else if (tag == 'W') { + p->is_workspace = true; + } else if (tag == 'X' && n == X_FIELDS && fld[F_1][0]) { + p->raw[p->nraw] = (rs_rawdep_t){.kind = fld[F_1][0], + .name = fld[F_2], + .attr = fld[F_3], + .value = fld[F_4], + .seq = p->nraw}; + p->nraw++; + } else if (tag == 'B') { + p->broken = true; + } +} + +static void rs_read_manifest(rs_index_t *ix, int fi, const char *blob) { + rs_pkg_t *p = &ix->pkgs[ix->npkgs]; + const char *rel = ix->files[fi].rel_path; + const char *slash = strrchr(rel, '/'); + rs_counts_t c = {0}; + rs_count_blob(blob, &c); + memset(p, 0, sizeof(*p)); + p->file = fi; + p->dir = ix_strndup(ix, rel, slash ? (size_t)(slash - rel) : 0); + p->lib_root = CBM_NOT_FOUND; + p->ws = CBM_NOT_FOUND; + p->raw = (rs_rawdep_t *)ix_alloc(ix, (size_t)c.deps * sizeof(rs_rawdep_t)); + p->targets = (const char **)ix_alloc(ix, (size_t)c.targets * sizeof(char *)); + char *buf = ix_strdup(ix, blob); + if (!buf || !p->dir || !p->raw || !p->targets) { + return; + } + int skipped = 0; + for (char *line = buf; line && *line;) { + char *nl = strchr(line, '\n'); + if (nl) { + *nl = '\0'; + } + if (skipped >= PAIR_LEN) { /* after the tag line and the "C" line */ + rs_manifest_record(p, line); + } + skipped++; + line = nl ? nl + SKIP_ONE : NULL; + } + ix->npkgs++; +} + +/* ── Paths -> files and packages ─────────────────────────────────── */ + +static int rs_file_at(const rs_index_t *ix, const char *rel) { + return (int)(intptr_t)cbm_ht_get(ix->file_by_path, rel) - SKIP_ONE; +} + +/* The source file at `rel`, CBM_NOT_FOUND when none is indexed. */ +static int rs_source_at(const rs_index_t *ix, const char *rel) { + int fi = rs_file_at(ix, rel); + return (fi >= 0 && ix->files[fi].kind == RS_FILE_SOURCE) ? fi : CBM_NOT_FOUND; +} + +/* The package whose manifest is in directory `dir` ("" at the root). */ +static int rs_pkg_at(const rs_index_t *ix, const char *dir) { + return (int)(intptr_t)cbm_ht_get(ix->pkg_by_dir, dir[0] ? dir : ".") - SKIP_ONE; +} + +/* `-` in a package name is `_` in the crate's name. */ +static const char *rs_crate_name(rs_index_t *ix, const char *name) { + char *out = ix_strdup(ix, name); + for (char *p = out; p && *p; p++) { + if (*p == '-') { + *p = '_'; + } + } + return out; +} + +/* The workspace root of package `pi`: the nearest manifest with a [workspace] + * table at or above its directory. */ +static int rs_workspace_of(const rs_index_t *ix, int pi) { + char dir[RS_PATH_BUF]; + if (snprintf(dir, sizeof(dir), "%s", ix->pkgs[pi].dir) >= (int)sizeof(dir)) { + return CBM_NOT_FOUND; + } + for (;;) { + int q = rs_pkg_at(ix, dir); + if (q >= 0 && ix->pkgs[q].is_workspace) { + return q; + } + if (!dir[0]) { + return CBM_NOT_FOUND; + } + char *slash = strrchr(dir, '/'); + if (slash) { + *slash = '\0'; + } else { + dir[0] = '\0'; + } + } +} + +/* The edition of package `pkg`: a year, or RS_EDITION_UNKNOWN. A manifest + * without the key is edition 2015 (Cargo's default). */ +static int rs_edition(const rs_index_t *ix, int pkg) { + if (pkg < 0) { + return RS_EDITION_UNKNOWN; + } + const rs_pkg_t *p = &ix->pkgs[pkg]; + int edition = p->edition; + if (edition == RS_EDITION_INHERITED) { + edition = p->ws >= 0 ? ix->pkgs[p->ws].ws_edition : RS_EDITION_UNREADABLE; + } + if (edition == 0) { + return p->broken ? RS_EDITION_UNKNOWN : RS_EDITION_2015; + } + return edition > 0 ? edition : RS_EDITION_UNKNOWN; +} + +/* ── Crate roots ─────────────────────────────────────────────────── */ + +static void rs_mark_root(rs_index_t *ix, int fi, int pi, bool lib) { + rs_file_t *f = &ix->files[fi]; + if (!f->crate_root) { + f->crate_root = true; + f->root_pkg = pi; + ix->crate_roots++; + } else if (f->root_pkg != pi) { + f->root_pkg = RS_PKG_CONFLICT; /* a target of two packages */ + } + if (lib && ix->pkgs[pi].lib_root < 0) { + ix->pkgs[pi].lib_root = f->mod_base; + } +} + +/* A target path written in a manifest. */ +static void rs_claim_path(rs_index_t *ix, int pi, const char *path, bool lib) { + char rel[RS_PATH_BUF]; + if (strcmp(path, "?") == 0 || !rs_join_path(ix->pkgs[pi].dir, path, rel, sizeof(rel))) { + return; + } + int fi = rs_source_at(ix, rel); + if (fi >= 0) { + rs_mark_root(ix, fi, pi, lib); + } +} + +static void rs_explicit_roots(rs_index_t *ix, int pi) { + const rs_pkg_t *p = &ix->pkgs[pi]; + if (!p->has_package) { + return; + } + if (p->lib_path) { + rs_claim_path(ix, pi, p->lib_path, true); + } + for (int i = 0; i < p->ntargets; i++) { + rs_claim_path(ix, pi, p->targets[i], false); + } + if (p->build && !rs_is_false(p->build) && strcmp(p->build, "true") != 0) { + rs_claim_path(ix, pi, p->build, false); + } +} + +/* The last components of a path, c[0] the file name. */ +typedef struct { + const char *c[RS_TAIL]; + size_t len[RS_TAIL]; + int n; +} rs_tail_t; + +static void rs_tail(const char *rel, rs_tail_t *t) { + memset(t, 0, sizeof(*t)); + const char *end = rel + strlen(rel); + while (t->n < RS_TAIL && end > rel) { + const char *s = end; + while (s > rel && s[-SKIP_ONE] != '/') { + s--; + } + t->c[t->n] = s; + t->len[t->n] = (size_t)(end - s); + t->n++; + end = s > rel ? s - SKIP_ONE : rel; + } +} + +static bool rs_tail_is(const rs_tail_t *t, int k, const char *name) { + return k < t->n && strlen(name) == t->len[k] && memcmp(t->c[k], name, t->len[k]) == 0; +} + +/* The package whose directory holds component `k` of `rel`. */ +static int rs_pkg_before(const rs_index_t *ix, const char *rel, const rs_tail_t *t, int k) { + char dir[RS_PATH_BUF]; + if (k >= t->n) { + return CBM_NOT_FOUND; + } + size_t dl = (size_t)(t->c[k] - rel); + size_t n = dl > 0 ? dl - SKIP_ONE : 0; + if (n >= sizeof(dir)) { + return CBM_NOT_FOUND; + } + memcpy(dir, rel, n); + dir[n] = '\0'; + return rs_pkg_at(ix, dir); +} + +/* A file where Cargo discovers a target of package `pi` on its own. */ +static void rs_claim_auto(rs_index_t *ix, int fi, int pi, int target) { + if (pi < 0 || !ix->pkgs[pi].has_package) { + return; + } + const rs_pkg_t *p = &ix->pkgs[pi]; + bool off = + (target == RS_TARGET_LIB && p->lib_path) || (target == RS_TARGET_BIN && p->no_autobins) || + (target == RS_TARGET_EXAMPLE && p->no_autoexamples) || + (target == RS_TARGET_TEST && p->no_autotests) || + (target == RS_TARGET_BENCH && p->no_autobenches) || (target == RS_TARGET_BUILD && p->build); + if (!off) { + rs_mark_root(ix, fi, pi, target == RS_TARGET_LIB); + } +} + +/* Cargo's target auto-discovery, read off the file's own path. */ +static void rs_auto_roots(rs_index_t *ix, int fi) { + static const struct { + const char *dir; + int target; + } dirs[] = { + {"examples", RS_TARGET_EXAMPLE}, {"tests", RS_TARGET_TEST}, {"benches", RS_TARGET_BENCH}}; + enum { C0 = 0, C1, C2, C3 }; + const char *rel = ix->files[fi].rel_path; + rs_tail_t t; + rs_tail(rel, &t); + bool main_rs = rs_tail_is(&t, C0, "main.rs"); + if (rs_tail_is(&t, C1, "src")) { + if (rs_tail_is(&t, C0, "lib.rs")) { + rs_claim_auto(ix, fi, rs_pkg_before(ix, rel, &t, C1), RS_TARGET_LIB); + } + if (main_rs) { + rs_claim_auto(ix, fi, rs_pkg_before(ix, rel, &t, C1), RS_TARGET_BIN); + } + } + if (rs_tail_is(&t, C1, "bin") && rs_tail_is(&t, C2, "src")) { + rs_claim_auto(ix, fi, rs_pkg_before(ix, rel, &t, C2), RS_TARGET_BIN); + } + if (main_rs && rs_tail_is(&t, C2, "bin") && rs_tail_is(&t, C3, "src")) { + rs_claim_auto(ix, fi, rs_pkg_before(ix, rel, &t, C3), RS_TARGET_BIN); + } + for (size_t i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) { + if (rs_tail_is(&t, C1, dirs[i].dir)) { + rs_claim_auto(ix, fi, rs_pkg_before(ix, rel, &t, C1), dirs[i].target); + } + if (main_rs && rs_tail_is(&t, C2, dirs[i].dir)) { + rs_claim_auto(ix, fi, rs_pkg_before(ix, rel, &t, C2), dirs[i].target); + } + } + if (rs_tail_is(&t, C0, "build.rs")) { + rs_claim_auto(ix, fi, rs_pkg_before(ix, rel, &t, C0), RS_TARGET_BUILD); + } +} + +static void rs_crate_roots(rs_index_t *ix) { + for (int pi = 0; pi < ix->npkgs; pi++) { + ix->pkgs[pi].ws = rs_workspace_of(ix, pi); + rs_explicit_roots(ix, pi); + } + for (int fi = 0; fi < ix->nfiles; fi++) { + if (ix->files[fi].kind == RS_FILE_SOURCE) { + rs_auto_roots(ix, fi); + } + } + for (int pi = 0; pi < ix->npkgs; pi++) { + rs_pkg_t *p = &ix->pkgs[pi]; + const char *name = p->lib_name ? p->lib_name : p->name; + p->lib_name = (name && strcmp(name, "?") != 0) ? rs_crate_name(ix, name) : NULL; + } +} + +/* ── `mod x;` -> file ────────────────────────────────────────────── */ + +/* The directory the file of declaration `d` is looked for in, under view + * `view` of the declaring file: the file's directory, the file's stem (view + * B), then the inline modules around the declaration. A `#[path]` directly in + * a file is relative to the file's directory under either view. false when + * it is not known (a `#[path]` on an inline module) or too long to be a path. */ +static bool rs_decl_dir(const rs_index_t *ix, const rs_decl_t *d, int view, char *out, size_t cap) { + const char *names[RS_PATH_DEPTH]; + int n = 0; + int m = d->mod; + while (ix->mods[m].parent >= 0) { + if (ix->mods[m].path_attr || n >= RS_PATH_DEPTH) { + return false; + } + names[n++] = ix->mods[m].name; + m = ix->mods[m].parent; + } + const char *rel = ix->files[ix->mods[m].file].rel_path; + const char *base = rs_basename(rel); + size_t w = 0; + out[0] = '\0'; + if (base > rel && !rs_path_put(out, cap, &w, rel, (size_t)(base - rel) - SKIP_ONE)) { + return false; + } + bool stem = view == RS_VIEW_B && !(d->path && n == 0); + size_t bl = strlen(base); + if (stem && (bl <= RS_RS_EXT || !rs_path_put(out, cap, &w, base, bl - RS_RS_EXT))) { + return false; + } + for (int i = n - SKIP_ONE; i >= 0; i--) { + if (!rs_path_put(out, cap, &w, names[i], strlen(names[i]))) { + return false; + } + } + return true; +} + +/* A file a declaration can name, and the view the file is then read under. */ +typedef struct { + int file; + uint8_t implies; +} rs_cand_t; + +static int rs_cand_add(const rs_index_t *ix, const char *rel, uint8_t implies, rs_cand_t *out, + int n) { + int fi = rs_source_at(ix, rel); + if (fi >= 0) { + out[n++] = (rs_cand_t){.file = fi, .implies = implies}; + } + return n; +} + +/* The indexed files declaration `d` can name under `view`: rustc reads + * `/.rs` (its own children then live under `/`) or + * `//mod.rs`, or the `#[path]` file (children beside it). */ +static int rs_decl_cands(const rs_index_t *ix, const rs_decl_t *d, int view, + rs_cand_t out[PAIR_LEN]) { + char dir[RS_PATH_BUF]; + char path[RS_PATH_BUF]; + int n = 0; + if (d->cond_path || !rs_decl_dir(ix, d, view, dir, sizeof(dir))) { + return 0; + } + if (d->path) { + if (rs_join_path(dir, d->path, path, sizeof(path))) { + n = rs_cand_add(ix, path, RS_VIEW_A, out, n); + } + return n; + } + const char *sep = dir[0] ? "/" : ""; + if (snprintf(path, sizeof(path), "%s%s%s.rs", dir, sep, d->name) < (int)sizeof(path)) { + n = rs_cand_add(ix, path, RS_VIEW_B, out, n); + } + if (snprintf(path, sizeof(path), "%s%s%s/mod.rs", dir, sep, d->name) < (int)sizeof(path)) { + n = rs_cand_add(ix, path, RS_VIEW_A, out, n); + } + return n; +} + +typedef struct { + int *v; + int n; +} rs_stack_t; + +static void rs_views_push(rs_index_t *ix, rs_stack_t *st, int fi, uint8_t bits) { + rs_file_t *f = &ix->files[fi]; + if ((f->views | bits) == f->views) { + return; + } + f->views |= bits; + if (!f->queued) { + f->queued = true; + st->v[st->n++] = fi; + } +} + +/* Propagate views until nothing changes. A file is processed once per view it + * gains, so the work is linear in the declarations. */ +static void rs_views_run(rs_index_t *ix, rs_stack_t *st) { + while (st->n > 0) { + int fi = st->v[--st->n]; + rs_file_t *f = &ix->files[fi]; + f->queued = false; + uint8_t todo = f->views & (uint8_t)~f->views_done; + f->views_done |= todo; + for (int view = RS_VIEW_A; view <= RS_VIEW_B; view++) { + for (int d = f->decl_first; (todo & view) && d < f->decl_first + f->decl_count; d++) { + rs_cand_t c[PAIR_LEN]; + int n = rs_decl_cands(ix, &ix->decls[d], view, c); + for (int k = 0; k < n; k++) { + rs_views_push(ix, st, c[k].file, c[k].implies); + } + } + } + } +} + +/* The one file declaration `d` names under the views of its file, or + * CBM_NOT_FOUND when there is none or more than one. */ +static int rs_decl_file(const rs_index_t *ix, const rs_decl_t *d) { + const rs_file_t *f = &ix->files[ix->mods[d->mod].file]; + int found = CBM_NOT_FOUND; + for (int view = RS_VIEW_A; view <= RS_VIEW_B; view++) { + rs_cand_t c[PAIR_LEN]; + int n = (f->views & view) ? rs_decl_cands(ix, d, view, c) : 0; + for (int k = 0; k < n; k++) { + if (found >= 0 && found != c[k].file) { + return CBM_NOT_FOUND; + } + found = c[k].file; + } + } + return found; +} + +static int rs_attach_cmp(const void *a, const void *b) { + const rs_attach_t *x = (const rs_attach_t *)a; + const rs_attach_t *y = (const rs_attach_t *)b; + if (x->child != y->child) { + return x->child < y->child ? -1 : 1; + } + return (x->decl > y->decl) - (x->decl < y->decl); +} + +/* Decide which file every `mod x;` names. + * + * Where rustc looks depends on how the DECLARING file was reached: beside it + * (view A: a crate root, a mod.rs, a `#[path]` file) or under a directory + * named like it (view B: `name.rs` found by its parent). Views are propagated + * from the crate roots to a fixed point; a file nothing reaches may be either, + * and so may one reached both ways. Under both views a declaration names a + * file only when exactly one indexed file fits. */ +static void rs_module_tree(rs_index_t *ix) { + rs_stack_t st = {.v = (int *)ix_alloc(ix, (size_t)ix->nfiles * sizeof(int))}; + ix->attach = (rs_attach_t *)ix_alloc(ix, (size_t)ix->ndecls * sizeof(rs_attach_t)); + if (!st.v || !ix->attach) { + return; + } + for (int fi = 0; fi < ix->nfiles; fi++) { + const rs_file_t *f = &ix->files[fi]; + bool beside = f->crate_root || strcmp(rs_basename(f->rel_path), "mod.rs") == 0; + if (f->kind == RS_FILE_SOURCE && beside) { + rs_views_push(ix, &st, fi, RS_VIEW_A); + } + } + rs_views_run(ix, &st); + for (int fi = 0; fi < ix->nfiles; fi++) { + if (ix->files[fi].kind == RS_FILE_SOURCE && ix->files[fi].views == 0) { + rs_views_push(ix, &st, fi, RS_VIEW_A | RS_VIEW_B); + } + } + rs_views_run(ix, &st); + for (int d = 0; d < ix->ndecls; d++) { + int fi = rs_decl_file(ix, &ix->decls[d]); + if (fi >= 0) { + ix->decls[d].child = ix->files[fi].mod_base; + ix->attach[ix->nattach++] = (rs_attach_t){.child = ix->files[fi].mod_base, .decl = d}; + } + } + if (ix->nattach > SKIP_ONE) { + qsort(ix->attach, (size_t)ix->nattach, sizeof(rs_attach_t), rs_attach_cmp); + } + for (int i = 0; i < ix->nattach; i++) { + rs_mod_t *child = &ix->mods[ix->attach[i].child]; + if (child->attach_count == 0) { + child->attach_first = i; + } + child->attach_count++; + } +} + +/* ── Crate roots of every file, product code ─────────────────────── */ + +static int rs_pkg_join(int a, int b) { + if (a == RS_PKG_NONE || a == b) { + return b; + } + return b == RS_PKG_NONE ? a : RS_PKG_CONFLICT; +} + +static bool rs_roots_add(rs_file_t *f, int root) { + for (int i = 0; i < f->nroots; i++) { + if (f->roots[i] == root) { + return false; + } + } + if (f->nroots >= RS_ROOTS_KEPT) { + bool changed = !f->roots_top; + f->roots_top = true; + return changed; + } + f->roots[f->nroots++] = root; + return true; +} + +/* What file `c` takes over from a file that declares it. true on a change. */ +static bool rs_file_inherit(rs_file_t *c, const rs_file_t *p) { + bool changed = p->roots_top && !c->roots_top; + c->roots_top = c->roots_top || p->roots_top; + for (int i = 0; i < p->nroots; i++) { + changed = rs_roots_add(c, p->roots[i]) || changed; + } + int pkg = rs_pkg_join(c->pkg, p->pkg); + changed = changed || pkg != c->pkg || (p->in_lib && !c->in_lib); + c->pkg = pkg; + c->in_lib = c->in_lib || p->in_lib; + return changed; +} + +/* The crate roots, the package and the lib membership of every file: a + * fixed point over the `mod x;` edges. Each value only grows and is bounded, + * so a file is revisited a bounded number of times. */ +static void rs_file_roots(rs_index_t *ix) { + rs_stack_t st = {.v = (int *)ix_alloc(ix, (size_t)ix->nfiles * sizeof(int))}; + if (!st.v) { + return; + } + for (int fi = 0; fi < ix->nfiles; fi++) { + rs_file_t *f = &ix->files[fi]; + if (f->kind != RS_FILE_SOURCE || !f->crate_root) { + continue; + } + f->roots[f->nroots++] = f->mod_base; + f->pkg = f->root_pkg; + f->in_lib = f->root_pkg >= 0 && ix->pkgs[f->root_pkg].lib_root == f->mod_base; + f->queued = true; + st.v[st.n++] = fi; + } + while (st.n > 0) { + int fi = st.v[--st.n]; + rs_file_t *f = &ix->files[fi]; + f->queued = false; + for (int d = f->decl_first; d < f->decl_first + f->decl_count; d++) { + int child = ix->decls[d].child; + if (child < 0) { + continue; + } + rs_file_t *c = &ix->files[ix->mods[child].file]; + if (rs_file_inherit(c, f) && !c->queued) { + c->queued = true; + st.v[st.n++] = ix->mods[child].file; + } + } + } +} + +/* A file no crate root reaches still lies in some package's directory: its + * dependencies are that package's. Which target it would belong to (and so + * what `crate` is) stays unknown. */ +static void rs_orphan_packages(rs_index_t *ix) { + for (int fi = 0; fi < ix->nfiles; fi++) { + rs_file_t *f = &ix->files[fi]; + char dir[RS_PATH_BUF]; + const char *base = rs_basename(f->rel_path); + size_t dl = base > f->rel_path ? (size_t)(base - f->rel_path) - SKIP_ONE : 0; + if (f->kind != RS_FILE_SOURCE || f->nroots > 0 || f->pkg != RS_PKG_NONE || + dl >= sizeof(dir)) { + continue; + } + memcpy(dir, f->rel_path, dl); + dir[dl] = '\0'; + for (;;) { + int q = rs_pkg_at(ix, dir); + if (q >= 0 && ix->pkgs[q].has_package) { + f->pkg = q; + break; + } + char *slash = strrchr(dir, '/'); + if (!dir[0]) { + break; + } + dir[slash ? (size_t)(slash - dir) : 0] = '\0'; + } + } +} + +static void rs_product_seed(rs_index_t *ix, rs_stack_t *st, int fi) { + const rs_file_t *f = &ix->files[fi]; + rs_mod_t *root = &ix->mods[f->mod_base]; + if (root->product || f->test_path) { + return; + } + root->product = true; + st->v[st->n++] = fi; +} + +/* Product code: what is reached from a crate root (or an orphan file) outside + * the test directories without passing a `#[cfg(test)]` module or + * declaration. Everything else is test-only. */ +static void rs_product(rs_index_t *ix) { + rs_stack_t st = {.v = (int *)ix_alloc(ix, (size_t)ix->nfiles * sizeof(int))}; + if (!st.v) { + return; + } + for (int fi = 0; fi < ix->nfiles; fi++) { + const rs_file_t *f = &ix->files[fi]; + if (f->kind != RS_FILE_SOURCE) { + continue; + } + bool orphan = !f->crate_root && ix->mods[f->mod_base].attach_count == 0; + ix->orphans += orphan; + if (f->crate_root || orphan) { + rs_product_seed(ix, &st, fi); + } + } + while (st.n > 0) { + const rs_file_t *f = &ix->files[st.v[--st.n]]; + for (int m = SKIP_ONE; m < f->nmods; m++) { + rs_mod_t *mod = &ix->mods[f->mod_base + m]; + mod->product = mod->parent >= 0 && ix->mods[mod->parent].product && !mod->cfg_test; + } + for (int d = f->decl_first; d < f->decl_first + f->decl_count; d++) { + const rs_decl_t *decl = &ix->decls[d]; + if (decl->child >= 0 && !decl->cfg_test && ix->mods[decl->mod].product) { + rs_product_seed(ix, &st, ix->mods[decl->child].file); + } + } + } +} + +/* ── Lookup: state ───────────────────────────────────────────────── */ + +typedef struct { + const rs_index_t *ix; + int pass; /* while imports are resolved: one done in an earlier pass may be read; + * 0: all are done */ + bool assoc; /* associated items are looked up (not while the index is built) */ + bool blocked; /* an import the answer depends on is not resolved yet */ +} rs_look_t; + +/* Where a path is written. */ +typedef struct { + rs_look_t *lk; + int mod; /* the module */ + int root; /* the crate root module it is resolved under; CBM_NOT_FOUND: not known */ + int pkg; /* that crate's package; CBM_NOT_FOUND: not known */ + bool own_lib; /* the package's lib crate is in the extern prelude */ + int edition; + int skip; /* the import being resolved; CBM_NOT_FOUND */ +} rs_at_t; + +/* A path as plain segments (no raw-identifier markers). */ +typedef struct { + const char *const *segs; + int n; + bool global; /* written with a leading `::` */ +} rs_path_t; + +/* What a path names. */ +typedef struct { + rs_set_t ns[RS_NS_COUNT]; /* the last segment, per namespace */ + rs_set_t owners; /* what the last segment was looked up in */ + bool ambiguous; /* a segment before the last names several things */ +} rs_found_t; + +static bool rs_is(const char *s, const char *word) { + return strcmp(s, word) == 0; +} + +static bool rs_import_ready(const rs_look_t *lk, const rs_import_t *imp) { + return imp->state == RS_STATE_DONE && (lk->pass == 0 || imp->done_pass < lk->pass); +} + +/* First binding in binds[lo, hi) that is not below `name` (`upper`: that is + * above it). */ +static int rs_bind_bound(const rs_index_t *ix, int lo, int hi, const char *name, bool upper) { + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + int c = strcmp(ix->binds[mid].name, name); + if (c < 0 || (upper && c == 0)) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return lo; +} + +/* The bindings of module `m` named `name`: their count, the first in *first. */ +static int rs_bind_range(const rs_index_t *ix, int m, const char *name, int *first) { + const rs_mod_t *mod = &ix->mods[m]; + int end = mod->bind_first + mod->bind_count; + *first = rs_bind_bound(ix, mod->bind_first, end, name, false); + return rs_bind_bound(ix, *first, end, name, true) - *first; +} + +static int rs_member_bound(const rs_index_t *ix, int lo, int hi, const char *name, bool upper) { + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + int c = strcmp(ix->members[mid].name, name); + if (c < 0 || (upper && c == 0)) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return lo; +} + +/* The members named `name` among members[first, first + count) (sorted by + * name) whose kind is one of `kinds` and whose namespace is in `ns`. */ +static void rs_owner_members(const rs_index_t *ix, int first, int count, const char *name, int ns, + const char *kinds, rs_set_t *out) { + int lo = rs_member_bound(ix, first, first + count, name, false); + int hi = rs_member_bound(ix, lo, first + count, name, true); + for (int i = lo; i < hi && !out->overflow; i++) { + const rs_member_t *m = &ix->members[i]; + if ((rs_member_ns(m->kind) & ns) && strchr(kinds, m->kind)) { + rs_set_add(out, rs_ent(RS_E_MEMBER, i)); + } + } +} + +/* ── Lookup: module tree questions ───────────────────────────────── */ + +/* The modules `super` names in module `m`: the inline parent, or every module + * that declares the file. An orphan file's parent is not known. */ +static void rs_parents(const rs_index_t *ix, int m, rs_set_t *out) { + const rs_mod_t *mod = &ix->mods[m]; + if (mod->parent >= 0) { + rs_set_add(out, rs_ent(RS_E_MOD, mod->parent)); + return; + } + if (mod->attach_count > RS_SET_CAP || + (mod->attach_count == 0 && !ix->files[mod->file].crate_root)) { + rs_set_add(out, rs_weak(RS_E_GAP)); + return; + } + for (int i = 0; i < mod->attach_count; i++) { + rs_set_add(out, rs_ent(RS_E_MOD, ix->decls[ix->attach[mod->attach_first + i].decl].mod)); + } +} + +/* Is module `m` the module `ancestor` or inside it? 1 yes, 0 no, -1 not + * known (an orphan on the way up, or too far to walk). */ +static int rs_mod_within(const rs_index_t *ix, int m, int ancestor) { + int stack[RS_SET_CAP]; + int sp = 0; + bool unknown = false; + stack[sp++] = m; + for (int steps = 0; sp > 0; steps++) { + int cur = stack[--sp]; + if (cur == ancestor) { + return SKIP_ONE; + } + const rs_mod_t *mod = &ix->mods[cur]; + if (steps >= RS_UP_STEPS || (mod->parent < 0 && mod->attach_count > RS_SET_CAP - sp)) { + return CBM_NOT_FOUND; + } + if (mod->parent >= 0) { + stack[sp++] = mod->parent; + continue; + } + unknown = unknown || (mod->attach_count == 0 && !ix->files[mod->file].crate_root); + for (int i = 0; i < mod->attach_count; i++) { + stack[sp++] = ix->decls[ix->attach[mod->attach_first + i].decl].mod; + } + } + return unknown ? CBM_NOT_FOUND : 0; +} + +/* Are two files part of one crate? 1 yes, 0 no, -1 not known. */ +static int rs_same_crate(const rs_index_t *ix, int fa, int fb) { + const rs_file_t *a = &ix->files[fa]; + const rs_file_t *b = &ix->files[fb]; + if (fa == fb) { + return SKIP_ONE; + } + if (a->roots_top || b->roots_top || a->nroots == 0 || b->nroots == 0) { + return CBM_NOT_FOUND; + } + for (int i = 0; i < a->nroots; i++) { + for (int j = 0; j < b->nroots; j++) { + if (a->roots[i] == b->roots[j]) { + return SKIP_ONE; + } + } + } + return 0; +} + +/* Can module `from` see a binding of visibility `vis` declared in module + * `owner`? 1 yes, 0 no, -1 not known. */ +static int rs_visible(const rs_index_t *ix, char vis, int owner, int from) { + if (vis == 'p' || owner == from) { + return SKIP_ONE; + } + if (vis == 'c') { + return rs_same_crate(ix, ix->mods[owner].file, ix->mods[from].file); + } + if (vis == '-') { + return rs_mod_within(ix, from, owner); + } + if (vis != 's') { + return CBM_NOT_FOUND; /* pub(in path): the path is not carried */ + } + rs_set_t up = {0}; + rs_parents(ix, owner, &up); + int best = up.n == 0 ? rs_mod_within(ix, from, owner) : 0; + for (int i = 0; i < up.n && best <= 0; i++) { + int v = up.v[i].kind == RS_E_MOD ? rs_mod_within(ix, from, up.v[i].idx) : CBM_NOT_FOUND; + best = (v > 0 || best == 0) ? v : best; + } + return best; +} + +/* ── Lookup: names a macro's body declares ───────────────────────── */ + +static int rs_macuse_cmp(const void *a, const void *b) { + const rs_macuse_t *x = (const rs_macuse_t *)a; + const rs_macuse_t *y = (const rs_macuse_t *)b; + if (x->mod != y->mod) { + return x->mod < y->mod ? -1 : 1; + } + return strcmp(x->macro, y->macro); +} + +static int rs_macdecl_cmp(const void *a, const void *b) { + const rs_macdecl_t *x = (const rs_macdecl_t *)a; + const rs_macdecl_t *y = (const rs_macdecl_t *)b; + int c = strcmp(x->name, y->name); + return c ? c : strcmp(x->macro, y->macro); +} + +/* True when module `m` invokes a macro named `macro` among its items. */ +static bool rs_mod_invokes(const rs_index_t *ix, int m, const char *macro) { + rs_macuse_t key = {.mod = m, .macro = macro}; + int lo = 0; + int hi = ix->nmacuses; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (rs_macuse_cmp(&ix->macuses[mid], &key) < 0) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return lo < ix->nmacuses && rs_macuse_cmp(&ix->macuses[lo], &key) == 0; +} + +/* May a macro invoked in module `m` declare `name` there? True when the body + * of some `macro_rules!` macro of the repository declares that name and the + * module invokes a macro of that macro's name. Macros are matched by name + * only (which macro an invocation means is not resolved), so this can say + * "maybe" too often, never too rarely. */ +static bool rs_macro_may_declare(const rs_index_t *ix, int m, const char *name) { + int lo = 0; + int hi = ix->nmacdecls; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (strcmp(ix->macdecls[mid].name, name) < 0) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + for (int i = lo; i < ix->nmacdecls && rs_is(ix->macdecls[i].name, name); i++) { + if (i - lo >= RS_GLOB_VISIT || rs_mod_invokes(ix, m, ix->macdecls[i].macro)) { + return true; /* declared by more macros than are checked: maybe */ + } + } + return false; +} + +/* ── Lookup: level 1, a module's own bindings ────────────────────── */ + +typedef struct { + int skip; /* an import that is not consulted; CBM_NOT_FOUND */ + int from; /* a glob import of this module asks: only what it can see; + * CBM_NOT_FOUND: everything */ +} rs_own_opt_t; + +/* What binding `b` is in namespace `ns`; RS_E_NONE when it is nothing there. + * `glob`: asked through a glob import, which a textual macro does not pass. */ +static rs_ent_t rs_bind_ent(rs_look_t *lk, const rs_bind_t *b, int ns, bool glob) { + const rs_index_t *ix = lk->ix; + rs_ent_t none = {0}; + if (b->kind == RS_BIND_ITEM) { + const rs_item_t *it = &ix->items[b->ref]; + bool textual = it->kind == 'M' && !it->macro_export; + return ((rs_item_ns(it->kind) & ns) && !(glob && textual)) ? rs_ent(RS_E_ITEM, b->ref) + : none; + } + if (b->kind == RS_BIND_CHILD) { + if (ns != RS_NS_TYPE) { + return none; + } + return b->ref >= 0 ? rs_ent(RS_E_MOD, b->ref) : rs_ent(RS_E_GAP, 0); + } + const rs_import_t *imp = &ix->imports[b->ref]; + if (!rs_import_ready(lk, imp)) { + lk->blocked = true; + return none; + } + bool type_only = imp->kind != 'b'; + if (type_only && ns != RS_NS_TYPE) { + return none; + } + rs_ent_t e = imp->res[type_only ? 0 : rs_ns_index(ns)]; + e.via_import = e.kind != RS_E_NONE; + return e; +} + +/* Level 1: what module `m` itself binds as `name` in namespace `ns`. A name + * it does not show may still be declared there when a macro invocation could + * declare it or the file did not parse: that is a gap, not an absence. */ +static void rs_own(rs_look_t *lk, int m, const char *name, int ns, const rs_own_opt_t *opt, + rs_set_t *out) { + const rs_index_t *ix = lk->ix; + int first = 0; + int n = rs_bind_range(ix, m, name, &first); + bool maybe = false; + rs_set_t vague = {0}; /* imports of something whose namespace is not known */ + for (int i = first; i < first + n && !out->overflow; i++) { + const rs_bind_t *b = &ix->binds[i]; + if (b->kind == RS_BIND_MAYBE) { + maybe = true; + continue; + } + if (b->kind == RS_BIND_IMPORT && b->ref == opt->skip) { + continue; + } + /* visibility first: a binding the asking module cannot see is not + * looked at, so an import of its own that is not resolved yet does + * not hold the answer up */ + int v = opt->from >= 0 ? rs_visible(ix, b->vis, m, opt->from) : SKIP_ONE; + if (v == 0) { + continue; + } + rs_ent_t e = rs_bind_ent(lk, b, ns, opt->from >= 0); + if (e.kind == RS_E_NONE) { + continue; + } + bool spread = b->kind == RS_BIND_IMPORT && ix->imports[b->ref].spread; + rs_set_add(spread ? &vague : out, v > 0 ? e : rs_weak(RS_E_GAP)); + } + if (rs_set_empty(out)) { + /* nothing the module really has in this namespace: the import may be it */ + rs_set_merge(out, &vague); + } + if (rs_set_empty(out) && + (maybe || ix->files[ix->mods[m].file].broken || rs_macro_may_declare(ix, m, name))) { + rs_set_add(out, rs_weak(RS_E_GAP)); + } +} + +/* ── Lookup: level 2, glob imports ───────────────────────────────── */ + +static bool rs_int_in(const int *v, int n, int x) { + for (int i = 0; i < n; i++) { + if (v[i] == x) { + return true; + } + } + return false; +} + +/* What a walk through glob imports met besides bindings. */ +typedef struct { + bool open; /* a glob of something outside the repository: it may hold the name */ + bool gap; /* a glob that cannot be followed, or a binding of unknown visibility */ + bool pending; /* a glob that is not resolved yet (only while imports are resolved) */ +} rs_glob_flags_t; + +/* One glob import `imp` of module `cur`, asked for `name` on behalf of module + * `m`. Returns the module whose own globs are to be asked next (the target + * does not bind the name itself), or CBM_NOT_FOUND. */ +static int rs_glob_step(rs_look_t *lk, const rs_import_t *imp, int cur, const rs_own_opt_t *opt, + const char *name, int ns, rs_set_t *out, rs_glob_flags_t *fl) { + const rs_index_t *ix = lk->ix; + int m = opt->from; + int v = cur == m ? SKIP_ONE : rs_visible(ix, imp->vis, cur, m); + if (v <= 0) { + fl->gap = fl->gap || v < 0; + return CBM_NOT_FOUND; + } + if (!rs_import_ready(lk, imp)) { + fl->pending = true; + return CBM_NOT_FOUND; + } + rs_ent_t t = imp->res[0]; + if (t.kind == RS_E_MOD) { + rs_set_t found = {0}; + rs_own(lk, t.idx, name, ns, opt, &found); + for (int i = 0; i < found.n; i++) { + if (found.v[i].weak) { + fl->gap = true; + } else { + rs_set_add(out, found.v[i]); + } + } + out->overflow = out->overflow || found.overflow; + return rs_set_empty(&found) ? t.idx : CBM_NOT_FOUND; + } + if (t.kind == RS_E_ITEM && ix->items[t.idx].kind == 'E') { + const rs_item_t *en = &ix->items[t.idx]; + rs_owner_members(ix, en->member_first, en->member_count, name, ns, "v", out); + return CBM_NOT_FOUND; + } + fl->open = fl->open || t.kind == RS_E_EXTERNAL; + fl->gap = fl->gap || t.kind != RS_E_EXTERNAL; + return CBM_NOT_FOUND; +} + +/* Level 2: what the glob imports of module `m` bring in as `name`, through + * re-exporting globs. `skip`: the import being resolved, which is neither + * asked as a glob nor read as a binding (it cannot supply its own name). + * Anything that cannot be followed is a gap. */ +static void rs_globs(rs_look_t *lk, int m, const char *name, int ns, int skip, rs_set_t *out, + rs_glob_flags_t *fl) { + const rs_index_t *ix = lk->ix; + rs_own_opt_t opt = {.skip = skip, .from = m}; + int seen[RS_GLOB_VISIT]; /* also the queue: seen[next, nseen) is still to be asked */ + int nseen = 0; + int next = 0; + int steps = 0; + seen[nseen++] = m; + while (next < nseen && !out->overflow) { + int cur = seen[next++]; + const rs_mod_t *cm = &ix->mods[cur]; + for (int g = 0; g < cm->glob_count; g++) { + int gi = ix->globs[cm->glob_first + g].ref; + if (gi == skip) { + continue; + } + if (++steps > RS_UP_STEPS) { + fl->gap = true; + break; + } + int into = rs_glob_step(lk, &ix->imports[gi], cur, &opt, name, ns, out, fl); + if (into < 0 || rs_int_in(seen, nseen, into)) { + continue; + } + if (nseen >= RS_GLOB_VISIT) { + fl->gap = true; + continue; + } + seen[nseen++] = into; + } + } + if (rs_set_empty(out) && fl->gap) { + rs_set_add(out, rs_weak(RS_E_GAP)); + } +} + +/* Levels 1 and 2 of module `m`: what `m::name` names. A glob that is not + * resolved yet may still bring the name in: the answer has to wait. */ +static void rs_mod_member(rs_look_t *lk, int m, const char *name, int ns, int skip, rs_set_t *out) { + rs_own_opt_t opt = {.skip = skip, .from = CBM_NOT_FOUND}; + rs_glob_flags_t fl = {0}; + rs_own(lk, m, name, ns, &opt, out); + if (!rs_set_empty(out)) { + return; + } + rs_globs(lk, m, name, ns, skip, out, &fl); + bool real = out->n > 0 && !out->v[0].weak; + if (fl.pending && !real) { + memset(out, 0, sizeof(*out)); + lk->blocked = true; + return; + } + if (rs_set_empty(out) && fl.open) { + rs_set_add(out, rs_weak(RS_E_EXTERNAL)); + } +} + +/* ── Lookup: levels 3 and 4, the preludes ────────────────────────── */ + +/* Defined by the language and its standard library, not by any corpus. */ +static const char *const RS_BUILTIN_CRATES[] = {"std", "core", "alloc", "proc_macro", "test"}; + +/* The std prelude, type namespace (all editions; a name of a later edition's + * prelude is still the standard library's). */ +static const char *const RS_PRELUDE_TYPES[] = {"Option", + "Result", + "Vec", + "String", + "Box", + "Some", + "None", + "Ok", + "Err", + "Send", + "Sized", + "Sync", + "Unpin", + "Copy", + "Drop", + "Fn", + "FnMut", + "FnOnce", + "AsyncFn", + "AsyncFnMut", + "AsyncFnOnce", + "AsMut", + "AsRef", + "From", + "Into", + "Clone", + "Default", + "Eq", + "Ord", + "PartialEq", + "PartialOrd", + "Extend", + "IntoIterator", + "Iterator", + "DoubleEndedIterator", + "ExactSizeIterator", + "ToOwned", + "ToString", + "TryFrom", + "TryInto", + "FromIterator", + "Future", + "IntoFuture"}; + +static const char *const RS_PRELUDE_VALUES[] = { + "Some", "None", "Ok", "Err", "drop", "size_of", "size_of_val", "align_of", "align_of_val"}; + +/* std's macros, and the derive and attribute macros of the prelude. */ +static const char *const RS_STD_MACROS[] = {"assert", + "assert_eq", + "assert_ne", + "cfg", + "column", + "compile_error", + "concat", + "dbg", + "debug_assert", + "debug_assert_eq", + "debug_assert_ne", + "env", + "eprint", + "eprintln", + "file", + "format", + "format_args", + "include", + "include_bytes", + "include_str", + "is_x86_feature_detected", + "line", + "matches", + "module_path", + "option_env", + "panic", + "print", + "println", + "stringify", + "thread_local", + "todo", + "try", + "unimplemented", + "unreachable", + "vec", + "write", + "writeln", + "Clone", + "Copy", + "Debug", + "Default", + "Eq", + "Hash", + "Ord", + "PartialEq", + "PartialOrd", + "derive", + "test", + "bench", + "global_allocator"}; + +/* rustdoc's names for primitive types (resolve_primitive). */ +static const char *const RS_PRIMITIVES[] = { + "isize", "i8", "i16", "i32", "i64", "i128", "usize", "u8", "u16", + "u32", "u64", "u128", "f16", "f32", "f64", "f128", "char", "bool", + "true", "false", "str", "&str", "slice", "array", "tuple", "unit", "pointer", + "*const", "*mut", "reference", "&", "&mut", "fn", "never", "!"}; + +static bool rs_std_name(const char *name, int ns) { + if (ns == RS_NS_TYPE) { + return RS_IN(RS_PRELUDE_TYPES, name); + } + return ns == RS_NS_VALUE ? RS_IN(RS_PRELUDE_VALUES, name) : RS_IN(RS_STD_MACROS, name); +} + +/* Level 3: the extern prelude of the crate the path is written in. `*unknown`: + * that crate's dependencies are not known, so a miss says nothing. */ +static void rs_extern(const rs_at_t *at, const char *name, rs_set_t *out, bool *unknown) { + const rs_index_t *ix = at->lk->ix; + if (RS_IN(RS_BUILTIN_CRATES, name)) { + rs_set_add(out, rs_ent(RS_E_EXTERNAL, 0)); + return; + } + if (at->pkg < 0) { + *unknown = true; + return; + } + const rs_pkg_t *p = &ix->pkgs[at->pkg]; + if (p->lib_root >= 0 && p->lib_name && rs_is(p->lib_name, name)) { + /* the package's own lib: in scope in its other targets only, and which + * target a file without a crate root belongs to is not known */ + if (at->own_lib) { + rs_set_add(out, rs_ent(RS_E_MOD, p->lib_root)); + } + *unknown = !at->own_lib && at->root < 0; + if (at->own_lib || *unknown) { + return; + } + } + int lo = 0; + int hi = p->ndeps; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (strcmp(p->deps[mid].key, name) < 0) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + if (lo < p->ndeps && rs_is(p->deps[lo].key, name)) { + rs_set_add(out, p->deps[lo].target); + return; + } + *unknown = p->broken; +} + +/* A `macro_rules!` macro is in textual scope in the modules below the one + * that defines it. */ +static void rs_textual_macro(const rs_index_t *ix, int m, const char *name, rs_set_t *out) { + int cur = m; + for (int step = 0; step < RS_UP_STEPS; step++) { + const rs_mod_t *mod = &ix->mods[cur]; + int up = mod->parent; + if (up < 0 && mod->attach_count == SKIP_ONE) { + up = ix->decls[ix->attach[mod->attach_first].decl].mod; + } + if (up < 0) { + return; + } + int first = 0; + int n = rs_bind_range(ix, up, name, &first); + for (int i = first; i < first + n; i++) { + const rs_bind_t *b = &ix->binds[i]; + if (b->kind == RS_BIND_ITEM && ix->items[b->ref].kind == 'M') { + rs_set_add(out, rs_ent(RS_E_ITEM, b->ref)); + return; + } + } + cur = up; + } + rs_set_add(out, rs_weak(RS_E_GAP)); /* deeper than is walked: not known */ +} + +/* The lexical lookup of a name: levels 1 to 4, the first that binds it in the + * namespace. + * + * Level 2 can hold a name without showing it: a glob of something outside the + * repository, a glob that cannot be followed, and (while imports are being + * resolved) a glob that is not resolved yet. What then follows depends on + * whose path it is: + * - an import path may look on. rustc rejects an import path whose first + * name is brought in by a glob AND bound further out (E0659), so in + * source that compiles a name found at level 3 or 4 is not in the glob; + * - any other path (a doc link, a type) may not: there a glob's name + * silently wins over the preludes. The lookup ends with a weak answer. + * A name found nowhere waits for an unresolved glob, which may still bring + * it in. */ +static void rs_lexical(const rs_at_t *at, const char *name, int ns, rs_set_t *out) { + rs_look_t *lk = at->lk; + rs_own_opt_t opt = {.skip = at->skip, .from = CBM_NOT_FOUND}; + rs_glob_flags_t fl = {0}; + rs_set_t outer = {0}; + bool unknown = false; + rs_own(lk, at->mod, name, ns, &opt, out); + if (rs_set_empty(out) && ns == RS_NS_MACRO) { + rs_textual_macro(lk->ix, at->mod, name, out); + } + if (!rs_set_empty(out)) { + if (out->v[0].weak && at->skip >= 0 && ns == RS_NS_TYPE) { + /* an import path in a module that may declare more than it shows: + * a crate of the extern prelude stands all the same, because an + * import path whose first name is both a local name and a crate + * is an error (E0659) */ + rs_extern(at, name, &outer, &unknown); + if (!rs_set_empty(&outer)) { + *out = outer; + } + } + return; + } + rs_globs(lk, at->mod, name, ns, at->skip, out, &fl); + if (out->n > 0 && !out->v[0].weak) { + return; /* brought in by a glob that is resolved */ + } + bool hidden = fl.open || fl.pending || !rs_set_empty(out); + if (!hidden || at->skip >= 0) { + if (ns == RS_NS_TYPE) { + rs_extern(at, name, &outer, &unknown); + } + if (rs_set_empty(&outer) && rs_std_name(name, ns)) { + rs_set_add(&outer, rs_ent(RS_E_EXTERNAL, 0)); + } + if (!rs_set_empty(&outer)) { + *out = outer; + return; + } + } + if (fl.pending) { + memset(out, 0, sizeof(*out)); + lk->blocked = true; + return; + } + if (rs_set_empty(out) && (fl.open || unknown)) { + rs_set_add(out, rs_weak(fl.open ? RS_E_EXTERNAL : RS_E_GAP)); + } + if (rs_set_empty(out) && at->skip >= 0 && ns == RS_NS_TYPE && at->pkg >= 0) { + /* the first name of an import path, bound nowhere: the module shows + * everything it declares (else the answer above was a gap), so what + * is left is a crate of the extern prelude under a name the manifest + * does not give (a lib target named unlike its package) */ + rs_set_add(out, rs_weak(RS_E_EXTERNAL)); + } +} + +/* ── Lookup: associated items ────────────────────────────────────── */ + +/* Declarations of one entity under different cfg: two items of one module, + * name and kind, or two members of one owner. */ +static bool rs_cfg_twins(const rs_index_t *ix, rs_ent_t a, rs_ent_t b) { + if (a.kind != b.kind) { + return false; + } + if (a.kind == RS_E_ITEM) { + const rs_item_t *x = &ix->items[a.idx]; + const rs_item_t *y = &ix->items[b.idx]; + return x->mod == y->mod && x->kind == y->kind && rs_is(x->name, y->name); + } + if (a.kind == RS_E_MEMBER) { + const rs_member_t *x = &ix->members[a.idx]; + const rs_member_t *y = &ix->members[b.idx]; + return x->owner == y->owner && x->kind == y->kind && rs_is(x->name, y->name); + } + return false; +} + +static bool rs_has_node(const rs_index_t *ix, rs_ent_t e) { + if (e.kind == RS_E_ITEM) { + return ix->items[e.idx].label != 0; + } + return e.kind == RS_E_MEMBER && ix->members[e.idx].label != 0; +} + +/* The graph keeps one node per qualified name, so of two cfg twins the one + * that owns the node stands for both. Two with a node each stay two. */ +static void rs_collapse(const rs_index_t *ix, rs_set_t *s) { + for (int i = 0; i < s->n; i++) { + for (int j = i + SKIP_ONE; j < s->n;) { + bool ni = rs_has_node(ix, s->v[i]); + bool nj = rs_has_node(ix, s->v[j]); + if (!rs_cfg_twins(ix, s->v[i], s->v[j]) || (ni && nj)) { + j++; + continue; + } + if (nj) { + bool via = s->v[i].via_import || s->v[j].via_import; + s->v[i] = s->v[j]; + s->v[i].via_import = via; + } + s->v[j] = s->v[--s->n]; + } + } +} + +/* The item a chain of type aliases ends at; an external or gap entity when it + * leaves what is known. */ +static rs_ent_t rs_alias_end(const rs_index_t *ix, int item) { + rs_ent_t e = rs_ent(RS_E_ITEM, item); + for (int hop = 0; hop < RS_ALIAS_HOPS; hop++) { + if (ix->items[e.idx].kind != 'A') { + return e; + } + e = ix->items[e.idx].alias_ent; + if (e.kind != RS_E_ITEM) { + return rs_weak(e.kind == RS_E_EXTERNAL ? RS_E_EXTERNAL : RS_E_GAP); + } + } + return rs_weak(RS_E_GAP); +} + +static int rs_assoc_bound(const rs_index_t *ix, int item, const char *name, bool upper) { + int lo = 0; + int hi = ix->nassoc; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + const rs_assoc_t *a = &ix->assoc[mid]; + int c = a->item != item ? (a->item < item ? -1 : 1) : strcmp(a->name, name); + if (c < 0 || (upper && c == 0)) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return lo; +} + +static const char *rs_last_segment(const char *path) { + const char *last = path; + for (const char *p = path; *p; p++) { + if (p[0] == ':' && p[SKIP_ONE] == ':') { + last = p + PAIR_LEN; + } + } + return rs_plain(last); +} + +/* Is the trait of impl `im` in scope in the module the path is written in? + * rustdoc offers a trait impl's items only then. 1 yes, 0 no, -1 not known. + * A trait outside the repository is known by its name only. */ +static int rs_trait_in_scope(const rs_at_t *at, const rs_impl_t *im) { + const rs_index_t *ix = at->lk->ix; + rs_ent_t t = im->trait_ent; + if (t.kind != RS_E_ITEM && t.kind != RS_E_EXTERNAL) { + return CBM_NOT_FOUND; + } + const char *name = + t.kind == RS_E_ITEM ? ix->items[t.idx].name : rs_last_segment(im->trait_path); + rs_set_t found = {0}; + rs_lexical(at, name, RS_NS_TYPE, &found); + if (rs_set_has(&found, t)) { + return SKIP_ONE; + } + const rs_mod_t *mod = &ix->mods[at->mod]; + for (int i = 0; i < mod->named_count; i++) { /* imported under another name */ + const rs_import_t *imp = &ix->imports[ix->named[mod->named_first + i].ref]; + rs_ent_t e = imp->res[0]; + if (imp->kind != 'b' || e.kind != t.kind || imp->nsegs == 0) { + continue; + } + if (t.kind == RS_E_ITEM ? e.idx == t.idx : rs_is(imp->segs[imp->nsegs - SKIP_ONE], name)) { + return SKIP_ONE; + } + } + return rs_set_has(&found, rs_ent(RS_E_GAP, 0)) ? CBM_NOT_FOUND : 0; +} + +/* Items a trait declares and an impl of it for type `item` does not define + * itself. Returns true when some could not be decided. */ +static bool rs_trait_defaults(const rs_at_t *at, int item, const char *name, int ns, + rs_set_t *out) { + const rs_index_t *ix = at->lk->ix; + const rs_item_t *it = &ix->items[item]; + bool unsure = it->impl_count > RS_IMPL_SCAN; + for (int i = 0; i < it->impl_count && i < RS_IMPL_SCAN && !out->overflow; i++) { + const rs_impl_t *im = &ix->impls[ix->item_impls[it->impl_first + i].impl]; + if (!im->trait_path || im->trait_ent.kind != RS_E_ITEM) { + continue; + } + const rs_item_t *tr = &ix->items[im->trait_ent.idx]; + rs_set_t own = {0}; + rs_set_t dflt = {0}; + rs_owner_members(ix, im->member_first, im->member_count, name, ns, "fct", &own); + if (!rs_set_empty(&own) || tr->kind != 'R') { + continue; + } + rs_owner_members(ix, tr->member_first, tr->member_count, name, ns, "fct", &dflt); + if (rs_set_empty(&dflt)) { + continue; + } + int v = rs_trait_in_scope(at, im); + if (v > 0) { + rs_set_merge(out, &dflt); + } + unsure = unsure || v < 0; + } + return unsure; +} + +/* Two impls of one trait: the scope question has one answer for both. */ +static bool rs_same_trait(const rs_impl_t *a, const rs_impl_t *b) { + if (a->trait_ent.kind != b->trait_ent.kind) { + return false; + } + if (a->trait_ent.kind == RS_E_ITEM) { + return a->trait_ent.idx == b->trait_ent.idx; + } + return a->trait_ent.kind == RS_E_EXTERNAL && + rs_is(rs_last_segment(a->trait_path), rs_last_segment(b->trait_path)); +} + +/* The items of the impls of type `item` named `name`: inherent impls first, + * then the trait impls whose trait is in scope where the path is written. */ +static void rs_impl_items(const rs_at_t *at, int item, const char *name, int ns, rs_set_t *out) { + const rs_index_t *ix = at->lk->ix; + int first = rs_assoc_bound(ix, item, name, false); + int end = rs_assoc_bound(ix, item, name, true); + bool unsure = false; + const rs_impl_t *asked = NULL; /* the impl whose trait was asked about last */ + int answer = 0; + for (int i = first; i < end && !out->overflow; i++) { + const rs_assoc_t *a = &ix->assoc[i]; + if (!ix->impls[a->impl].trait_path && (rs_member_ns(ix->members[a->member].kind) & ns)) { + rs_set_add(out, rs_ent(RS_E_MEMBER, a->member)); + } + } + bool inherent = !rs_set_empty(out); + for (int i = first; i < end && !inherent && !out->overflow; i++) { + const rs_assoc_t *a = &ix->assoc[i]; + const rs_impl_t *im = &ix->impls[a->impl]; + if (!im->trait_path || !(rs_member_ns(ix->members[a->member].kind) & ns)) { + continue; + } + if (!asked || !rs_same_trait(asked, im)) { + asked = im; + answer = rs_trait_in_scope(at, im); + } + if (answer > 0) { + rs_set_add(out, rs_ent(RS_E_MEMBER, a->member)); + } + unsure = unsure || answer < 0; + } + if (rs_set_empty(out)) { + unsure = rs_trait_defaults(at, item, name, ns, out) || unsure; + } + if (rs_set_empty(out) && unsure) { + rs_set_add(out, rs_weak(RS_E_GAP)); + } +} + +/* `Type::name`: a variant, an item of an impl, a field (rustdoc's order). */ +static void rs_type_assoc(const rs_at_t *at, int item, const char *name, int ns, rs_set_t *out) { + const rs_index_t *ix = at->lk->ix; + rs_ent_t end = rs_alias_end(ix, item); + if (end.kind != RS_E_ITEM) { + rs_set_add(out, end); + return; + } + const rs_item_t *it = &ix->items[end.idx]; + if (it->kind == 'R') { + rs_owner_members(ix, it->member_first, it->member_count, name, ns, "fct", out); + return; + } + if (it->kind != 'S' && it->kind != 'E' && it->kind != 'U') { + return; + } + if (it->kind == 'E') { + rs_owner_members(ix, it->member_first, it->member_count, name, ns, "v", out); + } + if (rs_set_empty(out)) { + rs_impl_items(at, end.idx, name, ns, out); + } + if (rs_set_empty(out) && ns == RS_NS_VALUE) { + rs_owner_members(ix, it->member_first, it->member_count, name, ns, "d", out); + } +} + +/* What `name` is inside item `item`. While the index is being built only an + * enum's variants are known; anything else through a type is not followed. */ +static void rs_item_member(const rs_at_t *at, int item, const char *name, int ns, rs_set_t *out) { + const rs_index_t *ix = at->lk->ix; + const rs_item_t *it = &ix->items[item]; + if (!rs_item_is_type(it->kind)) { + return; /* a function, a constant, a macro: nothing is inside */ + } + if (at->lk->assoc) { + rs_type_assoc(at, item, name, ns, out); + return; + } + if (it->kind == 'E') { + rs_owner_members(ix, it->member_first, it->member_count, name, ns, "v", out); + } + if (rs_set_empty(out)) { + rs_set_add(out, rs_weak(RS_E_GAP)); + } +} + +/* What `name` is inside `owner`, in namespace `ns`. */ +static void rs_member(const rs_at_t *at, rs_ent_t owner, const char *name, int ns, rs_set_t *out) { + const rs_index_t *ix = at->lk->ix; + switch (owner.kind) { + case RS_E_MOD: + rs_mod_member(at->lk, owner.idx, name, ns, at->skip, out); + break; + case RS_E_ITEM: + rs_item_member(at, owner.idx, name, ns, out); + break; + case RS_E_IMPL: { + const rs_impl_t *im = &ix->impls[owner.idx]; + rs_owner_members(ix, im->member_first, im->member_count, name, ns, "fct", out); + if (rs_set_empty(out)) { + rs_set_add(out, rs_weak(im->self_ent.kind == RS_E_EXTERNAL ? RS_E_EXTERNAL : RS_E_GAP)); + } + break; + } + case RS_E_MEMBER: + if (ix->members[owner.idx].kind == 'v') { + rs_set_add(out, rs_weak(RS_E_GAP)); /* a variant's fields are not carried */ + } + break; + case RS_E_EXTERNAL: + case RS_E_GAP: + rs_set_add(out, rs_weak(owner.kind)); + break; + default: + break; + } +} + +/* ── Lookup: a whole path ────────────────────────────────────────── */ + +/* The modules or the type that the keywords a path starts with name: `crate`, + * `self`, `super` (repeatable), `Self`, or a leading `::`. Returns how many + * segments they are; 0 when the path starts with a plain name. */ +static int rs_path_head(const rs_at_t *at, const rs_path_t *p, rs_ent_t self, rs_set_t *cur) { + const rs_index_t *ix = at->lk->ix; + const char *first = p->segs[0]; + int i = SKIP_ONE; + if (p->global) { + bool unknown = false; + rs_extern(at, first, cur, &unknown); + if (rs_set_empty(cur) && (unknown || at->edition < RS_EDITION_2018)) { + rs_set_add(cur, rs_weak(RS_E_GAP)); /* before 2018 `::name` is crate-relative */ + } + } else if (rs_is(first, "crate")) { + rs_set_add(cur, at->root >= 0 ? rs_ent(RS_E_MOD, at->root) : rs_weak(RS_E_GAP)); + } else if (rs_is(first, "self")) { + rs_set_add(cur, rs_ent(RS_E_MOD, at->mod)); + } else if (rs_is(first, "super")) { + rs_parents(ix, at->mod, cur); + } else if (rs_is(first, "Self")) { + rs_set_add(cur, self); + } else { + return 0; + } + for (; i < p->n && rs_is(p->segs[i], "super"); i++) { + rs_set_t up = {0}; + for (int k = 0; k < cur->n; k++) { + if (cur->v[k].kind == RS_E_MOD) { + rs_parents(ix, cur->v[k].idx, &up); + } else { + rs_set_add(&up, rs_weak(RS_E_GAP)); + } + } + up.overflow = up.overflow || cur->overflow; + *cur = up; + } + return i; +} + +/* A name looked up from the crate root: an edition-2015 `use` path. */ +static void rs_crate_relative(const rs_at_t *at, const char *name, int ns, rs_set_t *out) { + bool unknown = false; + if (ns == RS_NS_TYPE && RS_IN(RS_BUILTIN_CRATES, name)) { + rs_set_add(out, rs_ent(RS_E_EXTERNAL, 0)); + return; + } + if (at->root < 0) { + rs_set_add(out, rs_weak(RS_E_GAP)); + return; + } + rs_mod_member(at->lk, at->root, name, ns, at->skip, out); + if (rs_set_empty(out) && ns == RS_NS_TYPE) { + rs_extern(at, name, out, &unknown); + } + if (rs_set_empty(out) && unknown) { + rs_set_add(out, rs_weak(RS_E_GAP)); + } +} + +/* The first segment of a path that starts with a plain name. A doc link, a + * type path and an edition-2018 import look it up lexically; an edition-2015 + * import starts at the crate root. With the edition unknown both must agree. */ +static void rs_first(const rs_at_t *at, const char *name, int ns, bool import, rs_set_t *out) { + if (!import || at->edition >= RS_EDITION_2018) { + rs_lexical(at, name, ns, out); + return; + } + rs_set_t rooted = {0}; + rs_crate_relative(at, name, ns, &rooted); + if (at->edition == RS_EDITION_2015) { + *out = rooted; + return; + } + rs_lexical(at, name, ns, out); + if (!rs_set_equal(out, &rooted)) { + memset(out, 0, sizeof(*out)); + rs_set_add(out, rs_weak(RS_E_GAP)); + } +} + +/* One segment before the last: what `name` is, as a type or module, inside + * each thing of `cur`. false when one of them holds several. */ +static bool rs_walk_step(const rs_at_t *at, const rs_set_t *cur, const char *name, rs_set_t *next) { + for (int k = 0; k < cur->n; k++) { + rs_set_t one = {0}; + rs_member(at, cur->v[k], name, RS_NS_TYPE, &one); + rs_collapse(at->lk->ix, &one); + if (one.n > SKIP_ONE || one.overflow) { + return false; + } + rs_set_merge(next, &one); + } + return true; +} + +/* Resolve path `p` written at `at`: the candidates of its last segment in + * every namespace of `mask`. */ +static void rs_walk(const rs_at_t *at, const rs_path_t *p, int mask, rs_ent_t self, bool import, + rs_found_t *out) { + rs_set_t cur = {0}; + int i = rs_path_head(at, p, self, &cur); + if (i == 0 && p->n == SKIP_ONE) { + for (int k = 0; k < RS_NS_COUNT; k++) { + if (mask & RS_NS_LIST[k]) { + rs_first(at, p->segs[0], RS_NS_LIST[k], import, &out->ns[k]); + } + } + return; + } + if (i == 0) { + rs_first(at, p->segs[0], RS_NS_TYPE, import, &cur); + rs_collapse(at->lk->ix, &cur); + if (cur.n > SKIP_ONE) { + out->ambiguous = true; + return; + } + if (rs_set_empty(&cur) && RS_IN(RS_PRIMITIVES, p->segs[0])) { + rs_set_add(&cur, rs_ent(RS_E_EXTERNAL, 0)); /* `str::len`: of a primitive type */ + } + i = SKIP_ONE; + } + if (cur.overflow) { + out->ambiguous = true; + return; + } + if (i == p->n) { /* the keywords are the whole path */ + if (mask & RS_NS_TYPE) { + rs_set_merge(&out->ns[0], &cur); + } + return; + } + for (; i + SKIP_ONE < p->n && !rs_set_empty(&cur); i++) { + rs_set_t next = {0}; + if (!rs_walk_step(at, &cur, p->segs[i], &next)) { + out->ambiguous = true; + return; + } + cur = next; + } + for (int k = 0; k < RS_NS_COUNT; k++) { + for (int e = 0; (mask & RS_NS_LIST[k]) && e < cur.n; e++) { + rs_member(at, cur.v[e], p->segs[p->n - SKIP_ONE], RS_NS_LIST[k], &out->ns[k]); + } + } + out->owners = cur; +} + +/* The contexts a path written in module `mod` is resolved under: one per + * crate root of its file, or a single one without a root when the file has + * none it can name (an orphan, or a file of more crates than are kept). */ +static int rs_contexts(rs_look_t *lk, int mod, int skip, rs_at_t out[RS_ROOTS_KEPT]) { + const rs_index_t *ix = lk->ix; + const rs_file_t *f = &ix->files[ix->mods[mod].file]; + rs_at_t base = { + .lk = lk, .mod = mod, .root = CBM_NOT_FOUND, .pkg = CBM_NOT_FOUND, .skip = skip}; + if (f->nroots == 0 || f->roots_top) { + base.pkg = f->pkg >= 0 ? f->pkg : CBM_NOT_FOUND; + base.own_lib = base.pkg >= 0 && f->roots_top && !f->in_lib; + base.edition = rs_edition(ix, base.pkg); + out[0] = base; + return SKIP_ONE; + } + for (int i = 0; i < f->nroots; i++) { + int pkg = ix->files[ix->mods[f->roots[i]].file].root_pkg; + out[i] = base; + out[i].root = f->roots[i]; + out[i].pkg = pkg >= 0 ? pkg : CBM_NOT_FOUND; + out[i].own_lib = pkg >= 0 && ix->pkgs[pkg].lib_root != f->roots[i]; + out[i].edition = rs_edition(ix, out[i].pkg); + } + return f->nroots; +} + +/* Resolve `p` written in module `mod`, under every crate root of its file: + * what the roots find is united, so roots that disagree make it ambiguous. + * `import`: the import whose path it is, CBM_NOT_FOUND for any other path. */ +static void rs_resolve_path(rs_look_t *lk, int mod, const rs_path_t *p, int mask, rs_ent_t self, + int import, rs_found_t *out) { + rs_at_t at[RS_ROOTS_KEPT]; + int n = rs_contexts(lk, mod, import, at); + memset(out, 0, sizeof(*out)); + for (int i = 0; i < n; i++) { + rs_found_t one; + memset(&one, 0, sizeof(one)); + rs_walk(&at[i], p, mask, self, import >= 0, &one); + for (int k = 0; k < RS_NS_COUNT; k++) { + rs_set_merge(&out->ns[k], &one.ns[k]); + } + rs_set_merge(&out->owners, &one.owners); + out->ambiguous = out->ambiguous || one.ambiguous; + } +} + +/* The one thing among the candidates of `s` that were really found: RS_E_NONE + * when there is none, a gap when there are several. */ +static rs_ent_t rs_only_strong(const rs_index_t *ix, const rs_set_t *s) { + rs_set_t strong = {0}; + for (int i = 0; i < s->n; i++) { + if (!s->v[i].weak) { + rs_set_add(&strong, s->v[i]); + } + } + rs_collapse(ix, &strong); + if (s->overflow || strong.n > SKIP_ONE) { + return rs_ent(RS_E_GAP, 0); + } + return strong.n ? strong.v[0] : (rs_ent_t){0}; +} + +/* What the weak candidates of `s` come to: a gap before external. */ +static uint8_t rs_only_weak(const rs_set_t *s) { + uint8_t kind = RS_E_NONE; + for (int i = 0; i < s->n; i++) { + if (s->v[i].weak && kind != RS_E_GAP) { + kind = s->v[i].kind; + } + } + return kind; +} + +/* ── Build: dependencies ─────────────────────────────────────────── */ + +static int rs_rawdep_cmp(const void *a, const void *b) { + const rs_rawdep_t *x = (const rs_rawdep_t *)a; + const rs_rawdep_t *y = (const rs_rawdep_t *)b; + int c = strcmp(x->name, y->name); + return c ? c : (x->seq > y->seq) - (x->seq < y->seq); +} + +static int rs_dep_cmp(const void *a, const void *b) { + return strcmp(((const rs_dep_t *)a)->key, ((const rs_dep_t *)b)->key); +} + +/* What the dependency tables of a manifest say about one dependency. */ +typedef struct { + const char *name; + const char *path; /* NULL: none written */ + const char *package; /* set: `name` is a rename */ + bool workspace; /* inherited from the workspace table */ + bool conflict; /* two tables give it different paths */ +} rs_depspec_t; + +static void rs_depspec_add(rs_depspec_t *spec, const rs_rawdep_t *r) { + if (rs_is(r->attr, "path")) { + spec->conflict = spec->conflict || (spec->path && !rs_is(spec->path, r->value)); + spec->path = r->value; + } else if (rs_is(r->attr, "package")) { + spec->package = r->value; + } else if (rs_is(r->attr, "workspace") && rs_is(r->value, "true")) { + spec->workspace = true; + } +} + +/* The entry `name` of the [workspace.dependencies] table of `ws` (its raw + * records are sorted by name). false when there is none. */ +static bool rs_ws_dep(const rs_pkg_t *ws, const char *name, rs_depspec_t *spec) { + int lo = 0; + int hi = ws->nraw; + bool found = false; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (strcmp(ws->raw[mid].name, name) < 0) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + for (int i = lo; i < ws->nraw && rs_is(ws->raw[i].name, name); i++) { + if (ws->raw[i].kind == 'w') { + found = true; + rs_depspec_add(spec, &ws->raw[i]); + } + } + return found; +} + +/* The crate a dependency brings into the extern prelude. Only a `path` + * dependency names a crate of the repository; one whose path, manifest or + * lib target is not known is a gap, never matched by name. */ +static rs_dep_t rs_dep_make(rs_index_t *ix, const rs_pkg_t *p, rs_depspec_t spec) { + rs_dep_t dep = {.key = rs_crate_name(ix, spec.name), .target = rs_ent(RS_E_GAP, 0)}; + const char *base = p->dir; + char dir[RS_PATH_BUF]; + if (spec.workspace) { + const rs_pkg_t *ws = p->ws >= 0 ? &ix->pkgs[p->ws] : NULL; + rs_depspec_t inherited = {.name = spec.name}; + if (!ws || !rs_ws_dep(ws, spec.name, &inherited)) { + return dep; + } + base = ws->dir; + spec = inherited; + } + if (spec.conflict) { + return dep; + } + if (!spec.path) { + dep.target = rs_ent(RS_E_EXTERNAL, 0); + return dep; + } + if (rs_is(spec.path, "?") || !rs_join_path(base, spec.path, dir, sizeof(dir))) { + return dep; + } + int q = rs_pkg_at(ix, dir); + if (q < 0 || !ix->pkgs[q].has_package || ix->pkgs[q].lib_root < 0) { + return dep; + } + dep.target = rs_ent(RS_E_MOD, ix->pkgs[q].lib_root); + if (!spec.package && ix->pkgs[q].lib_name) { + dep.key = ix->pkgs[q].lib_name; /* not renamed: the lib crate's own name */ + } + return dep; +} + +static void rs_pkg_deps(rs_index_t *ix, int pi) { + rs_pkg_t *p = &ix->pkgs[pi]; + p->deps = (rs_dep_t *)ix_alloc(ix, (size_t)p->nraw * sizeof(rs_dep_t)); + if (!p->deps) { + return; + } + for (int i = 0; i < p->nraw;) { + rs_depspec_t spec = {.name = p->raw[i].name}; + bool own = false; + int j = i; + for (; j < p->nraw && rs_is(p->raw[j].name, spec.name); j++) { + if (p->raw[j].kind != 'w') { + own = true; + rs_depspec_add(&spec, &p->raw[j]); + } + } + if (own) { + p->deps[p->ndeps++] = rs_dep_make(ix, p, spec); + } + i = j; + } + if (p->ndeps > SKIP_ONE) { + qsort(p->deps, (size_t)p->ndeps, sizeof(rs_dep_t), rs_dep_cmp); + } + int w = 0; /* one crate name for two targets: which one is not known */ + for (int i = 0; i < p->ndeps; i++) { + if (w > 0 && rs_is(p->deps[w - SKIP_ONE].key, p->deps[i].key)) { + if (!rs_ent_same(p->deps[w - SKIP_ONE].target, p->deps[i].target)) { + p->deps[w - SKIP_ONE].target = rs_ent(RS_E_GAP, 0); + } + continue; + } + p->deps[w++] = p->deps[i]; + } + p->ndeps = w; +} + +static void rs_deps(rs_index_t *ix) { + for (int pi = 0; pi < ix->npkgs; pi++) { + rs_pkg_t *p = &ix->pkgs[pi]; + if (p->nraw > SKIP_ONE) { + qsort(p->raw, (size_t)p->nraw, sizeof(rs_rawdep_t), rs_rawdep_cmp); + } + } + for (int pi = 0; pi < ix->npkgs; pi++) { + rs_pkg_deps(ix, pi); + } +} + +/* ── Build: bindings ─────────────────────────────────────────────── */ + +static int rs_bind_cmp(const void *a, const void *b) { + const rs_bind_t *x = (const rs_bind_t *)a; + const rs_bind_t *y = (const rs_bind_t *)b; + if (x->mod != y->mod) { + return x->mod < y->mod ? -1 : 1; + } + int c = strcmp(x->name, y->name); + return c ? c : (x->seq > y->seq) - (x->seq < y->seq); +} + +static int rs_ref_cmp(const void *a, const void *b) { + const rs_ref_t *x = (const rs_ref_t *)a; + const rs_ref_t *y = (const rs_ref_t *)b; + if (x->mod != y->mod) { + return x->mod < y->mod ? -1 : 1; + } + return (x->seq > y->seq) - (x->seq < y->seq); +} + +static int rs_member_cmp(const void *a, const void *b) { + const rs_member_t *x = (const rs_member_t *)a; + const rs_member_t *y = (const rs_member_t *)b; + int c = strcmp(x->name, y->name); + return c ? c : (x->seq > y->seq) - (x->seq < y->seq); +} + +/* The members of every type, trait and impl, by name. */ +static void rs_sort_members(rs_index_t *ix) { + for (int i = 0; i < ix->nitems; i++) { + const rs_item_t *it = &ix->items[i]; + if (it->member_count > SKIP_ONE) { + qsort(&ix->members[it->member_first], (size_t)it->member_count, sizeof(rs_member_t), + rs_member_cmp); + } + } + for (int i = 0; i < ix->nimpls; i++) { + const rs_impl_t *im = &ix->impls[i]; + if (im->member_count > SKIP_ONE) { + qsort(&ix->members[im->member_first], (size_t)im->member_count, sizeof(rs_member_t), + rs_member_cmp); + } + } +} + +static void rs_bind_put(rs_index_t *ix, int mod, const char *name, int kind, int ref, char vis) { + ix->binds[ix->nbinds] = (rs_bind_t){ + .mod = mod, .name = name, .kind = kind, .ref = ref, .seq = ix->nbinds, .vis = vis}; + ix->nbinds++; +} + +/* An item: bound in its module, and a `#[macro_export]` macro also at the + * crate roots of its file. */ +static void rs_bind_item(rs_index_t *ix, int idx) { + const rs_item_t *it = &ix->items[idx]; + const rs_file_t *f = &ix->files[it->file]; + rs_bind_put(ix, it->mod, it->name, RS_BIND_ITEM, idx, it->vis); + for (int r = 0; it->macro_export && !f->roots_top && r < f->nroots; r++) { + if (f->roots[r] != it->mod) { + rs_bind_put(ix, f->roots[r], it->name, RS_BIND_ITEM, idx, 'p'); + } + } +} + +/* Sort `refs` by module and set each module's range through `first`/`count` + * (offsets of the two fields in rs_mod_t). */ +static void rs_ref_ranges(rs_index_t *ix, rs_ref_t *refs, int n, bool globs) { + if (n > SKIP_ONE) { + qsort(refs, (size_t)n, sizeof(rs_ref_t), rs_ref_cmp); + } + for (int i = 0; i < n; i++) { + rs_mod_t *m = &ix->mods[refs[i].mod]; + int *first = globs ? &m->glob_first : &m->named_first; + int *count = globs ? &m->glob_count : &m->named_count; + if (*count == 0) { + *first = i; + } + (*count)++; + } +} + +static void rs_bindings(rs_index_t *ix) { + size_t cap = ((size_t)ix->nitems * (RS_ROOTS_KEPT + SKIP_ONE)) + (size_t)ix->ndecls + + (size_t)ix->nmods + (size_t)ix->nimports + (size_t)ix->nmaybes; + ix->binds = (rs_bind_t *)ix_alloc(ix, cap * sizeof(rs_bind_t)); + ix->globs = (rs_ref_t *)ix_alloc(ix, (size_t)ix->nimports * sizeof(rs_ref_t)); + ix->named = (rs_ref_t *)ix_alloc(ix, (size_t)ix->nimports * sizeof(rs_ref_t)); + if (!ix->binds || !ix->globs || !ix->named) { + return; + } + for (int i = 0; i < ix->nitems; i++) { + rs_bind_item(ix, i); + } + for (int m = 0; m < ix->nmods; m++) { + if (ix->mods[m].parent >= 0) { + rs_bind_put(ix, ix->mods[m].parent, ix->mods[m].name, RS_BIND_CHILD, m, + ix->mods[m].vis); + } + } + for (int d = 0; d < ix->ndecls; d++) { + const rs_decl_t *decl = &ix->decls[d]; + rs_bind_put(ix, decl->mod, decl->name, RS_BIND_CHILD, decl->child, decl->vis); + } + for (int i = 0; i < ix->nimports; i++) { + const rs_import_t *imp = &ix->imports[i]; + rs_ref_t ref = {.mod = imp->mod, .seq = imp->seq, .ref = i}; + if (imp->kind == 'g') { + ix->globs[ix->nglobs++] = ref; + } else { + ix->named[ix->nnamed++] = ref; + rs_bind_put(ix, imp->mod, imp->local, RS_BIND_IMPORT, i, imp->vis); + } + } + for (int i = 0; i < ix->nmaybes; i++) { + rs_bind_put(ix, ix->maybes[i].mod, ix->maybe_names[i], RS_BIND_MAYBE, i, 'p'); + } + if (ix->nbinds > SKIP_ONE) { + qsort(ix->binds, (size_t)ix->nbinds, sizeof(rs_bind_t), rs_bind_cmp); + } + for (int i = 0; i < ix->nbinds; i++) { + rs_mod_t *m = &ix->mods[ix->binds[i].mod]; + if (m->bind_count == 0) { + m->bind_first = i; + } + m->bind_count++; + } + rs_ref_ranges(ix, ix->globs, ix->nglobs, true); + rs_ref_ranges(ix, ix->named, ix->nnamed, false); +} + +/* ── Build: imports ──────────────────────────────────────────────── */ + +/* `extern crate name;`: the crate of that name, or `self` for the crate + * itself. */ +static void rs_import_extern(rs_look_t *lk, const rs_import_t *imp, int idx, rs_found_t *found) { + rs_at_t at[RS_ROOTS_KEPT]; + int n = rs_contexts(lk, imp->mod, idx, at); + for (int i = 0; i < n; i++) { + bool unknown = false; + if (rs_is(imp->segs[0], "self")) { + unknown = at[i].root < 0; + rs_set_add(&found->ns[0], unknown ? rs_weak(RS_E_GAP) : rs_ent(RS_E_MOD, at[i].root)); + continue; + } + rs_set_t one = {0}; + rs_extern(&at[i], imp->segs[0], &one, &unknown); + if (rs_set_empty(&one)) { + /* a crate the manifest names differently (see rs_lexical), unless + * the crate's dependencies are not known at all */ + rs_set_add(&one, rs_weak(unknown ? RS_E_GAP : RS_E_EXTERNAL)); + } + rs_set_merge(&found->ns[0], &one); + } +} + +/* Keep what an import path was found to name. An import DECLARES its name: + * whatever the path leads to, the name is bound, so a path that leads into + * something unknown binds the name to a gap (or to something external) in + * every namespace rather than leaving it unbound. */ +static void rs_import_store(rs_index_t *ix, rs_import_t *imp, const rs_found_t *found, int pass) { + bool strong = false; + uint8_t weak = RS_E_NONE; + for (int k = 0; k < RS_NS_COUNT; k++) { + uint8_t w = rs_only_weak(&found->ns[k]); + imp->res[k] = rs_only_strong(ix, &found->ns[k]); + imp->res[k].via_import = false; + strong = strong || imp->res[k].kind != RS_E_NONE; + weak = (weak == RS_E_GAP || w == RS_E_NONE) ? weak : w; + } + if (found->ambiguous || (!strong && weak != RS_E_EXTERNAL)) { + weak = RS_E_GAP; + ix->gap_imports++; + } + imp->spread = imp->kind == 'b' && (found->ambiguous || !strong); + for (int k = 0; k < RS_NS_COUNT && (found->ambiguous || !strong); k++) { + imp->res[k] = rs_ent(weak, 0); + } + imp->state = RS_STATE_DONE; + imp->done_pass = pass; +} + +/* Resolve import `idx` in pass `pass`. An import that waits for another one + * stays unresolved, unless this is the `last` attempt: then it is not + * followed. */ +static void rs_import_resolve(rs_index_t *ix, int idx, int pass, bool last) { + rs_import_t *imp = &ix->imports[idx]; + rs_look_t lk = {.ix = ix, .pass = pass}; + rs_found_t found; + memset(&found, 0, sizeof(found)); + if (imp->nsegs > 0 && imp->kind == 'x') { + rs_import_extern(&lk, imp, idx, &found); + } else if (imp->nsegs > 0) { + rs_path_t p = {.segs = imp->segs, .n = imp->nsegs, .global = imp->global}; + rs_ent_t no_self = {0}; + int mask = imp->kind == 'b' ? RS_NS_ALL : RS_NS_TYPE; + rs_resolve_path(&lk, imp->mod, &p, mask, no_self, idx, &found); + } + if (lk.blocked && !last) { + return; + } + if (lk.blocked) { + memset(&found, 0, sizeof(found)); + } + rs_import_store(ix, imp, &found, pass); +} + +/* Resolve every import. Imports depend on each other (a path can run through + * an imported name or a glob), so they are resolved in passes: a pass reads + * only what EARLIER passes resolved, which makes the result independent of + * the order of the imports. What is still waiting after the last pass (a + * cycle, or a chain longer than the passes) is not followed. */ +static void rs_imports(rs_index_t *ix) { + int pending = ix->nimports; + int pass = SKIP_ONE; + for (; pass <= RS_IMPORT_PASSES && pending > 0; pass++) { + int before = pending; + for (int i = 0; i < ix->nimports; i++) { + if (ix->imports[i].state != RS_STATE_DONE) { + rs_import_resolve(ix, i, pass, false); + pending -= ix->imports[i].state == RS_STATE_DONE; + } + } + if (pending == before) { + break; + } + } + for (int i = 0; pending > 0 && i < ix->nimports; i++) { + if (ix->imports[i].state != RS_STATE_DONE) { + rs_import_resolve(ix, i, pass + SKIP_ONE, true); + } + } +} + +/* ── Build: aliases and impls ────────────────────────────────────── */ + +/* What the type path `text`, written in module `mod`, names. */ +static rs_ent_t rs_type_path(rs_index_t *ix, int mod, const char *text) { + CBMDocLinkRsPath parsed; + const char *segs[CBM_DOCLINK_RS_MAX_SEGS]; + if (cbm_doclink_rs_parse_path(text, strlen(text), false, &parsed) != CBM_DOCLINK_RS_PATH_OK) { + return rs_ent(RS_E_GAP, 0); + } + for (int i = 0; i < parsed.nsegs; i++) { + segs[i] = rs_plain(parsed.segs[i]); + } + rs_path_t p = {.segs = segs, .n = parsed.nsegs, .global = parsed.global}; + rs_look_t lk = {.ix = ix}; + rs_found_t found; + rs_ent_t no_self = {0}; + rs_resolve_path(&lk, mod, &p, RS_NS_TYPE, no_self, CBM_NOT_FOUND, &found); + if (found.ambiguous) { + return rs_ent(RS_E_GAP, 0); + } + rs_ent_t e = rs_only_strong(ix, &found.ns[0]); + uint8_t weak = rs_only_weak(&found.ns[0]); + return (e.kind == RS_E_NONE && weak != RS_E_NONE) ? rs_ent(weak, 0) : e; +} + +static int rs_item_impl_cmp(const void *a, const void *b) { + const rs_item_impl_t *x = (const rs_item_impl_t *)a; + const rs_item_impl_t *y = (const rs_item_impl_t *)b; + if (x->item != y->item) { + return x->item < y->item ? -1 : 1; + } + return (x->impl > y->impl) - (x->impl < y->impl); +} + +static int rs_assoc_cmp(const void *a, const void *b) { + const rs_assoc_t *x = (const rs_assoc_t *)a; + const rs_assoc_t *y = (const rs_assoc_t *)b; + if (x->item != y->item) { + return x->item < y->item ? -1 : 1; + } + int c = strcmp(x->name, y->name); + if (c) { + return c; + } + return x->impl != y->impl ? (x->impl < y->impl ? -1 : 1) + : (x->member > y->member) - (x->member < y->member); +} + +/* The type an impl is for and the trait it implements. */ +static void rs_impl_types(rs_index_t *ix, rs_impl_t *im) { + rs_ent_t none = {0}; + rs_ent_t self = im->self_path ? rs_type_path(ix, im->mod, im->self_path) : none; + if (self.kind == RS_E_ITEM) { + self = rs_alias_end(ix, self.idx); + self.weak = false; + } + if (self.kind == RS_E_ITEM && !rs_item_is_type(ix->items[self.idx].kind)) { + self = rs_ent(RS_E_GAP, 0); + } + im->self_ent = self; + if (im->trait_path) { + rs_ent_t trait = rs_is(im->trait_path, "?") ? rs_ent(RS_E_GAP, 0) + : rs_type_path(ix, im->mod, im->trait_path); + im->trait_ent = trait.kind == RS_E_NONE ? rs_ent(RS_E_GAP, 0) : trait; + } +} + +/* Resolve alias targets and impl headers, and index every impl's members by + * the type the impl is for. */ +static void rs_types(rs_index_t *ix) { + int members = 0; + for (int i = 0; i < ix->nitems; i++) { + rs_item_t *it = &ix->items[i]; + if (it->alias) { + it->alias_ent = rs_type_path(ix, it->mod, it->alias); + } + } + for (int i = 0; i < ix->nimpls; i++) { + rs_impl_types(ix, &ix->impls[i]); + members += ix->impls[i].member_count; + } + ix->item_impls = (rs_item_impl_t *)ix_alloc(ix, (size_t)ix->nimpls * sizeof(rs_item_impl_t)); + ix->assoc = (rs_assoc_t *)ix_alloc(ix, (size_t)members * sizeof(rs_assoc_t)); + if (!ix->item_impls || !ix->assoc) { + return; + } + for (int i = 0; i < ix->nimpls; i++) { + const rs_impl_t *im = &ix->impls[i]; + for (int k = 0; k < im->member_count; k++) { + int m = im->member_first + k; + if (im->self_ent.kind == RS_E_ITEM) { + ix->assoc[ix->nassoc++] = (rs_assoc_t){ + .item = im->self_ent.idx, .name = ix->members[m].name, .impl = i, .member = m}; + } else if (!im->self_path) { + cbm_ht_set(ix->blanket, ix->members[m].name, (void *)(intptr_t)SKIP_ONE); + } + } + if (im->self_ent.kind == RS_E_ITEM) { + ix->item_impls[ix->nitem_impls++] = + (rs_item_impl_t){.item = im->self_ent.idx, .impl = i}; + } + } + if (ix->nitem_impls > SKIP_ONE) { + qsort(ix->item_impls, (size_t)ix->nitem_impls, sizeof(rs_item_impl_t), rs_item_impl_cmp); + } + if (ix->nassoc > SKIP_ONE) { + qsort(ix->assoc, (size_t)ix->nassoc, sizeof(rs_assoc_t), rs_assoc_cmp); + } + for (int i = 0; i < ix->nitem_impls; i++) { + rs_item_t *it = &ix->items[ix->item_impls[i].item]; + if (it->impl_count == 0) { + it->impl_first = i; + } + it->impl_count++; + } +} + +/* ── Build: line indexes of the re-extracted files ───────────────── */ + +typedef struct { + uint32_t line; + int order; /* at one line: modules are left innermost first, then entered outermost first */ + int mod; /* the innermost module from that line on */ +} rs_event_t; + +static int rs_event_cmp(const void *a, const void *b) { + const rs_event_t *x = (const rs_event_t *)a; + const rs_event_t *y = (const rs_event_t *)b; + if (x->line != y->line) { + return x->line < y->line ? -1 : 1; + } + return (x->order > y->order) - (x->order < y->order); +} + +static int rs_scope_cmp(const void *a, const void *b) { + const rs_scope_t *x = (const rs_scope_t *)a; + const rs_scope_t *y = (const rs_scope_t *)b; + if (x->start != y->start) { + return x->start < y->start ? -1 : 1; + } + return (x->end > y->end) - (x->end < y->end); +} + +static int rs_note_cmp(const void *a, const void *b) { + const rs_note_t *x = (const rs_note_t *)a; + const rs_note_t *y = (const rs_note_t *)b; + if (x->line != y->line) { + return x->line < y->line ? -1 : 1; + } + return strcmp(x->text, y->text); +} + +static int rs_line_cmp(const void *a, const void *b) { + uint32_t x = *(const uint32_t *)a; + uint32_t y = *(const uint32_t *)b; + return (x > y) - (x < y); +} + +/* The spans of file `f`: for every line, the innermost inline module. */ +static void rs_file_spans(rs_index_t *ix, rs_file_t *f, rs_event_t *ev) { + int n = 0; + for (int m = SKIP_ONE; m < f->nmods; m++) { + const rs_mod_t *mod = &ix->mods[f->mod_base + m]; + if (mod->parent < 0) { + continue; + } + ev[n++] = (rs_event_t){.line = mod->start, .order = m, .mod = f->mod_base + m}; + ev[n++] = (rs_event_t){.line = mod->end + SKIP_ONE, .order = -m, .mod = mod->parent}; + } + if (n > SKIP_ONE) { + qsort(ev, (size_t)n, sizeof(rs_event_t), rs_event_cmp); + } + f->span_first = ix->nspans; + ix->spans[ix->nspans++] = (rs_span_t){.from = 0, .mod = f->mod_base}; + for (int i = 0; i < n; i++) { + ix->spans[ix->nspans++] = (rs_span_t){.from = ev[i].line, .mod = ev[i].mod}; + } + f->span_count = ix->nspans - f->span_first; +} + +static void rs_file_lines(rs_index_t *ix, rs_file_t *f, rs_event_t *ev) { + rs_file_spans(ix, f, ev); + if (f->scope_count > SKIP_ONE) { + qsort(&ix->scopes[f->scope_first], (size_t)f->scope_count, sizeof(rs_scope_t), + rs_scope_cmp); + } + if (f->local_count > SKIP_ONE) { + qsort(&ix->locals[f->local_first], (size_t)f->local_count, sizeof(rs_note_t), rs_note_cmp); + } + if (f->unowned_count > SKIP_ONE) { + qsort(&ix->unowned[f->unowned_first], (size_t)f->unowned_count, sizeof(rs_note_t), + rs_note_cmp); + } + f->test_first = ix->ntest_lines; + for (int i = f->item_first; i < f->item_first + f->item_count; i++) { + if (ix->items[i].test) { + ix->test_lines[ix->ntest_lines++] = ix->items[i].start; + } + } + f->test_count = ix->ntest_lines - f->test_first; + if (f->test_count > SKIP_ONE) { + qsort(&ix->test_lines[f->test_first], (size_t)f->test_count, sizeof(uint32_t), rs_line_cmp); + } +} + +static void rs_line_indexes(rs_index_t *ix) { + size_t spans = 0; + size_t events = 0; + for (int fi = 0; fi < ix->nfiles; fi++) { + const rs_file_t *f = &ix->files[fi]; + if (f->kind == RS_FILE_SOURCE && f->fresh) { + size_t ev = (size_t)(f->nmods - SKIP_ONE) * PAIR_LEN; + spans += ev + SKIP_ONE; + events = ev > events ? ev : events; + } + } + ix->spans = (rs_span_t *)ix_alloc(ix, spans * sizeof(rs_span_t)); + ix->test_lines = (uint32_t *)ix_alloc(ix, (size_t)ix->nitems * sizeof(uint32_t)); + rs_event_t *ev = (rs_event_t *)ix_alloc(ix, events * sizeof(rs_event_t)); + if (!ix->spans || !ix->test_lines || !ev) { + return; + } + for (int fi = 0; fi < ix->nfiles; fi++) { + if (ix->files[fi].kind == RS_FILE_SOURCE && ix->files[fi].fresh) { + rs_file_lines(ix, &ix->files[fi], ev); + } + } +} + +/* ── Build ───────────────────────────────────────────────────────── */ + +/* The scope blob of a Rust source file, NULL when it has none this code + * reads. */ +static const char *rs_source_blob(const char *scope) { + static const char tag[] = CBM_DOCLINK_RS_SCOPE_TAG; + const size_t tl = sizeof(tag) - SKIP_ONE; + if (!scope || strncmp(scope, tag, tl) != 0 || (scope[tl] != '\n' && scope[tl] != '\0') || + rs_blob_is_manifest(scope)) { + return NULL; + } + return scope; +} + +static void rs_count_add(rs_counts_t *sum, const rs_counts_t *c) { + sum->mods += c->mods; + sum->decls += c->decls; + sum->items += c->items; + sum->members += c->members; + sum->impls += c->impls; + sum->imports += c->imports; + sum->maybes += c->maybes; + sum->locals += c->locals; + sum->unowned += c->unowned; + sum->macuses += c->macuses; + sum->macdecls += c->macdecls; +} + +/* The facts of source file `f` that need no blob, and the size of its blob. */ +static void rs_source_meta(rs_index_t *ix, rs_file_t *f, const cbm_doclink_file_t *src, + rs_counts_t *sum) { + rs_counts_t c = {0}; + /* the qualified name of an item `x` of this file, minus the `x` */ + const char *probe = cbm_fqn_compute(&ix->arena, ix->project, src->rel_path, "x"); + size_t pl = probe ? strlen(probe) : 0; + f->kind = RS_FILE_SOURCE; + f->fresh = src->run_file >= 0; + f->test_path = rs_is_test_path(src->rel_path); + f->item_prefix = pl > 0 ? ix_strndup(ix, probe, pl - SKIP_ONE) : NULL; + f->module_qn = + cbm_fqn_module_source_lang(&ix->arena, ix->project, src->rel_path, CBM_LANG_RUST); + ix->oom = ix->oom || !f->item_prefix || !f->module_qn; + rs_count_blob(rs_source_blob(src->scope), &c); + f->nmods = c.mods + SKIP_ONE; + f->mod_base = ix->nmods; + ix->nmods += f->nmods; + ix->nsources++; + rs_count_add(sum, &c); +} + +static bool rs_alloc_tables(rs_index_t *ix, const rs_counts_t *c, int manifests) { + ix->mods = (rs_mod_t *)ix_calloc(ix, (size_t)ix->nmods, sizeof(rs_mod_t)); + ix->decls = (rs_decl_t *)ix_calloc(ix, (size_t)c->decls, sizeof(rs_decl_t)); + ix->items = (rs_item_t *)ix_calloc(ix, (size_t)c->items, sizeof(rs_item_t)); + ix->members = (rs_member_t *)ix_calloc(ix, (size_t)c->members, sizeof(rs_member_t)); + ix->impls = (rs_impl_t *)ix_calloc(ix, (size_t)c->impls, sizeof(rs_impl_t)); + ix->imports = (rs_import_t *)ix_calloc(ix, (size_t)c->imports, sizeof(rs_import_t)); + ix->maybes = (rs_ref_t *)ix_calloc(ix, (size_t)c->maybes, sizeof(rs_ref_t)); + ix->maybe_names = (const char **)ix_calloc(ix, (size_t)c->maybes, sizeof(char *)); + ix->macuses = (rs_macuse_t *)ix_calloc(ix, (size_t)c->macuses, sizeof(rs_macuse_t)); + ix->macdecls = (rs_macdecl_t *)ix_calloc(ix, (size_t)c->macdecls, sizeof(rs_macdecl_t)); + ix->scopes = + (rs_scope_t *)ix_calloc(ix, (size_t)c->items + (size_t)c->impls, sizeof(rs_scope_t)); + ix->locals = (rs_note_t *)ix_calloc(ix, (size_t)c->locals, sizeof(rs_note_t)); + ix->unowned = (rs_note_t *)ix_calloc(ix, (size_t)c->unowned, sizeof(rs_note_t)); + ix->pkgs = (rs_pkg_t *)ix_calloc(ix, (size_t)manifests, sizeof(rs_pkg_t)); + return !ix->oom; +} + +/* Read every file's scope blob into the index. */ +static bool rs_load(rs_index_t *ix, const cbm_doclink_build_in_t *in) { + rs_counts_t sum = {0}; + int manifests = 0; + ix->nfiles = in->file_count; + ix->files = (rs_file_t *)ix_calloc(ix, (size_t)in->file_count, sizeof(rs_file_t)); + if (!ix->files) { + return false; + } + for (int i = 0; i < in->file_count; i++) { + const cbm_doclink_file_t *src = &in->files[i]; + rs_file_t *f = &ix->files[i]; + f->rel_path = src->rel_path; + f->root_pkg = RS_PKG_NONE; + f->pkg = RS_PKG_NONE; + cbm_ht_set(ix->file_by_path, f->rel_path, (void *)(intptr_t)(i + SKIP_ONE)); + if (src->run_file >= 0 && src->run_file < in->run_file_count) { + ix->run_to_file[src->run_file] = i; + } + if (rs_ends_with(src->rel_path, ".rs")) { + rs_source_meta(ix, f, src, &sum); + } else if (rs_is(rs_basename(src->rel_path), "Cargo.toml") && + rs_blob_is_manifest(src->scope)) { + f->kind = RS_FILE_MANIFEST; + manifests++; + } + } + if (!rs_alloc_tables(ix, &sum, manifests)) { + return false; + } + for (int i = 0; i < in->file_count; i++) { + rs_file_t *f = &ix->files[i]; + if (f->kind == RS_FILE_MANIFEST) { + rs_read_manifest(ix, i, in->files[i].scope); + } + for (int m = 0; f->kind == RS_FILE_SOURCE && m < f->nmods; m++) { + ix->mods[f->mod_base + m].file = i; + ix->mods[f->mod_base + m].parent = CBM_NOT_FOUND; + } + if (f->kind == RS_FILE_SOURCE) { + rs_read_source(ix, i, rs_source_blob(in->files[i].scope)); + } + } + for (int pi = 0; pi < ix->npkgs; pi++) { + const char *dir = ix->pkgs[pi].dir; + cbm_ht_set(ix->pkg_by_dir, dir[0] ? dir : ".", (void *)(intptr_t)(pi + SKIP_ONE)); + } + if (ix->nmacuses > SKIP_ONE) { + qsort(ix->macuses, (size_t)ix->nmacuses, sizeof(rs_macuse_t), rs_macuse_cmp); + } + if (ix->nmacdecls > SKIP_ONE) { + qsort(ix->macdecls, (size_t)ix->nmacdecls, sizeof(rs_macdecl_t), rs_macdecl_cmp); + } + return !ix->oom; +} + +static void rs_destroy(void *index) { + rs_index_t *ix = (rs_index_t *)index; + if (!ix) { + return; + } + cbm_ht_free(ix->file_by_path); + cbm_ht_free(ix->pkg_by_dir); + cbm_ht_free(ix->blanket); + cbm_free(CBM_MEM_CLASS_OTHER, ix->run_to_file); + cbm_arena_destroy(&ix->arena); + cbm_free(CBM_MEM_CLASS_OTHER, ix); +} + +static void rs_log_index(const rs_index_t *ix) { + enum { N_FILES = 0, N_PKGS, N_ROOTS, N_ORPHANS, N_MODS, N_ITEMS, N_IMPORTS, N_GAPS, N_COUNT }; + char b[N_COUNT][CBM_SZ_32]; + snprintf(b[N_FILES], sizeof(b[N_FILES]), "%d", ix->nsources); + snprintf(b[N_PKGS], sizeof(b[N_PKGS]), "%d", ix->npkgs); + snprintf(b[N_ROOTS], sizeof(b[N_ROOTS]), "%d", ix->crate_roots); + snprintf(b[N_ORPHANS], sizeof(b[N_ORPHANS]), "%d", ix->orphans); + snprintf(b[N_MODS], sizeof(b[N_MODS]), "%d", ix->nmods); + snprintf(b[N_ITEMS], sizeof(b[N_ITEMS]), "%d", ix->nitems); + snprintf(b[N_IMPORTS], sizeof(b[N_IMPORTS]), "%d", ix->nimports); + snprintf(b[N_GAPS], sizeof(b[N_GAPS]), "%d", ix->gap_imports); + cbm_log_info("doc_links.rs.index", "files", b[N_FILES], "manifests", b[N_PKGS], "crate_roots", + b[N_ROOTS], "orphan_files", b[N_ORPHANS], "modules", b[N_MODS], "items", + b[N_ITEMS], "imports", b[N_IMPORTS], "unfollowed_imports", b[N_GAPS]); +} + +static void *rs_build(const cbm_doclink_build_in_t *in) { + rs_index_t *ix = (rs_index_t *)cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*ix)); + if (!ix) { + return NULL; + } + cbm_arena_init(&ix->arena); + ix->project = in->ctx->project_name; + ix->file_by_path = cbm_ht_create(CBM_SZ_4K); + ix->pkg_by_dir = cbm_ht_create(CBM_SZ_64); + ix->blanket = cbm_ht_create(CBM_SZ_256); + ix->run_count = in->run_file_count; + ix->run_to_file = (int *)cbm_alloc( + CBM_MEM_CLASS_OTHER, + (size_t)(in->run_file_count ? in->run_file_count : SKIP_ONE) * sizeof(int)); + if (!ix->file_by_path || !ix->pkg_by_dir || !ix->blanket || !ix->run_to_file) { + cbm_log_error("doc_links.rs.error", "step", "tables", "reason", "alloc"); + rs_destroy(ix); + return NULL; + } + for (int i = 0; i < in->run_file_count; i++) { + ix->run_to_file[i] = CBM_NOT_FOUND; + } + if (rs_load(ix, in)) { + rs_crate_roots(ix); + rs_module_tree(ix); + rs_file_roots(ix); + rs_orphan_packages(ix); + rs_product(ix); + rs_deps(ix); + rs_sort_members(ix); + rs_bindings(ix); + } + if (!ix->oom) { + rs_imports(ix); + rs_types(ix); + rs_line_indexes(ix); + } + if (ix->oom) { + cbm_log_error("doc_links.rs.error", "step", "index", "reason", "alloc"); + rs_destroy(ix); + return NULL; + } + rs_log_index(ix); + return ix; +} + +/* ── One reference ───────────────────────────────────────────────── */ + +typedef struct { + const rs_index_t *ix; + const rs_file_t *f; + const CBMDocLink *link; + const char *segs[CBM_DOCLINK_RS_MAX_SEGS]; + rs_path_t path; + int dis; + int mod; /* the module the documented definition is in */ + rs_ent_t self; /* what `Self` names there */ + bool single; /* one plain name: no `::`, no keyword */ + bool file_level; + bool product; /* the documented definition is product code */ +} rs_link_t; + +static bool rs_is_keyword(const char *seg) { + return rs_is(seg, "crate") || rs_is(seg, "self") || rs_is(seg, "super") || rs_is(seg, "Self"); +} + +/* The innermost module of file `f` at `line`. */ +static int rs_mod_at(const rs_index_t *ix, const rs_file_t *f, uint32_t line) { + int lo = f->span_first; + int hi = lo + f->span_count; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (ix->spans[mid].from <= line) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return lo > f->span_first ? ix->spans[lo - SKIP_ONE].mod : f->mod_base; +} + +/* What `Self` names at `line`: the type or trait whose definition holds the + * line, the self type of the impl that does, or nothing. */ +static rs_ent_t rs_self_at(const rs_index_t *ix, const rs_file_t *f, uint32_t line) { + rs_ent_t none = {0}; + int lo = f->scope_first; + int hi = lo + f->scope_count; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (ix->scopes[mid].start <= line) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + if (lo == f->scope_first || ix->scopes[lo - SKIP_ONE].end < line) { + return none; + } + const rs_scope_t *s = &ix->scopes[lo - SKIP_ONE]; + if (s->item >= 0) { + return rs_ent(RS_E_ITEM, s->item); + } + rs_ent_t self = ix->impls[s->impl].self_ent; + return self.kind == RS_E_ITEM ? self : rs_ent(RS_E_IMPL, s->impl); +} + +/* First note of notes[first, first + count) at or after `line`. */ +static int rs_note_at(const rs_note_t *notes, int first, int count, uint32_t line) { + int lo = first; + int hi = first + count; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (notes[mid].line < line) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return lo; +} + +/* True when the documented definition does not own its graph node: another + * definition of the file has the same qualified name and keeps the node, so + * an edge would leave from that other definition. */ +static bool rs_unowned(const rs_index_t *ix, const rs_file_t *f, const CBMDocLink *link) { + size_t pl = strlen(f->item_prefix); + const char *qn = link->source_qn ? link->source_qn : ""; + int end = f->unowned_first + f->unowned_count; + for (int i = rs_note_at(ix->unowned, f->unowned_first, f->unowned_count, link->def_line); + i < end && ix->unowned[i].line == link->def_line; i++) { + if (strncmp(qn, f->item_prefix, pl) == 0 && rs_is(qn + pl, ix->unowned[i].text)) { + return true; + } + } + return false; +} + +/* True when `name` is a parameter or type parameter of the definition the + * reference documents. */ +static bool rs_is_local(const rs_link_t *q, const char *name) { + const rs_index_t *ix = q->ix; + const rs_file_t *f = q->f; + size_t nl = strlen(name); + int end = f->local_first + f->local_count; + if (q->file_level) { + return false; + } + for (int i = rs_note_at(ix->locals, f->local_first, f->local_count, q->link->def_line); + i < end && ix->locals[i].line == q->link->def_line; i++) { + for (const char *p = ix->locals[i].text; *p;) { + const char *comma = strchr(p, ','); + size_t len = comma ? (size_t)(comma - p) : strlen(p); + const char *plain = (len > PAIR_LEN && p[0] == 'r' && p[1] == '#') ? p + PAIR_LEN : p; + size_t plen = len - (size_t)(plain - p); + if (plen == nl && memcmp(plain, name, nl) == 0) { + return true; + } + p = comma ? comma + SKIP_ONE : p + len; + } + } + return false; +} + +/* Is the documented definition product code? Not in a test-only module, not + * an item marked as a test, not inside a type marked as one. */ +static bool rs_source_product(const rs_link_t *q) { + const rs_index_t *ix = q->ix; + const rs_file_t *f = q->f; + if (!ix->mods[q->mod].product) { + return false; + } + if (q->file_level) { + return true; + } + if (q->self.kind == RS_E_ITEM && ix->items[q->self.idx].test) { + return false; + } + int lo = f->test_first; + int hi = lo + f->test_count; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (ix->test_lines[mid] < q->link->def_line) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return !(lo < f->test_first + f->test_count && ix->test_lines[lo] == q->link->def_line); +} + +static const char *rs_label_name(char code) { + static const struct { + char code; + const char *label; + } table[] = { + {'S', "Struct"}, {'E', "Enum"}, {'C', "Class"}, {'I', "Interface"}, {'T', "Type"}, + {'F', "Function"}, {'V', "Variable"}, {'M', "Method"}, {'D', "Field"}, + }; + for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); i++) { + if (table[i].code == code) { + return table[i].label; + } + } + return NULL; +} + +/* The node of a declaration of file `fi`: looked up by qualified name, and + * taken only when its label and file are the declaration's (another + * definition can own that name: a same-named item of another kind, another + * file's module). */ +static const cbm_gbuf_node_t *rs_node(const rs_index_t *ix, const cbm_gbuf_t *graph, int fi, + const char *label, const char *qn) { + const cbm_gbuf_node_t *n = (label && qn) ? cbm_gbuf_find_by_qn(graph, qn) : NULL; + if (!n || !n->label || !n->file_path || !rs_is(n->label, label) || + !rs_is(n->file_path, ix->files[fi].rel_path)) { + return NULL; + } + return n; +} + +static const cbm_gbuf_node_t *rs_decl_node(const rs_index_t *ix, const cbm_gbuf_t *graph, int fi, + char label, const char *suffix) { + char qn[RS_QN_BUF]; + if (!label || !suffix || + snprintf(qn, sizeof(qn), "%s%s", ix->files[fi].item_prefix, suffix) >= (int)sizeof(qn)) { + return NULL; + } + return rs_node(ix, graph, fi, rs_label_name(label), qn); +} + +/* The outcome for the one entity a reference names. */ +static void rs_outcome(const rs_link_t *q, const cbm_gbuf_t *graph, rs_ent_t e, + cbm_doclink_outcome_t *out) { + const rs_index_t *ix = q->ix; + const cbm_gbuf_node_t *node = NULL; + bool product = true; + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; + if (e.kind == RS_E_ITEM) { + const rs_item_t *it = &ix->items[e.idx]; + product = ix->mods[it->mod].product && !it->test; + node = rs_decl_node(ix, graph, it->file, it->label, it->suffix); + } else if (e.kind == RS_E_MEMBER) { + const rs_member_t *m = &ix->members[e.idx]; + product = ix->mods[m->mod].product && !m->owner_test; + node = rs_decl_node(ix, graph, m->file, m->label, m->suffix); + } else if (e.kind == RS_E_MOD) { + const rs_mod_t *mod = &ix->mods[e.idx]; + product = mod->product; + node = mod->parent < 0 + ? rs_node(ix, graph, mod->file, "Module", ix->files[mod->file].module_qn) + : NULL; /* an inline module has no node */ + } else { + bool external = e.kind == RS_E_EXTERNAL || + (e.kind == RS_E_IMPL && ix->impls[e.idx].self_ent.kind == RS_E_EXTERNAL); + out->reason = external ? CBM_DOCLINK_REASON_EXTERNAL : CBM_DOCLINK_REASON_GRAPH_GAP; + return; + } + if (q->product && !product) { + out->reason = CBM_DOCLINK_REASON_TEST_ONLY; + } else if (node) { + out->kind = CBM_DOCLINK_EDGE; + out->target = node; + } +} + +/* The namespaces a disambiguator or suffix asks for. */ +static int rs_dis_mask(int dis) { + switch (dis) { + case CBM_DOCLINK_RS_DIS_NONE: + return RS_NS_ALL; + case CBM_DOCLINK_RS_DIS_CONST: + case CBM_DOCLINK_RS_DIS_STATIC: + case CBM_DOCLINK_RS_DIS_FN: + case CBM_DOCLINK_RS_DIS_FIELD: + case CBM_DOCLINK_RS_DIS_VALUE_NS: + return RS_NS_VALUE; + case CBM_DOCLINK_RS_DIS_DERIVE: + case CBM_DOCLINK_RS_DIS_MACRO_BANG: + case CBM_DOCLINK_RS_DIS_MACRO_NS: + return RS_NS_MACRO; + default: + return RS_NS_TYPE; + } +} + +/* The item kinds and member kinds a disambiguator accepts ("" for none, NULL + * for any). */ +static void rs_dis_kinds(int dis, const char **items, const char **members, bool *mod) { + *items = ""; + *members = ""; + *mod = false; + switch (dis) { + case CBM_DOCLINK_RS_DIS_NONE: + case CBM_DOCLINK_RS_DIS_TYPE_NS: + case CBM_DOCLINK_RS_DIS_VALUE_NS: + case CBM_DOCLINK_RS_DIS_MACRO_NS: + *items = NULL; + *members = NULL; + *mod = true; + break; + case CBM_DOCLINK_RS_DIS_STRUCT: + *items = "S"; + break; + case CBM_DOCLINK_RS_DIS_ENUM: + *items = "E"; + break; + case CBM_DOCLINK_RS_DIS_TRAIT: + *items = "R"; + break; + case CBM_DOCLINK_RS_DIS_UNION: + *items = "U"; + break; + case CBM_DOCLINK_RS_DIS_TYALIAS: + *items = "A"; + break; + case CBM_DOCLINK_RS_DIS_MOD: + *mod = true; + break; + case CBM_DOCLINK_RS_DIS_CONST: + *items = "C"; + *members = "c"; + break; + case CBM_DOCLINK_RS_DIS_STATIC: + *items = "G"; + break; + case CBM_DOCLINK_RS_DIS_FN: + *items = "F"; + *members = "f"; + break; + case CBM_DOCLINK_RS_DIS_DERIVE: + *items = "V"; + break; + case CBM_DOCLINK_RS_DIS_FIELD: + *members = "d"; + break; + case CBM_DOCLINK_RS_DIS_VARIANT: + *members = "v"; + break; + case CBM_DOCLINK_RS_DIS_MACRO_BANG: + *items = "MP"; + break; + default: + break; + } +} + +/* Drop the candidates of another kind than the disambiguator names. What is + * outside the repository or not placed has no known kind and stays. */ +static void rs_filter_dis(const rs_index_t *ix, rs_set_t *s, int dis) { + const char *items = NULL; + const char *members = NULL; + bool mod = false; + int w = 0; + rs_dis_kinds(dis, &items, &members, &mod); + for (int i = 0; i < s->n; i++) { + rs_ent_t e = s->v[i]; + bool keep = true; + if (e.kind == RS_E_ITEM) { + keep = !items || strchr(items, ix->items[e.idx].kind) != NULL; + } else if (e.kind == RS_E_MEMBER) { + keep = !members || strchr(members, ix->members[e.idx].kind) != NULL; + } else if (e.kind == RS_E_MOD) { + keep = mod; + } + if (keep) { + s->v[w++] = e; + } + } + s->n = w; +} + +/* rustdoc: a trait and a derive macro of one name are the trait. A derive + * macro of another crate is known only as something external of that name. */ +static void rs_trait_wins(const rs_index_t *ix, rs_set_t *s) { + if (s->overflow || s->n != PAIR_LEN) { + return; + } + for (int i = 0; i < PAIR_LEN; i++) { + rs_ent_t a = s->v[i]; + rs_ent_t b = s->v[SKIP_ONE - i]; + bool trait = a.kind == RS_E_ITEM && ix->items[a.idx].kind == 'R'; + bool derive = + b.kind == RS_E_EXTERNAL || (b.kind == RS_E_ITEM && ix->items[b.idx].kind == 'V'); + if (trait && derive) { + s->v[0] = a; + s->n = SKIP_ONE; + return; + } + } +} + +/* The candidates of a reference: what was really found in any namespace, or, + * when nothing was, the one weak answer (`*weak`). */ +static void rs_candidates(const rs_index_t *ix, const rs_found_t *found, rs_set_t *out, + bool *weak) { + uint8_t w = RS_E_NONE; + for (int k = 0; k < RS_NS_COUNT; k++) { + const rs_set_t *s = &found->ns[k]; + uint8_t wk = rs_only_weak(s); + for (int i = 0; i < s->n; i++) { + if (!s->v[i].weak) { + rs_set_add(out, s->v[i]); + } + } + out->overflow = out->overflow || s->overflow; + w = (w == RS_E_GAP || wk == RS_E_NONE) ? w : wk; + } + rs_collapse(ix, out); + *weak = rs_set_empty(out) && w != RS_E_NONE; + if (*weak) { + rs_set_add(out, rs_ent(w, 0)); + } +} + +/* True when one of the standard derives in the comma-joined list `derives` + * generates a method `name` (the methods of the trait each one implements: + * defined by the language). What a derive of another crate generates is not + * known and is not assumed. */ +static bool rs_derive_declares(const char *derives, const char *name) { + static const struct { + const char *derive; + const char *methods; /* each between commas */ + } table[] = { + {"Clone", ",clone,clone_from,"}, + {"Debug", ",fmt,"}, + {"Default", ",default,"}, + {"PartialEq", ",eq,ne,"}, + {"PartialOrd", ",partial_cmp,lt,le,gt,ge,"}, + {"Ord", ",cmp,max,min,clamp,"}, + {"Hash", ",hash,hash_slice,"}, + }; + char key[CBM_SZ_64]; + if (snprintf(key, sizeof(key), ",%s,", name) >= (int)sizeof(key)) { + return false; + } + for (const char *p = derives; *p;) { + const char *comma = strchr(p, ','); + size_t len = comma ? (size_t)(comma - p) : strlen(p); + for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); i++) { + if (strlen(table[i].derive) == len && memcmp(table[i].derive, p, len) == 0 && + strstr(table[i].methods, key)) { + return true; + } + } + p = comma ? comma + SKIP_ONE : p + len; + } + return false; +} + +/* A name no associated item of type `item` carries: is it still declared by + * something the scan does not show as a member? By a standard derive of the + * type, or by an impl for no named type (`impl Trait for T`), or + * possibly among more impls than are searched. */ +static bool rs_assoc_hidden(const rs_index_t *ix, int item, const char *name) { + rs_ent_t end = rs_alias_end(ix, item); + if (end.kind != RS_E_ITEM) { + return false; + } + const rs_item_t *it = &ix->items[end.idx]; + if (it->kind != 'S' && it->kind != 'E' && it->kind != 'U') { + return false; + } + return (it->derives && rs_derive_declares(it->derives, name)) || + it->impl_count > RS_IMPL_SCAN || cbm_ht_get(ix->blanket, name) != NULL; +} + +/* A reference that names nothing that was found. */ +static void rs_link_miss(const rs_link_t *q, const rs_set_t *owners, bool mismatch, + cbm_doclink_outcome_t *out) { + const char *last = q->segs[q->path.n - SKIP_ONE]; + bool hidden = false; + for (int i = 0; !mismatch && i < owners->n && !hidden; i++) { + hidden = owners->v[i].kind == RS_E_ITEM && rs_assoc_hidden(q->ix, owners->v[i].idx, last); + } + if (hidden) { + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; + } else if (!mismatch && q->single && rs_is_local(q, q->segs[0])) { + out->kind = CBM_DOCLINK_LOCAL; + } +} + +static void rs_link_decide(const rs_link_t *q, const cbm_gbuf_t *graph, + cbm_doclink_outcome_t *out) { + const rs_index_t *ix = q->ix; + rs_look_t lk = {.ix = ix, .assoc = true}; + rs_found_t found; + rs_set_t cand = {0}; + bool weak = false; + bool prim = q->single && RS_IN(RS_PRIMITIVES, q->segs[0]); + bool any_type = q->dis == CBM_DOCLINK_RS_DIS_NONE || q->dis == CBM_DOCLINK_RS_DIS_TYPE_NS; + rs_resolve_path(&lk, q->mod, &q->path, rs_dis_mask(q->dis), q->self, CBM_NOT_FOUND, &found); + if (found.ambiguous) { + out->reason = CBM_DOCLINK_REASON_AMBIGUOUS; + return; + } + rs_candidates(ix, &found, &cand, &weak); + if (q->dis == CBM_DOCLINK_RS_DIS_PRIM || (prim && (weak || rs_set_empty(&cand)))) { + /* a primitive type: rustdoc's fallback when the path names nothing */ + out->reason = prim ? CBM_DOCLINK_REASON_EXTERNAL : CBM_DOCLINK_REASON_MISSING; + return; + } + if (prim && any_type) { + out->reason = CBM_DOCLINK_REASON_AMBIGUOUS; /* the primitive, or what the name binds */ + return; + } + bool had = !rs_set_empty(&cand); + rs_filter_dis(ix, &cand, q->dis); + rs_trait_wins(ix, &cand); + if (cand.overflow || cand.n > SKIP_ONE) { + out->reason = CBM_DOCLINK_REASON_AMBIGUOUS; + return; + } + if (cand.n == 0) { + rs_link_miss(q, &found.owners, had, out); + return; + } + rs_outcome(q, graph, cand.v[0], out); + out->exact = !q->single || cand.v[0].via_import; + if (weak && cand.v[0].kind == RS_E_GAP && q->single && rs_is_local(q, q->segs[0])) { + out->kind = CBM_DOCLINK_LOCAL; + } +} + +/* One reference of run file `run_file`. */ +static void rs_resolve(const void *index, void *state, int run_file, const CBMDocLink *link, + const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out) { + (void)state; /* Rust keeps no per-file working state */ + const rs_index_t *ix = (const rs_index_t *)index; + CBMDocLinkRsPath parsed; + out->kind = CBM_DOCLINK_UNRESOLVED; + out->reason = CBM_DOCLINK_REASON_MISSING; + out->target = NULL; + out->exact = false; + if (!ix || !link || !link->raw || run_file < 0 || run_file >= ix->run_count || + ix->run_to_file[run_file] < 0) { + return; + } + const rs_file_t *f = &ix->files[ix->run_to_file[run_file]]; + if (f->kind != RS_FILE_SOURCE) { + return; + } + if (cbm_doclink_rs_parse_path(link->raw, strlen(link->raw), false, &parsed) != + CBM_DOCLINK_RS_PATH_OK) { + out->reason = CBM_DOCLINK_REASON_UNPARSEABLE; + return; + } + rs_link_t q = {.ix = ix, + .f = f, + .link = link, + .dis = parsed.dis, + .file_level = (link->flags & CBM_DOCLINK_FLAG_FILE) != 0}; + if (!q.file_level && f->err_line && link->def_line >= f->err_line) { + /* the definition lies where the file no longer parses: its scope is not known */ + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; + return; + } + for (int i = 0; i < parsed.nsegs; i++) { + q.segs[i] = rs_plain(parsed.segs[i]); + } + q.path = (rs_path_t){.segs = q.segs, .n = parsed.nsegs, .global = parsed.global}; + q.single = parsed.nsegs == SKIP_ONE && !parsed.global && !rs_is_keyword(q.segs[0]); + q.mod = q.file_level ? f->mod_base : rs_mod_at(ix, f, link->def_line); + if (!q.file_level) { + q.self = rs_self_at(ix, f, link->def_line); + } + q.product = rs_source_product(&q); + rs_link_decide(&q, graph, out); + if (out->kind == CBM_DOCLINK_EDGE && !q.file_level && rs_unowned(ix, f, link)) { + out->kind = CBM_DOCLINK_UNRESOLVED; /* no node of its own to leave from */ + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; + out->target = NULL; + out->exact = false; + } +} + +/* ── Incremental scope rules ─────────────────────────────────────── */ + +/* The next line of a blob; false at the end. */ +static bool rs_delta_line(const char **cursor, const char **line, size_t *len) { + const char *p = *cursor; + if (!p || !*p) { + return false; + } + const char *nl = strchr(p, '\n'); + *line = p; + *len = nl ? (size_t)(nl - p) : strlen(p); + *cursor = nl ? nl + SKIP_ONE : p + *len; + return true; +} + +/* The name of an `M start kind name node` line whose member no path can reach + * under another name: a function, a constant, a type or a field (not a + * variant, which `use` can rename). The name must be one the incremental + * route can find in an unresolved reference: ASCII letters, digits and `_`. */ +static bool rs_delta_member(const char *line, size_t len, const char **name, size_t *nlen) { + enum { F_KIND = 2, F_NAME = 3 }; + const char *field[F_NAME + PAIR_LEN]; + int n = 0; + field[n++] = line; + for (size_t i = 0; i < len && n < F_NAME + PAIR_LEN; i++) { + if (line[i] == '\t') { + field[n++] = line + i + SKIP_ONE; + } + } + if (line[0] != 'M' || n != F_NAME + PAIR_LEN || field[F_NAME] - field[F_KIND] != PAIR_LEN || + !strchr("fctd", field[F_KIND][0])) { + return false; + } + const char *s = field[F_NAME]; + size_t sl = (size_t)(field[F_NAME + SKIP_ONE] - s) - SKIP_ONE; + if (sl > PAIR_LEN && s[0] == 'r' && s[1] == '#') { + s += PAIR_LEN; + sl -= PAIR_LEN; + } + for (size_t i = 0; i < sl; i++) { + if (!(isalnum((unsigned char)s[i]) || s[i] == '_')) { + return false; + } + } + *name = s; + *nlen = sl; + return sl > 0; +} + +/* Compare a changed file's stored and fresh scopes line by line, in order + * (both in the persisted form: no line numbers, no per-definition records). + * + * Equal scopes leave every other file's resolution alone: other files see a + * file only through these records. + * + * One difference does too: a member (function, constant, type, field of a + * type, trait or impl) the fresh scope no longer has. A member is reached + * only by a path that ends in its name, so a reference that depended on it + * either bound it (an edge into this file: re-resolved with the closure) or + * names it in its unresolved row: the name is reported. + * + * Everything else is GLOBAL: a new or changed line; a removed item, module, + * import or variant (`use` can bring any of them into another file under + * another name, so no name finds the references that depended on it); a + * changed order (same-named declarations own their node by order); and any + * change of a manifest. */ +static int rs_scope_delta(const char *stored, const char *fresh, cbm_doclink_name_fn removed, + void *ud) { + const char *sp = stored; + const char *fp = fresh; + const char *sl = NULL; + const char *fl = NULL; + size_t slen = 0; + size_t flen = 0; + bool hs = rs_delta_line(&sp, &sl, &slen); + bool hf = rs_delta_line(&fp, &fl, &flen); + while (hs || hf) { + if (hs && hf && slen == flen && memcmp(sl, fl, slen) == 0) { + hs = rs_delta_line(&sp, &sl, &slen); + hf = rs_delta_line(&fp, &fl, &flen); + continue; + } + const char *name = NULL; + size_t nlen = 0; + if (!hs || !rs_delta_member(sl, slen, &name, &nlen)) { + return CBM_DOCLINK_DELTA_GLOBAL; + } + if (!removed || !removed(ud, name, nlen)) { + return CBM_NOT_FOUND; + } + hs = rs_delta_line(&sp, &sl, &slen); + } + return CBM_DOCLINK_DELTA_LOCAL; +} + +const cbm_doclink_resolver_t cbm_doclink_rs_resolver = { + .langs = {CBM_LANG_RUST, CBM_LANG_TOML}, + .lang_count = 2, + .scope_tag = CBM_DOCLINK_RS_SCOPE_TAG, + .build = rs_build, + .destroy = rs_destroy, + .resolve = rs_resolve, + .scope_delta = rs_scope_delta, +}; diff --git a/src/pipeline/doc_links_rst.c b/src/pipeline/doc_links_rst.c index ee06620cb..e5b9cf40d 100644 --- a/src/pipeline/doc_links_rst.c +++ b/src/pipeline/doc_links_rst.c @@ -1685,6 +1685,11 @@ static void rr_resolve(const void *index, void *state, int run_file, const CBMDo cbm_doclink_md_resolver.resolve(x->md, NULL, run_file, link, graph, out); return; } + if (cbm_doclink_md_twin(link->syntax) != CBM_DOCLINK_NONE) { /* a diagram's link, an + * API's operationId */ + cbm_doclink_md_resolver.resolve(x->md, NULL, run_file, link, graph, out); + return; + } if (link->syntax == CBM_DOCLINK_RST_CODE_PATH || link->syntax == CBM_DOCLINK_RST_CODE_NAME) { CBMDocLink md = *link; md.syntax = link->syntax == CBM_DOCLINK_RST_CODE_PATH ? CBM_DOCLINK_MD_CODE_PATH diff --git a/src/pipeline/doc_links_ts.c b/src/pipeline/doc_links_ts.c new file mode 100644 index 000000000..9ac4e4fce --- /dev/null +++ b/src/pipeline/doc_links_ts.c @@ -0,0 +1,1108 @@ +/* + * doc_links_ts.c — resolving TypeScript and JavaScript doc references + * (doclink_ts.c), one index over TS, TSX and JS files, which import each other. + * + * A reference is a path: segments joined by `.`, `#` (an instance member) or + * `~` (a module's member), with an optional `()` or meaning suffix + * (`:class`, `:0`) that is dropped (one node holds every overload). Its first + * segment is looked up as the TypeScript checker would see it from the + * documented declaration, and the field test's prototype does: + * 1. the declaration's own parameters (dropped at extraction); + * 2. the namespaces around the reference's line, innermost first; + * 3. the file's own top-level declarations; + * 4. the file's imports: named, default and namespace imports, followed + * through `export { a as b }`, `export ... from` and `export *` barrels + * (a cycle guard and a depth bound; two barrels that answer differently + * are ambiguous); a relative specifier resolves with the usual extension + * and index rules; a bare one through the nearest tsconfig/jsconfig + * `paths` (the first target, as TypeScript tries it first) or + * `baseUrl`, else, when it is the `name` of a workspace package (the + * pipeline's package map), to that package's entry module (`exports`, + * `main`, `module`) when the repository indexes it; every other bare + * specifier, a package's subpath included, is external; + * 5. the standard library's global names: external; + * 6. the top-level declarations of the repository's script files (files + * without an import or an export are global): one binds, several are + * ambiguous; + * 7. the members of the documented declaration's class or interface (of the + * declaration itself for a class or interface doc), `this.` included. + * Nothing found is missing. The other segments walk members (a node's + * qualified name + `.` + segment) or a module's exports. A member a class, + * interface or enum declares without a node of its own is a graph gap. Test + * code binds only from test code. Edges are exact for a name the file + * declares or imports, unique otherwise. + */ +#include "pipeline/doc_links.h" +#include "pipeline/path_alias.h" +#include "pipeline/pipeline_internal.h" /* cbm_pipeline_file_node, cbm_pipeline_get_pkgmap */ +#include "cbm.h" +#include "doclink.h" +#include "helpers.h" /* cbm_fqn_module_source_lang, cbm_is_test_file */ +#include "foundation/arena.h" +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/log.h" +#include "foundation/mem_core.h" +#include "graph_buffer/graph_buffer.h" + +#include +#include +#include +#include +#include +#include +#include + +enum { + TR_MAX_DEPTH = 16, + TR_MAX_SEGS = 16, + TR_QN_MAX = 2048, + TR_PATH_MAX = 1024, + TR_FIELDS = 5, + TR_HTML_MIN = 11, /* HTML + Element */ + TR_SVG_MIN = 10, /* SVG + Element */ +}; + +/* ── The index ───────────────────────────────────────────────────── */ + +typedef struct { + const char *a; /* local name / exported name */ + const char *spec; + const char *b; /* orig / local */ + char kind; /* imports: d n s */ +} tr_rec_t; + +typedef struct { + const char *path; + uint32_t first; + uint32_t last; +} tr_ns_t; + +typedef struct { + const char *name; + uint32_t line; /* the declaration's */ + uint32_t first; /* the function body it is visible in */ + uint32_t last; +} tr_nest_t; + +typedef struct { + const char *path; + const char *module_qn; + const char *sym_qn; /* what its declarations' QNs start with: an index file's + * module QN without `.index` (fqn.c, strip_init_or_index) */ + bool test; + bool module_syntax; + tr_rec_t *imps; /* I */ + int nimps; + tr_rec_t *stars; /* A: spec */ + int nstars; + tr_rec_t *star_as; /* S: a = name, spec */ + int nstar_as; + tr_rec_t *froms; /* F: a = name, spec, b = orig */ + int nfroms; + tr_rec_t *locals; /* L: a = name, b = local */ + int nlocals; + tr_rec_t *decls; /* D: a = name, kind e/d flags: 'e', 'd', 'b' (both), '-' */ + int ndecls; + tr_ns_t *nss; + int nnss; + tr_nest_t *nested; /* V: only in a fresh blob, the run's own files */ + int nnested; +} tr_file_t; + +typedef struct { + CBMArena arena; + const char *project; + tr_file_t *files; + int nfiles; + int *run_to_file; + int run_count; + CBMHashTable *by_path; /* rel_path -> (file index + 1) as a pointer */ + CBMHashTable *globals; /* name -> tr_global_t */ + CBMHashTable *by_module; /* module QN -> (first file index + 1) */ + const cbm_path_alias_collection_t *aliases; /* the pipeline's, borrowed */ + CBMHashTable *pkgmap; /* the pipeline's, borrowed */ + bool oom; +} tr_index_t; + +typedef struct { + int file; + int count; /* script files declaring the name */ +} tr_global_t; + +static const char *tr_copy(tr_index_t *ix, const char *s, size_t n) { + char *c = cbm_arena_strndup(&ix->arena, s, n); + ix->oom = ix->oom || !c; + return c; +} + +static bool tr_dash(const char *s, size_t n) { + return n == SKIP_ONE && s[0] == '-'; +} + +/* Append one record to a growing arena array. */ +static tr_rec_t *tr_push(tr_index_t *ix, tr_rec_t **items, int *count) { + if (*count == 0 || (*count >= CBM_SZ_4 && (*count & (*count - SKIP_ONE)) == 0)) { + int ncap = *count ? *count * PAIR_LEN : CBM_SZ_4; /* 4, 8, 16, ... */ + tr_rec_t *g = (tr_rec_t *)cbm_arena_alloc(&ix->arena, (size_t)ncap * sizeof(tr_rec_t)); + if (!g) { + ix->oom = true; + return NULL; + } + if (*count) { + memcpy(g, *items, (size_t)*count * sizeof(tr_rec_t)); + } + *items = g; + } + tr_rec_t *r = &(*items)[(*count)++]; + memset(r, 0, sizeof(*r)); + return r; +} + +static void tr_push_ns(tr_index_t *ix, tr_file_t *f, const char *path, uint32_t first, + uint32_t last) { + if (f->nnss == 0 || (f->nnss >= CBM_SZ_4 && (f->nnss & (f->nnss - SKIP_ONE)) == 0)) { + int ncap = f->nnss ? f->nnss * PAIR_LEN : CBM_SZ_4; + tr_ns_t *g = (tr_ns_t *)cbm_arena_alloc(&ix->arena, (size_t)ncap * sizeof(tr_ns_t)); + if (!g) { + ix->oom = true; + return; + } + if (f->nnss) { + memcpy(g, f->nss, (size_t)f->nnss * sizeof(tr_ns_t)); + } + f->nss = g; + } + f->nss[f->nnss++] = (tr_ns_t){path, first, last}; +} + +static void tr_push_nested(tr_index_t *ix, tr_file_t *f, const char *name, uint32_t line, + uint32_t first, uint32_t last) { + if (f->nnested == 0 || + (f->nnested >= CBM_SZ_4 && (f->nnested & (f->nnested - SKIP_ONE)) == 0)) { + int ncap = f->nnested ? f->nnested * PAIR_LEN : CBM_SZ_4; + tr_nest_t *g = (tr_nest_t *)cbm_arena_alloc(&ix->arena, (size_t)ncap * sizeof(tr_nest_t)); + if (!g) { + ix->oom = true; + return; + } + if (f->nnested) { + memcpy(g, f->nested, (size_t)f->nnested * sizeof(tr_nest_t)); + } + f->nested = g; + } + f->nested[f->nnested++] = (tr_nest_t){name, line, first, last}; +} + +static void tr_read_record(tr_index_t *ix, tr_file_t *f, const char **fl, const size_t *ln, int n) { + char tag = fl[0][0]; + tr_rec_t *r = NULL; + if (tag == 'M') { + f->module_syntax = true; + } else if (tag == 'I' && n >= TR_FIELDS && (r = tr_push(ix, &f->imps, &f->nimps))) { + r->a = tr_copy(ix, fl[1], ln[1]); + r->kind = fl[PAIR_LEN][0]; + r->spec = tr_copy(ix, fl[CBM_SZ_3], ln[CBM_SZ_3]); + r->b = tr_dash(fl[CBM_SZ_4], ln[CBM_SZ_4]) ? NULL : tr_copy(ix, fl[CBM_SZ_4], ln[CBM_SZ_4]); + } else if (tag == 'A' && n >= PAIR_LEN && (r = tr_push(ix, &f->stars, &f->nstars))) { + r->spec = tr_copy(ix, fl[1], ln[1]); + } else if (tag == 'S' && n >= CBM_SZ_3 && (r = tr_push(ix, &f->star_as, &f->nstar_as))) { + r->a = tr_copy(ix, fl[1], ln[1]); + r->spec = tr_copy(ix, fl[PAIR_LEN], ln[PAIR_LEN]); + } else if (tag == 'F' && n >= CBM_SZ_4 && (r = tr_push(ix, &f->froms, &f->nfroms))) { + r->a = tr_copy(ix, fl[1], ln[1]); + r->spec = tr_copy(ix, fl[PAIR_LEN], ln[PAIR_LEN]); + r->b = tr_copy(ix, fl[CBM_SZ_3], ln[CBM_SZ_3]); + } else if (tag == 'L' && n >= CBM_SZ_3 && (r = tr_push(ix, &f->locals, &f->nlocals))) { + r->a = tr_copy(ix, fl[1], ln[1]); + r->b = tr_copy(ix, fl[PAIR_LEN], ln[PAIR_LEN]); + } else if (tag == 'D' && n >= CBM_SZ_3 && (r = tr_push(ix, &f->decls, &f->ndecls))) { + r->a = tr_copy(ix, fl[1], ln[1]); + bool e = memchr(fl[PAIR_LEN], 'e', ln[PAIR_LEN]) != NULL; + bool d = memchr(fl[PAIR_LEN], 'd', ln[PAIR_LEN]) != NULL; + r->kind = (e && d) ? 'b' : e ? 'e' : d ? 'd' : '-'; + } else if (tag == 'V' && n >= TR_FIELDS) { + tr_push_nested(ix, f, tr_copy(ix, fl[1], ln[1]), + (uint32_t)strtoul(fl[PAIR_LEN], NULL, CBM_DECIMAL_BASE), + (uint32_t)strtoul(fl[CBM_SZ_3], NULL, CBM_DECIMAL_BASE), + (uint32_t)strtoul(fl[CBM_SZ_4], NULL, CBM_DECIMAL_BASE)); + } else if (tag == 'N' && n >= PAIR_LEN) { + uint32_t first = + n >= CBM_SZ_4 ? (uint32_t)strtoul(fl[PAIR_LEN], NULL, CBM_DECIMAL_BASE) : 0; + uint32_t last = n >= CBM_SZ_4 ? (uint32_t)strtoul(fl[CBM_SZ_3], NULL, CBM_DECIMAL_BASE) : 0; + tr_push_ns(ix, f, tr_copy(ix, fl[1], ln[1]), first, last); + } +} + +static void tr_read_blob(tr_index_t *ix, tr_file_t *f, const char *blob) { + const char *line = strchr(blob, '\n'); /* the tag line */ + while (line && !ix->oom) { + const char *b = line + SKIP_ONE; + const char *e = strchr(b, '\n'); + if (!e) { + e = b + strlen(b); + } + const char *fl[TR_FIELDS]; + size_t ln[TR_FIELDS]; + int n = 0; + const char *s = b; + for (const char *p = b; p <= e && n < TR_FIELDS; p++) { + if (p == e || *p == '\t') { + fl[n] = s; + ln[n] = (size_t)(p - s); + n++; + s = p + SKIP_ONE; + } + } + if (n > 0 && ln[0] == SKIP_ONE) { + tr_read_record(ix, f, fl, ln, n); + } + line = *e ? e : NULL; + } +} + +static CBMLanguage tr_lang(const char *path) { + const char *dot = strrchr(path, '.'); + if (dot && (strcmp(dot, ".ts") == 0 || strcmp(dot, ".mts") == 0 || strcmp(dot, ".cts") == 0)) { + return CBM_LANG_TYPESCRIPT; + } + return dot && strcmp(dot, ".tsx") == 0 ? CBM_LANG_TSX : CBM_LANG_JAVASCRIPT; +} + +static void tr_destroy(void *index) { + tr_index_t *ix = (tr_index_t *)index; + if (!ix) { + return; + } + cbm_ht_free(ix->by_path); + cbm_ht_free(ix->globals); + cbm_ht_free(ix->by_module); + cbm_free(CBM_MEM_CLASS_OTHER, ix->run_to_file); + cbm_arena_destroy(&ix->arena); + cbm_free(CBM_MEM_CLASS_OTHER, ix); +} + +static bool tr_add_globals(tr_index_t *ix) { + ix->globals = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_256); + if (!ix->globals) { + return false; + } + for (int i = 0; i < ix->nfiles; i++) { + const tr_file_t *f = &ix->files[i]; + if (f->module_syntax) { + continue; /* a module's names are its own */ + } + for (int k = 0; k < f->ndecls; k++) { + tr_global_t *g = (tr_global_t *)cbm_ht_get(ix->globals, f->decls[k].a); + if (g) { + g->count += g->file == i ? 0 : SKIP_ONE; + continue; + } + g = (tr_global_t *)cbm_arena_alloc(&ix->arena, sizeof(tr_global_t)); + if (!g) { + return false; + } + *g = (tr_global_t){i, SKIP_ONE}; + cbm_ht_set(ix->globals, f->decls[k].a, g); + } + } + return true; +} + +/* The QN prefix of a file's declarations: an index module's QN loses its + * last segment, as a symbol QN does in fqn.c. */ +static const char *tr_symbol_prefix(tr_index_t *ix, const char *module_qn) { + static const char suffix[] = ".index"; + size_t sl = sizeof(suffix) - SKIP_ONE; + size_t n = module_qn ? strlen(module_qn) : 0; + if (n <= sl || strcmp(module_qn + n - sl, suffix) != 0) { + return module_qn; + } + return tr_copy(ix, module_qn, n - sl); +} + +static void *tr_build(const cbm_doclink_build_in_t *in) { + tr_index_t *ix = (tr_index_t *)cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*ix)); + if (!ix) { + return NULL; + } + cbm_arena_init(&ix->arena); + ix->project = in->ctx->project_name; + ix->nfiles = in->file_count; + ix->run_count = in->run_file_count; + ix->files = + in->file_count + ? (tr_file_t *)cbm_arena_calloc(&ix->arena, (size_t)in->file_count * sizeof(tr_file_t)) + : NULL; + ix->run_to_file = (int *)cbm_calloc(CBM_MEM_CLASS_OTHER, + (size_t)(in->run_file_count + SKIP_ONE) * sizeof(int)); + ix->by_path = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(in->file_count + SKIP_ONE)); + bool ok = (!in->file_count || ix->files) && ix->run_to_file && ix->by_path; + for (int r = 0; ok && r < in->run_file_count; r++) { + ix->run_to_file[r] = CBM_NOT_FOUND; + } + for (int i = 0; ok && i < in->file_count && !ix->oom; i++) { + const cbm_doclink_file_t *src = &in->files[i]; + tr_file_t *f = &ix->files[i]; + f->path = src->rel_path; + f->module_qn = + cbm_fqn_module_source_lang(&ix->arena, ix->project, src->rel_path, CBM_LANG_TYPESCRIPT); + f->test = cbm_is_test_file(src->rel_path, tr_lang(src->rel_path)); + ix->oom = ix->oom || !f->module_qn; + f->sym_qn = tr_symbol_prefix(ix, f->module_qn); + cbm_ht_set(ix->by_path, src->rel_path, (void *)(uintptr_t)(i + SKIP_ONE)); + if (src->run_file >= 0 && src->run_file < in->run_file_count) { + ix->run_to_file[src->run_file] = i; + } + if (src->scope) { + tr_read_blob(ix, f, src->scope); + } + } + ok = ok && !ix->oom && tr_add_globals(ix); + ix->aliases = in->ctx->path_aliases; + ix->pkgmap = cbm_pipeline_get_pkgmap(); + ix->by_module = + ok ? cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(ix->nfiles + SKIP_ONE)) : NULL; + ok = ok && ix->by_module; + for (int i = ix->nfiles - SKIP_ONE; ok && i >= 0; i--) { + if (ix->files[i].module_qn) { + cbm_ht_set(ix->by_module, ix->files[i].module_qn, (void *)(uintptr_t)(i + SKIP_ONE)); + } + } + if (!ok) { + cbm_log_error("doc_links.ts.error", "step", "index", "reason", "alloc"); + tr_destroy(ix); + return NULL; + } + char b[PAIR_LEN][CBM_SZ_32]; + snprintf(b[0], sizeof(b[0]), "%d", ix->nfiles); + snprintf(b[SKIP_ONE], sizeof(b[SKIP_ONE]), "%u", cbm_ht_count(ix->globals)); + cbm_log_info("doc_links.ts.index", "files", b[0], "global_names", b[SKIP_ONE]); + return ix; +} + +/* ── Resolution ──────────────────────────────────────────────────── */ + +typedef enum { + TR_NONE = 0, /* nothing found */ + TR_NODE, + TR_MODULE, /* a file's exports (a namespace import) */ + TR_EXTERNAL, + TR_AMBIGUOUS, + TR_GAP, /* declared, without a node */ + TR_MISSING, + TR_NOT_INDEXED, /* a relative import of a file the index does not hold */ +} tr_kind_t; + +typedef struct { + tr_kind_t kind; + const cbm_gbuf_node_t *node; + int file; + bool exact; +} tr_res_t; + +typedef struct { + const tr_index_t *ix; + const cbm_gbuf_t *g; + int src; + cbm_doclink_outcome_t *out; + int visited[TR_MAX_DEPTH * PAIR_LEN]; + const char *visited_name[TR_MAX_DEPTH * PAIR_LEN]; + int nvisited; +} tr_ctx_t; + +static tr_res_t tr_mk(tr_kind_t k) { + return (tr_res_t){k, NULL, CBM_NOT_FOUND, false}; +} + +/* The node at `qn`, when it belongs to file f (any file when f is NULL). */ +static const cbm_gbuf_node_t *tr_node(const tr_ctx_t *c, const char *qn, const tr_file_t *f) { + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(c->g, qn); + if (!n || !n->label) { + return NULL; + } + if (f && (!n->file_path || strcmp(n->file_path, f->path) != 0)) { + return NULL; + } + return n; +} + +static bool tr_join(char *out, size_t cap, const char *a, const char *b, const char *c) { + int n = c ? snprintf(out, cap, "%s.%s.%s", a, b, c) : snprintf(out, cap, "%s.%s", a, b); + return n > 0 && (size_t)n < cap; +} + +static const tr_rec_t *tr_decl(const tr_file_t *f, const char *name) { + for (int i = 0; i < f->ndecls; i++) { + if (strcmp(f->decls[i].a, name) == 0) { + return &f->decls[i]; + } + } + return NULL; +} + +/* A top-level declaration of file f: its node, or a graph gap. */ +static tr_res_t tr_decl_res(const tr_ctx_t *c, const tr_file_t *f, const char *name) { + char qn[TR_QN_MAX]; + if (!tr_join(qn, sizeof(qn), f->sym_qn, name, NULL)) { + return tr_mk(TR_GAP); + } + const cbm_gbuf_node_t *n = tr_node(c, qn, f); + tr_res_t r = tr_mk(n ? TR_NODE : TR_GAP); + r.node = n; + return r; +} + +static int tr_file_at(const tr_index_t *ix, const char *path) { + void *v = cbm_ht_get(ix->by_path, path); + return v ? (int)((uintptr_t)v - SKIP_ONE) : CBM_NOT_FOUND; +} + +/* Normalize dir + "/" + rel into out ("." and ".." resolved); false when it + * leaves the repository. */ +static bool tr_norm(const char *dir, size_t dl, const char *rel, char *out, size_t cap) { + char buf[TR_PATH_MAX]; + int n = dl ? snprintf(buf, sizeof(buf), "%.*s/%s", (int)dl, dir, rel) + : snprintf(buf, sizeof(buf), "%s", rel); + if (n <= 0 || (size_t)n >= sizeof(buf)) { + return false; + } + size_t w = 0; + for (char *seg = buf; *seg;) { + char *slash = strchr(seg, '/'); + size_t sl = slash ? (size_t)(slash - seg) : strlen(seg); + if (sl == PAIR_LEN && seg[0] == '.' && seg[SKIP_ONE] == '.') { + if (w == 0) { + return false; + } + while (w > 0 && out[w - SKIP_ONE] != '/') { + w--; + } + w = w > 0 ? w - SKIP_ONE : 0; + } else if (!(sl == SKIP_ONE && seg[0] == '.') && sl > 0) { + if (w > 0) { + out[w++] = '/'; + } + if (w + sl + SKIP_ONE >= cap) { + return false; + } + memcpy(out + w, seg, sl); + w += sl; + } + seg = slash ? slash + SKIP_ONE : seg + sl; + } + out[w] = '\0'; + return w > 0; +} + +/* The file a repository-relative module path names: the path itself, the + * path with a module extension, or its index file. */ +static int tr_path_file(const tr_index_t *ix, const char *base) { + static const char *const strip[] = {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", NULL}; + static const char *const exts[] = {".ts", ".tsx", ".d.ts", ".mts", ".cts", + ".js", ".jsx", ".mjs", ".cjs", NULL}; + static const char *const index[] = {"/index.ts", "/index.tsx", "/index.d.ts", "/index.js", + NULL}; + int hit = tr_file_at(ix, base); + if (hit >= 0) { + return hit; + } + char stem[TR_PATH_MAX]; + snprintf(stem, sizeof(stem), "%s", base); + size_t bl = strlen(stem); + for (int i = 0; strip[i]; i++) { + size_t el = strlen(strip[i]); + if (bl > el && strcmp(stem + bl - el, strip[i]) == 0) { + stem[bl - el] = '\0'; + break; + } + } + char cand[TR_PATH_MAX + CBM_SZ_16]; + for (int i = 0; exts[i] && hit < 0; i++) { + snprintf(cand, sizeof(cand), "%s%s", stem, exts[i]); + hit = tr_file_at(ix, cand); + } + for (int i = 0; index[i] && hit < 0; i++) { + snprintf(cand, sizeof(cand), "%s%s", base, index[i]); + hit = tr_file_at(ix, cand); + } + return hit; +} + +/* The workspace package a bare specifier names (`name` or `@scope/name`, no + * subpath): the file of its entry module, as the pipeline's package map + * holds it. */ +static int tr_package_file(const tr_index_t *ix, const char *spec) { + const char *slash = strchr(spec, '/'); + if (spec[0] == '@' && slash) { + slash = strchr(slash + SKIP_ONE, '/'); + } + if (slash || !ix->pkgmap || !ix->by_module) { + return CBM_NOT_FOUND; + } + const char *qn = (const char *)cbm_ht_get(ix->pkgmap, spec); + void *v = qn ? cbm_ht_get(ix->by_module, qn) : NULL; + return v ? (int)((uintptr_t)v - SKIP_ONE) : CBM_NOT_FOUND; +} + +/* A bare specifier the repository answers itself: the nearest + * tsconfig/jsconfig `paths` or `baseUrl`, else a workspace package. */ +static int tr_bare_file(const tr_index_t *ix, int from, const char *spec) { + const cbm_path_alias_map_t *map = + ix->aliases ? cbm_path_alias_find_for_file(ix->aliases, ix->files[from].path) : NULL; + char aliased[TR_PATH_MAX]; + if (map && cbm_path_alias_resolve_into(map, spec, aliased, sizeof(aliased))) { + int hit = tr_path_file(ix, aliased); + if (hit >= 0) { + return hit; + } + } + return tr_package_file(ix, spec); +} + +/* The file a module specifier written in file `from` names: -1 with + * *external for a bare specifier the repository does not answer (a + * package). */ +static int tr_spec_file(const tr_index_t *ix, int from, const char *spec, bool *external) { + *external = false; + if (!(spec[0] == '.' && + (spec[SKIP_ONE] == '/' || spec[SKIP_ONE] == '\0' || + (spec[SKIP_ONE] == '.' && (spec[PAIR_LEN] == '/' || spec[PAIR_LEN] == '\0'))))) { + int hit = tr_bare_file(ix, from, spec); + *external = hit < 0; + return hit; + } + const char *path = ix->files[from].path; + const char *slash = strrchr(path, '/'); + char base[TR_PATH_MAX]; + if (!tr_norm(path, slash ? (size_t)(slash - path) : 0, spec, base, sizeof(base))) { + return CBM_NOT_FOUND; + } + return tr_path_file(ix, base); +} + +static tr_res_t tr_export_of(tr_ctx_t *c, int file, const char *name, int depth); + +/* What a specifier's file exports as `name`. */ +static tr_res_t tr_from(tr_ctx_t *c, int file, const char *spec, const char *name, int depth) { + bool external = false; + int f = tr_spec_file(c->ix, file, spec, &external); + if (f < 0) { + return tr_mk(external ? TR_EXTERNAL : TR_NOT_INDEXED); + } + if (!name) { + tr_res_t r = tr_mk(TR_MODULE); + r.file = f; + return r; + } + return tr_export_of(c, f, name, depth + SKIP_ONE); +} + +/* A name bound at the top level of file `file`: its declaration or import. */ +static tr_res_t tr_top_binding(tr_ctx_t *c, int file, const char *name, int depth) { + const tr_file_t *f = &c->ix->files[file]; + if (tr_decl(f, name)) { + tr_res_t r = tr_decl_res(c, f, name); + r.exact = true; + return r; + } + for (int i = 0; i < f->nimps; i++) { + const tr_rec_t *im = &f->imps[i]; + if (strcmp(im->a, name) != 0) { + continue; + } + tr_res_t r = im->kind == 's' ? tr_from(c, file, im->spec, NULL, depth) + : im->kind == 'd' ? tr_from(c, file, im->spec, "default", depth) + : tr_from(c, file, im->spec, im->b ? im->b : name, depth); + r.exact = r.kind == TR_NODE; + return r; + } + return tr_mk(TR_NONE); +} + +static bool tr_seen(tr_ctx_t *c, int file, const char *name) { + for (int i = 0; i < c->nvisited; i++) { + if (c->visited[i] == file && strcmp(c->visited_name[i], name) == 0) { + return true; + } + } + if (c->nvisited < (int)(sizeof(c->visited) / sizeof(c->visited[0]))) { + c->visited[c->nvisited] = file; + c->visited_name[c->nvisited] = name; + c->nvisited++; + } + return false; +} + +static bool tr_same(const tr_res_t *a, const tr_res_t *b) { + return a->kind == b->kind && a->node == b->node && a->file == b->file; +} + +/* What file `file` exports as `name`; TR_NONE when it exports no such name. */ +static tr_res_t tr_export_of(tr_ctx_t *c, int file, const char *name, int depth) { + if (depth > TR_MAX_DEPTH || tr_seen(c, file, name)) { + return tr_mk(TR_NONE); + } + const tr_file_t *f = &c->ix->files[file]; + bool dflt = strcmp(name, "default") == 0; + for (int i = 0; i < f->ndecls; i++) { + const tr_rec_t *d = &f->decls[i]; + bool hit = dflt ? (d->kind == 'd' || d->kind == 'b') + : (strcmp(d->a, name) == 0 && + (d->kind == 'e' || d->kind == 'b' || !f->module_syntax)); + if (hit) { + return tr_decl_res(c, f, d->a); + } + } + for (int i = 0; i < f->nlocals; i++) { + if (strcmp(f->locals[i].a, name) == 0) { + return tr_top_binding(c, file, f->locals[i].b, depth + SKIP_ONE); + } + } + for (int i = 0; i < f->nfroms; i++) { + if (strcmp(f->froms[i].a, name) == 0) { + return tr_from(c, file, f->froms[i].spec, f->froms[i].b, depth); + } + } + for (int i = 0; i < f->nstar_as; i++) { + if (strcmp(f->star_as[i].a, name) == 0) { + return tr_from(c, file, f->star_as[i].spec, NULL, depth); + } + } + tr_res_t found = tr_mk(TR_NONE); + for (int i = 0; i < f->nstars && !dflt; i++) { + tr_res_t r = tr_from(c, file, f->stars[i].spec, name, depth); + if (r.kind == TR_NONE || r.kind == TR_NOT_INDEXED) { + continue; + } + if (found.kind == TR_NONE) { + found = r; + } else if (!tr_same(&found, &r)) { + return tr_mk(TR_AMBIGUOUS); + } + } + return found; +} + +/* ECMAScript, DOM and Node.js globals: external. Sorted. */ +static const char *const TR_LIB[] = {"AbortController", + "AbortSignal", + "AggregateError", + "Array", + "ArrayBuffer", + "ArrayLike", + "AsyncGenerator", + "AsyncIterable", + "AsyncIterableIterator", + "AsyncIterator", + "Atomics", + "Awaited", + "BigInt", + "BigInt64Array", + "BigUint64Array", + "Blob", + "Boolean", + "Buffer", + "CSSStyleDeclaration", + "Capitalize", + "ConstructorParameters", + "CustomEvent", + "DOMException", + "DataView", + "Date", + "Document", + "DocumentFragment", + "Element", + "Error", + "ErrorEvent", + "Event", + "EventListener", + "EventTarget", + "Exclude", + "Extract", + "File", + "FileList", + "Float32Array", + "Float64Array", + "FormData", + "Function", + "Generator", + "HTMLElement", + "Headers", + "Infinity", + "InstanceType", + "Int16Array", + "Int32Array", + "Int8Array", + "Intl", + "Iterable", + "IterableIterator", + "Iterator", + "JSON", + "KeyboardEvent", + "Location", + "Lowercase", + "Map", + "Math", + "MessageEvent", + "MouseEvent", + "MutationObserver", + "NaN", + "Navigator", + "Node", + "NodeJS", + "NodeList", + "NonNullable", + "Number", + "Object", + "Omit", + "OmitThisParameter", + "Parameters", + "Partial", + "Pick", + "Promise", + "PromiseLike", + "PropertyKey", + "Proxy", + "RangeError", + "Readonly", + "ReadonlyArray", + "ReadonlyMap", + "ReadonlySet", + "Record", + "ReferenceError", + "Reflect", + "RegExp", + "Request", + "Required", + "Response", + "ReturnType", + "Set", + "SharedArrayBuffer", + "String", + "Symbol", + "SyntaxError", + "TemplateStringsArray", + "TextDecoder", + "TextEncoder", + "ThisParameterType", + "ThisType", + "TypeError", + "URIError", + "URL", + "URLSearchParams", + "Uint16Array", + "Uint32Array", + "Uint8Array", + "Uint8ClampedArray", + "Uncapitalize", + "Uppercase", + "WeakMap", + "WeakRef", + "WeakSet", + "WebSocket", + "Window", + "Worker", + "XMLHttpRequest", + "__dirname", + "__filename", + "clearInterval", + "clearTimeout", + "console", + "decodeURIComponent", + "document", + "encodeURIComponent", + "exports", + "fetch", + "globalThis", + "isFinite", + "isNaN", + "module", + "parseFloat", + "parseInt", + "process", + "queueMicrotask", + "require", + "setImmediate", + "setInterval", + "setTimeout", + "structuredClone", + "undefined", + "window"}; + +static int tr_lib_cmp(const void *k, const void *e) { + return strcmp((const char *)k, *(const char *const *)e); +} + +static bool tr_lib(const char *name) { + size_t n = strlen(name); + if ((n > TR_HTML_MIN && strncmp(name, "HTML", CBM_SZ_4) == 0 && + strcmp(name + n - CBM_SZ_7, "Element") == 0) || + (n > TR_SVG_MIN && strncmp(name, "SVG", CBM_SZ_3) == 0 && + strcmp(name + n - CBM_SZ_7, "Element") == 0)) { + return true; + } + return bsearch(name, TR_LIB, sizeof(TR_LIB) / sizeof(TR_LIB[0]), sizeof(TR_LIB[0]), + tr_lib_cmp) != NULL; +} + +static bool tr_label_container(const char *l) { + return l && (strcmp(l, "Class") == 0 || strcmp(l, "Interface") == 0 || strcmp(l, "Enum") == 0); +} + +/* The class, interface or enum whose members a bare name may be: the + * documented declaration itself when it is one, else the one it is a member + * of. NULL: none. */ +static const cbm_gbuf_node_t *tr_container(const tr_ctx_t *c, const char *source_qn) { + const cbm_gbuf_node_t *s = cbm_gbuf_find_by_qn(c->g, source_qn); + if (s && tr_label_container(s->label)) { + return s; + } + const char *dot = strrchr(source_qn, '.'); + if (!dot || (size_t)(dot - source_qn) >= TR_QN_MAX) { + return NULL; + } + char parent[TR_QN_MAX]; + memcpy(parent, source_qn, (size_t)(dot - source_qn)); + parent[dot - source_qn] = '\0'; + const cbm_gbuf_node_t *p = cbm_gbuf_find_by_qn(c->g, parent); + return p && tr_label_container(p->label) ? p : NULL; +} + +static tr_res_t tr_member(const tr_ctx_t *c, const cbm_gbuf_node_t *of, const char *name) { + char qn[TR_QN_MAX]; + if (!tr_join(qn, sizeof(qn), of->qualified_name, name, NULL)) { + return tr_mk(TR_MISSING); + } + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(c->g, qn); + if (n) { + tr_res_t r = tr_mk(TR_NODE); + r.node = n; + r.exact = true; + return r; + } + /* a class, interface or enum member often has no node of its own */ + return tr_mk(tr_label_container(of->label) ? TR_GAP : TR_MISSING); +} + +/* The first segment, seen from the documented declaration. */ +static tr_res_t tr_head(tr_ctx_t *c, const CBMDocLink *link, const char *name) { + const tr_file_t *src = &c->ix->files[c->src]; + if (strcmp(name, "this") == 0) { + const cbm_gbuf_node_t *k = tr_container(c, link->source_qn); + tr_res_t r = tr_mk(k ? TR_NODE : TR_MISSING); + r.node = k; + r.exact = true; + return r; + } + /* the namespaces around the reference, innermost first */ + const tr_ns_t *best[TR_MAX_SEGS]; + int nb = 0; + for (int i = 0; i < src->nnss && nb < TR_MAX_SEGS; i++) { + const tr_ns_t *ns = &src->nss[i]; + if (ns->first <= link->line && link->line <= ns->last) { + best[nb++] = ns; + } + } + for (int k = nb - SKIP_ONE; k >= 0; k--) { /* nested ones come later in the blob */ + char qn[TR_QN_MAX]; + const cbm_gbuf_node_t *n = + tr_join(qn, sizeof(qn), src->sym_qn, best[k]->path, name) ? tr_node(c, qn, src) : NULL; + if (n) { + tr_res_t r = tr_mk(TR_NODE); + r.node = n; + r.exact = true; + return r; + } + } + /* a function or class declared in a function body around the reference, + * innermost first; the graph keeps it under a file-level name, which is + * its own only when the node starts on its line */ + const tr_nest_t *vis = NULL; + for (int i = 0; i < src->nnested; i++) { + const tr_nest_t *v = &src->nested[i]; + if (strcmp(v->name, name) == 0 && v->first <= link->line && link->line <= v->last && + (!vis || v->last - v->first < vis->last - vis->first)) { + vis = v; + } + } + if (vis) { + tr_res_t r = tr_decl_res(c, src, name); + if (r.kind == TR_NODE && r.node->start_line != (int)vis->line) { + r = tr_mk(TR_GAP); /* the node is another declaration's of that name */ + } + r.exact = true; + return r; + } + tr_res_t r = tr_top_binding(c, c->src, name, 0); + if (r.kind != TR_NONE) { + return r; + } + if (tr_lib(name)) { + return tr_mk(TR_EXTERNAL); + } + const tr_global_t *g = (const tr_global_t *)cbm_ht_get(c->ix->globals, name); + if (g) { + if (g->count > SKIP_ONE) { + return tr_mk(TR_AMBIGUOUS); + } + r = tr_decl_res(c, &c->ix->files[g->file], name); + return r; + } + const cbm_gbuf_node_t *k = tr_container(c, link->source_qn); + if (k) { + r = tr_member(c, k, name); + if (r.kind == TR_NODE) { + return r; + } + } + return tr_mk(TR_MISSING); +} + +static bool tr_ident(const char *s, size_t n) { + if (n == 0 || isdigit((unsigned char)s[0])) { + return false; + } + for (size_t i = 0; i < n; i++) { + unsigned char ch = (unsigned char)s[i]; + if (!(isalnum(ch) || ch == '_' || ch == '$' || ch >= 0x80)) { + return false; + } + } + return true; +} + +/* Split a reference into its segments; false when it is no name path. */ +static int tr_segments(char *buf, char **segs) { + size_t n = strlen(buf); + char *paren = strchr(buf, '('); + if (paren) { + if (buf[n - SKIP_ONE] != ')') { + return 0; + } + *paren = '\0'; /* (), (args): one node holds every overload */ + } + char *colon = strchr(buf, ':'); + if (colon) { + *colon = '\0'; /* a meaning: :class, :0 */ + } + int ns = 0; + for (char *p = buf; ns < TR_MAX_SEGS;) { + size_t k = strcspn(p, ".#~"); + if (!tr_ident(p, k)) { + return 0; + } + segs[ns++] = p; + if (p[k] == '\0') { + break; + } + p[k] = '\0'; + p += k + SKIP_ONE; + } + return ns; +} + +static void tr_out(tr_ctx_t *c, tr_res_t r) { + switch (r.kind) { + case TR_NODE: { + const tr_file_t *src = &c->ix->files[c->src]; + const char *tp = r.node->file_path; + int tf = tp ? tr_file_at(c->ix, tp) : CBM_NOT_FOUND; + if (tf >= 0 && c->ix->files[tf].test && !src->test) { + c->out->kind = CBM_DOCLINK_UNRESOLVED; + c->out->reason = CBM_DOCLINK_REASON_TEST_ONLY; + return; + } + c->out->kind = CBM_DOCLINK_EDGE; + c->out->target = r.node; + c->out->exact = r.exact; + return; + } + case TR_EXTERNAL: + c->out->reason = CBM_DOCLINK_REASON_EXTERNAL; + break; + case TR_AMBIGUOUS: + c->out->reason = CBM_DOCLINK_REASON_AMBIGUOUS; + break; + case TR_GAP: + c->out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; + break; + case TR_NOT_INDEXED: + c->out->reason = CBM_DOCLINK_REASON_NOT_INDEXED; + break; + default: + c->out->reason = CBM_DOCLINK_REASON_MISSING; + break; + } + c->out->kind = CBM_DOCLINK_UNRESOLVED; +} + +static void tr_resolve(const void *index, void *state, int run_file, const CBMDocLink *link, + const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out) { + (void)state; /* TS/JS keeps no per-file working state */ + const tr_index_t *ix = (const tr_index_t *)index; + out->kind = CBM_DOCLINK_UNRESOLVED; + out->reason = CBM_DOCLINK_REASON_MISSING; + out->target = NULL; + out->exact = false; + if (!ix || !link->raw || run_file < 0 || run_file >= ix->run_count || + ix->run_to_file[run_file] < 0) { + return; + } + tr_ctx_t c = {.ix = ix, .g = graph, .src = ix->run_to_file[run_file], .out = out}; + char buf[CBM_DOCLINK_TS_REF_MAX + PAIR_LEN]; + size_t rl = strlen(link->raw); + char *segs[TR_MAX_SEGS]; + int ns = 0; + if (rl <= CBM_DOCLINK_TS_REF_MAX && strncmp(link->raw, "module:", CBM_SZ_7) != 0) { + memcpy(buf, link->raw, rl + SKIP_ONE); + ns = tr_segments(buf, segs); + } + if (ns == 0) { + out->reason = CBM_DOCLINK_REASON_UNPARSEABLE; + return; + } + tr_res_t r = tr_head(&c, link, segs[0]); + for (int i = SKIP_ONE; i < ns && (r.kind == TR_NODE || r.kind == TR_MODULE); i++) { + if (r.kind == TR_MODULE) { + c.nvisited = 0; + r = tr_export_of(&c, r.file, segs[i], 0); + r.kind = r.kind == TR_NONE ? TR_MISSING : r.kind; + } else { + r = tr_member(&c, r.node, segs[i]); + } + } + if (r.kind == TR_MODULE) { + /* a module named by itself: its file */ + const cbm_gbuf_node_t *fn = + cbm_pipeline_file_node(graph, ix->project, ix->files[r.file].path); + r = tr_mk(fn ? TR_NODE : TR_GAP); + r.node = fn; + r.exact = true; + } + tr_out(&c, r); +} + +/* A top-level declaration (D name flags) declares one name: other files reach + * it through an import of it, so a reference that did either bound it (an + * edge into this file) or names it in its unresolved row. Imports, exports + * and namespaces change how others resolve: GLOBAL. */ +static bool tr_removable(const char *line, size_t len, const char **name, size_t *nlen) { + return len > PAIR_LEN && line[0] == 'D' && line[1] == '\t' && + cbm_doclinks_scope_field(line, len, 1, name, nlen); +} + +static int tr_scope_delta(const char *stored, const char *fresh, cbm_doclink_name_fn removed, + void *ud) { + return cbm_doclinks_scope_delta_lines(stored, fresh, tr_removable, removed, ud); +} + +const cbm_doclink_resolver_t cbm_doclink_ts_resolver = { + .langs = {CBM_LANG_TYPESCRIPT, CBM_LANG_TSX, CBM_LANG_JAVASCRIPT}, + .lang_count = CBM_SZ_3, + .scope_tag = CBM_DOCLINK_TS_SCOPE_TAG, + .build = tr_build, + .destroy = tr_destroy, + .resolve = tr_resolve, + .scope_delta = tr_scope_delta, +}; diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 60e1a6bcc..d2cbec401 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -446,6 +446,7 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const cbm_gbuf_insert_edge(ctx->gbuf, parent->id, node_id, "DEFINES_METHOD", "{}"); } } + cbm_pipeline_diagram_parent_edge(ctx, def, node_id); } /* Create Channel nodes + EMITS / LISTENS_ON edges for one file's channels. @@ -1004,6 +1005,7 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * map is available without the cache (single-file scope). */ total_imports += create_import_edges_for_file(ctx, result, rel, NULL); create_channel_edges_for_file(ctx, result, rel); + cbm_pipeline_diagram_arrows(ctx, result); cbm_pipeline_create_env_configures_for_file(ctx, result, rel); cbm_free_result(result); } @@ -1037,6 +1039,7 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t total_imports += create_import_edges_for_file(ctx, result, files[i].rel_path, namespace_map); create_channel_edges_for_file(ctx, result, files[i].rel_path); + cbm_pipeline_diagram_arrows(ctx, result); cbm_pipeline_create_env_configures_for_file(ctx, result, files[i].rel_path); } cbm_pipeline_namespace_map_free(namespace_map); diff --git a/src/pipeline/pass_diagrams.c b/src/pipeline/pass_diagrams.c new file mode 100644 index 000000000..e745d4092 --- /dev/null +++ b/src/pipeline/pass_diagrams.c @@ -0,0 +1,68 @@ +/* + * pass_diagrams.c — the graph side of diagrams (internal/cbm/diagram.c). + * + * A Diagram node is a definition of the file that holds it; a diagram block of + * a document also hangs under the Section it is written in, and each + * DiagramElement under its Diagram (DEFINES, from the definition's + * parent_class). The arrows of a diagram are DIAGRAM_ARROW edges between the + * file's DiagramElement nodes, with the arrow's text, its style and its line. + * Both paths of the definitions pass (pass_definitions.c, pass_parallel.c) + * call in here, so they cannot drift apart. + */ +#include "pipeline/pipeline_internal.h" +#include "cbm.h" +#include "foundation/constants.h" +#include "foundation/str_util.h" +#include "graph_buffer/graph_buffer.h" + +#include +#include +#include + +enum { PD_TEXT_MAX = 512, PD_PROPS_MAX = 1536 }; + +static bool pd_is_diagram_label(const char *label) { + return label && (strcmp(label, "Diagram") == 0 || strcmp(label, "DiagramElement") == 0); +} + +void cbm_pipeline_diagram_parent_edge(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, + int64_t node_id) { + if (!ctx || !def || !def->parent_class || node_id <= 0 || !pd_is_diagram_label(def->label)) { + return; + } + const cbm_gbuf_node_t *parent = cbm_gbuf_find_by_qn(ctx->gbuf, def->parent_class); + if (parent && parent->id != node_id) { + cbm_gbuf_insert_edge(ctx->gbuf, parent->id, node_id, "DEFINES", "{}"); + } +} + +void cbm_pipeline_diagram_arrows(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result) { + if (!ctx || !result) { + return; + } + for (int i = 0; i < result->diagram_arrows.count; i++) { + const CBMDiagramArrow *a = &result->diagram_arrows.items[i]; + if (!a->from_qn || !a->to_qn) { + continue; + } + const cbm_gbuf_node_t *from = cbm_gbuf_find_by_qn(ctx->gbuf, a->from_qn); + const cbm_gbuf_node_t *to = cbm_gbuf_find_by_qn(ctx->gbuf, a->to_qn); + if (!from || !to) { + continue; + } + char label[PD_TEXT_MAX]; + char style[PD_TEXT_MAX]; + cbm_json_escape(label, sizeof(label), a->label ? a->label : ""); + cbm_json_escape(style, sizeof(style), a->style ? a->style : ""); + char props[PD_PROPS_MAX]; + int n = + a->label && a->label[0] + ? snprintf(props, sizeof(props), "{\"label\":\"%s\",\"style\":\"%s\",\"line\":%u}", + label, style, a->line) + : snprintf(props, sizeof(props), "{\"style\":\"%s\",\"line\":%u}", style, a->line); + if (n <= 0 || (size_t)n >= sizeof(props)) { + continue; + } + cbm_gbuf_insert_edge(ctx->gbuf, from->id, to->id, "DIAGRAM_ARROW", props); + } +} diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 950d74573..56fbe6361 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1783,6 +1783,9 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d cbm_gbuf_insert_edge(ctx->gbuf, parent->id, def_node->id, "DEFINES_METHOD", "{}"); } } + if (def_node) { + cbm_pipeline_diagram_parent_edge(ctx, def, def_node->id); + } return edges; } @@ -1971,6 +1974,7 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t imports_edges += create_imports_edges(ctx, result, rel, namespace_map); create_channel_edges(ctx, result, rel); + cbm_pipeline_diagram_arrows(ctx, result); cbm_pipeline_create_env_configures_for_file(ctx, result, rel); if (loaded) { cbm_free_result(result); diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 878dd7f50..51701851c 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -38,95 +38,6 @@ enum { #include #include -/* True for characters that may appear in a ":name" route parameter. */ -static inline bool is_route_ident_char(char c) { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; -} - -/* Canonicalize route-path parameter placeholders to a single "{}" token so that - * client call sites and server handlers rendezvous on the same Route QN - * regardless of framework syntax. Each parameter token collapses to "{}": - * - * :name Express / React-Router / Rails / typical JS API clients - * {name} Axum / Spring / OpenAPI / ASP.NET - * Flask / Rocket (incl. typed "") - * ${...} JS template interpolation captured into the path - * - * Parameter names are intentionally discarded so the same logical endpoint - * matches across services that name the path variable differently. Static path - * text is copied verbatim; the result never exceeds the input length. */ -const char *cbm_route_canon_path(const char *in, char *out, size_t out_sz) { - if (out == NULL || out_sz == 0) { - /* WHY: cppcheck value-flow follows a caller that passes an as-yet - * uninitialized output buffer (e.g. `char cpath[256];`) and flags this - * `return out` as uninitvar. False positive: we only return the pointer - * here and never read the buffer; returning it (NULL, or a buffer too - * small to write into) is the intended early-out contract. */ - // cppcheck-suppress uninitvar - return out; - } - if (in == NULL) { - out[0] = '\0'; - return out; - } - const size_t last = out_sz - 1; - size_t oi = 0; - size_t i = 0; - while (in[i] != '\0' && oi < last) { - char c = in[i]; - bool at_seg_start = (oi == 0) || (out[oi - 1] == '/'); - bool is_param = false; - - if (c == ':' && at_seg_start && is_route_ident_char(in[i + 1])) { - i++; - while (in[i] != '\0' && is_route_ident_char(in[i])) { - i++; - } - is_param = true; - } else if (c == '{') { - i++; - while (in[i] != '\0' && in[i] != '}' && in[i] != '/') { - i++; - } - if (in[i] == '}') { - i++; - } - is_param = true; - } else if (c == '<') { - i++; - while (in[i] != '\0' && in[i] != '>' && in[i] != '/') { - i++; - } - if (in[i] == '>') { - i++; - } - is_param = true; - } else if (c == '$' && in[i + 1] == '{') { - i += 2; - while (in[i] != '\0' && in[i] != '}' && in[i] != '/') { - i++; - } - if (in[i] == '}') { - i++; - } - is_param = true; - } - - if (is_param) { - if (oi + 2 > last) { - break; - } - out[oi++] = '{'; - out[oi++] = '}'; - continue; - } - out[oi++] = c; - i++; - } - out[oi] = '\0'; - return out; -} - /* Extract a JSON string value by key from properties. * Returns pointer into buf (caller provides buffer). NULL if not found. */ static const char *json_extract(const char *json, const char *key, char *buf, int bufsz) { diff --git a/src/pipeline/pass_semantic_edges.c b/src/pipeline/pass_semantic_edges.c index 30b2c4954..e5822d809 100644 --- a/src/pipeline/pass_semantic_edges.c +++ b/src/pipeline/pass_semantic_edges.c @@ -2904,12 +2904,39 @@ typedef struct { int n; } doc_rows_t; +/* The diagram family of a section: the highest of the Diagram nodes it + * DEFINES (cbm_sem_diag_family), CBM_SEM_DIAG_NONE without one. */ +static cbm_sem_diag_family_t doc_section_family(const cbm_gbuf_t *gbuf, + const cbm_gbuf_node_t *sec) { + const cbm_gbuf_edge_t **edges = NULL; + int n = 0; + cbm_sem_diag_family_t fam = CBM_SEM_DIAG_NONE; + if (cbm_gbuf_find_edges_by_source_type(gbuf, sec->id, "DEFINES", &edges, &n) != 0) { + return fam; + } + for (int i = 0; i < n; i++) { + const cbm_gbuf_node_t *t = cbm_gbuf_find_by_id(gbuf, edges[i]->target_id); + if (!t || !t->label || strcmp(t->label, "Diagram") != 0 || !t->properties_json) { + continue; + } + char fmt[CBM_SZ_32]; + char kind[CBM_SZ_32]; + const char *f = json_str_value(t->properties_json, "format", fmt, sizeof(fmt)); + const char *k = json_str_value(t->properties_json, "diagram_kind", kind, sizeof(kind)); + cbm_sem_diag_family_t df = cbm_sem_diag_family(f, k); + if (df > fam) { + fam = df; + } + } + return fam; +} + /* Append the candidate (section -> target) when its judged p is above 0 and * the graph does not link the pair exactly; false: out of memory (the row at * rw->n may hold partial strings). */ static bool doc_row_add(doc_rows_t *rw, const cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sec, - const cbm_gbuf_node_t *target, const doc_hit_t *hit, - cbm_sem_doc_kind_t kind, const char *home) { + cbm_sem_diag_family_t fam, const cbm_gbuf_node_t *target, + const doc_hit_t *hit, cbm_sem_doc_kind_t kind, const char *home) { if (!target || !target->qualified_name || !sec->qualified_name) { return true; } @@ -2917,7 +2944,7 @@ static bool doc_row_add(doc_rows_t *rw, const cbm_gbuf_t *gbuf, const cbm_gbuf_n cbm_sem_doc_format_t fmt = cbm_sem_doc_format(sec->file_path); float p = kind == CBM_SEM_DOC_KIND_FOLDER ? cbm_sem_doc_folder_p(fmt, sec->file_path) - : cbm_sem_doc_calibrated_p(fmt, kind, pos, hit->tfidf); + : cbm_sem_doc_section_p(fmt, fam, kind, pos, hit->tfidf); if (p <= 0.0F || doc_pair_linked(gbuf, sec->id, target->id)) { return true; /* not stored for this band, kind or format; or linked exactly */ } @@ -2944,25 +2971,26 @@ static bool doc_section_rows(doc_rows_t *rw, const cbm_gbuf_t *gbuf, const doc_c const doc_place_t *pl, int s) { const cbm_gbuf_node_t *sec = dc->secs[s]; const doc_result_t *r = &dc->res[s]; + cbm_sem_diag_family_t fam = doc_section_family(gbuf, sec); bool ok = true; for (int h = 0; ok && h < r->n + r->nlocal; h++) { const doc_hit_t *hit = h < r->n ? &r->hits[h] : &r->local[h - r->n]; if (h < r->n && (hit->rank > CBM_SEM_DOC_TOP_K || hit->tfidf < CBM_SEM_DOC_MIN_SCORE)) { continue; } - ok = - doc_row_add(rw, gbuf, sec, cbm_gbuf_find_by_id(gbuf, dc->funcs[hit->func].node_id), hit, - h < r->n ? CBM_SEM_DOC_KIND_FUNCTION : CBM_SEM_DOC_KIND_LOCAL, pl->home[s]); + ok = doc_row_add( + rw, gbuf, sec, fam, cbm_gbuf_find_by_id(gbuf, dc->funcs[hit->func].node_id), hit, + h < r->n ? CBM_SEM_DOC_KIND_FUNCTION : CBM_SEM_DOC_KIND_LOCAL, pl->home[s]); } for (int h = 0; ok && h < r->nfiles; h++) { const doc_file_hit_t *fh = &r->files[h]; doc_hit_t hit = {.tfidf = fh->tfidf, .rank = fh->rank, .shared = fh->shared}; - ok = doc_row_add(rw, gbuf, sec, dc->files->node[fh->file], &hit, CBM_SEM_DOC_KIND_FILE, + ok = doc_row_add(rw, gbuf, sec, fam, dc->files->node[fh->file], &hit, CBM_SEM_DOC_KIND_FILE, pl->home[s]); } doc_hit_t first = {.rank = SKIP_ONE}; - return ok && doc_row_add(rw, gbuf, sec, doc_home_folder(pl, s), &first, CBM_SEM_DOC_KIND_FOLDER, - pl->home[s]); + return ok && doc_row_add(rw, gbuf, sec, fam, doc_home_folder(pl, s), &first, + CBM_SEM_DOC_KIND_FOLDER, pl->home[s]); } /* The stored candidates of every section -- its best CBM_SEM_DOC_TOP_K diff --git a/src/pipeline/path_alias.c b/src/pipeline/path_alias.c index ef999e14d..7ef54c369 100644 --- a/src/pipeline/path_alias.c +++ b/src/pipeline/path_alias.c @@ -317,11 +317,13 @@ void cbm_path_alias_collection_free(cbm_path_alias_collection_t *coll) { free(coll); } -char *cbm_path_alias_resolve(const cbm_path_alias_map_t *map, const char *module_path) { - if (!map || !module_path) { - return NULL; +bool cbm_path_alias_resolve_into(const cbm_path_alias_map_t *map, const char *module_path, + char *out, size_t cap) { + if (!map || !module_path || !out || cap == 0) { + return false; } size_t mod_len = strlen(module_path); + int n = CBM_NOT_FOUND; for (int i = 0; i < map->count; i++) { const cbm_path_alias_t *e = &map->entries[i]; @@ -339,41 +341,35 @@ char *cbm_path_alias_resolve(const cbm_path_alias_map_t *map, const char *module strcmp(module_path + mod_len - suffix_len, e->alias_suffix) != 0) { continue; } - size_t wild_len = mod_len - prefix_len - suffix_len; - const char *wild_start = module_path + prefix_len; - size_t tp_len = strlen(e->target_prefix); - size_t ts_len = strlen(e->target_suffix); - char *result = malloc(tp_len + wild_len + ts_len + 1); - if (!result) { - return NULL; - } - memcpy(result, e->target_prefix, tp_len); - memcpy(result + tp_len, wild_start, wild_len); - memcpy(result + tp_len + wild_len, e->target_suffix, ts_len); - result[tp_len + wild_len + ts_len] = '\0'; - return strip_resolved_ext(result); + int wild_len = (int)(mod_len - prefix_len - suffix_len); + n = snprintf(out, cap, "%s%.*s%s", e->target_prefix, wild_len, module_path + prefix_len, + e->target_suffix); + break; } if (strcmp(module_path, e->alias_prefix) == 0) { - return strip_resolved_ext(strdup(e->target_prefix)); + n = snprintf(out, cap, "%s", e->target_prefix); + break; } } /* baseUrl fallback. Apply only to non-relative imports that look * sub-path-ish (contain '/' but don't start with '.' or '@'); skips * obvious package names like "react" or "lodash". */ - if (map->base_url && module_path[0] != '.' && module_path[0] != '@' && + if (n < 0 && map->base_url && module_path[0] != '.' && module_path[0] != '@' && strchr(module_path, '/') != NULL) { - size_t bu_len = strlen(map->base_url); - size_t need = bu_len + 1 + mod_len + 1; - char *result = malloc(need); - if (!result) { - return NULL; - } - snprintf(result, need, "%s/%s", map->base_url, module_path); - return strip_resolved_ext(result); + n = snprintf(out, cap, "%s/%s", map->base_url, module_path); } - return NULL; + if (n < 0 || (size_t)n >= cap) { + return false; + } + strip_resolved_ext(out); + return true; +} + +char *cbm_path_alias_resolve(const cbm_path_alias_map_t *map, const char *module_path) { + char buf[CBM_PATH_ALIAS_RESULT_MAX]; + return cbm_path_alias_resolve_into(map, module_path, buf, sizeof(buf)) ? strdup(buf) : NULL; } /* ── Repo walk ─────────────────────────────────────────────────── */ diff --git a/src/pipeline/path_alias.h b/src/pipeline/path_alias.h index bc462e524..1f981c79e 100644 --- a/src/pipeline/path_alias.h +++ b/src/pipeline/path_alias.h @@ -93,4 +93,10 @@ const cbm_path_alias_map_t *cbm_path_alias_find_for_file(const cbm_path_alias_co * pipeline can consume it. */ char *cbm_path_alias_resolve(const cbm_path_alias_map_t *map, const char *module_path); +/* The same, written into out (cap bytes, NUL-terminated) instead of a heap + * string; false when no alias applies or the path does not fit. */ +enum { CBM_PATH_ALIAS_RESULT_MAX = 4096 }; +bool cbm_path_alias_resolve_into(const cbm_path_alias_map_t *map, const char *module_path, + char *out, size_t cap); + #endif /* CBM_PATH_ALIAS_H */ diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 2d66a6c7e..335380ff7 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1881,6 +1881,7 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c (size_t)r->impl_traits.count * sizeof(CBMImplTrait) + (size_t)r->infra_bindings.count * sizeof(CBMInfraBinding) + (size_t)r->channels.count * sizeof(CBMChannel) + + (size_t)r->diagram_arrows.count * sizeof(CBMDiagramArrow) + (size_t)r->field_types.count * sizeof(CBMFieldType); cap_other += (size_t)r->imports.cap * sizeof(CBMImport) + (size_t)r->resolved_calls.cap * sizeof(CBMResolvedCall) + @@ -1891,6 +1892,7 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c (size_t)r->impl_traits.cap * sizeof(CBMImplTrait) + (size_t)r->infra_bindings.cap * sizeof(CBMInfraBinding) + (size_t)r->channels.cap * sizeof(CBMChannel) + + (size_t)r->diagram_arrows.cap * sizeof(CBMDiagramArrow) + (size_t)r->field_types.cap * sizeof(CBMFieldType); n_defs += (size_t)r->defs.count; n_calls += (size_t)r->calls.count; diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 7ecddbb4e..da925d2ab 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -264,13 +264,7 @@ bool cbm_pipeline_frozen_manifests_equal(cbm_pipeline_t *p, const cbm_file_hash_ * "no incremental route; continue with a full index" sentinel. */ #define CBM_PIPELINE_FORCE_FULL_REINDEX (-3) -/* Canonicalize route-path parameter placeholders (":id", "{id}", "", - * "${...}") to a single "{}" token so that client call sites and server - * handlers rendezvous on the same Route QN regardless of framework syntax. - * Parameter names are intentionally discarded ("/u/{id}" and "/u/{slug}" both - * canonicalize to "/u/{}"). The result never exceeds the input length, so - * out_sz >= strlen(in) + 1 always suffices. Returns out. */ -const char *cbm_route_canon_path(const char *in, char *out, size_t out_sz); +/* cbm_route_canon_path: cbm.h (route_path.c). */ /* True when a graph node is a structural directory container (Folder/Project) * rather than a code node. In a directory-based-module language (Java/Go, see @@ -564,6 +558,15 @@ static inline int cbm_pipeline_relpath_is_excluded(const char *rel_path, char *c return 0; } +/* Diagrams (pass_diagrams.c). The DEFINES edge from a Diagram's or a + * DiagramElement's container (the Section a diagram block sits in, the + * Diagram of an element) to the definition's node; nothing for other + * definitions. */ +void cbm_pipeline_diagram_parent_edge(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, + int64_t node_id); +/* The DIAGRAM_ARROW edges of one file's diagrams. */ +void cbm_pipeline_diagram_arrows(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result); + /* Get the current pipeline's package map (NULL if none). */ CBMHashTable *cbm_pipeline_get_pkgmap(void); void cbm_pipeline_set_pkgmap(CBMHashTable *map); @@ -1279,7 +1282,13 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr * properties are read with their escapes, admission is best-first and * every edge carries "p"; doc_link_candidates holds doc section -> code * candidates. An index built before has the old pairs and no - * candidates, so it rebuilds once on upgrade. */ + * candidates, so it rebuilds once on upgrade. Released together with + * 7 (no release in between, so no number of its own): doc comments of + * Go, Java, Kotlin, Rust, Perl, TS/JS, PHP and C hold MENTIONS edges; + * diagrams are Diagram/DiagramElement nodes with DIAGRAM_ARROW edges; + * OpenAPI operations are Routes; a Markdown section's text keeps a + * diagram fence's labels instead of its syntax, and its candidates + * carry the p of its diagrams' kind family. */ enum { CBM_SEMANTIC_INDEX_VERSION = 7 }; typedef struct { diff --git a/src/semantic/semantic.c b/src/semantic/semantic.c index af738151e..704994ede 100644 --- a/src/semantic/semantic.c +++ b/src/semantic/semantic.c @@ -1973,6 +1973,72 @@ float cbm_sem_doc_folder_p(cbm_sem_doc_format_t fmt, const char *doc_path) { return readme ? 0.867F : 0.0F; } +cbm_sem_diag_family_t cbm_sem_diag_family(const char *format, const char *diagram_kind) { + static const char *const mermaid[] = {"sequence", "class", "er", + "c4_context", "c4_container", "c4_component", + "c4_dynamic", "c4_deployment", "architecture"}; + static const char *const plantuml[] = {"c4", "class", "sequence", "usecase", "component"}; + if (!format || !format[0]) { + return CBM_SEM_DIAG_NONE; + } + const char *const *structural = NULL; + size_t n = 0; + if (strcmp(format, "mermaid") == 0) { + structural = mermaid; + n = sizeof(mermaid) / sizeof(mermaid[0]); + } else if (strcmp(format, "plantuml") == 0) { + structural = plantuml; + n = sizeof(plantuml) / sizeof(plantuml[0]); + } else { + return CBM_SEM_DIAG_GRAPH; + } + for (size_t i = 0; diagram_kind && i < n; i++) { + if (strcmp(diagram_kind, structural[i]) == 0) { + return CBM_SEM_DIAG_STRUCTURAL; + } + } + return CBM_SEM_DIAG_PROSE; +} + +/* Markdown sections that embed a diagram, by the section's family (DIAG-C, + * 2026-10-11: 553 blind-judged candidates from sections of 16 held-out + * repositories that embed a diagram, three reading rounds; function + * candidates at or inside the home pooled, as both curves rose by band). + * A diagram section is about its candidates more often than SEM7's Markdown + * curve says, so its first band is stored too: + * prose 0.24 / 0.28 / 0.45, local extras 11 of 40 = 0.275 + * structural 0.32 / 0.46 / 0.57, local extras 7 of 9 = 0.78 (every + * local extra of the family; 95% interval 0.45-0.94) + * Graph diagrams (DOT, D2) were not found in Markdown sections: SEM7. */ +static const struct { + bool fitted; + float function[SEM_DOC_P_NBANDS]; + float local; +} SEM_DOC_P_MD_DIAGRAM[CBM_SEM_DIAG_COUNT] = { + [CBM_SEM_DIAG_PROSE] = {true, {0.0F, 0.24F, 0.28F, 0.45F}, 0.275F}, + [CBM_SEM_DIAG_STRUCTURAL] = {true, {0.0F, 0.32F, 0.464F, 0.567F}, 0.778F}, +}; + +float cbm_sem_doc_section_p(cbm_sem_doc_format_t fmt, cbm_sem_diag_family_t fam, + cbm_sem_doc_kind_t kind, cbm_sem_doc_pos_t pos, float score) { + bool fitted = fmt == CBM_SEM_DOC_FMT_MARKDOWN && (int)fam > 0 && fam < CBM_SEM_DIAG_COUNT && + SEM_DOC_P_MD_DIAGRAM[fam].fitted; + bool home = pos == CBM_SEM_DOC_POS_GLOBAL || pos == CBM_SEM_DOC_POS_LOCAL; + if (!fitted || !((kind == CBM_SEM_DOC_KIND_FUNCTION && home) || + (kind == CBM_SEM_DOC_KIND_LOCAL && pos == CBM_SEM_DOC_POS_LOCAL))) { + return cbm_sem_doc_calibrated_p(fmt, kind, pos, score); + } + size_t band = 0; + while (band < SEM_DOC_P_NBANDS - 1 && score >= SEM_DOC_P_CUTS[band]) { + band++; + } + if (band == 0) { + return 0.0F; /* below CBM_SEM_DOC_MIN_SCORE: never measured, nothing stored */ + } + return kind == CBM_SEM_DOC_KIND_LOCAL ? SEM_DOC_P_MD_DIAGRAM[fam].local + : SEM_DOC_P_MD_DIAGRAM[fam].function[band]; +} + float cbm_sem_combined_score(const cbm_sem_func_t *a, const cbm_sem_func_t *b, const cbm_sem_config_t *cfg) { if (!a || !b || !cfg) { diff --git a/src/semantic/semantic.h b/src/semantic/semantic.h index 66f4cc0a1..9f40c1041 100644 --- a/src/semantic/semantic.h +++ b/src/semantic/semantic.h @@ -319,6 +319,28 @@ float cbm_sem_doc_calibrated_p(cbm_sem_doc_format_t fmt, cbm_sem_doc_kind_t kind * stored. */ float cbm_sem_doc_folder_p(cbm_sem_doc_format_t fmt, const char *doc_path); +/* The kind family of a diagram a section embeds, by its format and + * diagram_kind: structural (Mermaid sequence, class, ER, C4 and architecture; + * PlantUML C4, class, sequence, use-case and component), prose (any other + * Mermaid or PlantUML diagram) or graph (any other format). A section's family + * is the highest any of its diagrams has, in this order. */ +typedef enum { + CBM_SEM_DIAG_NONE = 0, + CBM_SEM_DIAG_GRAPH, + CBM_SEM_DIAG_PROSE, + CBM_SEM_DIAG_STRUCTURAL, + CBM_SEM_DIAG_COUNT, +} cbm_sem_diag_family_t; +cbm_sem_diag_family_t cbm_sem_diag_family(const char *format, const char *diagram_kind); + +/* cbm_sem_doc_calibrated_p for a section that embeds diagrams of family fam: + * a Markdown section of a family with its own judged curve takes that curve + * for function candidates at or inside the home and for local extras; every + * other candidate, and every section of another family, as + * cbm_sem_doc_calibrated_p. */ +float cbm_sem_doc_section_p(cbm_sem_doc_format_t fmt, cbm_sem_diag_family_t fam, + cbm_sem_doc_kind_t kind, cbm_sem_doc_pos_t pos, float score); + /* p as stored and shown: two decimals, half up (0.225 -> 0.23, 0.325 -> * 0.33; the epsilon absorbs the float representation of a judged x.xx5). */ double cbm_sem_p_2dp(float p); diff --git a/tests/repro/repro_call_node_manifest.c b/tests/repro/repro_call_node_manifest.c index 6d87ac716..fc2a8b14c 100644 --- a/tests/repro/repro_call_node_manifest.c +++ b/tests/repro/repro_call_node_manifest.c @@ -552,7 +552,8 @@ TEST(repro_call_node_manifest_live_specs_contain_only_expected_primary_entries) * transform-only one and the DOCUMENT ones of PDF and AsciiDoc * (doc_pdf.c, doc_adoc.c). */ if (language == CBM_LANG_NIM || language == CBM_LANG_OBJECTSCRIPT_EXPORT || - language == CBM_LANG_PDF || language == CBM_LANG_ASCIIDOC) { + language == CBM_LANG_PDF || language == CBM_LANG_ASCIIDOC || + language == CBM_LANG_DIAGRAM || language == CBM_LANG_OPENAPI) { continue; } if (!spec || spec->language != language) { diff --git a/tests/repro/repro_language_registry.c b/tests/repro/repro_language_registry.c index e99faaa91..668f9fa61 100644 --- a/tests/repro/repro_language_registry.c +++ b/tests/repro/repro_language_registry.c @@ -219,6 +219,8 @@ static const LanguageCapabilityEntry LANGUAGE_CAPABILITIES[CBM_LANG_COUNT] = { CALL_WITHOUT_REFERENCE_VOCAB(CHIALISP), DOCUMENT(PDF), DOCUMENT(ASCIIDOC), + NO_CALL(OPENAPI), /* the JSON grammar; its operations are Routes, no calls */ + DOCUMENT(DIAGRAM), }; #undef CALL_WITH_REFERENCE_VOCAB @@ -310,9 +312,9 @@ TEST(repro_language_capability_ledger_covers_every_enum) { } if (counts[CAP_CALL_WITH_REFERENCE_VOCAB] != 88 || - counts[CAP_CALL_WITHOUT_REFERENCE_VOCAB] != 27 || counts[CAP_NO_CALL] != 49 || + counts[CAP_CALL_WITHOUT_REFERENCE_VOCAB] != 27 || counts[CAP_NO_CALL] != 50 || counts[CAP_TRANSFORM_ONLY] != 1 || counts[CAP_UNSUPPORTED] != 1 || - counts[CAP_DOCUMENT] != 2) { + counts[CAP_DOCUMENT] != 3) { fprintf(stderr, " [language-registry] invariant=capability_partition call_ref_vocab=%d ref_gap=%d " "no_call=%d transform_only=%d unsupported=%d document=%d\n", @@ -332,7 +334,7 @@ TEST(repro_call_argument_matrices_equal_call_capability_ledger) { EXPECTED_MATRIX_B_ROWS = 49, EXPECTED_CALL_CAPABLE_LANGUAGES = 115, EXPECTED_NON_CALL_LANGUAGES = - 53, /* 49 no-call + transform-only + unsupported + PDF, AsciiDoc */ + 55, /* 50 no-call + transform-only + unsupported + PDF, AsciiDoc, Diagram */ EXPECTED_NON_CALL_DOMAIN_CONTROLS = 2, }; CBMLanguage matrix_a_ids[CBM_LANG_COUNT]; diff --git a/tests/test_diagrams.c b/tests/test_diagrams.c new file mode 100644 index 000000000..574f27148 --- /dev/null +++ b/tests/test_diagrams.c @@ -0,0 +1,1117 @@ +/* + * test_diagrams.c -- diagrams as nodes (internal/cbm/diagram.c): Diagram and + * DiagramElement definitions, DIAGRAM_ARROW edges (src/pipeline/pass_diagrams.c). + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include "test_doc_mentions_helpers.h" + +#include "cbm.h" + +#include +#include +#include +#include +#include +#include + +/* ── extraction ──────────────────────────────────────────────────── */ + +static const char *const DG_DOC = "# Doc\n" /* 1 */ + "\n" /* 2 */ + "Intro.\n" /* 3 */ + "\n" /* 4 */ + "## Arch\n" /* 5 */ + "\n" /* 6 */ + "```mermaid\n" /* 7 */ + "---\n" /* 8 */ + "title: System overview\n" /* 9 */ + "---\n" /* 10 */ + "flowchart LR\n" /* 11 */ + " A[Web app] -->|calls| B(API)\n" /* 12 */ + " B --> C[(DB)] & D{Cache}\n" /* 13 */ + " B -. retries .-> E\n" /* 14 */ + " subgraph S [Backend]\n" /* 15 */ + " end\n" /* 16 */ + "```\n" /* 17 */ + "\n" /* 18 */ + "```mermaid\n" /* 19 */ + "sequenceDiagram\n" /* 20 */ + " participant U as User\n" /* 21 */ + " U->>API: login\n" /* 22 */ + " API-->>U: token\n" /* 23 */ + "```\n" /* 24 */ + "\n" /* 25 */ + "## Model\n" /* 26 */ + "\n" /* 27 */ + "```mermaid\n" /* 28 */ + "classDiagram\n" /* 29 */ + " Animal <|-- Dog : is a\n" /* 30 */ + " class Shop[\"Pet shop\"]\n" /* 31 */ + " Shop *-- Dog\n" /* 32 */ + "```\n"; /* 33 */ + +static const CBMDefinition *dg_def(const CBMFileResult *r, const char *qn_suffix) { + size_t sl = strlen(qn_suffix); + for (int i = 0; i < r->defs.count; i++) { + const char *qn = r->defs.items[i].qualified_name; + size_t ql = qn ? strlen(qn) : 0; + if (ql >= sl && strcmp(qn + ql - sl, qn_suffix) == 0) { + return &r->defs.items[i]; + } + } + printf(" no definition *%s\n", qn_suffix); + return NULL; +} + +static const char *dg_prop(const CBMDefinition *d, const char *key) { + for (const char **kv = d->extra_props; kv && kv[0]; kv += 2) { + if (strcmp(kv[0], key) == 0) { + return kv[1]; + } + } + return ""; +} + +static int dg_arrows(const CBMFileResult *r, const char *from_suffix, const char *to_suffix, + const char *style, const char *label) { + int n = 0; + size_t fl = strlen(from_suffix); + size_t tl = strlen(to_suffix); + for (int i = 0; i < r->diagram_arrows.count; i++) { + const CBMDiagramArrow *a = &r->diagram_arrows.items[i]; + size_t al = strlen(a->from_qn); + size_t bl = strlen(a->to_qn); + if (al >= fl && strcmp(a->from_qn + al - fl, from_suffix) == 0 && bl >= tl && + strcmp(a->to_qn + bl - tl, to_suffix) == 0 && strcmp(a->style, style) == 0 && + strcmp(a->label ? a->label : "", label) == 0) { + n++; + } + } + if (n != 1) { + printf(" arrow %s -> %s [%s \"%s\"]: %d found\n", from_suffix, to_suffix, style, label, n); + } + return n; +} + +TEST(diagrams_markdown_mermaid) { + CBMFileResult *r = dm_extract(DG_DOC, CBM_LANG_MARKDOWN, "docs/arch.md"); + ASSERT_NOT_NULL(r); + /* a flowchart under its section, titled by its front matter */ + const CBMDefinition *d = dg_def(r, ".Arch#diagram-1"); + ASSERT_NOT_NULL(d); + ASSERT_STR_EQ(d->label, "Diagram"); + ASSERT_STR_EQ(d->name, "System overview"); + ASSERT_STR_EQ(dg_prop(d, "format"), "mermaid"); + ASSERT_STR_EQ(dg_prop(d, "diagram_kind"), "flowchart"); + ASSERT_NOT_NULL(d->parent_class); + ASSERT(strstr(d->parent_class, ".Arch") != NULL); + ASSERT(d->docstring && strstr(d->docstring, "Web app") && strstr(d->docstring, "calls")); + ASSERT_EQ(d->start_line, 7); + const CBMDefinition *a = dg_def(r, ".Arch#diagram-1.A"); + ASSERT_NOT_NULL(a); + ASSERT_STR_EQ(a->label, "DiagramElement"); + ASSERT_STR_EQ(a->name, "Web app"); + ASSERT_STR_EQ(a->parent_class, d->qualified_name); + ASSERT_STR_EQ(dg_prop(a, "element_id"), "A"); + ASSERT_STR_EQ(dg_def(r, ".Arch#diagram-1.C")->name, "DB"); + ASSERT_STR_EQ(dg_def(r, ".Arch#diagram-1.D")->name, "Cache"); + ASSERT_STR_EQ(dg_def(r, ".Arch#diagram-1.E")->name, "E"); /* named only by an arrow */ + ASSERT_STR_EQ(dg_def(r, ".Arch#diagram-1.S")->name, "Backend"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.A", "#diagram-1.B", "arrow", "calls"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.B", "#diagram-1.C", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.B", "#diagram-1.D", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.B", "#diagram-1.E", "dotted", "retries"), 1); + /* a second diagram of the section: a sequence */ + ASSERT_STR_EQ(dg_prop(dg_def(r, ".Arch#diagram-2"), "diagram_kind"), "sequence"); + ASSERT_STR_EQ(dg_def(r, ".Arch#diagram-2.U")->name, "User"); + ASSERT_EQ(dg_arrows(r, "#diagram-2.U", "#diagram-2.API", "message", "login"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-2.API", "#diagram-2.U", "reply", "token"), 1); + /* a class diagram under the next section: inheritance points at the parent */ + ASSERT_STR_EQ(dg_def(r, ".Model#diagram-1.Shop")->name, "Pet shop"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Dog", "#diagram-1.Animal", "inheritance", "is a"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Shop", "#diagram-1.Dog", "composition", ""), 1); + cbm_free_result(r); + PASS(); +} + +/* A Section's text keeps a diagram fence's labels, not the format's syntax; + * prose around it stays in order, other fences stay as written. */ +TEST(diagrams_section_text_keeps_labels) { + CBMFileResult *r = dm_extract(DG_DOC, CBM_LANG_MARKDOWN, "docs/arch.md"); + ASSERT_NOT_NULL(r); + const CBMDefinition *arch = dg_def(r, ".Arch"); + ASSERT_NOT_NULL(arch); + ASSERT_STR_EQ(arch->label, "Section"); + const char *doc = arch->docstring; + ASSERT_NOT_NULL(doc); + const char *kept[] = {"Web app", "API", "DB", "Cache", "Backend", "calls", + "retries", "User", "login", "token", NULL}; + for (int i = 0; kept[i]; i++) { + if (!strstr(doc, kept[i])) { + printf(" label %s missing from: %s\n", kept[i], doc); + } + ASSERT(strstr(doc, kept[i]) != NULL); + } + const char *dropped[] = {"```", "mermaid", "flowchart", "-->", "sequenceDiagram", + "participant", "subgraph", NULL}; + for (int i = 0; dropped[i]; i++) { + if (strstr(doc, dropped[i])) { + printf(" syntax %s kept in: %s\n", dropped[i], doc); + } + ASSERT(strstr(doc, dropped[i]) == NULL); + } + ASSERT_STR_EQ(dg_def(r, ".Doc")->docstring, "Intro."); /* no fence: as before */ + cbm_free_result(r); + + r = dm_extract("# Mixed\n" + "\n" + "Before.\n" + "\n" + "```mermaid\n" + "flowchart LR\n" + " A[Login] --> B[Session]\n" + "```\n" + "\n" + "```python\n" + "x = 1\n" + "```\n" + "\n" + "After.\n", + CBM_LANG_MARKDOWN, "docs/mixed.md"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, ".Mixed")->docstring, + "Before. Login Session ```python x = 1 ``` After."); + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_mermaid_kinds) { + CBMFileResult *r = dm_extract("erDiagram\n" + " CUSTOMER ||--o{ ORDER : places\n" + " ORDER ||..|{ ITEM : contains\n" + " CUSTOMER {\n" + " string name\n" + " }\n", + CBM_LANG_MERMAID, "diagrams/er.mmd"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_prop(dg_def(r, "er#diagram-1"), "diagram_kind"), "er"); + ASSERT_EQ(dg_arrows(r, ".CUSTOMER", ".ORDER", "identifying", "places"), 1); + ASSERT_EQ(dg_arrows(r, ".ORDER", ".ITEM", "non_identifying", "contains"), 1); + cbm_free_result(r); + + r = dm_extract("stateDiagram-v2\n" + " [*] --> Idle\n" + " Idle --> Busy : start\n" + " state \"Working hard\" as Busy\n" + " Busy --> [*]\n" + " state Busy {\n" + " [*] --> Step\n" + " }\n", + CBM_LANG_MERMAID, "diagrams/state.mmd"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_arrows(r, ".start", ".Idle", "transition", ""), 1); + ASSERT_EQ(dg_arrows(r, ".Idle", ".Busy", "transition", "start"), 1); + ASSERT_EQ(dg_arrows(r, ".Busy", ".end", "transition", ""), 1); + /* a composite state's own start; states named by arrows are states */ + ASSERT_EQ(dg_arrows(r, ".Busy_start", ".Step", "transition", ""), 1); + ASSERT_STR_EQ(dg_prop(dg_def(r, "state#diagram-1.Idle"), "element_kind"), "state"); + ASSERT_STR_EQ(dg_def(r, "state#diagram-1.Busy")->name, "Working hard"); + cbm_free_result(r); + + r = dm_extract("C4Context\n" + " title Bank\n" + " Person(cust, \"Customer\", \"A customer\")\n" + " System_Ext(mail, \"Mail system\")\n" + " Rel(cust, mail, \"Reads\", \"SMTP\")\n", + CBM_LANG_MERMAID, "diagrams/c4.mmd"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_prop(dg_def(r, "c4#diagram-1"), "diagram_kind"), "c4_context"); + ASSERT_STR_EQ(dg_def(r, "c4#diagram-1")->name, "Bank"); /* its title line */ + const CBMDefinition *cust = dg_def(r, "c4#diagram-1.cust"); + ASSERT_NOT_NULL(cust); + ASSERT_STR_EQ(cust->name, "Customer"); + ASSERT_STR_EQ(dg_prop(cust, "element_kind"), "person"); + ASSERT_STR_EQ(dg_prop(dg_def(r, "c4#diagram-1.mail"), "element_kind"), "system_ext"); + ASSERT_EQ(dg_arrows(r, ".cust", ".mail", "relation", "Reads"), 1); + cbm_free_result(r); + + r = dm_extract("architecture-beta\n" + " group api(cloud)[API]\n" + " service db(database)[Database] in api\n" + " service server(server)[Server] in api\n" + " db:L -- R:server\n" + " server:T --> B:db\n", + CBM_LANG_MERMAID, "diagrams/arch.mmd"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "arch#diagram-1.db")->name, "Database"); + ASSERT_STR_EQ(dg_prop(dg_def(r, "arch#diagram-1.api"), "element_kind"), "group"); + ASSERT_EQ(dg_arrows(r, ".db", ".server", "line", ""), 1); + ASSERT_EQ(dg_arrows(r, ".server", ".db", "arrow", ""), 1); + cbm_free_result(r); + + /* a kind without elements is a Diagram all the same */ + r = dm_extract("pie title Pets\n \"Dogs\" : 386\n", CBM_LANG_MERMAID, "diagrams/pie.mmd"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_prop(dg_def(r, "pie#diagram-1"), "diagram_kind"), "pie"); + ASSERT_STR_EQ(dg_def(r, "pie#diagram-1")->name, "Pets"); + ASSERT_EQ(r->diagram_arrows.count, 0); + cbm_free_result(r); + PASS(); +} + +/* A fenced block of another language is no diagram. */ +TEST(diagrams_other_fences) { + CBMFileResult *r = dm_extract("# T\n\n```js\nA --> B\n```\n", CBM_LANG_MARKDOWN, "a.md"); + ASSERT_NOT_NULL(r); + for (int i = 0; i < r->defs.count; i++) { + ASSERT(strcmp(r->defs.items[i].label, "Diagram") != 0); + } + ASSERT_EQ(r->diagram_arrows.count, 0); + cbm_free_result(r); + PASS(); +} + +/* ── the other formats ───────────────────────────────────────────── */ + +static CBMFileResult *dg_file(const char *src, const char *rel) { + return dm_extract(src, CBM_LANG_DIAGRAM, rel); +} + +TEST(diagrams_plantuml) { + CBMFileResult *r = dg_file("@startuml Components\n" + "[Web] --> [API] : REST\n" + "component \"Billing\" as B\n" + "API ..> B\n" + "@enduml\n" + "@startuml\n" + "class Animal\n" + "class Dog {\n" + " +bark()\n" + "}\n" + "Animal <|-- Dog\n" + "Dog \"1\" *-- \"many\" Leg : has\n" + "Dog +-- Tail\n" + "@enduml\n", + "docs/model.puml"); + ASSERT_NOT_NULL(r); + const CBMDefinition *d = dg_def(r, "model#diagram-1"); + ASSERT_NOT_NULL(d); + ASSERT_STR_EQ(d->name, "Components"); + ASSERT_STR_EQ(dg_prop(d, "format"), "plantuml"); + ASSERT_STR_EQ(dg_prop(d, "diagram_kind"), "component"); + ASSERT_STR_EQ(dg_def(r, "model#diagram-1.B")->name, "Billing"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Web", "#diagram-1.API", "arrow", "REST"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.API", "#diagram-1.B", "dependency", ""), 1); + ASSERT_STR_EQ(dg_prop(dg_def(r, "model#diagram-2"), "diagram_kind"), "class"); + /* classes in packages: a class diagram, whatever line comes first */ + CBMFileResult *pk = dg_file("@startuml\npackage app {\n class A\n interface B\n}\n" + "A ..|> B\n@enduml\n", + "docs/pk.puml"); + ASSERT_STR_EQ(dg_prop(dg_def(pk, "pk#diagram-1"), "diagram_kind"), "class"); + ASSERT_STR_EQ(dg_def(pk, "pk#diagram-1")->name, "pk"); /* untitled: the file */ + cbm_free_result(pk); + ASSERT_EQ(dg_arrows(r, "#diagram-2.Dog", "#diagram-2.Animal", "inheritance", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-2.Dog", "#diagram-2.Leg", "composition", "has"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-2.Tail", "#diagram-2.Dog", "nesting", ""), 1); + cbm_free_result(r); + + /* a sequence in a Markdown block */ + r = dm_extract("# Flow\n\n```plantuml\n@startuml\nparticipant Alice\nactor Bob as B\n" + "Alice -> B : hello\nB --> Alice : hi\n@enduml\n```\n", + CBM_LANG_MARKDOWN, "docs/flow.md"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Alice", "#diagram-1.B", "message", "hello"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.B", "#diagram-1.Alice", "reply", "hi"), 1); + ASSERT_STR_EQ(dg_def(r, "Flow#diagram-1.B")->name, "Bob"); + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_dot) { + CBMFileResult *r = + dg_file("digraph G {\n" + " label=\"Pipeline\";\n" + " // a comment -> nowhere\n" + " a [label=\"Parse\"];\n" + " b [label=\"Resolve\"];\n" + " a -> b -> c [label=\"next\"];\n" + " subgraph cluster_x { d; e; }\n" + " c -> {d e};\n" + " \"Read every verification output in full before claiming that the step passed, " + "then record the result in the ledger with its timestamp\" -> b;\n" + " y [label=\"two\\nlines\"];\n" + "}\n", + "docs/pipe.dot"); + ASSERT_NOT_NULL(r); + const CBMDefinition *d = dg_def(r, "pipe#diagram-1"); + ASSERT_NOT_NULL(d); + ASSERT_STR_EQ(d->name, "Pipeline"); + ASSERT_STR_EQ(dg_prop(d, "diagram_kind"), "digraph"); + ASSERT_STR_EQ(dg_def(r, "pipe#diagram-1.a")->name, "Parse"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.a", "#diagram-1.b", "arrow", "next"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.b", "#diagram-1.c", "arrow", "next"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.c", "#diagram-1.d", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.c", "#diagram-1.e", "arrow", ""), 1); + ASSERT_EQ(r->diagram_arrows.count, 5); + /* a node named by a sentence too long for an id: kept, under a hashed key */ + int longs = 0; + for (int i = 0; i < r->diagram_arrows.count; i++) { + const CBMDiagramArrow *a = &r->diagram_arrows.items[i]; + longs += strstr(a->from_qn, "#diagram-1.Read_every_verification") && + strchr(a->from_qn, '~') && strstr(a->to_qn, "#diagram-1.b"); + } + ASSERT_EQ(longs, 1); + ASSERT_STR_EQ(dg_def(r, "pipe#diagram-1.y")->name, "two lines"); + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_d2) { + CBMFileResult *r = dg_file("server: Web Server\n" + "db: Database {\n" + " shape: cylinder\n" + "}\n" + "server -> db: queries\n" + "cloud: {\n" + " api -> worker: jobs\n" + "}\n", + "docs/sys.d2"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "sys#diagram-1.server")->name, "Web Server"); + ASSERT_STR_EQ(dg_def(r, "sys#diagram-1.db")->name, "Database"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.server", "#diagram-1.db", "arrow", "queries"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.cloud_api", "#diagram-1.cloud_worker", "arrow", "jobs"), 1); + ASSERT_EQ(r->diagram_arrows.count, 2); + cbm_free_result(r); + PASS(); +} + +static const char *const DG_DRAWIO_PAGE = + "" + "" + "" + "" + "" + "" + ""; + +static char *dg_b64(const unsigned char *in, size_t dn) { + static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + char *out = malloc(dn * 4 / 3 + 8); + size_t o = 0; + for (size_t i = 0; i < dn; i += 3) { + uint32_t v = (uint32_t)in[i] << 16; + v |= i + 1 < dn ? (uint32_t)in[i + 1] << 8 : 0; + v |= i + 2 < dn ? in[i + 2] : 0; + out[o++] = b64[(v >> 18) & 63]; + out[o++] = b64[(v >> 12) & 63]; + out[o++] = i + 1 < dn ? b64[(v >> 6) & 63] : '='; + out[o++] = i + 2 < dn ? b64[v & 63] : '='; + } + out[o] = '\0'; + return out; +} + +/* A draw.io page as the app stores it compressed: URI-encoded, raw deflate, + * base64. */ +static char *dg_drawio_compress(const char *xml) { + size_t n = strlen(xml); + char *enc = malloc(n * 3 + 1); + size_t w = 0; + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)xml[i]; + if (isalnum(c) || strchr("-_.!~*'()", c)) { + enc[w++] = (char)c; + } else { + w += (size_t)snprintf(enc + w, n * 3 + 1 - w, "%%%02X", c); + } + } + unsigned char *def = malloc(w + 64); + z_stream z; + memset(&z, 0, sizeof(z)); + deflateInit2(&z, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -MAX_WBITS, 8, Z_DEFAULT_STRATEGY); + z.next_in = (Bytef *)enc; + z.avail_in = (uInt)w; + z.next_out = def; + z.avail_out = (uInt)(w + 64); + deflate(&z, Z_FINISH); + size_t dn = z.total_out; + deflateEnd(&z); + char *out = dg_b64(def, dn); + free(enc); + free(def); + return out; +} + +TEST(diagrams_drawio) { + char file[4096]; + snprintf(file, sizeof(file), + "%s", DG_DRAWIO_PAGE); + CBMFileResult *r = dg_file(file, "docs/sys.drawio"); + ASSERT_NOT_NULL(r); + const CBMDefinition *d = dg_def(r, "sys#diagram-1"); + ASSERT_NOT_NULL(d); + ASSERT_STR_EQ(d->name, "Overview"); + ASSERT_STR_EQ(dg_def(r, "sys#diagram-1.2")->name, "Frontend"); + const CBMDefinition *b = dg_def(r, "sys#diagram-1.3"); + ASSERT_STR_EQ(b->name, "Backend"); /* its HTML dropped */ + ASSERT_STR_EQ(dg_prop(b, "element_kind"), "cylinder3"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.2", "#diagram-1.3", "arrow", "calls"), 1); + ASSERT_EQ(r->diagram_arrows.count, 1); /* the edge to a shape without text is none */ + cbm_free_result(r); + + /* the compressed form, two pages */ + char *packed = dg_drawio_compress(DG_DRAWIO_PAGE); + size_t big_cap = strlen(packed) * 2 + 256; + char *big = malloc(big_cap); + snprintf(big, big_cap, + "%s%s", + packed, packed); + r = dg_file(big, "docs/two.drawio"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "two#diagram-1")->name, "A"); + ASSERT_STR_EQ(dg_def(r, "two#diagram-2")->name, "B"); + ASSERT_EQ(dg_arrows(r, "#diagram-2.2", "#diagram-2.3", "arrow", "calls"), 1); + cbm_free_result(r); + free(big); + free(packed); + + /* draw.io's editable SVG keeps the file in its content attribute */ + r = dg_file("", + "docs/edit.drawio.svg"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "#diagram-1")->name, "S"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.2", "#diagram-1.3", "arrow", ""), 1); + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_excalidraw) { + CBMFileResult *r = + dg_file("{\"type\":\"excalidraw\",\"elements\":[" + "{\"id\":\"r1\",\"type\":\"rectangle\",\"isDeleted\":false}," + "{\"id\":\"t1\",\"type\":\"text\",\"text\":\"User\",\"containerId\":\"r1\"}," + "{\"id\":\"r2\",\"type\":\"ellipse\"}," + "{\"id\":\"t2\",\"type\":\"text\",\"text\":\"Service\",\"containerId\":\"r2\"}," + "{\"id\":\"a1\",\"type\":\"arrow\",\"startBinding\":{\"elementId\":\"r1\"}," + "\"endBinding\":{\"elementId\":\"r2\"}}," + "{\"id\":\"t3\",\"type\":\"text\",\"text\":\"calls\",\"containerId\":\"a1\"}," + "{\"id\":\"r3\",\"type\":\"rectangle\",\"isDeleted\":true}," + "{\"id\":\"t5\",\"type\":\"text\",\"text\":\"Gone\",\"containerId\":\"r3\"," + "\"isDeleted\":true}," + "{\"id\":\"t4\",\"type\":\"text\",\"text\":\"Note here\"}]}", + "docs/flow.excalidraw"); + ASSERT_NOT_NULL(r); + const CBMDefinition *d = dg_def(r, "flow#diagram-1"); + ASSERT_NOT_NULL(d); + ASSERT(d->docstring && strstr(d->docstring, "Note here")); + ASSERT_STR_EQ(dg_def(r, "flow#diagram-1.r1")->name, "User"); + ASSERT_STR_EQ(dg_prop(dg_def(r, "flow#diagram-1.r2"), "element_kind"), "ellipse"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.r1", "#diagram-1.r2", "arrow", "calls"), 1); + for (int i = 0; i < r->defs.count; i++) { + ASSERT(!strstr(r->defs.items[i].qualified_name, ".r3")); + } + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_d2_blocks) { + /* class and style blocks around containers (their braces once restored + * the path from an unset level), block-string labels, dotted attributes */ + CBMFileResult *r = dg_file("direction: down\n" + "classes: {\n" + " card: {\n" + " shape: text\n" + " }\n" + "}\n" + "label: Hierarchy\n" + "consumers: \"\" {\n" + " style: {\n" + " stroke-dash: 4\n" + " }\n" + " app: ||md\n" + " App\n" + " Cross-platform: everywhere\n" + " || {\n" + " class: card\n" + " }\n" + " cli: |md # CLI|\n" + "}\n" + "core.style.fill: red\n" + "core.label: Core\n" + "consumers.app -> core: |md uses|\n" + "consumers.cli -- core\n", + "docs/deps.d2"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "deps#diagram-1")->name, "Hierarchy"); + ASSERT_STR_EQ(dg_def(r, "deps#diagram-1.consumers_app")->name, "App"); + ASSERT_STR_EQ(dg_def(r, "deps#diagram-1.consumers_cli")->name, "CLI"); + ASSERT_STR_EQ(dg_def(r, "deps#diagram-1.core")->name, "Core"); + const char *doc = dg_def(r, "deps#diagram-1")->docstring; + ASSERT(doc && strstr(doc, "Cross-platform: everywhere")); + ASSERT_EQ(dg_arrows(r, "#diagram-1.consumers_app", "#diagram-1.core", "arrow", "uses"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.consumers_cli", "#diagram-1.core", "line", ""), 1); + ASSERT_EQ(r->diagram_arrows.count, 2); + for (int i = 0; i < r->defs.count; i++) { /* no attribute or block text became a shape */ + const char *qn = r->defs.items[i].qualified_name; + ASSERT(!strstr(qn, "style") && !strstr(qn, "card") && !strstr(qn, "class") && + !strstr(qn, "Cross")); + } + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_excalidraw_layout) { + /* text laid over shapes and arrows drawn up to them, nothing bound */ + CBMFileResult *r = dg_file( + "{\"type\":\"excalidraw\",\"elements\":[" + "{\"id\":\"f1\",\"type\":\"rectangle\",\"x\":0,\"y\":0,\"width\":900,\"height\":400}," + "{\"id\":\"t0\",\"type\":\"text\",\"text\":\"System\",\"x\":10,\"y\":5,\"width\":60," + "\"height\":20}," + "{\"id\":\"r1\",\"type\":\"rectangle\",\"x\":20,\"y\":50,\"width\":100,\"height\":60}," + "{\"id\":\"t2\",\"type\":\"text\",\"text\":\"browser\",\"x\":30,\"y\":95,\"width\":40," + "\"height\":10}," + "{\"id\":\"t1\",\"type\":\"text\",\"text\":\"Client\",\"x\":40,\"y\":70,\"width\":60," + "\"height\":20}," + "{\"id\":\"r2\",\"type\":\"ellipse\",\"x\":250,\"y\":50,\"width\":100,\"height\":60}," + "{\"id\":\"t3\",\"type\":\"text\",\"text\":\"Server\",\"x\":270,\"y\":70,\"width\":60," + "\"height\":20}," + "{\"id\":\"a1\",\"type\":\"arrow\",\"x\":122,\"y\":80,\"points\":[[0,0],[126,0]]}," + "{\"id\":\"l1\",\"type\":\"line\",\"x\":122,\"y\":90,\"points\":[[0,0],[126,0]]}," + "{\"id\":\"a2\",\"type\":\"arrow\",\"x\":30,\"y\":60,\"points\":[[0,0],[70,40]]}," + "{\"id\":\"a3\",\"type\":\"arrow\",\"x\":122,\"y\":200,\"points\":[[0,0],[500,400]]}," + /* from r2 deep into the frame around it all: at nothing named */ + "{\"id\":\"a4\",\"type\":\"arrow\",\"x\":300,\"y\":112,\"points\":[[0,0],[0,90]]}," + /* from r1 to the frame's border: at the frame */ + "{\"id\":\"a5\",\"type\":\"arrow\",\"x\":70,\"y\":112,\"points\":[[0,0],[0,284]]}," + /* two lanes, each with a named badge: an end deep in one is at the lane */ + "{\"id\":\"L1\",\"type\":\"rectangle\",\"x\":500,\"y\":0,\"width\":100,\"height\":300}," + "{\"id\":\"tl1\",\"type\":\"text\",\"text\":\"Lane A\",\"x\":520,\"y\":10,\"width\":60," + "\"height\":20}," + "{\"id\":\"b1\",\"type\":\"ellipse\",\"x\":540,\"y\":200,\"width\":20,\"height\":20}," + "{\"id\":\"tb1\",\"type\":\"text\",\"text\":\"1\",\"containerId\":\"b1\"}," + "{\"id\":\"L2\",\"type\":\"rectangle\",\"x\":700,\"y\":0,\"width\":100,\"height\":300}," + "{\"id\":\"tl2\",\"type\":\"text\",\"text\":\"Lane B\",\"x\":720,\"y\":10,\"width\":60," + "\"height\":20}," + "{\"id\":\"b2\",\"type\":\"rectangle\",\"x\":745,\"y\":150,\"width\":10,\"height\":40}," + "{\"id\":\"a6\",\"type\":\"arrow\",\"x\":555,\"y\":120,\"points\":[[0,0],[195,0]]}," + /* bound to the text that names r1: at r1 */ + "{\"id\":\"a7\",\"type\":\"arrow\",\"startBinding\":{\"elementId\":\"r2\"}," + "\"endBinding\":{\"elementId\":\"t1\"}}," + /* bound to free text that names no shape: at the text */ + "{\"id\":\"t9\",\"type\":\"text\",\"text\":\"Note\",\"x\":440,\"y\":350,\"width\":40," + "\"height\":20}," + "{\"id\":\"a8\",\"type\":\"arrow\",\"startBinding\":{\"elementId\":\"L2\"}," + "\"endBinding\":{\"elementId\":\"t9\"}}]}", + "docs/layout.excalidraw"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "layout#diagram-1.f1")->name, "System"); + ASSERT_STR_EQ(dg_def(r, "layout#diagram-1.r1")->name, + "Client"); /* the top text, not the frame */ + ASSERT_STR_EQ(dg_def(r, "layout#diagram-1.r2")->name, "Server"); + const char *doc = dg_def(r, "layout#diagram-1")->docstring; + ASSERT(doc && strstr(doc, "browser")); + ASSERT_EQ(dg_arrows(r, "#diagram-1.r1", "#diagram-1.r2", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.r1", "#diagram-1.f1", "arrow", ""), 1); + /* the loose line, the loop inside r1, the dangling end and the end deep in + * the frame are no arrows */ + ASSERT_EQ(dg_arrows(r, "#diagram-1.L1", "#diagram-1.L2", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.r2", "#diagram-1.r1", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.L2", "#diagram-1.t9", "arrow", ""), 1); + ASSERT_STR_EQ(dg_prop(dg_def(r, "layout#diagram-1.t9"), "element_kind"), "text"); + ASSERT_EQ(r->diagram_arrows.count, 5); + ASSERT_STR_EQ(dg_def(r, "layout#diagram-1")->name, "layout"); /* untitled: the file */ + cbm_free_result(r); + PASS(); +} + +/* An Excalidraw SVG export as the app writes it: the scene zlib-compressed, + * wrapped in JSON as a byte string, base64 in a payload comment. */ +static char *dg_excalidraw_svg(const char *scene) { + uLongf zn = compressBound((uLong)strlen(scene)); + unsigned char *z = malloc(zn); + compress2(z, &zn, (const Bytef *)scene, (uLong)strlen(scene), Z_DEFAULT_COMPRESSION); + size_t cap = zn * 6 + 128; + char *json = malloc(cap); + size_t w = (size_t)snprintf(json, cap, + "{\"version\":\"1\",\"encoding\":\"bstring\",\"compressed\":true," + "\"encoded\":\""); + for (uLongf i = 0; i < zn; i++) { + unsigned char b = z[i]; + if (b == '"' || b == '\\') { + json[w++] = '\\'; + json[w++] = (char)b; + } else if (b < 0x20) { + w += (size_t)snprintf(json + w, cap - w, "\\u%04x", b); + } else { + json[w++] = (char)b; /* a byte as one character: the wrapper is no UTF-8 */ + } + } + w += (size_t)snprintf(json + w, cap - w, "\"}"); + char *b64 = dg_b64((const unsigned char *)json, w); + size_t sc = strlen(b64) + 512; + char *svg = malloc(sc); + snprintf(svg, sc, + "\n" + " \n" + " " + "%s\n" + " \n\n", + b64); + free(b64); + free(json); + free(z); + return svg; +} + +TEST(diagrams_excalidraw_svg) { + char *svg = dg_excalidraw_svg( + "{\"type\":\"excalidraw\",\"elements\":[" + "{\"id\":\"r1\",\"type\":\"rectangle\"}," + "{\"id\":\"t1\",\"type\":\"text\",\"text\":\"Queue \xc3\xa9v\xc3\xa9nements\"," + "\"containerId\":\"r1\"}," + "{\"id\":\"r2\",\"type\":\"rectangle\"}," + "{\"id\":\"t2\",\"type\":\"text\",\"text\":\"Worker\",\"containerId\":\"r2\"}," + "{\"id\":\"a1\",\"type\":\"arrow\",\"startBinding\":{\"elementId\":\"r1\"}," + "\"endBinding\":{\"elementId\":\"r2\"}}]}"); + CBMFileResult *r = dg_file(svg, "docs/flow.excalidraw.svg"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "#diagram-1.r1")->name, "Queue \xc3\xa9v\xc3\xa9nements"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.r1", "#diagram-1.r2", "arrow", ""), 1); + cbm_free_result(r); + free(svg); + PASS(); +} + +TEST(diagrams_rst) { + CBMFileResult *r = dm_extract("Guide\n" + "=====\n" + "\n" + ".. mermaid::\n" + " :caption: Request flow\n" + "\n" + " flowchart LR\n" + " A --> B\n" + "\n" + ".. digraph:: deps\n" + "\n" + " core -> util;\n" + " app -> core;\n" + "\n" + ".. graphviz:: other.dot\n" + "\n" + ".. uml::\n" + "\n" + " @startuml\n" + " [Web] --> [API]\n" + " @enduml\n", + CBM_LANG_RST, "docs/guide.rst"); + ASSERT_NOT_NULL(r); + const CBMDefinition *m = dg_def(r, "guide#diagram-1"); + ASSERT_NOT_NULL(m); + ASSERT_STR_EQ(m->name, "Request flow"); + ASSERT_STR_EQ(dg_prop(m, "format"), "mermaid"); + ASSERT_EQ(dg_arrows(r, "guide#diagram-1.A", "guide#diagram-1.B", "arrow", ""), 1); + /* Sphinx's digraph: the statements inside the graph it names */ + ASSERT_STR_EQ(dg_prop(dg_def(r, "guide#diagram-2"), "format"), "dot"); + ASSERT_EQ(dg_arrows(r, "guide#diagram-2.core", "guide#diagram-2.util", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "guide#diagram-2.app", "guide#diagram-2.core", "arrow", ""), 1); + /* a graphviz file argument is no diagram here (the file is one itself) */ + ASSERT_STR_EQ(dg_prop(dg_def(r, "guide#diagram-3"), "format"), "plantuml"); + ASSERT_EQ(dg_arrows(r, "guide#diagram-3.Web", "guide#diagram-3.API", "arrow", ""), 1); + ASSERT_NULL(dg_def(r, "guide#diagram-4")); + cbm_free_result(r); + PASS(); +} + +/* The Diagram named `name`, NULL for none. */ +static const CBMDefinition *dg_named(const CBMFileResult *r, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->label && strcmp(d->label, "Diagram") == 0 && d->name && strcmp(d->name, name) == 0) { + return d; + } + } + return NULL; +} + +TEST(diagrams_asciidoc) { + CBMFileResult *r = dm_extract("= Guide\n" + "\n" + "== Flow\n" + "\n" + ".Login sequence\n" + "[plantuml, target=login, format=svg]\n" + "----\n" + "participant Alice\n" + "Alice -> Bob : hello\n" + "----\n" + "\n" + ".Steps\n" + "[source,mermaid]\n" + "....\n" + "flowchart TD\n" + " X --> Y\n" + "....\n" + "\n" + "[source,python]\n" + "----\n" + "a -> b\n" + "----\n" + "\n" + "[graphviz]\n" + "----\n" + "digraph { a -> b }\n" + "----\n", + CBM_LANG_ASCIIDOC, "docs/guide.adoc"); + ASSERT_NOT_NULL(r); + const CBMDefinition *p = dg_named(r, "Login sequence"); + ASSERT_NOT_NULL(p); + ASSERT_NOT_NULL(p->parent_class); /* under its Section */ + ASSERT(strncmp(p->qualified_name, p->parent_class, strlen(p->parent_class)) == 0); + ASSERT_STR_EQ(dg_prop(p, "diagram_kind"), "sequence"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Alice", "#diagram-1.Bob", "message", "hello"), 1); + ASSERT_STR_EQ(dg_prop(dg_named(r, "Steps"), "format"), "mermaid"); + ASSERT_EQ(dg_arrows(r, "#diagram-2.X", "#diagram-2.Y", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-3.a", "#diagram-3.b", "arrow", ""), 1); /* not python */ + ASSERT_EQ(r->diagram_arrows.count, 3); + cbm_free_result(r); + PASS(); +} + +/* The doc-link records of one syntax whose source ends in `src` and whose + * reference is `raw`. */ +static int dg_links(const CBMFileResult *r, int syntax, const char *src, const char *raw) { + int n = 0; + size_t sl = strlen(src); + for (int i = 0; i < r->doc_links.count; i++) { + const CBMDocLink *l = &r->doc_links.items[i]; + size_t ql = strlen(l->source_qn); + n += l->syntax == syntax && ql >= sl && strcmp(l->source_qn + ql - sl, src) == 0 && + strcmp(l->raw, raw) == 0; + } + if (n != 1) { + printf(" link %d %s -> %s: %d found\n", syntax, src, raw, n); + } + return n; +} + +TEST(diagrams_links) { + CBMFileResult *r = + dm_extract("# Flow\n\n```mermaid\nflowchart LR\n A[Api] --> B\n" + " click A \"src/api.py\" \"the API\"\n click B call cb()\n```\n", + CBM_LANG_MARKDOWN, "docs/flow.md"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_MERMAID_CLICK, "#diagram-1.A", "src/api.py"), 1); + ASSERT_EQ(r->doc_links.count, 1); /* a callback links nothing */ + cbm_free_result(r); + + r = dg_file("@startuml\n!include common.iuml\n!include \n" + "class Foo [[src/foo.py]]\nclass Bar\nurl of Bar is [[docs/bar.md{the bar}]]\n" + "Foo --> Bar\n@enduml\n", + "docs/m.puml"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_PLANTUML_INCLUDE, "m#diagram-1", "common.iuml"), 1); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_PLANTUML_URL, "#diagram-1.Foo", "src/foo.py"), 1); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_PLANTUML_URL, "#diagram-1.Bar", "docs/bar.md"), 1); + ASSERT_EQ(r->doc_links.count, 3); /* the standard library is none of the repo's */ + cbm_free_result(r); + + r = dg_file("digraph { a [label=\"A\", URL=\"src/a.c\"]; b [href=\"#top\"]; a -> b; }\n", + "docs/g.dot"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_DOT_URL, "#diagram-1.a", "src/a.c"), 1); + ASSERT_EQ(r->doc_links.count, 1); /* an anchor links nothing */ + cbm_free_result(r); + + r = dg_file("x: X\nx.link: src/x.go\ny: Y {\n link: https://example.com/y\n}\n", "docs/d.d2"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_D2_LINK, "#diagram-1.x", "src/x.go"), 1); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_D2_LINK, "#diagram-1.y", "https://example.com/y"), 1); + cbm_free_result(r); + + r = dg_file("" + "" + "" + "", + "docs/s.drawio"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_DRAWIO_LINK, "#diagram-1.7", "src/svc.py"), 1); + ASSERT_EQ(r->doc_links.count, 1); /* a page link links nothing of the repo */ + cbm_free_result(r); + + r = dg_file("{\"type\":\"excalidraw\",\"elements\":[" + "{\"id\":\"r1\",\"type\":\"rectangle\",\"link\":\"src/e.ts\"}," + "{\"id\":\"t1\",\"type\":\"text\",\"text\":\"E\",\"containerId\":\"r1\"}," + "{\"id\":\"r2\",\"type\":\"rectangle\",\"link\":null}]}", + "docs/e.excalidraw"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_EXCALIDRAW_LINK, "#diagram-1.r1", "src/e.ts"), 1); + ASSERT_EQ(r->doc_links.count, 1); + cbm_free_result(r); + PASS(); +} + +static int dg_db_edges(const char *db, const char *type, const char *src_suffix, + const char *tgt_suffix, const char *props_part); + +TEST(diagrams_links_graph) { + /* the families are held until their audit: shipped here */ + dm_ship_held_families(); + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dglinks_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(db, sizeof(db), "%s/g.db", tmp); + th_write_file(TH_PATH(repo, "src/api.py"), "def handle():\n return 1\n"); + th_write_file(TH_PATH(repo, "diagrams/a.puml"), + "@startuml\nclass Api [[../src/api.py]]\nclass Db\nApi --> Db\n@enduml\n"); + ASSERT_EQ(dm_index(repo, db, NULL), 0); + /* the element names the file it links to */ + ASSERT_EQ(dg_db_edges(db, "MENTIONS", "a#diagram-1.Api", "", NULL), 1); + th_cleanup(tmp); + cbm_doclink_test_reset_ships(); + PASS(); +} + +/* ── the graph ───────────────────────────────────────────────────── */ + +static int dg_db_edges(const char *db, const char *type, const char *src_suffix, + const char *tgt_suffix, const char *props_part) { + sqlite3 *h = NULL; + int n = 0; + if (sqlite3_open_v2(db, &h, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(h); + return -1; + } + sqlite3_stmt *st = NULL; + const char *sql = "SELECT e.properties FROM edges e JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id WHERE e.type = ?1 " + "AND s.qualified_name LIKE '%' || ?2 AND t.qualified_name LIKE '%' || ?3"; + if (sqlite3_prepare_v2(h, sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, type, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, src_suffix, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, tgt_suffix, -1, SQLITE_TRANSIENT); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *p = (const char *)sqlite3_column_text(st, 0); + n += !props_part || (p && strstr(p, props_part)); + } + } + sqlite3_finalize(st); + sqlite3_close(h); + if (n != 1) { + printf(" %s %s -> %s (%s): %d found\n", type, src_suffix, tgt_suffix, + props_part ? props_part : "", n); + } + return n; +} + +TEST(diagrams_graph) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dg_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(db, sizeof(db), "%s/g.db", tmp); + th_write_file(TH_PATH(repo, "docs/arch.md"), DG_DOC); + th_write_file(TH_PATH(repo, "diagrams/flow.mmd"), "flowchart TD\n X[Start] --> Y[Stop]\n"); + th_write_file(TH_PATH(repo, "diagrams/c.puml"), "@startuml\n[A] --> [B]\n@enduml\n"); + /* an image by its last extension, a diagram by its name */ + th_write_file(TH_PATH(repo, "diagrams/e.drawio.svg"), + "\n"); + ASSERT_EQ(dm_index(repo, db, NULL), 0); + /* containment: the section holds the diagram, the diagram its elements */ + ASSERT_EQ(dg_db_edges(db, "DEFINES", "docs.arch.Arch", "docs.arch.Arch#diagram-1", NULL), 1); + ASSERT_EQ(dg_db_edges(db, "DEFINES", "Arch#diagram-1", "Arch#diagram-1.A", NULL), 1); + /* the arrows, with their text */ + ASSERT_EQ(dg_db_edges(db, "DIAGRAM_ARROW", "Arch#diagram-1.A", "Arch#diagram-1.B", + "\"label\":\"calls\""), + 1); + ASSERT_EQ(dg_db_edges(db, "DIAGRAM_ARROW", "Model#diagram-1.Dog", "Model#diagram-1.Animal", + "\"style\":\"inheritance\""), + 1); + /* a diagram file: its diagram under the file */ + ASSERT_EQ(dg_db_edges(db, "DIAGRAM_ARROW", "flow#diagram-1.X", "flow#diagram-1.Y", NULL), 1); + /* a diagram file of another format: discovered, its arrows in the graph */ + ASSERT_EQ(dg_db_edges(db, "DIAGRAM_ARROW", "c#diagram-1.A", "c#diagram-1.B", NULL), 1); + ASSERT_EQ(dg_db_edges(db, "DIAGRAM_ARROW", "#diagram-1.p", "#diagram-1.q", NULL), 1); + th_cleanup(tmp); + PASS(); +} + +/* ── incremental == full ─────────────────────────────────────────── */ + +/* The diagram layer of a graph as sorted lines: its nodes, their containment, + * the arrows, and the edges the elements' links resolved to. */ +static char *dg_state(const char *db) { + sqlite3 *h = NULL; + if (sqlite3_open_v2(db, &h, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(h); + return NULL; + } + const char *sql = + "SELECT 'N|' || label || '|' || qualified_name || '|' || name || '|' || " + "COALESCE(properties, '') AS l FROM nodes WHERE label IN ('Diagram', 'DiagramElement') " + "UNION ALL SELECT 'E|' || e.type || '|' || s.qualified_name || '|' || t.qualified_name || " + "'|' || COALESCE(e.properties, '') FROM edges e JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id WHERE e.type = 'DIAGRAM_ARROW' OR (e.type = " + "'DEFINES' AND t.label IN ('Diagram', 'DiagramElement')) OR (e.type = 'MENTIONS' AND " + "s.label IN ('Diagram', 'DiagramElement')) ORDER BY 1"; + sqlite3_stmt *st = NULL; + size_t cap = 4096; + size_t len = 0; + char *buf = malloc(cap); + buf[0] = '\0'; + if (sqlite3_prepare_v2(h, sql, -1, &st, NULL) == SQLITE_OK) { + while (sqlite3_step(st) == SQLITE_ROW) { + const char *line = (const char *)sqlite3_column_text(st, 0); + size_t l = strlen(line); + if (len + l + 2 > cap) { + cap = (len + l + 2) * 2; + buf = realloc(buf, cap); + } + memcpy(buf + len, line, l); + len += l; + buf[len++] = '\n'; + buf[len] = '\0'; + } + } + sqlite3_finalize(st); + sqlite3_close(h); + return buf; +} + +/* An incremental index of `repo` into inc_db, a full one into a fresh + * full_db: the same diagram layer. 0, or -1 after printing the difference. */ +static int dg_step(const char *repo, const char *inc_db, const char *full_db, const char *what) { + if (dm_index(repo, inc_db, NULL) != 0) { + printf(" %s: incremental index failed\n", what); + return -1; + } + dm_unlink_db(full_db); + if (dm_index(repo, full_db, NULL) != 0) { + printf(" %s: full index failed\n", what); + return -1; + } + char *inc = dg_state(inc_db); + char *full = dg_state(full_db); + int rc = inc && full && strcmp(inc, full) == 0 && full[0] ? 0 : -1; + if (rc != 0) { + printf(" %s: incremental != full\n--- incremental\n%s--- full\n%s", what, + inc ? inc : "(null)", full ? full : "(null)"); + } + free(inc); + free(full); + return rc; +} + +static void dg_write_all(const char *repo, int round) { + char text[1024]; + snprintf(text, sizeof(text), + "# Arch%s\n\n```mermaid\nflowchart LR\n A[Api] --> B[Db%d]\n" + " click A \"../src/api.py\"\n```\n", + round == 2 ? "itecture" : "", round); + th_write_file(TH_PATH(repo, "docs/arch.md"), text); + snprintf(text, sizeof(text), + "@startuml\nclass Api [[../src/api.py]]\nclass Db\nApi --> Db : r%d\n" + "@enduml\n", + round); + th_write_file(TH_PATH(repo, "diagrams/m.puml"), text); + snprintf(text, sizeof(text), "digraph { a -> b [label=\"r%d\"]; }\n", round); + th_write_file(TH_PATH(repo, "diagrams/g.dot"), text); + snprintf(text, sizeof(text), "x: X\ny: Y%d\nx -> y\n", round); + th_write_file(TH_PATH(repo, "diagrams/s.d2"), text); + snprintf(text, sizeof(text), + "" + "" + "", + round); + th_write_file(TH_PATH(repo, "diagrams/p.drawio"), text); + snprintf(text, sizeof(text), + "Guide\n=====\n\n.. mermaid::\n\n flowchart LR\n G --> H%d\n", round); + th_write_file(TH_PATH(repo, "docs/guide.rst"), text); + snprintf(text, sizeof(text), "= Guide\n\n== Flow\n\n[plantuml]\n----\n[P] --> [Q%d]\n----\n", + round); + th_write_file(TH_PATH(repo, "docs/flow.adoc"), text); + if (round < 2) { + snprintf(text, sizeof(text), + "{\"type\":\"excalidraw\",\"elements\":[" + "{\"id\":\"r1\",\"type\":\"rectangle\"},{\"id\":\"t1\",\"type\":\"text\"," + "\"text\":\"U%d\",\"containerId\":\"r1\"},{\"id\":\"r2\",\"type\":\"rectangle\"}," + "{\"id\":\"t2\",\"type\":\"text\",\"text\":\"V\",\"containerId\":\"r2\"}," + "{\"id\":\"a\",\"type\":\"arrow\",\"startBinding\":{\"elementId\":\"r1\"}," + "\"endBinding\":{\"elementId\":\"r2\"}}]}", + round); + th_write_file(TH_PATH(repo, "diagrams/e.excalidraw"), text); + } +} + +TEST(diagrams_incremental_equals_full) { + dm_ship_held_families(); + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dg_inc_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char inc_db[512]; + char full_db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(inc_db, sizeof(inc_db), "%s/inc.db", tmp); + snprintf(full_db, sizeof(full_db), "%s/full.db", tmp); + th_write_file(TH_PATH(repo, "src/api.py"), "def handle():\n return 1\n"); + dg_write_all(repo, 0); + ASSERT_EQ(dm_index(repo, inc_db, NULL), 0); + /* every diagram's text edited */ + dg_write_all(repo, 1); + ASSERT_EQ(dg_step(repo, inc_db, full_db, "edit"), 0); + /* a heading renamed (the diagram's parent with it), a diagram file gone */ + dg_write_all(repo, 2); + char gone[600]; + snprintf(gone, sizeof(gone), "%s/diagrams/e.excalidraw", repo); + unlink(gone); + ASSERT_EQ(dg_step(repo, inc_db, full_db, "rename + delete"), 0); + /* and back */ + dg_write_all(repo, 1); + ASSERT_EQ(dg_step(repo, inc_db, full_db, "re-add"), 0); + th_cleanup(tmp); + cbm_doclink_test_reset_ships(); + PASS(); +} + +SUITE(diagrams) { + RUN_TEST(diagrams_markdown_mermaid); + RUN_TEST(diagrams_section_text_keeps_labels); + RUN_TEST(diagrams_mermaid_kinds); + RUN_TEST(diagrams_other_fences); + RUN_TEST(diagrams_plantuml); + RUN_TEST(diagrams_dot); + RUN_TEST(diagrams_d2); + RUN_TEST(diagrams_drawio); + RUN_TEST(diagrams_excalidraw); + RUN_TEST(diagrams_d2_blocks); + RUN_TEST(diagrams_excalidraw_layout); + RUN_TEST(diagrams_excalidraw_svg); + RUN_TEST(diagrams_rst); + RUN_TEST(diagrams_asciidoc); + RUN_TEST(diagrams_links); + RUN_TEST(diagrams_links_graph); + RUN_TEST(diagrams_incremental_equals_full); + RUN_TEST(diagrams_graph); +} diff --git a/tests/test_doc_links_md.c b/tests/test_doc_links_md.c index 2b66e63bd..21e74f37e 100644 --- a/tests/test_doc_links_md.c +++ b/tests/test_doc_links_md.c @@ -602,6 +602,18 @@ TEST(doc_links_md_ship_gate) { ASSERT_FALSE(cbm_doclink_syntax_ships(DM_HELD_FAMILIES[i])); } ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_MD_LINK)); + /* the doc-comment families whose held-out tiers passed (2026-10-11) */ + static const int passed[] = { + CBM_DOCLINK_GO_DOC_LINK, CBM_DOCLINK_JAVA_LINK, CBM_DOCLINK_JAVA_LINKPLAIN, + CBM_DOCLINK_JAVA_SEE, CBM_DOCLINK_JAVA_THROWS, CBM_DOCLINK_JAVA_VALUE, + CBM_DOCLINK_KT_KDOC_LINK, CBM_DOCLINK_KT_SEE, CBM_DOCLINK_KT_THROWS, + CBM_DOCLINK_KT_SAMPLE, CBM_DOCLINK_RS_INTRA_DOC, CBM_DOCLINK_RS_LINK_DEF, + CBM_DOCLINK_PERL_POD_L, CBM_DOCLINK_TS_LINK, CBM_DOCLINK_PHP_SEE, + CBM_DOCLINK_C_REF, CBM_DOCLINK_C_SEE, + }; + for (size_t i = 0; i < sizeof(passed) / sizeof(passed[0]); i++) { + ASSERT_TRUE(cbm_doclink_syntax_ships(passed[i])); + } ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_RST_ROLE)); ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_ADOC_INCLUDE)); ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_RST_LITERALINCLUDE)); diff --git a/tests/test_doc_mentions_c.c b/tests/test_doc_mentions_c.c new file mode 100644 index 000000000..93fa5f4cc --- /dev/null +++ b/tests/test_doc_mentions_c.c @@ -0,0 +1,346 @@ +/* + * test_doc_mentions_c.c -- C doc-comment references (Doxygen @ref, @see). + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include "test_doc_mentions_helpers.h" + +#include "cbm.h" +#include "doclink.h" + +#include +#include +#include + +/* ── extraction ──────────────────────────────────────────────────── */ + +static const char *const C_WIDGET = + "#include \"api.h\"\n" /* 1 */ + "\n" /* 2 */ + "/**\n" /* 3 */ + " * Makes widgets. @ref widget_free \"free it\" and \\ref api_counter.\n" /* 4 */ + " * Not `@ref Hidden` nor \"@ref Quoted\".\n" /* 5 */ + " * @code\n" /* 6 */ + " * @ref InCode\n" /* 7 */ + " * @endcode\n" /* 8 */ + " * ```\n" /* 9 */ + " * @ref Fenced\n" /* 10 */ + " * ```\n" /* 11 */ + " * @see widget_free(), helper, ::api_counter, https://example.com/w\n" /* 12 */ + " * also_this\n" /* 13 */ + " *\n" /* 14 */ + " * after_blank\n" /* 15 */ + " * \\sa \\c not_this, Other_thing.\n" /* 16 */ + " * @param n not_a_see\n" /* 17 */ + " */\n" /* 18 */ + "int widget_new(int n) { return n; }\n" /* 19 */ + "\n" /* 20 */ + "/// Line doc. @ref API_MAX\n" /* 21 */ + "/// @sa widget_new()\n" /* 22 */ + "static int helper(int x) { return x; }\n" /* 23 */ + "\n" /* 24 */ + "/** @defgroup grp The group */\n" /* 25 */ + "\n" /* 26 */ + "/** A prototype's doc: @ref grp and @ref widget_new. */\n" /* 27 */ + "int f2(void);\n" /* 28 */ + "#define API_MAX 3\n" /* 29 */ + "#define TWICE(x) ((x) * 2)\n" /* 30 */ + "static int counter;\n" /* 31 */ + "int api_counter = 0;\n" /* 32 */ + "enum mode { MODE_A };\n"; /* 33 */ + +static bool c_token_is(const CBMFileResult *r, const char *raw, uint32_t line, int syntax, + bool file) { + if (dm_count_tokens(r, raw) != 1) { + printf(" token %s: %d found\n", raw, dm_count_tokens(r, raw)); + return false; + } + const CBMDocLink *t = dm_find_token(r, raw); + bool is_file = (t->flags & CBM_DOCLINK_FLAG_FILE) != 0; + if (t->line != line || t->syntax != syntax || is_file != file) { + printf(" token %s: line %u syntax %d file %d\n", raw, t->line, t->syntax, is_file); + return false; + } + return true; +} + +TEST(doc_mentions_c_tokens) { + CBMFileResult *r = dm_extract(C_WIDGET, CBM_LANG_C, "src/widget.c"); + ASSERT_NOT_NULL(r); + ASSERT_TRUE(c_token_is(r, "widget_free", 4, CBM_DOCLINK_C_REF, false)); + ASSERT_TRUE(c_token_is(r, "api_counter", 4, CBM_DOCLINK_C_REF, false)); + /* the names an @see paragraph lists, to its empty line */ + ASSERT_TRUE(c_token_is(r, "widget_free()", 12, CBM_DOCLINK_C_SEE, false)); + ASSERT_TRUE(c_token_is(r, "helper", 12, CBM_DOCLINK_C_SEE, false)); + ASSERT_TRUE(c_token_is(r, "::api_counter", 12, CBM_DOCLINK_C_SEE, false)); + ASSERT_TRUE(c_token_is(r, "https://example.com/w", 12, CBM_DOCLINK_HREF, false)); + ASSERT_TRUE(c_token_is(r, "also_this", 13, CBM_DOCLINK_C_SEE, false)); + ASSERT_TRUE(c_token_is(r, "Other_thing", 16, CBM_DOCLINK_C_SEE, false)); + /* a /// run */ + ASSERT_TRUE(c_token_is(r, "API_MAX", 21, CBM_DOCLINK_C_REF, false)); + ASSERT_TRUE(c_token_is(r, "widget_new()", 22, CBM_DOCLINK_C_SEE, false)); + /* a prototype has no node: its doc belongs to the file */ + ASSERT_TRUE(c_token_is(r, "grp", 27, CBM_DOCLINK_C_REF, true)); + ASSERT_TRUE(c_token_is(r, "widget_new", 27, CBM_DOCLINK_C_REF, true)); + /* code spans, quotes, code blocks, fences, a new paragraph, \c */ + ASSERT_EQ(dm_count_tokens(r, "Hidden"), 0); + ASSERT_EQ(dm_count_tokens(r, "Quoted"), 0); + ASSERT_EQ(dm_count_tokens(r, "InCode"), 0); + ASSERT_EQ(dm_count_tokens(r, "Fenced"), 0); + ASSERT_EQ(dm_count_tokens(r, "after_blank"), 0); + ASSERT_EQ(dm_count_tokens(r, "not_this"), 0); + ASSERT_EQ(dm_count_tokens(r, "not_a_see"), 0); + ASSERT_EQ(r->doc_links.count, 12); + cbm_free_result(r); + PASS(); +} + +/* A C++ file is not part of the leg; a header is. */ +TEST(doc_mentions_c_files) { + const char *src = "/** See @ref other_fn. */\nint fn(void) { return 0; }\n"; + CBMFileResult *r = dm_extract(src, CBM_LANG_CPP, "src/x.cpp"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 0); + ASSERT_NULL(r->doc_scope); + cbm_free_result(r); + r = dm_extract(src, CBM_LANG_CPP, "include/x.h"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dm_count_tokens(r, "other_fn"), 1); + cbm_free_result(r); + PASS(); +} + +static bool c_has_prefix_line(const char *blob, const char *prefix) { + size_t n = strlen(prefix); + for (const char *p = blob; p && *p;) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len >= n && memcmp(p, prefix, n) == 0) { + return true; + } + p = nl ? nl + 1 : NULL; + } + printf(" no line: %s...\n", prefix); + return false; +} + +TEST(doc_mentions_c_scope_blob) { + CBMFileResult *r = dm_extract(C_WIDGET, CBM_LANG_C, "src/widget.c"); + ASSERT_NOT_NULL(r); + const char *s = r->doc_scope; + ASSERT_NOT_NULL(s); + ASSERT(strncmp(s, "c1\n", 3) == 0); + ASSERT(c_has_prefix_line(s, "D\tf\tx\twidget_new\t")); + ASSERT(c_has_prefix_line(s, "D\tf\ts\thelper\t")); + ASSERT(c_has_prefix_line(s, "D\tv\ts\tcounter\t")); + ASSERT(c_has_prefix_line(s, "D\tv\tx\tapi_counter\t")); + /* types, constants and macros of a .c file are its own */ + ASSERT(c_has_prefix_line(s, "D\tm\ts\tAPI_MAX\t")); + ASSERT(c_has_prefix_line(s, "D\tM\ts\tTWICE\t")); + ASSERT(c_has_prefix_line(s, "D\tt\ts\tmode\t")); + ASSERT(c_has_prefix_line(s, "D\te\ts\tMODE_A\t")); + ASSERT(c_has_prefix_line(s, "A\tgrp")); + cbm_free_result(r); + PASS(); +} + +/* ── resolution ──────────────────────────────────────────────────── */ + +static void c_write_repo(const char *repo) { + th_write_file(TH_PATH(repo, "include/api.h"), "#ifndef API_H\n" + "#define API_H\n" + "/** @defgroup widgets Widgets */\n" + "/** Makes one, see @ref widget_free. */\n" + "int widget_new(int n);\n" + "void widget_free(int w);\n" + "#define API_MAX 16\n" + "#define api_twice(x) ((x) * 2)\n" + "int dual(int x);\n" + "#define dual(x) ((x) + 1)\n" + "#define level(x) (x)\n" + "enum color { RED, GREEN };\n" + "struct st { int fld; };\n" + "typedef struct widget { int n; } widget_t;\n" + "extern int api_counter;\n" + "#endif\n"); + th_write_file(TH_PATH(repo, "src/widget.c"), + "#include \"api.h\"\n" + "int api_counter;\n" + "int level;\n" + "static int helper(int x) { return x; }\n" + "int widget_new(int n) { return n; }\n" + "void widget_free(int w) { (void)w; }\n" + "int (dual)(int x) { return x; }\n" + "/**\n" + " * The core. @ref helper, @ref api_counter, @ref API_MAX, @ref dual,\n" + " * @ref RED, @ref widgets, @ref other_static, @ref twin, @ref nope,\n" + " * @ref widget_t, @ref test_only_fn, @ref st::fld, @ref color::GREEN,\n" + " * @ref lfield, @ref st::nofield.\n" + " * @see api_twice() for details, unknown_fn()\n" + " */\n" + "int core(void) { return helper(0); }\n" + "struct local { int lfield; };\n"); + th_write_file(TH_PATH(repo, "src/other.c"), "#include \"api.h\"\n" + "static int other_static(void) { return 0; }\n" + "int twin(void) { return 1; }\n" + "/** Other: @ref dual, @ref level and\n" + " * @ref level(). */\n" + "int other(void) { return other_static(); }\n"); + th_write_file(TH_PATH(repo, "src/third.c"), "int twin(void) { return 2; }\n"); + th_write_file(TH_PATH(repo, "tests/test_widget.c"), "int test_only_fn(void) { return 0; }\n"); +} + +static bool c_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); + if (n != 1) { + printf(" edge %s -> %s: %d found\n", src, tgt, n); + return false; + } + return true; +} + +static bool c_row(const char *db, const char *raw, const char *reason) { + char got[64]; + dm_row(db, "src/widget.c", raw, got, sizeof(got), NULL, 0); + if (strcmp(got, reason) != 0) { + printf(" row [%s]: \"%s\", expected \"%s\"\n", raw, got, reason); + return false; + } + return true; +} + +TEST(doc_mentions_c_resolution) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlc_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(db, sizeof(db), "%s/g.db", tmp); + c_write_repo(repo); + ASSERT_EQ(dm_index(repo, db, NULL), 0); + const char *core = "src.widget.core"; + /* the documented file's own definitions, a static one too */ + ASSERT_TRUE(c_edge(db, core, "src.widget.helper")); + ASSERT_TRUE(c_edge(db, core, "src.widget.api_counter")); + ASSERT_TRUE(c_edge(db, core, "src.widget.dual")); + /* what every file sees: a header's macros, constants and types */ + ASSERT_TRUE(c_edge(db, core, "include.api.API_MAX#macro")); + ASSERT_TRUE(c_edge(db, core, "include.api.RED")); + ASSERT_TRUE(c_edge(db, core, "include.api.widget_t")); + ASSERT_TRUE(c_edge(db, core, "include.api.api_twice#macro")); + /* a group is no code entity; another file's static is not visible */ + ASSERT_TRUE(c_row(db, "widgets", "graph_gap")); + ASSERT_TRUE(c_row(db, "other_static", "missing")); + ASSERT_TRUE(c_row(db, "nope", "missing")); + ASSERT_TRUE(c_row(db, "twin", "ambiguous")); + ASSERT_TRUE(c_row(db, "test_only_fn", "test_only_target")); + /* Type::member: a struct field, an enum constant; a field of the file */ + ASSERT_TRUE(c_edge(db, core, "include.api.st.fld")); + ASSERT_TRUE(c_edge(db, core, "include.api.GREEN")); + ASSERT_TRUE(c_edge(db, core, "src.widget.local.lfield")); + ASSERT_TRUE(c_row(db, "st::nofield", "missing")); + /* prose in an @see paragraph is text; a code-shaped name is missing */ + ASSERT_TRUE(c_row(db, "for", "")); + ASSERT_TRUE(c_row(db, "details", "")); + ASSERT_TRUE(c_row(db, "unknown_fn()", "missing")); + /* a definition before a macro; with (), a function-like macro first */ + ASSERT_TRUE(c_edge(db, "src.other.other", "src.widget.dual")); + ASSERT_TRUE(c_edge(db, "src.other.other", "src.widget.level")); + ASSERT_TRUE(c_edge(db, "src.other.other", "include.api.level#macro")); + /* a header prototype's doc: the header file is its source */ + ASSERT_TRUE(c_edge(db, "include.api.h.__file__", "src.widget.widget_free")); + th_cleanup(tmp); + PASS(); +} + +/* ── incremental == full; one worker == several ──────────────────── */ + +TEST(doc_mentions_c_incremental_equals_full) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlc_inc_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char inc_db[512]; + char full_db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(inc_db, sizeof(inc_db), "%s/inc.db", tmp); + snprintf(full_db, sizeof(full_db), "%s/full.db", tmp); + th_write_file(TH_PATH(repo, "src/lib.c"), + "int a(void) { return 0; }\nint b(void) { return 1; }\n"); + th_write_file(TH_PATH(repo, "src/use.c"), + "/** Uses @ref a. */\nint user(void) { return 0; }\n"); + ASSERT_EQ(dm_index(repo, inc_db, NULL), 0); + ASSERT_TRUE(c_edge(inc_db, "src.use.user", "src.lib.a")); + + /* a doc edit: the file is repaired alone */ + th_write_file(TH_PATH(repo, "src/use.c"), + "/** Uses @ref a and @ref nope. */\nint user(void) { return 0; }\n"); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "doc edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0); + + /* a body edit in the target file: its scope is unchanged */ + th_write_file(TH_PATH(repo, "src/lib.c"), + "int a(void) { return 2; }\nint b(void) { return 1; }\n"); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "body edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0); + ASSERT_TRUE(c_edge(inc_db, "src.use.user", "src.lib.a")); + + /* b goes: the files that name it are repaired, nothing else */ + th_write_file(TH_PATH(repo, "src/lib.c"), "int a(void) { return 2; }\n"); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "b removed", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0); + ASSERT_TRUE(c_edge(inc_db, "src.use.user", "src.lib.a")); + + /* a becomes static (and b is back): changed records, a full run */ + th_write_file(TH_PATH(repo, "src/lib.c"), + "static int a(void) { return 0; }\nint b(void) { return 1; }\n"); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "made static", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0); + char props[512]; + int n = 0; + dm_edge(inc_db, "src.use.user", "src.lib.a", props, sizeof(props), &n); + ASSERT_EQ(n, 0); + th_cleanup(tmp); + PASS(); +} + +TEST(doc_mentions_c_workers_agree) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlc_par_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char par_db[512]; + char seq_db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(par_db, sizeof(par_db), "%s/par.db", tmp); + snprintf(seq_db, sizeof(seq_db), "%s/seq.db", tmp); + enum { MODULES = 60 }; /* past the parallel threshold */ + for (int i = 0; i < MODULES; i++) { + char rel[64]; + char src[1024]; + int next = (i + 1) % MODULES; + snprintf(rel, sizeof(rel), "src/m%02d.c", i); + snprintf(src, sizeof(src), + "/** F%02d; see @ref f%02d, @ref K%02d, @ref nope.\n" + " * @see g%02d() */\n" + "int f%02d(void) { return 0; }\n" + "int g%02d(void) { return 1; }\n" + "#define K%02d 1\n", + i, next, next, next, i, i, i); + th_write_file(TH_PATH(repo, rel), src); + } + ASSERT_EQ(dm_workers_agree(repo, par_db, seq_db), 0); + ASSERT_TRUE(c_edge(par_db, "src.m00.f00", "src.m01.f01")); + th_cleanup(tmp); + PASS(); +} + +SUITE(doc_mentions_c) { + dm_ship_held_families(); + RUN_TEST(doc_mentions_c_tokens); + RUN_TEST(doc_mentions_c_files); + RUN_TEST(doc_mentions_c_scope_blob); + RUN_TEST(doc_mentions_c_resolution); + RUN_TEST(doc_mentions_c_incremental_equals_full); + RUN_TEST(doc_mentions_c_workers_agree); +} diff --git a/tests/test_doc_mentions_go.c b/tests/test_doc_mentions_go.c new file mode 100644 index 000000000..1f81030db --- /dev/null +++ b/tests/test_doc_mentions_go.c @@ -0,0 +1,2113 @@ +/* + * test_doc_mentions_go.c — Go doc links ([Name], [pkg.Name], [Recv.Method]) + * -> MENTIONS edges. + * + * Extraction (doclink_go.c: the go/doc/comment text model, the candidate + * tokens, the scope blob) and the resolver (doc_links_go.c) through real + * fixtures indexed with cbm_pipeline_run. + */ +#include "test_framework.h" +#include "test_doc_mentions_helpers.h" + +/* ── helpers of this file ────────────────────────────────────────── */ + +static CBMFileResult *go_extract(const char *src, const char *rel) { + return dm_extract(src, CBM_LANG_GO, rel); +} + +/* The tokens of one doc comment put above `type T struct{}` in package p. */ +static CBMFileResult *go_doc(const char *comment) { + char src[8192]; + snprintf(src, sizeof(src), "package p\n\n%stype T struct{}\n", comment); + return go_extract(src, "pkg/t.go"); +} + +/* True when the scope blob holds exactly this record line. */ +static bool go_has_rec(const char *scope, const char *rec) { + size_t n = strlen(rec); + for (const char *p = scope; p && *p;) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len == n && memcmp(p, rec, n) == 0) { + return true; + } + p = nl ? nl + 1 : NULL; + } + return false; +} + +static void go_w(const char *repo, const char *rel, const char *content) { + th_write_file(TH_PATH(repo, rel), content); +} + +/* Tier of the one MENTIONS edge src -> tgt (qualified-name suffixes): "exact" + * or "unique"; "" when there is no such edge; "many" for more than one. */ +static const char *go_tier(const char *db, const char *src, const char *tgt) { + char props[512]; + int n = 0; + dm_edge(db, src, tgt, props, sizeof(props), &n); + if (n == 0) { + return ""; + } + if (n != 1) { + return "many"; + } + if (strstr(props, "\"syntax\":\"doc_link\"") == NULL) { + return "other-syntax"; + } + return strstr(props, "\"tier\":\"exact\"") ? "exact" : "unique"; +} + +/* Reason of the unresolved row for `raw` in `rel`; "" when there is none. */ +static const char *go_reason(const char *db, const char *rel, const char *raw) { + static char reason[64]; + dm_row(db, rel, raw, reason, sizeof(reason), NULL, 0); + return reason; +} + +typedef struct { + const char *src; + const char *tgt; + const char *tier; /* "": there must be no edge */ +} go_want_edge_t; + +typedef struct { + const char *rel; + const char *raw; + const char *reason; /* "": there must be no row */ +} go_want_row_t; + +/* The number of edges that are not as wanted (each one printed). */ +static int go_bad_edges(const char *db, const go_want_edge_t *want, size_t n) { + int bad = 0; + for (size_t i = 0; i < n; i++) { + const char *tier = go_tier(db, want[i].src, want[i].tgt); + if (strcmp(tier, want[i].tier) != 0) { + printf(" edge %s -> %s: \"%s\", want \"%s\"\n", want[i].src, want[i].tgt, tier, + want[i].tier); + bad++; + } + } + return bad; +} + +/* The number of rows that are not as wanted (each one printed). */ +static int go_bad_rows(const char *db, const go_want_row_t *want, size_t n) { + int bad = 0; + for (size_t i = 0; i < n; i++) { + const char *reason = go_reason(db, want[i].rel, want[i].raw); + if (strcmp(reason, want[i].reason) != 0) { + printf(" row %s [%s]: \"%s\", want \"%s\"\n", want[i].rel, want[i].raw, reason, + want[i].reason); + bad++; + } + } + return bad; +} + +#define GO_COUNT(a) (sizeof(a) / sizeof((a)[0])) + +/* ── extraction: tokens ──────────────────────────────────────────── */ + +TEST(doc_mentions_go_tokens) { + const char *src = + "package p\n" /* 1 */ + "\n" /* 2 */ + "// Widget does things. See [Thing], [*Star], [Thing.Run],\n" /* 3 */ + "// [fmt.Println], [encoding/json.Marshal], [io] and [pkg.Recv.Name].\n" /* 4 */ + "// Not links: map[string]Value, a[i], x[Mid]y, [not a link], [lower.case],\n" /* 5 */ + "// [Plural]s and s[Sub].\n" /* 6 */ + "//\n" /* 7 */ + "//\tcode [InCode] here\n" /* 8 */ + "//\n" /* 9 */ + "// # Heading [InHeading]\n" /* 10 */ + "//\n" /* 11 */ + "// - item [InList] one\n" /* 12 */ + "// - [RFC 7159] defined below\n" /* 13 */ + "//\n" /* 14 */ + "// [RFC 7159]: https://tools.ietf.org/html/rfc7159\n" /* 15 */ + "type Widget struct{}\n"; /* 16 */ + CBMFileResult *r = go_extract(src, "pkg/w.go"); + ASSERT_NOT_NULL(r); + const CBMDocLink *thing = dm_find_token(r, "Thing"); + ASSERT_NOT_NULL(thing); + ASSERT_EQ(thing->line, 3); + ASSERT_EQ(thing->syntax, CBM_DOCLINK_GO_DOC_LINK); + ASSERT_STR_EQ(thing->source_qn, "p.pkg.Widget"); + ASSERT_EQ(thing->def_line, 16); + /* the star stays in the raw text; the resolver skips it */ + ASSERT_NOT_NULL(dm_find_token(r, "*Star")); + ASSERT_NOT_NULL(dm_find_token(r, "Thing.Run")); + const CBMDocLink *println = dm_find_token(r, "fmt.Println"); + ASSERT_NOT_NULL(println); + ASSERT_EQ(println->line, 4); + ASSERT_NOT_NULL(dm_find_token(r, "encoding/json.Marshal")); + ASSERT_NOT_NULL(dm_find_token(r, "io")); + ASSERT_NOT_NULL(dm_find_token(r, "pkg.Recv.Name")); + /* the border rule: a bracket pair touching a letter is an index or a + * type, never a link */ + ASSERT_NULL(dm_find_token(r, "string")); + ASSERT_NULL(dm_find_token(r, "i")); + ASSERT_NULL(dm_find_token(r, "Mid")); + ASSERT_NULL(dm_find_token(r, "Plural")); + ASSERT_NULL(dm_find_token(r, "Sub")); + /* no doc-link shape */ + ASSERT_NULL(dm_find_token(r, "not a link")); + ASSERT_NULL(dm_find_token(r, "lower.case")); + /* a code block and a heading hold no links; a list item does */ + ASSERT_NULL(dm_find_token(r, "InCode")); + ASSERT_NULL(dm_find_token(r, "InHeading")); + const CBMDocLink *in_list = dm_find_token(r, "InList"); + ASSERT_NOT_NULL(in_list); + ASSERT_EQ(in_list->line, 12); + /* a text with a link definition is a URL: one href token per use, none + * for the definition itself */ + const CBMDocLink *href = dm_find_token(r, "https://tools.ietf.org/html/rfc7159"); + ASSERT_NOT_NULL(href); + ASSERT_EQ(href->syntax, CBM_DOCLINK_HREF); + ASSERT_EQ(href->line, 13); + ASSERT_EQ(dm_count_tokens(r, "https://tools.ietf.org/html/rfc7159"), 1); + ASSERT_NULL(dm_find_token(r, "RFC 7159")); + ASSERT_EQ(r->doc_links.count, 9); + cbm_free_result(r); + + /* the family and its name */ + ASSERT_STR_EQ(cbm_doclink_syntax_name(CBM_DOCLINK_GO_DOC_LINK), "doc_link"); + const CBMDocLinkFamily *f = cbm_doclink_family(CBM_DOCLINK_GO_DOC_LINK); + ASSERT_NOT_NULL(f); + ASSERT_EQ(f->lang, CBM_LANG_GO); + /* ships in production: its held-out audit passed */ + cbm_doclink_test_reset_ships(); + bool ships_in_production = cbm_doclink_syntax_ships(CBM_DOCLINK_GO_DOC_LINK); + dm_ship_held_families(); + ASSERT_TRUE(ships_in_production); + ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_GO_DOC_LINK)); + PASS(); +} + +/* Every documented kind of definition is a source. */ +TEST(doc_mentions_go_sources) { + const char *src = "package p\n" + "\n" + "// F doc [RefF].\n" + "func F() {}\n" + "\n" + "// S doc [RefS].\n" + "type S struct {\n" + "\t// Field doc [RefField].\n" + "\tField int\n" + "}\n" + "\n" + "// M doc [RefM].\n" + "func (s *S) M() {}\n" + "\n" + "// I doc [RefI].\n" + "type I interface {\n" + "\t// Do doc [RefDo].\n" + "\tDo()\n" + "}\n" + "\n" + "// C doc [RefC].\n" + "const C = 1\n" + "\n" + "const (\n" + "\t// K doc [RefK].\n" + "\tK = iota\n" + ")\n" + "\n" + "// V doc [RefV].\n" + "var V = 2\n" + "\n" + "// A doc [RefA].\n" + "type A = S\n"; + CBMFileResult *r = go_extract(src, "pkg/s.go"); + ASSERT_NOT_NULL(r); + static const struct { + const char *raw; + const char *source; + } want[] = { + {"RefF", "p.pkg.F"}, {"RefS", "p.pkg.S"}, {"RefField", "p.pkg.S.Field"}, + {"RefM", "p.pkg.M"}, /* Go method nodes have a flat name */ + {"RefI", "p.pkg.I"}, {"RefDo", "p.pkg.I.Do"}, {"RefC", "p.pkg.C"}, + {"RefK", "p.pkg.K"}, {"RefV", "p.pkg.V"}, {"RefA", "p.pkg.A"}, + }; + for (size_t i = 0; i < GO_COUNT(want); i++) { + const CBMDocLink *t = dm_find_token(r, want[i].raw); + if (!t || strcmp(t->source_qn, want[i].source) != 0) { + printf(" %s: source %s, want %s\n", want[i].raw, t ? t->source_qn : "(no token)", + want[i].source); + FAIL("source of a doc link"); + } + ASSERT_EQ(dm_count_tokens(r, want[i].raw), 1); + ASSERT_EQ(t->flags & CBM_DOCLINK_FLAG_FILE, 0); + } + cbm_free_result(r); + PASS(); +} + +/* The package comment is a source of its own (the File node), harvested once: + * no definition's docstring is the package comment. */ +TEST(doc_mentions_go_package_comment_tokens) { + static const struct { + const char *what; + const char *src; + int file_tokens; /* tokens [Ref] flagged as written in the file's doc */ + int def_tokens; /* tokens [Ref] of a definition */ + uint32_t line; /* line of the file-level token */ + } cases[] = { + {"package comment, then a documented declaration", + "// Package p has a [Ref].\npackage p\n\n// F is.\nfunc F() {}\n", 1, 0, 1}, + {"declaration without a doc right after the clause", + "// Package p has a [Ref].\npackage p\nfunc F() {}\ntype T struct{}\nvar V = 1\n", 1, 0, + 1}, + {"block comment", "/*\nPackage p has a [Ref].\n*/\npackage p\n\nfunc F() {}\n", 1, 0, 2}, + {"a header comment is no package comment", + "// Copyright [Ref].\n\npackage p\n\nfunc F() {}\n", 0, 0, 0}, + {"header, then the package comment", + "// Copyright.\n\n// Package p has a [Ref].\npackage p\n\nfunc F() {}\n", 1, 0, 3}, + {"build line, then the package comment", + "//go:build linux\n\n// Package p has a [Ref].\npackage p\n", 1, 0, 3}, + {"the same link in the package comment and in a declaration's doc", + "// Package p has a [Ref].\npackage p\n\n// F has a [Ref].\nfunc F() {}\n", 1, 1, 1}, + }; + for (size_t i = 0; i < GO_COUNT(cases); i++) { + CBMFileResult *r = go_extract(cases[i].src, "pkg/doc.go"); + ASSERT_NOT_NULL(r); + int file_tokens = 0; + int def_tokens = 0; + uint32_t line = 0; + for (int k = 0; k < r->doc_links.count; k++) { + const CBMDocLink *t = &r->doc_links.items[k]; + if (strcmp(t->raw, "Ref") != 0) { + continue; + } + if (t->flags & CBM_DOCLINK_FLAG_FILE) { + file_tokens++; + line = t->line; + } else { + def_tokens++; + } + } + if (file_tokens != cases[i].file_tokens || def_tokens != cases[i].def_tokens || + line != cases[i].line) { + printf(" %s: %d file tokens (line %u), %d definition tokens\n", cases[i].what, + file_tokens, line, def_tokens); + cbm_free_result(r); + FAIL("package comment harvested once, as the file's"); + } + cbm_free_result(r); + } + PASS(); +} + +/* The go/doc/comment text model, case by case. */ +TEST(doc_mentions_go_text_model) { + static const struct { + const char *what; + const char *comment; + const char *present; /* '|'-separated raw texts that must be tokens */ + const char *absent; /* '|'-separated raw texts that must not be */ + } cases[] = { + {"block comment", "/*\nBlock [InBlock] text.\n\n\tcode [BlockCode]\n*/\n", "InBlock", + "BlockCode"}, + {"block comment on one line", "/* One [OneLine] line. */\n", "OneLine", ""}, + {"common indent is no code", "/*\n Indented [Para] text\n more text\n*/\n", "Para", + ""}, + {"no space after the slashes", "//[Tight] text\n", "Tight", ""}, + {"directive lines are no text", "// See [Kept].\n//go:generate echo [Gen]\n", "Kept", + "Gen"}, + {"carriage returns", "// Line [Cr] one.\r\n// Line two.\r\n", "Cr", ""}, + {"numbered list", "// Steps:\n// 1. first [NumOne]\n// 2) second [NumTwo]\n", + "NumOne|NumTwo", ""}, + {"list item continuation", "// List:\n// - item starts\n// and [Continued] here\n", + "Continued", ""}, + {"code after a paragraph without a blank line", + "// Text [BeforeCode] then\n//\tx := [NotThis]\n", "BeforeCode", "NotThis"}, + {"brace line joins the code block", + "// func main() {\n//\tuse([InBody])\n// }\n// After [AfterBrace].\n", "AfterBrace", + "InBody"}, + {"unindented list before its wrapped line", + "// - first [ListA]\n// - second\n// wrapped [ListB]\n", "ListA|ListB", ""}, + {"heading needs blank lines only towards text", + "// Intro.\n//\n// # Title [InTitle]\n//\n// Body [InBody2].\n", "InBody2", "InTitle"}, + {"hash line inside a paragraph is text", "// Intro\n// # not a heading [InPara]\n", + "InPara", ""}, + {"a pair restarts at every opening bracket", "// See [[Inner]] and [a [Second]].\n", + "Inner|Second", "a [Second"}, + {"brackets across a line break are no link", "// See [Split\n// Name] here.\n", "", + "Split\nName|Split Name"}, + {"unicode punctuation borders", + "// See \xE2\x80\x9C[Quoted]\xE2\x80\x9D \xE2\x80\x94[Dashed]\xE2\x80\xA6\n", + "Quoted|Dashed", ""}, + {"letters of any script are no border", + "// See \xC3\xA9[AfterLetter] and [BeforeLetter]\xD0\xB6.\n", "", + "AfterLetter|BeforeLetter"}, + {"symbols are no punctuation", "// See `[Ticked]` =[Equals] +[Plus]+ $[Dollar].\n", "", + "Ticked|Equals|Plus|Dollar"}, + {"punctuation on both sides", "// See ([Paren]), \"[Quote]\", [Comma], [Dot].\n", + "Paren|Quote|Comma|Dot", ""}, + {"paragraph edges", "// [First] word and last [Last]\n", "First|Last", ""}, + {"link definitions must fill their paragraph", + "// [Mixed]: https://example.com/a\n// and text [Other].\n", "Other|Mixed", ""}, + {"unknown scheme defines nothing", "// Use [Odd].\n//\n// [Odd]: gopher2://example.com\n", + "Odd", ""}, + /* go/doc looks a link text up from the FIRST opening bracket since the + * last closing one, without the brackets: `[a [Def]` asks for `a Def` */ + {"nested opening brackets: the pair's own text is not the looked-up text", + "// See [a [Def] here.\n//\n// [Def]: https://example.com/def\n", "Def", + "https://example.com/def"}, + {"nested opening brackets: the text since the first one is", + "// See [a [Def] here.\n//\n// [a Def]: https://example.com/adef\n", + "https://example.com/adef", "Def"}, + {"name shapes", "// [x.Y] [x.Y.Z] [X.Y] [a/b] [a/b.C] [a/b.C.D] [*a/b.C]\n", + "x.Y|x.Y.Z|X.Y|a/b|a/b.C|a/b.C.D|*a/b.C", ""}, + {"bad shapes", + "// [x.y.Z] [X.y] [1x] [X-Y] [a//b] [a/b/] [-a/b] [a/.b] [x.Y.Z.W] [**X] [] [*]\n", "", + "x.y.Z|X.y|X-Y|a//b|a/b/|-a/b|a/.b|x.Y.Z.W|**X|*"}, + {"a quirk kept from go/doc: a leading dot", "// See [.Dotted].\n", ".Dotted", ""}, + /* a Name starts with an upper-case letter of any script; the word + * before the dot is a package name whatever its letters */ + {"capital letters beyond ASCII", + "// See [\xC3\x9C" + "ber], [\xC3\xBC" + "ber.Name] and [X.\xC3\xBC" + "ber].\n", + "\xC3\x9C" + "ber|\xC3\xBC" + "ber.Name", + "X.\xC3\xBC" + "ber"}, + }; + for (size_t i = 0; i < GO_COUNT(cases); i++) { + CBMFileResult *r = go_doc(cases[i].comment); + ASSERT_NOT_NULL(r); + for (int pass = 0; pass < 2; pass++) { + char list[512]; + snprintf(list, sizeof(list), "%s", pass == 0 ? cases[i].present : cases[i].absent); + for (char *tok = list; tok && *tok;) { + char *bar = strchr(tok, '|'); + if (bar) { + *bar = '\0'; + } + bool found = dm_find_token(r, tok) != NULL; + if (found != (pass == 0)) { + printf(" %s: [%s] %s\n", cases[i].what, tok, + found ? "is a token" : "is no token"); + cbm_free_result(r); + FAIL("go/doc text model"); + } + tok = bar ? bar + 1 : NULL; + } + } + cbm_free_result(r); + } + PASS(); +} + +/* A reference to the documented function's own parameter is local. */ +TEST(doc_mentions_go_own_parameter) { + const char *src = "package p\n" + "\n" + "// Fetch reads from [url] with [Client] and reports to [Other].\n" + "func Fetch(url string, Client int) {}\n" + "\n" + "// Later mentions [url] and [Client], which are no parameters here.\n" + "func Later() {}\n"; + CBMFileResult *r = go_extract(src, "pkg/f.go"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(dm_find_token(r, "Other")); + /* one token each: the one of Later */ + ASSERT_EQ(dm_count_tokens(r, "url"), 1); + ASSERT_EQ(dm_count_tokens(r, "Client"), 1); + ASSERT_STR_EQ(dm_find_token(r, "url")->source_qn, "p.pkg.Later"); + ASSERT_STR_EQ(dm_find_token(r, "Client")->source_qn, "p.pkg.Later"); + cbm_free_result(r); + PASS(); +} + +/* ── extraction: hostile input ───────────────────────────────────── */ + +TEST(doc_mentions_go_hostile_doc) { + /* an unterminated block comment, a bracket at the very end, a lone + * closing bracket, a bracket that never closes */ + CBMFileResult *r = go_doc("// ] [ [Ok] [\n"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 1); + ASSERT_NOT_NULL(dm_find_token(r, "Ok")); + cbm_free_result(r); + + /* invalid UTF-8 next to a bracket is no border: no token, no crash */ + r = go_doc("// \xFF[Bad1] [Bad2]\xC3 [Bad3]\x80 \xE2\x80[Bad4] [Good].\n"); + ASSERT_NOT_NULL(r); + ASSERT_NULL(dm_find_token(r, "Bad1")); + ASSERT_NULL(dm_find_token(r, "Bad2")); + ASSERT_NULL(dm_find_token(r, "Bad3")); + ASSERT_NULL(dm_find_token(r, "Bad4")); + ASSERT_NOT_NULL(dm_find_token(r, "Good")); + cbm_free_result(r); + + /* an oversized reference is harvested clipped (one byte past the limit, + * so the resolver sees that it is too long and never resolves it); the + * next reference is read as usual */ + enum { BIG = 1 << 20 }; + char *src = malloc(BIG + 256); + ASSERT_NOT_NULL(src); + size_t w = (size_t)snprintf(src, 64, "package p\n\n// See ["); + memset(src + w, 'A', BIG); + w += BIG; + w += (size_t)snprintf(src + w, 128, "] and [Small].\ntype T struct{}\n"); + r = cbm_extract_file(src, (int)w, CBM_LANG_GO, "p", "pkg/big.go", 0, NULL, NULL); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 2); + ASSERT_NOT_NULL(dm_find_token(r, "Small")); + ASSERT_EQ(strlen(r->doc_links.items[0].raw), (size_t)CBM_DOCLINK_GO_REF_MAX + 1); + ASSERT_EQ(r->doc_links.items[0].syntax, CBM_DOCLINK_GO_DOC_LINK); + cbm_free_result(r); + + /* oversized text that is no link (it has spaces) is no token at all */ + w = (size_t)snprintf(src, 64, "package p\n\n// See ["); + memset(src + w, 'A', BIG); + src[w + 100] = ' '; + w += BIG; + w += (size_t)snprintf(src + w, 128, "] and [Small].\ntype T struct{}\n"); + r = cbm_extract_file(src, (int)w, CBM_LANG_GO, "p", "pkg/prose.go", 0, NULL, NULL); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 1); + ASSERT_NOT_NULL(dm_find_token(r, "Small")); + cbm_free_result(r); + + /* a megabyte of opening brackets, then one pair: linear, one token */ + w = (size_t)snprintf(src, 64, "package p\n\n// "); + memset(src + w, '[', BIG); + w += BIG; + w += (size_t)snprintf(src + w, 128, "Deep] end.\ntype T struct{}\n"); + r = cbm_extract_file(src, (int)w, CBM_LANG_GO, "p", "pkg/deep.go", 0, NULL, NULL); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 1); + ASSERT_NOT_NULL(dm_find_token(r, "Deep")); + cbm_free_result(r); + + /* thousands of link definitions and uses: every use resolves to its own + * definition (first definition wins), without a quadratic scan */ + enum { DEFS = 4000 }; + size_t cap = (size_t)DEFS * 96 + 256; + char *many = malloc(cap); + ASSERT_NOT_NULL(many); + w = (size_t)snprintf(many, cap, "package p\n\n"); + for (int i = 0; i < DEFS; i++) { + w += (size_t)snprintf(many + w, cap - w, "// Use [t%d].\n", i); + } + w += (size_t)snprintf(many + w, cap - w, "//\n"); + for (int i = 0; i < DEFS; i++) { + w += (size_t)snprintf(many + w, cap - w, "// [t%d]: https://example.com/%d\n", i, i); + } + w += (size_t)snprintf(many + w, cap - w, + "// [t7]: https://example.com/shadowed\n" + "type T struct{}\n"); + r = cbm_extract_file(many, (int)w, CBM_LANG_GO, "p", "pkg/many.go", 0, NULL, NULL); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, DEFS); + ASSERT_EQ(dm_count_tokens(r, "https://example.com/7"), 1); + ASSERT_NULL(dm_find_token(r, "https://example.com/shadowed")); + cbm_free_result(r); + free(many); + + /* a NUL byte ends the comment text; what precedes it is still read */ + static const char nul_src[] = "package p\n\n// See [Before]\0[After].\ntype T struct{}\n"; + r = cbm_extract_file(nul_src, (int)sizeof(nul_src) - 1, CBM_LANG_GO, "p", "pkg/nul.go", 0, NULL, + NULL); + ASSERT_NOT_NULL(r); + ASSERT_NULL(dm_find_token(r, "After")); + cbm_free_result(r); + free(src); + PASS(); +} + +/* A fixed pseudo-random sequence: the same texts on every run and platform. */ +static uint32_t go_rand(uint32_t *state) { + *state = (*state * 1664525U) + 1013904223U; + return *state >> 8; +} + +/* Append one of `count` pieces, chosen by the sequence. */ +static size_t go_rand_piece(char *buf, size_t len, size_t cap, uint32_t *state, + const char *const *pieces, size_t count) { + const char *p = pieces[go_rand(state) % count]; + size_t pl = strlen(p); + if (len + pl >= cap) { + return len; + } + memcpy(buf + len, p, pl); + return len + pl; +} + +/* Comment text made of the pieces the text model reacts to, in every order: + * the parser must stay inside its buffers (the runner has the sanitizers on) + * and every token must keep the shape the resolver relies on. */ +TEST(doc_mentions_go_random_docs) { + static const char *const pieces[] = { + "[", + "]", + "[", + "]", + "*", + ".", + "/", + "Name", + "pkg", + "a/b", + " ", + " ", + "\n// ", + "\n//\t", + "\n//", + "\n//\n// ", + "# ", + "- ", + "1. ", + ": ", + "https://x", + "\xC3\xA9", + "\xFF", + "\xE2\x80", + "\xE2\x80\x9C", + "{", + "}", + "\\", + "\r", + "\t", + "T", + "x", + "]: ", + "[Name]", + "[p.Name]", + "[a/b.Name.M]", + "\n//\n// [Name]: https://x/y\n//\n// ", + "/*", + "*/", + "_", + "9", + }; + uint32_t state = 20261002U; + char src[4096]; + int doc_links = 0; + int hrefs = 0; + for (int round = 0; round < 1500; round++) { + size_t len = (size_t)snprintf(src, sizeof(src), "package p\n\n// "); + int n = 1 + (int)(go_rand(&state) % 120); + for (int i = 0; i < n; i++) { + len = go_rand_piece(src, len, sizeof(src) - 64, &state, pieces, GO_COUNT(pieces)); + } + len += (size_t)snprintf(src + len, sizeof(src) - len, "\nfunc F(Name int) {}\n"); + CBMFileResult *r = + cbm_extract_file(src, (int)len, CBM_LANG_GO, "p", "pkg/r.go", 0, NULL, NULL); + ASSERT_NOT_NULL(r); + for (int k = 0; k < r->doc_links.count; k++) { + const CBMDocLink *t = &r->doc_links.items[k]; + size_t raw_len = t->raw ? strlen(t->raw) : 0; + bool shape_ok = t->source_qn && raw_len > 0 && t->line >= 3 && + (t->syntax == CBM_DOCLINK_GO_DOC_LINK || t->syntax == CBM_DOCLINK_HREF); + CBMDocLinkGoRef ref; + if (shape_ok && t->syntax == CBM_DOCLINK_GO_DOC_LINK) { + /* a doc-link token is what the resolver can read back */ + shape_ok = raw_len <= CBM_DOCLINK_GO_REF_MAX && + cbm_doclink_go_parse_ref(t->raw, raw_len, &ref) && + memchr(t->raw, '\n', raw_len) == NULL; + } + if (!shape_ok) { + printf(" round %d: token [%s] syntax %d line %u\n", round, t->raw ? t->raw : "", + (int)t->syntax, t->line); + cbm_free_result(r); + FAIL("token shape"); + } + doc_links += t->syntax == CBM_DOCLINK_GO_DOC_LINK; + hrefs += t->syntax == CBM_DOCLINK_HREF; + } + cbm_free_result(r); + } + /* the texts do hold links of both families: the checks above ran */ + if (doc_links < 1000 || hrefs < 1) { + printf(" %d doc links, %d hrefs\n", doc_links, hrefs); + FAIL("random texts yield tokens"); + } + PASS(); +} + +/* Declarations in every order and state of repair: the scope scan must stay + * inside its buffers and write only well-formed records. */ +TEST(doc_mentions_go_random_scopes) { + static const char *const pieces[] = { + "func ", "type ", + "var ", "const ", + "import ", "package q\n", + "struct ", "interface ", + "(", ")", + "{", "}", + "[", "]", + "\n", "\n", + "\t", " ", + "=", ",", + ".", "*", + "A", "Bee", + "c", "T", + "int", "\"a/b\"", + "`x`", "//go:build ignore\n", + "// c\n", "/* c */", + "1", ";", + "any", "func (r *T[K]) ", + "Bee int", "A, Bee string", + }; + uint32_t state = 20261003U; + char src[4096]; + for (int round = 0; round < 1500; round++) { + size_t len = + (size_t)snprintf(src, sizeof(src), "%s", (round % 4 == 0) ? "" : "package p\n\n"); + int n = 1 + (int)(go_rand(&state) % 150); + for (int i = 0; i < n; i++) { + len = go_rand_piece(src, len, sizeof(src) - 8, &state, pieces, GO_COUNT(pieces)); + } + src[len] = '\0'; + CBMFileResult *r = + cbm_extract_file(src, (int)len, CBM_LANG_GO, "p", "pkg/r.go", 0, NULL, NULL); + ASSERT_NOT_NULL(r); + const char *s = r->doc_scope; + bool ok = s && strncmp(s, "go1", 3) == 0 && (s[3] == '\n' || s[3] == '\0'); + for (const char *p = ok ? strchr(s, '\n') : NULL; ok && p && p[1];) { + const char *line = p + 1; + ok = strchr("PIFMTVfmg", line[0]) != NULL && line[1] == '\t' && line[2] != '\0' && + line[2] != '\n'; + p = strchr(line, '\n'); + } + char *portable = ok ? cbm_doclink_portable_scope(s) : NULL; + ok = ok && portable && strstr(portable, "\ng\t") == NULL; + cbm_free(CBM_MEM_CLASS_OTHER, portable); + if (!ok) { + printf(" round %d: scope\n%s\nof\n%s\n", round, s ? s : "(null)", src); + cbm_free_result(r); + FAIL("scope records"); + } + cbm_free_result(r); + } + PASS(); +} + +/* ── extraction: scope blob ──────────────────────────────────────── */ + +TEST(doc_mentions_go_scope_blob) { + const char *src = "package alpha\n" + "\n" + "import (\n" + "\t\"fmt\"\n" + "\tal \"example.com/demo/pkg/alpha\"\n" + "\t. \"example.com/dot\"\n" + "\t_ \"example.com/blank\"\n" + "\tbad \"a\\tb\"\n" + ")\n" + "import \"io\"\n" + "\n" + "const Max = 10\n" + "const (\n" + "\tKindA, KindB = 1, 2\n" + ")\n" + "var Err = fmt.Errorf(\"x\")\n" + "var (\n" + "\tBlockVar = 1\n" + ")\n" + "var hidden = 1\n" + "\n" + "type Widget struct {\n" + "\tName, Size string\n" + "\tio.Reader\n" + "\t*Kind\n" + "\tsecret int\n" + "}\n" + "type Runner interface {\n" + "\tRun() error\n" + "\tstop()\n" + "\tio.Closer\n" + "}\n" + "type Kind int\n" + "type Alias = al.Widget\n" + "type Same = Kind\n" + "type List[T any, U comparable] struct{ items []T }\n" + "type lower struct{ Field int }\n" + "\n" + "func New() *Widget { return nil }\n" + "func (w *Widget) Run() error { return nil }\n" + "func (k Kind) Run() error { return nil }\n" + "func (l *List[A, B]) Push(v A) {}\n" + "func (x lower) Exported() {}\n" + "func Map[K comparable, V any](m map[K]V) {}\n" + "func helper() {}\n"; + CBMFileResult *r = go_extract(src, "pkg/alpha/a.go"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(r->doc_scope); + const char *s = r->doc_scope; + ASSERT(strncmp(s, "go1\n", 4) == 0); + static const char *const want[] = { + "P\talpha\t-", + "I\t-\tfmt", + "I\t-\tio", + "I\tal\texample.com/demo/pkg/alpha", + "I\tbad\t?", /* a path this scan cannot record: the qualifier still exists */ + /* values: the first name of a constant spec or of a parenthesis-free + * var has a node (and keeps it: w 1); the others have none (w -) */ + "V\tMax\t1", + "V\tKindA\t1", + "V\tKindB\t-", + "V\tErr\t1", + "V\tBlockVar\t-", + /* types; fields (the first name of a declaration has a node, embedded + * fields and later names have none); interface methods */ + "T\tWidget\ts\t-\t1", + "f\tWidget\tName\t1", + "f\tWidget\tSize\t0", + "f\tWidget\tReader\t0", + "f\tWidget\tKind\t0", + "T\tRunner\ti\t-\t1", + "m\tRunner\tRun", + "T\tKind\to\t-\t1", + "T\tAlias\ta\tal.Widget\t1", + "T\tSame\ta\tKind\t1", + "T\tList\ts\t-\t1", + /* functions and methods; two methods named Run compete for one node + * and the later one keeps it */ + "F\tNew\t1", + "F\tMap\t1", + "M\tWidget\tRun\t0", + "M\tKind\tRun\t1", + "M\tList\tPush\t1", + "M\tlower\tExported\t1", + /* own type parameters, by the declaration's first and last line */ + "g\t36\t36\tT,U", + "g\t42\t42\tA,B", + "g\t44\t44\tK,V", + }; + for (size_t i = 0; i < GO_COUNT(want); i++) { + if (!go_has_rec(s, want[i])) { + printf(" no record \"%s\" in\n%s\n", want[i], s); + FAIL("scope record"); + } + } + ASSERT_NULL(strstr(s, "example.com/dot")); /* a dot import is no qualifier */ + ASSERT_NULL(strstr(s, "example.com/blank")); /* nor a blank one */ + ASSERT_NULL(strstr(s, "hidden")); + ASSERT_NULL(strstr(s, "secret")); + ASSERT_NULL(strstr(s, "stop")); + ASSERT_NULL(strstr(s, "\tlower\tField")); /* unexported type: nothing to name */ + ASSERT_NULL(strstr(s, "helper")); + /* records are sorted: the blob does not depend on declaration order */ + for (const char *p = strchr(s, '\n'); p && p[1];) { + const char *next = strchr(p + 1, '\n'); + if (next && next[1]) { + const char *after = strchr(next + 1, '\n'); + size_t la = (size_t)(next - p - 1); + size_t lb = after ? (size_t)(after - next - 1) : strlen(next + 1); + int c = memcmp(p + 1, next + 1, la < lb ? la : lb); + ASSERT(c < 0 || (c == 0 && la < lb)); + } + p = next; + } + /* the persisted scope has no line number: the g records are gone */ + char *portable = cbm_doclink_portable_scope(s); + ASSERT_NOT_NULL(portable); + ASSERT_NULL(strstr(portable, "\ng\t")); + ASSERT_TRUE(go_has_rec(portable, "M\tKind\tRun\t1")); + ASSERT(strncmp(portable, "go1\n", 4) == 0); + cbm_free(CBM_MEM_CLASS_OTHER, portable); + cbm_free_result(r); + + /* moving declarations changes nothing unless a node changes its owner */ + const char *a = "package p\n\nfunc (a A) Run() {}\nfunc (b B) Run() {}\nfunc F() {}\n"; + const char *moved = "package p\n\nfunc F() {}\n\n\nfunc (a A) Run() {}\nfunc (b B) Run() {}\n"; + const char *swapped = "package p\n\nfunc (b B) Run() {}\nfunc (a A) Run() {}\nfunc F() {}\n"; + CBMFileResult *ra = go_extract(a, "p/x.go"); + CBMFileResult *rm = go_extract(moved, "p/x.go"); + CBMFileResult *rs = go_extract(swapped, "p/x.go"); + ASSERT_NOT_NULL(ra); + ASSERT_NOT_NULL(rm); + ASSERT_NOT_NULL(rs); + ASSERT_STR_EQ(ra->doc_scope, rm->doc_scope); + ASSERT(strcmp(ra->doc_scope, rs->doc_scope) != 0); + ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tA\tRun\t1")); + ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tB\tRun\t0")); + cbm_free_result(ra); + cbm_free_result(rm); + cbm_free_result(rs); + + /* the node . is one for a function, a type, a value and the + * methods of that name: the last declaration keeps it, whatever its kind, + * and two on one line contest it */ + CBMFileResult *k1 = go_extract("package p\n\nfunc Open() {}\nfunc (t T) Open() {}\n", "p/k.go"); + CBMFileResult *k2 = go_extract("package p\n\nfunc (t T) Open() {}\nfunc Open() {}\n", "p/k.go"); + CBMFileResult *k3 = go_extract("package p\n\ntype Open int\nvar Open = 1\n" + "const Open = 2; func (t T) Open() {}\n", + "p/k.go"); + ASSERT_NOT_NULL(k1); + ASSERT_NOT_NULL(k2); + ASSERT_NOT_NULL(k3); + ASSERT_TRUE(go_has_rec(k1->doc_scope, "F\tOpen\t0")); + ASSERT_TRUE(go_has_rec(k1->doc_scope, "M\tT\tOpen\t1")); + ASSERT_TRUE(go_has_rec(k2->doc_scope, "F\tOpen\t1")); + ASSERT_TRUE(go_has_rec(k2->doc_scope, "M\tT\tOpen\t0")); + ASSERT_TRUE(go_has_rec(k3->doc_scope, "T\tOpen\to\t-\t0")); + ASSERT_TRUE(go_has_rec(k3->doc_scope, "V\tOpen\t0")); + ASSERT_TRUE(go_has_rec(k3->doc_scope, "V\tOpen\tx")); + ASSERT_TRUE(go_has_rec(k3->doc_scope, "M\tT\tOpen\tx")); + cbm_free_result(k1); + cbm_free_result(k2); + cbm_free_result(k3); + + /* a build-ignored file, a build-constrained file and a file with a parse + * error say so */ + CBMFileResult *ig = + go_extract("//go:build ignore\n\npackage main\n\nfunc Gen() {}\n", "p/gen.go"); + ASSERT_NOT_NULL(ig); + ASSERT_TRUE(go_has_rec(ig->doc_scope, "P\tmain\ti")); + cbm_free_result(ig); + static const char *const constrained[] = { + "//go:build linux && !cgo\n\npackage p\n", + "// Copyright.\n\n// +build windows\n\npackage p\n", + "//+build darwin\n\npackage p\n", + }; + for (size_t i = 0; i < GO_COUNT(constrained); i++) { + CBMFileResult *cr = go_extract(constrained[i], "p/c.go"); + ASSERT_NOT_NULL(cr); + ASSERT_STR_EQ(cr->doc_scope, "go1\nP\tp\tc"); + cbm_free_result(cr); + } + static const char *const unconstrained[] = { + "package p\n\n//go:build linux\n", /* after the package clause: a comment */ + "//go:buildx linux\n\npackage p\n", /* no build line */ + "/* //go:build linux */\n\npackage p\n", /* inside a block comment */ + }; + for (size_t i = 0; i < GO_COUNT(unconstrained); i++) { + CBMFileResult *fr = go_extract(unconstrained[i], "p/f.go"); + ASSERT_NOT_NULL(fr); + ASSERT_STR_EQ(fr->doc_scope, "go1\nP\tp\t-"); + cbm_free_result(fr); + } + CBMFileResult *bad = go_extract("package q\n\nfunc Broken( {\n\tx := \n}\n\nfunc After() {}\n" + "type T struct{}\nfunc (t T) M() {}\n", + "q/broken.go"); + ASSERT_NOT_NULL(bad); + ASSERT_TRUE(go_has_rec(bad->doc_scope, "P\tq\te")); + /* what the extractor still finds after the error, the scan finds too */ + for (int i = 0; i < bad->defs.count; i++) { + const CBMDefinition *d = &bad->defs.items[i]; + if (!d->name || d->name[0] < 'A' || d->name[0] > 'Z' || !d->label || + strcmp(d->label, "Module") == 0) { + continue; + } + char needle[128]; + snprintf(needle, sizeof(needle), "\t%s", d->name); + const char *hit = strstr(bad->doc_scope, needle); + char after = hit ? hit[strlen(needle)] : 'x'; + if (after != '\t' && after != '\n' && after != '\0') { + printf(" %s %s has a definition but no scope record\n", d->label, d->name); + FAIL("scope of a file with a parse error"); + } + } + cbm_free_result(bad); + + /* go.mod: the module path */ + const char *mod = "module example.com/demo\n\ngo 1.22\n\nrequire example.com/dep v1.0.0\n"; + CBMFileResult *gm = + cbm_extract_file(mod, (int)strlen(mod), CBM_LANG_GOMOD, "p", "go.mod", 0, NULL, NULL); + ASSERT_NOT_NULL(gm); + ASSERT_NOT_NULL(gm->doc_scope); + ASSERT_STR_EQ(gm->doc_scope, "go1\nG\texample.com/demo"); + cbm_free_result(gm); + PASS(); +} + +/* ── the resolver fixture ────────────────────────────────────────── */ + +static const char GO_ALPHA_A[] = + "package alpha\n" + "\n" + "import (\n" + "\t\"fmt\"\n" + "\n" + "\t\"example.com/demo/alpha/testdata/fixture\"\n" + "\t\"example.com/demo/beta\"\n" + "\tgam \"example.com/demo/gamma\"\n" + "\t\"example.com/demo/realname\"\n" + "\t\"example.com/demo/sub/deep\"\n" + "\t\"example.com/dep/ext\"\n" + "\t\"example.com/other/pkg\"\n" + "\tbad \"a\\tb\"\n" + ")\n" + "\n" + "// Widget is the main type. See [New], [Widget.Run], [Runner.Run], [Max],\n" + "// [Default] and [Block].\n" + "type Widget struct {\n" + "\t// Name names it; its size is [Widget.Size].\n" + "\tName string\n" + "\tSize, Weight int\n" + "\tbeta.Embedded\n" + "}\n" + "\n" + "// New makes a [Widget] through [beta.Make] and [gam.Fn]; see [other.Real],\n" + "// [deep.Deep], [pkg.Far], [beta.Thing.Do], [beta.Thing.Field],\n" + "// [beta.Thing.Second], [beta.Doer.Act], [beta.Gr\xC3\xB6\xC3\x9F" + "e], [beta.\xC3\x9C" + "ber], [beta] and [bad.T].\n" + "func New() *Widget { return nil }\n" + "\n" + "// Run runs. Plain text: [Make], [Thing], [realname.Real], [gamma.Fn],\n" + "// [Widget.Promoted], [Widget.Depth], [Widget.Unknown], [nosuch.Name], [Nope], " + "[beta.Fehlt_\xC3\x9F].\n" + "// Rows: [beta.Nope], [beta.Thing.Nope], [beta.InBlock], [ext.Thing],\n" + "// [fmt.Println], [encoding/json.Marshal], [io], [example.com/demo/nodir.X]\n" + "// and [example.com/demo/nested/pkg.Far]. Fields: [Widget.Size], [Widget.Weight], " + "[Widget.Embedded].\n" + "func (w *Widget) Run() {}\n" + "\n" + "// Runner runs.\n" + "type Runner interface {\n" + "\t// Run runs; see [Widget].\n" + "\tRun() error\n" + "}\n" + "\n" + "// Max is the maximum.\n" + "const Max = 10\n" + "\n" + "// Default is the default; tests have [Helper].\n" + "var Default = New()\n" + "\n" + "var (\n" + "\t// Block is in a block.\n" + "\tBlock = 1\n" + ")\n" + "\n" + "// T is a package-level type.\n" + "type T struct{}\n" + "\n" + "// Map shadows: [T] is its own type parameter, [Widget] is not.\n" + "func Map[T any](x T) {}\n" + "\n" + "// Plain has no type parameter: [T] is the type.\n" + "func Plain() {}\n" + "\n" + "// Spec follows the [spec].\n" + "//\n" + "// [spec]: https://example.com/spec\n" + "func Spec() {}\n" + "\n" + "// Box holds a value.\n" + "type Box[T any] struct {\n" + "\t// Value is of type [T], the type parameter of Box; [Widget] is a type.\n" + "\tValue T\n" + "}\n" + "\n" + "// Fixture names [fixture.Data], which is test data.\n" + "func Fixture() {}\n"; + +static const char GO_ALPHA_B[] = + "package alpha\n" + "\n" + "// Sibling imports nothing: [beta.Thing] and [gam.Fn] are plain text here,\n" + "// [Widget] is a link.\n" + "func Sibling() {}\n"; + +static const char GO_ALPHA_C[] = "package alpha\n" + "\n" + "// A is a.\n" + "type A struct{}\n" + "\n" + "// B is b.\n" + "type B struct{}\n" + "\n" + "// Close closes A; see [Target].\n" + "func (A) Close() {}\n" + "\n" + "// Close closes B; see [Target].\n" + "func (B) Close() {}\n" + "\n" + "// Target is a target; see [A.Close] and [B.Close].\n" + "func Target() {}\n" + "\n" + "// Open is a type that loses its node name to the method below.\n" + "type Open struct{}\n" + "\n" + "// Open opens: a method named like the type.\n" + "func (B) Open() {}\n" + "\n" + "// UsesOpen names [Open] and [B.Open].\n" + "func UsesOpen() {}\n"; + +static const char GO_ALPHA_D[] = "package alpha\n" + "\n" + "import \"example.com/demo/beta\"\n" + "\n" + "// Alias is beta's Thing.\n" + "type Alias = beta.Thing\n" + "\n" + "// Same is Alias again.\n" + "type Same = Alias\n" + "\n" + "// Bare names [Alias.Do]: plain text without a package.\n" + "func Bare() {}\n" + "\n" + "type L0 = L1\n" + "type L1 = L2\n" + "type L2 = L3\n" + "type L3 = L4\n" + "type L4 = L5\n" + "type L5 = L6\n" + "type L6 = L7\n" + "type L7 = L8\n" + "type L8 = L9\n" + "type L9 = Deep10\n" + "\n" + "// Deep10 ends the chain.\n" + "type Deep10 struct{}\n" + "\n" + "// End is its method.\n" + "func (Deep10) End() {}\n" + "\n" + "type CycA = CycB\n" + "type CycB = CycA\n"; + +static const char GO_BETA[] = "package beta\n" + "\n" + "// Thing is a thing.\n" + "type Thing struct {\n" + "\t// Field is a field.\n" + "\tField int\n" + "\tFirst, Second int\n" + "}\n" + "\n" + "// Do does.\n" + "func (t Thing) Do() {}\n" + "\n" + "// Make makes.\n" + "func Make() Thing { return Thing{} }\n" + "\n" + "// Embedded is embedded elsewhere.\n" + "type Embedded struct {\n" + "\t// Depth is a field of Embedded, promoted where it is embedded.\n" + "\tDepth int\n" + "}\n" + "\n" + "// Promoted is promoted through embedding.\n" + "func (Embedded) Promoted() {}\n" + "\n" + "// Gr\xC3\xB6\xC3\x9F" + "e has letters beyond ASCII.\n" + "type Gr\xC3\xB6\xC3\x9F" + "e struct{}\n" + "\n" + "// \xC3\x9C" + "ber starts with a capital letter beyond ASCII: exported.\n" + "type \xC3\x9C" + "ber struct{}\n" + "\n" + "var (\n" + "\t// InBlock has no node.\n" + "\tInBlock = 1\n" + ")\n" + "\n" + "// Doer does.\n" + "type Doer interface {\n" + "\t// Act acts.\n" + "\tAct()\n" + "}\n"; + +static const char GO_GAMMA_K[] = + "package gamma\n" + "\n" + "import \"example.com/demo/alpha\"\n" + "\n" + "// ViaAlias reaches [alpha.Alias.Do] and [alpha.Same.Field] through aliases;\n" + "// [alpha.Alias] is the alias itself, [alpha.Alias.Nope] is nothing,\n" + "// [alpha.Widget.Promoted] is promoted and not Widget's own,\n" + "// [alpha.Widget.Name] is a field, [alpha.Widget.Weight] a field without a\n" + "// node, [alpha.L3.End] is seven aliases away, [alpha.L0.End] ten and\n" + "// [alpha.CycA.End] never ends. [alpha.Helper] is test code.\n" + "func ViaAlias() {}\n"; + +/* The module example.com/demo with a nested module of its own path + * (sub/), a nested module of another path (nested/) and the packages the + * lookup tests need. */ +static void go_write_lookup_fixture(const char *repo) { + go_w(repo, "go.mod", "module example.com/demo\n\ngo 1.22\n"); + go_w(repo, "root.go", + "// Package demo is the root. See [alpha.Widget].\n" + "package demo\n" + "\n" + "import \"example.com/demo/alpha\"\n" + "\n" + "// Root names [example.com/demo], [alpha], [example.com/demo/mixed.F] and\n" + "// [example.com/demo/fmt.Println].\n" + "func Root() {}\n"); + go_w(repo, "alpha/doc.go", + "// Package alpha is documented here. It builds a [Widget]; printing is\n" + "// [fmt.Println] and [nosuch.Thing] is nothing.\n" + "// [beta.Thing] is no link here: this file imports nothing, a.go imports beta.\n" + "package alpha\n"); + go_w(repo, "alpha/a.go", GO_ALPHA_A); + go_w(repo, "alpha/b.go", GO_ALPHA_B); + go_w(repo, "alpha/c.go", GO_ALPHA_C); + go_w(repo, "alpha/d.go", GO_ALPHA_D); + go_w(repo, "alpha/a_test.go", + "package alpha\n" + "\n" + "// Helper exists only in tests.\n" + "type Helper struct{}\n" + "\n" + "// TestWidget uses [Helper] and [Widget].\n" + "func TestWidget() {}\n"); + go_w(repo, "alpha/x_test.go", + "package alpha_test\n" + "\n" + "import \"example.com/demo/alpha\"\n" + "\n" + "// Example uses [alpha.Widget]; [Widget] alone is plain text in this package.\n" + "func Example() {}\n"); + go_w(repo, "alpha/testdata/fixture/f.go", + "package fixture\n\n// Data is test data; it may name [Data] itself and [Twin].\n" + "var Data = 1\n\n// Twin is another value.\nvar Twin = 2\n"); + go_w(repo, "beta/b.go", GO_BETA); + go_w(repo, "gamma/g.go", "package gamma\n\n// Fn is a function.\nfunc Fn() {}\n"); + go_w(repo, "gamma/h.go", + "package gamma\n" + "\n" + "import \"example.com/demo/fmt\"\n" + "\n" + "// Local names the repository's own [fmt.Println].\n" + "func Local() {}\n"); + go_w(repo, "gamma/k.go", GO_GAMMA_K); + go_w(repo, "realname/r.go", "package other\n\n// Real is real.\nfunc Real() {}\n"); + go_w(repo, "fmt/f.go", + "package fmt\n\n// Println is the repository's own, not the standard library's.\n" + "func Println() {}\n"); + go_w(repo, "log/l.go", + "package log\n" + "\n" + "// Printf prints.\n" + "func Printf() {}\n" + "\n" + "// Use names its own package's [log.Printf], not the standard library's.\n" + "func Use() {}\n"); + go_w(repo, "strings/s.go", + "package strings\n" + "\n" + "import \"strings\"\n" + "\n" + "// Join joins.\n" + "func Join() {}\n" + "\n" + "// Use imports the standard library's strings: [strings.Join] is that one.\n" + "func Use() {}\n"); + go_w(repo, "sub/go.mod", "module example.com/demo/sub\n"); + go_w(repo, "sub/deep/d.go", "package deep\n\n// Deep is deep.\nfunc Deep() {}\n"); + go_w(repo, "nested/go.mod", "module example.com/other\n"); + go_w(repo, "nested/pkg/p.go", "package pkg\n\n// Far is far.\nfunc Far() {}\n"); + /* one import name, two packages: each file's own import decides */ + go_w(repo, "same/a.go", + "package same\n\nimport x \"example.com/demo/beta\"\n\n// A names [x.Thing].\nfunc A() " + "{}\n"); + go_w( + repo, "same/b.go", + "package same\n\nimport x \"example.com/demo/gamma\"\n\n// B names [x.Fn].\nfunc B() {}\n"); + go_w(repo, "same/doc.go", + "// Package same: [x.Thing] is no link in a file without imports.\npackage same\n"); + go_w(repo, "mixed/a.go", "package p1\n\n// F is p1's.\nfunc F() {}\n"); + go_w(repo, "mixed/b.go", "package p2\n\n// F is p2's.\nfunc F() {}\n"); + /* stand-alone programs in one directory: no package */ + go_w(repo, "progs/one.go", + "package main\n\n// Config is one program's.\ntype Config struct{}\n\n" + "// Load loads a [Config].\nfunc Load() {}\n"); + go_w(repo, "progs/two.go", + "package main\n\n// Config is another program's.\ntype Config struct{}\n\n" + "// Save saves a [Config].\nfunc Save() {}\n"); + go_w(repo, "progs/three.go", + "package main\n\n// Print prints a [Config], whichever.\nfunc Print() {}\n"); + /* build variants of one function */ + go_w(repo, "plat/impl_linux.go", "package plat\n\n// Open opens on Linux.\nfunc Open() {}\n"); + go_w(repo, "plat/impl_windows.go", + "package plat\n\n// Open opens on Windows.\nfunc Open() {}\n\n" + "// Helper calls [Open].\nfunc Helper() {}\n"); + go_w(repo, "plat/tagged_a.go", + "//go:build a\n\npackage plat\n\n// Tagged is one variant.\nfunc Tagged() {}\n"); + go_w(repo, "plat/tagged_b.go", + "//go:build !a\n\npackage plat\n\n// Tagged is the other variant.\nfunc Tagged() {}\n"); + go_w(repo, "plat/use.go", "package plat\n\n// Use calls [Open] and [Tagged].\nfunc Use() {}\n"); + /* a test file's variant keeps the node */ + go_w(repo, "tw/aa_test.go", + "//go:build integration\n\npackage tw\n\n// Shared is the test double.\n" + "func Shared() {}\n"); + go_w(repo, "tw/zz.go", + "package tw\n\n// Shared is the product one.\nfunc Shared() {}\n\n" + "// Caller calls [Shared].\nfunc Caller() {}\n"); + /* one name, two kinds */ + go_w(repo, "amb/x.go", "package amb\n\n// Dual is a function here.\nfunc Dual() {}\n"); + go_w(repo, "amb/y.go", + "//go:build other\n\npackage amb\n\n// Dual is a type there.\ntype Dual struct{}\n"); + go_w(repo, "amb/z.go", "package amb\n\n// Z names [Dual].\nfunc Z() {}\n"); + /* a reference longer than a reference can be */ + char *big = malloc(2048); + size_t w = (size_t)snprintf(big, 2048, "package long\n\n// L names ["); + memset(big + w, 'A', CBM_DOCLINK_GO_REF_MAX + 88); + w += CBM_DOCLINK_GO_REF_MAX + 88; + snprintf(big + w, 2048 - w, + "] and [Other].\nfunc L() {}\n\n// Other is other.\nfunc Other() {}\n"); + go_w(repo, "long/l.go", big); + free(big); +} + +/* Write the lookup fixture below `tmp` and index it into `db`. */ +static int go_index_lookup(char *tmp, size_t tmp_cap, char *db, size_t db_cap) { + snprintf(tmp, tmp_cap, "/tmp/cbm_dm_go_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + return -1; + } + char repo[400]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + go_write_lookup_fixture(repo); + snprintf(db, db_cap, "%s/go.db", tmp); + return dm_index(repo, db, NULL); +} + +/* ── resolver: every reference form, in its first lookup level ───── */ + +TEST(doc_mentions_go_forms) { + char tmp[256]; + char db[512]; + ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0); + static const go_want_edge_t edges[] = { + /* [Name], [Recv.Method], an interface's method, a constant, a variable */ + {"alpha.Widget", "alpha.New", "unique"}, + {"alpha.Widget", "alpha.Run", "unique"}, + {"alpha.Widget", "alpha.Runner.Run", "unique"}, + {"alpha.Widget", "alpha.Max", "unique"}, + {"alpha.Widget", "alpha.Default", "unique"}, + {"alpha.New", "alpha.Widget", "unique"}, + /* an interface method and a field are sources */ + {"alpha.Runner.Run", "alpha.Widget", "unique"}, + /* [pkg.Name]: the file's import, its renamed import, the real package + * name of an import whose directory is called otherwise, a nested + * module, a module with another path */ + {"alpha.New", "beta.Make", "exact"}, + {"alpha.New", "gamma.Fn", "exact"}, + {"alpha.New", "realname.Real", "exact"}, + {"alpha.New", "sub.deep.Deep", "exact"}, + {"alpha.New", "nested.pkg.Far", "exact"}, + /* [pkg.Recv.Name]: a method, a field, an interface method */ + {"alpha.New", "beta.Do", "exact"}, + {"alpha.New", "beta.Thing.Field", "exact"}, + {"alpha.New", "beta.Doer.Act", "exact"}, + /* an identifier with letters beyond ASCII */ + {"alpha.New", + "beta.Gr\xC3\xB6\xC3\x9F" + "e", + "exact"}, + {"alpha.New", + "beta.\xC3\x9C" + "ber", + "exact"}, + /* [pkg]: the package's directory node */ + {"alpha.New", "beta", "exact"}, + /* an import path */ + {"Root", "fmt.Println", "exact"}, + {"Root", "alpha", "exact"}, + /* test code may name test code; an external test package imports */ + {"alpha.TestWidget", "alpha.Helper", "unique"}, + {"alpha.TestWidget", "alpha.Widget", "unique"}, + {"alpha.Example", "alpha.Widget", "exact"}, + {"fixture.Data", "fixture.Twin", "unique"}, /* inside a testdata directory */ + }; + ASSERT_EQ(go_bad_edges(db, edges, GO_COUNT(edges)), 0); + /* the edge's properties, in full */ + char props[512]; + int n = 0; + dm_edge(db, "alpha.New", "beta.Make", props, sizeof(props), &n); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(props, "{\"via\":\"doc_comment\",\"syntax\":\"doc_link\",\"tier\":\"exact\"," + "\"line\":25,\"count\":1}"); + /* 12 targets; [bad.T] and [beta.Thing.Second] are rows */ + ASSERT_EQ(dm_mentions_from(db, "alpha.New"), 12); + /* a [Text] with a link definition is the href family: a row, never an edge */ + char reason[64]; + char syntax[64]; + dm_row(db, "alpha/a.go", "https://example.com/spec", reason, sizeof(reason), syntax, + sizeof(syntax)); + ASSERT_STR_EQ(reason, "external"); + ASSERT_STR_EQ(syntax, "href"); + ASSERT_EQ(dm_mentions_from(db, "alpha.Spec"), 0); + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── resolver: text go/doc renders as text is neither edge nor row ── */ + +TEST(doc_mentions_go_plain_text) { + char tmp[256]; + char db[512]; + ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0); + const char *a = "alpha/a.go"; + static const go_want_row_t rows[] = { + /* a name the package does not declare, although an imported package + * or a sibling directory does */ + {"alpha/a.go", "Make", ""}, + {"alpha/a.go", "Thing", ""}, + {"alpha/a.go", "Nope", ""}, + /* a qualifier that is no import name of the file: the directory name + * of a package called otherwise, the path element of a renamed + * import, a word */ + {"alpha/a.go", "realname.Real", ""}, + {"alpha/a.go", "gamma.Fn", ""}, + {"alpha/a.go", "nosuch.Name", ""}, + /* members the type does not declare itself: a method and a field + * promoted through an embedded type, a name nobody declares */ + {"alpha/a.go", "Widget.Promoted", ""}, + {"alpha/a.go", "Widget.Depth", ""}, + {"alpha/a.go", "Widget.Unknown", ""}, + /* a qualified name with a byte this scan cannot classify */ + {"alpha/a.go", "beta.Fehlt_\xC3\x9F", ""}, + /* the file's own imports only: a sibling's import is no qualifier */ + {"alpha/b.go", "beta.Thing", ""}, + {"alpha/b.go", "gam.Fn", ""}, + /* a member through an alias, without a package */ + {"alpha/d.go", "Alias.Do", ""}, + /* another package clause in the same directory is another package */ + {"alpha/x_test.go", "Widget", ""}, + {"alpha/doc.go", "nosuch.Thing", ""}, + }; + ASSERT_EQ(go_bad_rows(db, rows, GO_COUNT(rows)), 0); + /* and none of them is an edge: Run's doc holds rows, plain text and one + * link, to the field Widget.Size */ + ASSERT_EQ(dm_mentions_from(db, "alpha.Run"), 1); + ASSERT_EQ(dm_mentions_from(db, "alpha.Bare"), 0); + static const go_want_edge_t edges[] = { + {"alpha.Sibling", "alpha.Widget", "unique"}, + {"alpha.Sibling", "beta.Thing", ""}, + {"alpha.Sibling", "gamma.Fn", ""}, + {"alpha.Example", "alpha.Widget", "exact"}, + }; + ASSERT_EQ(go_bad_edges(db, edges, GO_COUNT(edges)), 0); + ASSERT_EQ(dm_mentions_from(db, "alpha.Sibling"), 1); + ASSERT_STR_EQ(go_reason(db, a, "beta.Nope"), "missing"); /* the fixture is the one meant */ + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── resolver: struct fields ─────────────────────────────────────── */ + +/* [Type.Field] binds the field written in that type's own struct declaration, + * with or without a package. (Without one, go/doc itself is believed to + * render it as plain text: a deliberate difference.) Nothing promoted, and + * nothing for a name the type does not declare. */ +TEST(doc_mentions_go_struct_fields) { + char tmp[256]; + char db[512]; + ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0); + static const go_want_edge_t edges[] = { + /* the same package: a field named from a method's doc and from a + * sibling field's doc */ + {"alpha.Run", "alpha.Widget.Size", "exact"}, + {"alpha.Widget.Name", "alpha.Widget.Size", "exact"}, + /* another package, by its import */ + {"alpha.New", "beta.Thing.Field", "exact"}, + {"gamma.ViaAlias", "alpha.Widget.Name", "exact"}, + /* Depth is a field of beta.Embedded, which Widget embeds: it is no + * field of Widget */ + {"alpha.Run", "beta.Embedded.Depth", ""}, + }; + ASSERT_EQ(go_bad_edges(db, edges, GO_COUNT(edges)), 0); + /* the promoted field exists as a node: the test above is not vacuous */ + ASSERT_EQ(dm_count(db, + "SELECT COUNT(*) FROM nodes WHERE label = 'Field' AND qualified_name LIKE " + "'%.beta.Embedded.Depth'"), + 1); + static const go_want_row_t rows[] = { + {"alpha/a.go", "Widget.Size", ""}, /* an edge, no row */ + {"alpha/a.go", "Widget.Depth", ""}, /* promoted: plain text */ + {"alpha/a.go", "Widget.Promoted", ""}, /* a promoted method: plain text */ + {"alpha/a.go", "Widget.Unknown", ""}, /* nobody's: plain text */ + /* a field the graph has no node for is a gap, not plain text: the + * second name of one declaration, and the embedded field itself */ + {"alpha/a.go", "Widget.Weight", "graph_gap"}, + {"alpha/a.go", "Widget.Embedded", "graph_gap"}, + {"gamma/k.go", "alpha.Widget.Promoted", "missing"}, /* with a package: a broken link */ + }; + ASSERT_EQ(go_bad_rows(db, rows, GO_COUNT(rows)), 0); + ASSERT_EQ(dm_mentions_from(db, "alpha.Widget.Name"), 1); + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── resolver: the package of a qualifier is the FILE's import ───── */ + +/* The limit of this resolver against go/doc: a qualifier is looked up in the + * imports of the documented file only, not in the union of the imports of + * the package's files. A package comment in a file without imports (the + * usual doc.go) therefore cannot name what only a sibling file imports: no + * edge and no row. The other side of the same rule: where two files import + * different packages under one name, go/doc links neither, and here each + * file's links go through its own import. */ +TEST(doc_mentions_go_file_only_package_lookup) { + char tmp[256]; + char db[512]; + ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0); + static const go_want_row_t rows[] = { + {"alpha/doc.go", "beta.Thing", ""}, /* a.go imports beta, doc.go nothing */ + {"same/doc.go", "x.Thing", ""}, + {"alpha/b.go", "beta.Thing", ""}, + {"alpha/b.go", "gam.Fn", ""}, + }; + ASSERT_EQ(go_bad_rows(db, rows, GO_COUNT(rows)), 0); + static const go_want_edge_t edges[] = { + {"alpha.doc.go.__file__", "beta.Thing", ""}, + {"same.doc.go.__file__", "beta.Thing", ""}, + {"alpha.Sibling", "beta.Thing", ""}, + /* the file that does import it links it */ + {"alpha.New", "beta.Make", "exact"}, + /* one name, two packages: the file's own import decides */ + {"same.A", "beta.Thing", "exact"}, + {"same.B", "gamma.Fn", "exact"}, + {"same.A", "gamma.Fn", ""}, + {"same.B", "beta.Thing", ""}, + }; + ASSERT_EQ(go_bad_edges(db, edges, GO_COUNT(edges)), 0); + /* the package comment's one link is the one to its own package's type */ + 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 IN ('alpha/doc.go', 'same/doc.go')"), + 1); + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── resolver: the local rules ───────────────────────────────────── */ + +TEST(doc_mentions_go_local_rules) { + char tmp[256]; + char db[512]; + ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0); + static const go_want_edge_t edges[] = { + /* the function's own type parameter shadows the package's type T */ + {"alpha.Map", "alpha.T", ""}, + {"alpha.Map", "alpha.Widget", "unique"}, + /* a function without that type parameter means the type */ + {"alpha.Plain", "alpha.T", "unique"}, + /* a generic type's parameter is in scope of its fields' docs too */ + {"alpha.Box.Value", "alpha.T", ""}, + {"alpha.Box.Value", "alpha.Widget", "unique"}, + }; + ASSERT_EQ(go_bad_edges(db, edges, GO_COUNT(edges)), 0); + ASSERT_STR_EQ(go_reason(db, "alpha/a.go", "T"), ""); + ASSERT_EQ(dm_mentions_from(db, "alpha.Map"), 1); + ASSERT_EQ(dm_mentions_from(db, "alpha.Box.Value"), 1); + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── resolver: one test per level boundary of the package lookup ─── */ + +TEST(doc_mentions_go_lookup_order) { + char tmp[256]; + char db[512]; + ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0); + /* 1. the file's import beats the package's own name: package strings + * imports the standard library's strings */ + ASSERT_STR_EQ(go_reason(db, "strings/s.go", "strings.Join"), "external"); + ASSERT_STR_EQ(go_tier(db, "strings.Use", "strings.Join"), ""); + /* 2. the package's own name beats the standard-library list: package log + * without an import of log */ + ASSERT_STR_EQ(go_tier(db, "log.Use", "log.Printf"), "exact"); + ASSERT_STR_EQ(go_reason(db, "log/l.go", "log.Printf"), ""); + /* 3. the standard-library list is the last level: [io] without an import */ + ASSERT_STR_EQ(go_reason(db, "alpha/a.go", "io"), "external"); + /* 4. an import decides by its PATH: fmt is the standard library's in a + * file that imports "fmt", the repository's own in a file that imports + * example.com/demo/fmt */ + ASSERT_STR_EQ(go_reason(db, "alpha/a.go", "fmt.Println"), "external"); + ASSERT_STR_EQ(go_tier(db, "alpha.Run", "fmt.Println"), ""); + ASSERT_STR_EQ(go_tier(db, "gamma.Local", "fmt.Println"), "exact"); + /* 5. the longest module path decides: a directory inside the root module + * that belongs to a nested module of another path is not reachable through + * the root module's path */ + ASSERT_STR_EQ(go_reason(db, "alpha/a.go", "example.com/demo/nested/pkg.Far"), "missing"); + ASSERT_STR_EQ(go_tier(db, "alpha.New", "nested.pkg.Far"), "exact"); + ASSERT_STR_EQ(go_tier(db, "alpha.New", "sub.deep.Deep"), "exact"); + /* 6. the bare name stops at the package: beta.Make is imported and still + * no [Make] */ + ASSERT_STR_EQ(go_tier(db, "alpha.Run", "beta.Make"), ""); + /* 7. members stop at the type: nothing promoted through embedding */ + ASSERT_STR_EQ(go_reason(db, "gamma/k.go", "alpha.Widget.Promoted"), "missing"); + ASSERT_STR_EQ(go_tier(db, "gamma.ViaAlias", "beta.Promoted"), ""); + /* 8. an alias is followed for a member written with its package, to the + * type that declares the member */ + static const go_want_edge_t alias[] = { + {"gamma.ViaAlias", "beta.Do", "exact"}, + {"gamma.ViaAlias", "beta.Thing.Field", "exact"}, + {"gamma.ViaAlias", "alpha.Alias", "exact"}, + {"gamma.ViaAlias", "alpha.End", "exact"}, /* seven aliases away */ + {"gamma.ViaAlias", "alpha.Widget.Name", "exact"}, + }; + ASSERT_EQ(go_bad_edges(db, alias, GO_COUNT(alias)), 0); + ASSERT_STR_EQ(go_reason(db, "gamma/k.go", "alpha.Alias.Nope"), "missing"); + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── resolver: one test per reason ───────────────────────────────── */ + +TEST(doc_mentions_go_reasons) { + char tmp[256]; + char db[512]; + ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0); + char long_raw[CBM_DOCLINK_GO_REF_MAX + 2]; + memset(long_raw, 'A', CBM_DOCLINK_GO_REF_MAX + 1); + long_raw[CBM_DOCLINK_GO_REF_MAX + 1] = '\0'; + const go_want_row_t rows[] = { + /* external: the standard library by import, a dependency by import, + * the standard library by path */ + {"alpha/a.go", "fmt.Println", "external"}, + {"alpha/a.go", "ext.Thing", "external"}, + {"alpha/a.go", "encoding/json.Marshal", "external"}, + /* missing: a repository package that does not declare the name or the + * member; a path inside a module with no package there */ + {"alpha/a.go", "beta.Nope", "missing"}, + {"alpha/a.go", "beta.Thing.Nope", "missing"}, + {"alpha/a.go", "example.com/demo/nodir.X", "missing"}, + /* graph_gap: declared, but without a node of its own: a variable of a + * var block, a later name of a field declaration, the root package + * (the Project node is no package node) */ + {"alpha/a.go", "Block", "graph_gap"}, + {"alpha/a.go", "beta.InBlock", "graph_gap"}, + {"alpha/a.go", "beta.Thing.Second", "graph_gap"}, + {"gamma/k.go", "alpha.Widget.Weight", "graph_gap"}, + {"root.go", "example.com/demo", "graph_gap"}, + /* test_only_target: product code naming what only test files declare, + * or what a testdata directory holds */ + {"alpha/a.go", "Helper", "test_only_target"}, + {"gamma/k.go", "alpha.Helper", "test_only_target"}, + {"alpha/a.go", "fixture.Data", "test_only_target"}, + /* ambiguous: one name, a function here and a type there; one + * directory, two packages, and nothing that picks one */ + {"amb/z.go", "Dual", "ambiguous"}, + {"root.go", "example.com/demo/mixed.F", "ambiguous"}, + /* unparseable: an import path the scan could not record, an alias + * chain that is too long or never ends, a reference that is too long + * (never resolved by a prefix) */ + {"alpha/a.go", "bad.T", "unparseable"}, + {"gamma/k.go", "alpha.L0.End", "unparseable"}, + {"gamma/k.go", "alpha.CycA.End", "unparseable"}, + {"long/l.go", long_raw, "unparseable"}, + }; + ASSERT_EQ(go_bad_rows(db, rows, GO_COUNT(rows)), 0); + ASSERT_STR_EQ(go_tier(db, "long.L", "long.Other"), "unique"); + ASSERT_EQ(dm_mentions_from(db, "long.L"), 1); + /* every row of the fixture is a Go doc link or a URL */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM doc_link_unresolved WHERE syntax NOT IN " + "('doc_link','href')"), + 0); + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── resolver: one node name, several declarations ───────────────── */ + +TEST(doc_mentions_go_node_owner) { + char tmp[256]; + char db[512]; + ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0); + /* Go method nodes have a flat name: A.Close and B.Close share + * alpha.Close, and the later declaration keeps it */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE qualified_name LIKE '%.alpha.Close'"), + 1); + ASSERT_STR_EQ(go_tier(db, "alpha.Target", "alpha.Close"), "unique"); + ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "A.Close"), "graph_gap"); + ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "B.Close"), ""); + /* the doc of the method that lost the name is not attributed to the one + * that kept it: B.Close's [Target] is the edge, A.Close's a row */ + char props[512]; + int n = 0; + dm_edge(db, "alpha.Close", "alpha.Target", props, sizeof(props), &n); + ASSERT_EQ(n, 1); + ASSERT_NOT_NULL(strstr(props, "\"line\":12,\"count\":1")); + ASSERT_EQ(dm_count(db, "SELECT line FROM doc_link_unresolved WHERE rel_path = 'alpha/c.go' " + "AND raw = 'Target'"), + 9); + /* a type that lost its name to a method of that name is no target, the + * method is */ + ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "Open"), "graph_gap"); + ASSERT_STR_EQ(go_tier(db, "alpha.UsesOpen", "alpha.Open"), "unique"); + ASSERT_EQ(dm_mentions_from(db, "alpha.UsesOpen"), 1); + + /* build variants are one entity: whichever file holds the node */ + static const go_want_edge_t variants[] = { + {"plat.Use", "plat.Open", "unique"}, + {"plat.Use", "plat.Tagged", "unique"}, + {"plat.Helper", "plat.Open", "unique"}, + }; + ASSERT_EQ(go_bad_edges(db, variants, GO_COUNT(variants)), 0); + + /* two unconstrained files that declare one name are no package: the name + * means the file's own declaration, and nothing from elsewhere */ + ASSERT_STR_EQ(go_tier(db, "progs.Load", "progs.Config"), "unique"); + ASSERT_STR_EQ(go_reason(db, "progs/two.go", "Config"), "graph_gap"); + ASSERT_STR_EQ(go_tier(db, "progs.Save", "progs.Config"), ""); + ASSERT_STR_EQ(go_reason(db, "progs/three.go", "Config"), "ambiguous"); + ASSERT_STR_EQ(go_tier(db, "progs.Print", "progs.Config"), ""); + + /* a node kept by a test file's variant is no target for product code */ + ASSERT_STR_EQ(go_reason(db, "tw/zz.go", "Shared"), "graph_gap"); + ASSERT_STR_EQ(go_tier(db, "tw.Caller", "tw.Shared"), ""); + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── resolver: the package comment, from the File node ───────────── */ + +TEST(doc_mentions_go_package_comment) { + char tmp[256]; + char db[512]; + ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0); + /* a reference of the package comment is an edge from the File node */ + char props[512]; + int n = 0; + dm_edge(db, "alpha.doc.go.__file__", "alpha.Widget", props, sizeof(props), &n); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(props, "{\"via\":\"doc_comment\",\"syntax\":\"doc_link\",\"tier\":\"unique\"," + "\"line\":1,\"count\":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 = 'alpha/doc.go'"), + 1); + /* ... or a row with the file's path and the reference's line */ + ASSERT_STR_EQ(go_reason(db, "alpha/doc.go", "fmt.Println"), "external"); + ASSERT_EQ(dm_count(db, "SELECT line FROM doc_link_unresolved WHERE rel_path = 'alpha/doc.go' " + "AND raw = 'fmt.Println'"), + 2); + /* a file with imports: its package comment reads them */ + ASSERT_STR_EQ(go_tier(db, "root.go.__file__", "alpha.Widget"), "exact"); + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── resolver: the repository's own standard library ─────────────── */ + +TEST(doc_mentions_go_std_module) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dm_gostd_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[400]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + go_w(repo, "src/go.mod", "module std\n\ngo 1.22\n"); + go_w(repo, "src/fmt/print.go", "package fmt\n\n// Println prints.\nfunc Println() {}\n"); + go_w(repo, "src/os/exec/exec.go", "package exec\n\n// Cmd is a command.\ntype Cmd struct{}\n"); + go_w(repo, "src/log/doc.go", + "// Package log links [os/exec.Cmd] by its path and [fmt.Println] by the\n" + "// standard library's own name.\n" + "package log\n"); + go_w(repo, "src/log/log.go", + "package log\n" + "\n" + "import (\n" + "\t\"fmt\"\n" + "\t\"os/exec\"\n" + ")\n" + "\n" + "// Print uses [fmt.Println], [exec.Cmd] and [fmt.Missing].\n" + "func Print() {}\n"); + char db[512]; + snprintf(db, sizeof(db), "%s/std.db", tmp); + ASSERT_EQ(dm_index(repo, db, NULL), 0); + /* a package of `module std` is imported by its bare directory path */ + static const go_want_edge_t edges[] = { + {"log.Print", "fmt.Println", "exact"}, + {"log.Print", "exec.Cmd", "exact"}, + {"log.doc.go.__file__", "exec.Cmd", "exact"}, + {"log.doc.go.__file__", "fmt.Println", "exact"}, + }; + ASSERT_EQ(go_bad_edges(db, edges, GO_COUNT(edges)), 0); + ASSERT_STR_EQ(go_reason(db, "src/log/log.go", "fmt.Missing"), "missing"); + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── the ship gate ───────────────────────────────────────────────── */ + +TEST(doc_mentions_go_ship_gate) { + char tmp[256]; + char db[512]; + cbm_doclink_test_set_ships(CBM_DOCLINK_GO_DOC_LINK, false); + int rc = go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)); + cbm_doclink_test_reset_ships(); /* production gates again for the tests after this one */ + dm_ship_held_families(); + ASSERT_EQ(rc, 0); + /* a resolved doc link is a row with the gate's reason and no edge */ + ASSERT_STR_EQ(go_tier(db, "alpha.New", "beta.Make"), ""); + ASSERT_STR_EQ(go_reason(db, "alpha/a.go", "beta.Make"), "below_bar_tier"); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges WHERE type = 'MENTIONS'"), 0); + /* one that does not resolve keeps its own reason; plain text stays nothing */ + ASSERT_STR_EQ(go_reason(db, "alpha/a.go", "beta.Nope"), "missing"); + ASSERT_STR_EQ(go_reason(db, "alpha/a.go", "Make"), ""); + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── incremental == full ─────────────────────────────────────────── */ + +#define GO_INC_A_HEAD "package a\n\nimport \"example.com/inc/b\"\n\n" +#define GO_INC_A_LOCAL "// Local is local.\nfunc Local() {}\n" +static const char GO_INC_A[] = GO_INC_A_HEAD + "// Use names [b.Target], [b.Gone], [b.T.M], [b.W.Name], [b.V.Name] and [Local].\n" + "func Use() {}\n\n" GO_INC_A_LOCAL; +static const char GO_INC_A_DOC_EDIT[] = + GO_INC_A_HEAD "// Use names [b.Target], [b.Gone], [b.T.M], [b.W.Name], [b.V.Name], [Local]\n" + "// and now [b.T].\n" + "func Use() {}\n\n" GO_INC_A_LOCAL; +static const char GO_INC_OTHER[] = "package a\n\n// Other names [b.Target].\nfunc Other() {}\n"; +static const char GO_INC_OTHER_IMPORT[] = + "package a\n\nimport \"example.com/inc/b\"\n\n// Other names [b.Target].\nfunc Other() {}\n"; +static const char GO_INC_DOC[] = "// Package a is documented. See [Local].\npackage a\n"; +static const char GO_INC_DOC_EDIT[] = + "// Package a is documented. See [Local] and [Use].\npackage a\n"; + +#define GO_INC_B_TARGET "// Target is the target.\nfunc Target() {}\n\n" +#define GO_INC_B_TARGET_BODY "// Target is the target.\nfunc Target() {\n\t_ = 1\n}\n\n" +#define GO_INC_B_RENAMED "// Renamed was the target.\nfunc Renamed() {}\n\n" +#define GO_INC_B_GONE "// Gone will go.\nfunc Gone() {}\n\n" +#define GO_INC_B_T "// T is a type.\ntype T struct{}\n\n" +#define GO_INC_B_TM "// M is a method of T.\nfunc (T) M() {}\n" +static const char GO_INC_B[] = "package b\n\n" GO_INC_B_TARGET GO_INC_B_GONE GO_INC_B_T GO_INC_B_TM; +static const char GO_INC_B_BODY[] = + "package b\n\n" GO_INC_B_TARGET_BODY GO_INC_B_GONE GO_INC_B_T GO_INC_B_TM; +static const char GO_INC_B_NO_GONE[] = "package b\n\n" GO_INC_B_TARGET_BODY GO_INC_B_T GO_INC_B_TM; +static const char GO_INC_B_NO_M[] = "package b\n\n" GO_INC_B_TARGET_BODY GO_INC_B_T; +static const char GO_INC_B_RENAME[] = "package b\n\n" GO_INC_B_RENAMED GO_INC_B_T; +static const char GO_INC_C[] = "package b\n\n// U is another type.\ntype U struct{}\n\n" + "// M is U's method; see [Target].\nfunc (U) M() {}\n"; +#define GO_INC_D_HEAD "package b\n\n// V is v.\ntype V struct{}\n\n// W is w.\ntype W struct{}\n\n" +#define GO_INC_D_V "// Name of V.\nfunc (V) Name() {}\n\n" +#define GO_INC_D_W "// Name of W.\nfunc (W) Name() {}\n\n" +static const char GO_INC_D[] = GO_INC_D_HEAD GO_INC_D_V GO_INC_D_W; +static const char GO_INC_D_SWAPPED[] = GO_INC_D_HEAD GO_INC_D_W GO_INC_D_V; + +TEST(doc_mentions_go_incremental_equals_full) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dm_goinc_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[400]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + go_w(repo, "go.mod", "module example.com/inc\n\ngo 1.22\n"); + go_w(repo, "a/a.go", GO_INC_A); + go_w(repo, "a/doc.go", GO_INC_DOC); + go_w(repo, "a/other.go", GO_INC_OTHER); + go_w(repo, "b/b.go", GO_INC_B); + go_w(repo, "b/c.go", GO_INC_C); + go_w(repo, "b/d.go", GO_INC_D); + char inc_db[512]; + char full_db[512]; + snprintf(inc_db, sizeof(inc_db), "%s/inc.db", tmp); + snprintf(full_db, sizeof(full_db), "%s/full.db", tmp); + ASSERT_EQ(dm_index(repo, inc_db, NULL), 0); + /* the preconditions the steps below move away from */ + static const go_want_edge_t before[] = { + {"a.Use", "b.Target", "exact"}, {"a.Use", "b.Gone", "exact"}, + {"a.Use", "b.M", "exact"}, /* T.M, in the first file of the directory, keeps b.M */ + {"a.Use", "b.Name", "exact"}, /* W.Name, the later declaration, keeps b.Name */ + {"a.Use", "a.Local", "unique"}, {"a.doc.go.__file__", "a.Local", "unique"}, + {"a.Other", "b.Target", ""}, /* other.go does not import b */ + }; + ASSERT_EQ(go_bad_edges(inc_db, before, GO_COUNT(before)), 0); + ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), "graph_gap"); + ASSERT_STR_EQ(go_reason(inc_db, "b/c.go", "Target"), "graph_gap"); /* U.M has no node */ + + /* a doc-comment edit re-extracts the file alone */ + go_w(repo, "a/a.go", GO_INC_A_DOC_EDIT); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "doc edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0); + ASSERT_STR_EQ(go_tier(inc_db, "a.Use", "b.T"), "exact"); + + /* a body edit in a file other files' references point into: its scope is + * unchanged, and the edges into it stand */ + go_w(repo, "b/b.go", GO_INC_B_BODY); + ASSERT_EQ( + dm_step(repo, inc_db, full_db, "target body edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), + 0); + ASSERT_STR_EQ(go_tier(inc_db, "a.Use", "b.Target"), "exact"); + + /* a file's imports are its own business: an import edit repairs the file */ + go_w(repo, "a/other.go", GO_INC_OTHER_IMPORT); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "import added", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), + 0); + ASSERT_STR_EQ(go_tier(inc_db, "a.Other", "b.Target"), "exact"); + + /* a reference added to the package comment: an edge from the File node */ + go_w(repo, "a/doc.go", GO_INC_DOC_EDIT); + ASSERT_EQ( + dm_step(repo, inc_db, full_db, "package comment", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0); + ASSERT_STR_EQ(go_tier(inc_db, "a.doc.go.__file__", "a.Use"), "unique"); + ASSERT_STR_EQ(go_tier(inc_db, "a.doc.go.__file__", "a.Local"), "unique"); + + /* a removed function: the files with an edge into the changed file + * re-resolve */ + go_w(repo, "b/b.go", GO_INC_B_NO_GONE); + ASSERT_EQ( + dm_step(repo, inc_db, full_db, "function removed", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), + 0); + ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.Gone"), "missing"); + + /* the method that kept a shared node name goes: the other file's method of + * that name takes the node over, and its doc becomes a source */ + go_w(repo, "b/b.go", GO_INC_B_NO_M); + ASSERT_EQ( + dm_step(repo, inc_db, full_db, "name keeper removed", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), + 0); + ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.T.M"), "missing"); + ASSERT_STR_EQ(go_tier(inc_db, "b.M", "b.Target"), "unique"); + ASSERT_STR_EQ(go_reason(inc_db, "b/c.go", "Target"), ""); + + /* declaration order decides which declaration keeps a shared name: + * swapping two methods moves the node, for files with no edge into it too */ + go_w(repo, "b/d.go", GO_INC_D_SWAPPED); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "methods swapped", CBM_INCREMENTAL_ROUTE_FORCED_FULL), + 0); + ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.W.Name"), "graph_gap"); + ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), ""); + + /* renaming a target */ + go_w(repo, "b/b.go", GO_INC_B_RENAME); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "rename", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0); + ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.Target"), "missing"); + ASSERT_STR_EQ(go_reason(inc_db, "a/other.go", "b.Target"), "missing"); + + /* a deleted file takes its declarations along */ + ASSERT_EQ(unlink(TH_PATH(repo, "b/d.go")), 0); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "delete file", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0); + ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), "missing"); + + /* the module path names every package of the repository */ + go_w(repo, "go.mod", "module example.com/moved\n\ngo 1.22\n"); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "module path", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0); + ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.T"), "external"); + + dm_unlink_db(inc_db); + dm_unlink_db(full_db); + th_rmtree(tmp); + PASS(); +} + +/* ── which scope changes are repairable file by file ─────────────── */ + +#define GO_DELTA_HEAD "package p\n\nimport \"example.com/m/q\"\n\n" +#define GO_DELTA_F "// F is.\nfunc F() {}\n\n" +#define GO_DELTA_G "// G is.\nfunc G() {}\n\n" +#define GO_DELTA_T "type T struct {\n\tA int\n\tB int\n}\n\n" +#define GO_DELTA_T_NO_B "type T struct {\n\tA int\n}\n\n" +#define GO_DELTA_M "func (T) M() {}\n\n" +#define GO_DELTA_I "type I interface {\n\tDo()\n\tUndo()\n}\n\n" +#define GO_DELTA_I_NO_UNDO "type I interface {\n\tDo()\n}\n\n" +#define GO_DELTA_C "const C = 1\n" +#define GO_DELTA_V "var V = 2\n" +#define GO_DELTA_REST GO_DELTA_T GO_DELTA_M GO_DELTA_I GO_DELTA_C GO_DELTA_V + +TEST(doc_mentions_go_scope_delta_rules) { + const char *base = GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_REST; + const char *twins = "package p\n\nfunc Open() {}\nfunc (T) Open() {}\n"; + const char *alias = "package p\n\nimport \"example.com/m/q\"\n\ntype X = q.Y\n"; + const struct { + const char *what; + const char *before; + const char *after; + int want; + const char *names; + } cases[] = { + {"unchanged", base, base, CBM_DOCLINK_DELTA_LOCAL, ""}, + /* nothing of the persisted scope carries a line number or a body */ + {"lines moved", base, "// moved\n\n\n" GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_REST, + CBM_DOCLINK_DELTA_LOCAL, ""}, + {"body and doc edited", base, + GO_DELTA_HEAD "// F is [G].\nfunc F() {\n\t_ = 1\n}\n\n" GO_DELTA_G GO_DELTA_REST, + CBM_DOCLINK_DELTA_LOCAL, ""}, + {"declarations reordered", base, GO_DELTA_HEAD GO_DELTA_G GO_DELTA_F GO_DELTA_REST, + CBM_DOCLINK_DELTA_LOCAL, ""}, + {"unexported declaration added", base, + GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G "func helper() {}\n\n" GO_DELTA_REST, + CBM_DOCLINK_DELTA_LOCAL, ""}, + /* the file's imports scope the file itself */ + {"import added", base, + "package p\n\nimport (\n\t\"example.com/m/q\"\n\t\"example.com/m/r\"\n)\n\n" GO_DELTA_F + GO_DELTA_G GO_DELTA_REST, + CBM_DOCLINK_DELTA_LOCAL, ""}, + {"import removed", base, "package p\n\n" GO_DELTA_F GO_DELTA_G GO_DELTA_REST, + CBM_DOCLINK_DELTA_LOCAL, ""}, + {"import renamed", base, + "package p\n\nimport qq \"example.com/m/q\"\n\n" GO_DELTA_F GO_DELTA_G GO_DELTA_REST, + CBM_DOCLINK_DELTA_LOCAL, ""}, + /* a declaration that only LEFT: its name is reported */ + {"function removed", base, GO_DELTA_HEAD GO_DELTA_F GO_DELTA_REST, CBM_DOCLINK_DELTA_LOCAL, + "G"}, + {"method removed", base, + GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_T GO_DELTA_I GO_DELTA_C GO_DELTA_V, + CBM_DOCLINK_DELTA_LOCAL, "M"}, + {"field removed", base, + GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_T_NO_B GO_DELTA_M GO_DELTA_I GO_DELTA_C + GO_DELTA_V, + CBM_DOCLINK_DELTA_LOCAL, "B"}, + {"interface method removed", base, + GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_T GO_DELTA_M GO_DELTA_I_NO_UNDO GO_DELTA_C + GO_DELTA_V, + CBM_DOCLINK_DELTA_LOCAL, "Undo"}, + {"constant removed", base, + GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_T GO_DELTA_M GO_DELTA_I GO_DELTA_V, + CBM_DOCLINK_DELTA_LOCAL, "C"}, + /* anything that appears or changes can re-route references in files + * with no edge into this one */ + {"function added", base, + GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G "func H() {}\n\n" GO_DELTA_REST, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"type removed", base, + GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_T GO_DELTA_M GO_DELTA_C GO_DELTA_V, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"type changes its kind", base, + GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_T GO_DELTA_M + "type I struct {\n\tDo int\n\tUndo int\n}\n\n" GO_DELTA_C GO_DELTA_V, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"package clause renamed", base, + "package other\n\nimport \"example.com/m/q\"\n\n" GO_DELTA_F GO_DELTA_G GO_DELTA_REST, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"build constraint added", base, + "//go:build linux\n\n" GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_REST, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"variable moved into a block (it loses its node)", base, + GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_T GO_DELTA_M GO_DELTA_I GO_DELTA_C + "var (\n\tV = 2\n)\n", + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + /* the declaration that keeps a shared node name changes */ + {"same-named declarations swapped", twins, + "package p\n\nfunc (T) Open() {}\nfunc Open() {}\n", CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"one of two same-named declarations removed", twins, "package p\n\nfunc Open() {}\n", + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + /* other files follow `type X = q.Y` through this file's imports */ + {"import changed under a qualified alias", alias, + "package p\n\nimport q \"example.com/m/other\"\n\ntype X = q.Y\n", + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + /* a file that appears or goes */ + {"file deleted", base, NULL, CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"file added", NULL, base, CBM_DOCLINK_DELTA_GLOBAL, NULL}, + }; + for (size_t i = 0; i < GO_COUNT(cases); i++) { + char names[256]; + int got = dm_scope_delta(CBM_LANG_GO, "p/x.go", cases[i].before, cases[i].after, names, + sizeof(names)); + bool names_ok = !cases[i].names || strcmp(names, cases[i].names) == 0; + if (got != cases[i].want || !names_ok) { + printf(" %s: delta %d names \"%s\", want %d \"%s\"\n", cases[i].what, got, names, + cases[i].want, cases[i].names ? cases[i].names : "(any)"); + FAIL("scope delta"); + } + } + /* go.mod: the module path is everybody's scope; the rest of the file is not */ + char names[256]; + ASSERT_EQ(dm_scope_delta(CBM_LANG_GOMOD, "go.mod", "module example.com/a\n", + "module example.com/b\n", names, sizeof(names)), + CBM_DOCLINK_DELTA_GLOBAL); + ASSERT_EQ(dm_scope_delta(CBM_LANG_GOMOD, "go.mod", "module example.com/a\n\ngo 1.21\n", + "module example.com/a\n\ngo 1.22\n\nrequire example.com/x v1.0.0\n", + names, sizeof(names)), + CBM_DOCLINK_DELTA_LOCAL); + PASS(); +} + +/* ── one worker == several workers ───────────────────────────────── */ + +TEST(doc_mentions_go_workers_agree) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dm_gopar_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[400]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + go_w(repo, "go.mod", "module example.com/par\n\ngo 1.22\n"); + enum { PKGS = 12, FILES = 6 }; /* 72 files: past the parallel threshold */ + for (int p = 0; p < PKGS; p++) { + for (int f = 0; f < FILES; f++) { + char rel[64]; + char src[2048]; + int next = (p + 1) % PKGS; + snprintf(rel, sizeof(rel), "p%02d/f%d.go", p, f); + snprintf(src, sizeof(src), + "package p%02d\n" + "\n" + "import \"example.com/par/p%02d\"\n" + "\n" + "// T%d is a type; see [F%d], [T%d.Close], [p%02d.T%d], [p%02d.T%d.Close],\n" + "// [p%02d.Gone], [fmt.Println], [Shared] and [Nothing].\n" + "type T%d struct{}\n" + "\n" + "// Close closes T%d; see [T%d].\n" + "func (T%d) Close() {}\n" + "\n" + "// F%d is a function; see [p%02d].\n" + "func F%d() {}\n" + "\n" + "// Shared is declared by every file of the package.\n" + "func Shared() {}\n", + p, next, f, (f + 1) % FILES, (f + 2) % FILES, next, f, next, (f + 3) % FILES, + next, f, f, f, f, f, next, f); + go_w(repo, rel, src); + } + } + char par_db[512]; + char seq_db[512]; + snprintf(par_db, sizeof(par_db), "%s/par.db", tmp); + snprintf(seq_db, sizeof(seq_db), "%s/seq.db", tmp); + ASSERT_EQ(dm_workers_agree(repo, par_db, seq_db), 0); + /* and the fixture exercises what it should: edges, and rows of more than + * one reason */ + ASSERT_STR_EQ(go_tier(par_db, "p00.T0", "p00.F1"), "unique"); + ASSERT_STR_EQ(go_tier(par_db, "p00.T0", "p01.T0"), "exact"); + ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "p01.Gone"), "missing"); + ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "fmt.Println"), "external"); + ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "p01.T3.Close"), "graph_gap"); + ASSERT_STR_EQ(go_reason(par_db, "p00/f3.go", "Shared"), "graph_gap"); + ASSERT_TRUE(dm_count(par_db, "SELECT COUNT(*) FROM edges WHERE type = 'MENTIONS'") > 100); + dm_unlink_db(par_db); + dm_unlink_db(seq_db); + th_rmtree(tmp); + PASS(); +} + +SUITE(doc_mentions_go) { + dm_ship_held_families(); + RUN_TEST(doc_mentions_go_tokens); + RUN_TEST(doc_mentions_go_sources); + RUN_TEST(doc_mentions_go_package_comment_tokens); + RUN_TEST(doc_mentions_go_text_model); + RUN_TEST(doc_mentions_go_own_parameter); + RUN_TEST(doc_mentions_go_hostile_doc); + RUN_TEST(doc_mentions_go_random_docs); + RUN_TEST(doc_mentions_go_random_scopes); + RUN_TEST(doc_mentions_go_scope_blob); + RUN_TEST(doc_mentions_go_forms); + RUN_TEST(doc_mentions_go_plain_text); + RUN_TEST(doc_mentions_go_struct_fields); + RUN_TEST(doc_mentions_go_file_only_package_lookup); + RUN_TEST(doc_mentions_go_local_rules); + RUN_TEST(doc_mentions_go_lookup_order); + RUN_TEST(doc_mentions_go_reasons); + RUN_TEST(doc_mentions_go_node_owner); + RUN_TEST(doc_mentions_go_package_comment); + RUN_TEST(doc_mentions_go_std_module); + RUN_TEST(doc_mentions_go_ship_gate); + RUN_TEST(doc_mentions_go_incremental_equals_full); + RUN_TEST(doc_mentions_go_scope_delta_rules); + RUN_TEST(doc_mentions_go_workers_agree); +} diff --git a/tests/test_doc_mentions_helpers.h b/tests/test_doc_mentions_helpers.h index 19714fad6..7fc75e1ad 100644 --- a/tests/test_doc_mentions_helpers.h +++ b/tests/test_doc_mentions_helpers.h @@ -430,9 +430,12 @@ static inline int dm_reason_lines(const char *db, const char *block) { * so it is the resolution an audit would ship; doc_links_md_ship_gate tests * the production gate itself. */ static const int DM_HELD_FAMILIES[] = { - CBM_DOCLINK_MD_BARE_PATH, CBM_DOCLINK_MD_SUPERSEDES_PROSE, CBM_DOCLINK_RST_INCLUDE, - CBM_DOCLINK_RST_KERNEL_DOC, CBM_DOCLINK_ADOC_ATTRIBUTE, CBM_DOCLINK_ADOC_CODE_PATH, - CBM_DOCLINK_ADOC_CODE_NAME, + CBM_DOCLINK_MD_BARE_PATH, CBM_DOCLINK_MD_SUPERSEDES_PROSE, CBM_DOCLINK_RST_INCLUDE, + CBM_DOCLINK_RST_KERNEL_DOC, CBM_DOCLINK_ADOC_ATTRIBUTE, CBM_DOCLINK_ADOC_CODE_PATH, + CBM_DOCLINK_ADOC_CODE_NAME, CBM_DOCLINK_TS_LINKCODE, CBM_DOCLINK_TS_SEE, + CBM_DOCLINK_DG_MERMAID_CLICK, CBM_DOCLINK_DG_PLANTUML_URL, CBM_DOCLINK_DG_PLANTUML_INCLUDE, + CBM_DOCLINK_DG_DRAWIO_LINK, CBM_DOCLINK_DG_EXCALIDRAW_LINK, CBM_DOCLINK_DG_DOT_URL, + CBM_DOCLINK_DG_D2_LINK, CBM_DOCLINK_OPENAPI_OPERATION_ID, }; static inline void dm_ship_held_families(void) { diff --git a/tests/test_doc_mentions_jvm.c b/tests/test_doc_mentions_jvm.c new file mode 100644 index 000000000..3fa1bc459 --- /dev/null +++ b/tests/test_doc_mentions_jvm.c @@ -0,0 +1,3669 @@ +/* + * test_doc_mentions_jvm.c — doc-comment references of Java and Kotlin + * (internal/cbm/doclink_jvm.c, src/pipeline/doc_links_jvm.c). + * + * Extraction is tested on source text (tokens, scope blobs, hostile input); + * resolution through the pipeline on fixture repositories, read back from + * the published database: every family and reason of both languages, the + * lookup order of each level by level with a decoy one level further out, + * references across the two languages, test code, parse errors, the scope + * changes an incremental run repairs file by file, incremental == full and + * one worker == several. + */ +#include "test_framework.h" +#include "test_doc_mentions_helpers.h" + +/* ── extraction: tokens ──────────────────────────────────────────── */ + +/* The token written as `raw`, or a stand-in that fails every check. */ +static const CBMDocLink *jt_token(const CBMFileResult *r, const char *raw) { + static const CBMDocLink none = {.raw = "", .syntax = CBM_DOCLINK_NONE}; + const CBMDocLink *l = dm_find_token(r, raw); + return l ? l : &none; +} + +TEST(doc_mentions_jvm_java_tokens) { + const char *src = + "package a;\n" /* 1 */ + "\n" /* 2 */ + "/**\n" /* 3 */ + " * See {@link Gadget} and {@linkplain Gadget#spin() spin it},\n" /* 4 */ + " * {@link Helper#run(int, String) label text}, {@value #MAX}.\n" /* 5 */ + " * A tag over two lines {@link\n" /* 6 */ + " * Wrapped#name}. {@code {@link InCode}} {@literal {@link InLiteral}}\n" /* 7 */ + " * \n" /* 8 */ + " * {@linkage NotATag} {@link Open\n" /* 9 */ + " * @see Gadget#spin()\n" /* 10 */ + " * @see site\n" /* 11 */ + " * @see \"The Book\"\n" /* 12 */ + " * @throws IllegalStateException never\n" /* 13 */ + " * @exception a.CustomError worse\n" /* 14 */ + " * @param the type {@link T}\n" /* 15 */ + " * @return {@value} nothing, {@value \"%x\" Helper#MASK}\n" /* 16 */ + " */\n" /* 17 */ + "public class Widget {\n" /* 18 */ + " /** Max {@value}. {@link Widget#MAX} */\n" /* 19 */ + " public static final int MAX = 10;\n" /* 20 */ + " /**\n" /* 21 */ + " *
{@code\n"                                                    /* 22 */
+        "     * @Override {@link InSample}\n"                                     /* 23 */
+        "     * }
after {@link AfterSample}\n" /* 24 */ + " */\n" /* 25 */ + " void sample() {}\n" /* 26 */ + "}\n"; + CBMFileResult *r = dm_extract(src, CBM_LANG_JAVA, "a/Widget.java"); + ASSERT_NOT_NULL(r); + /* every family, with the line the reference is written on */ + const CBMDocLink *t = jt_token(r, "Gadget"); + ASSERT_EQ(t->syntax, CBM_DOCLINK_JAVA_LINK); + ASSERT_EQ(t->line, 4); + ASSERT_EQ(t->def_line, 18); + ASSERT_STR_EQ(t->source_qn, "p.a.Widget"); + ASSERT_EQ(dm_count_tokens(r, "Gadget#spin()"), 2); + ASSERT_EQ(jt_token(r, "Gadget#spin()")->syntax, CBM_DOCLINK_JAVA_LINKPLAIN); + /* the label is not part of the reference; blanks inside it are one */ + ASSERT_EQ(jt_token(r, "Helper#run(int, String)")->line, 5); + ASSERT_EQ(jt_token(r, "#MAX")->syntax, CBM_DOCLINK_JAVA_VALUE); + ASSERT_EQ(jt_token(r, "Wrapped#name")->line, 7); + ASSERT_EQ(jt_token(r, "IllegalStateException")->syntax, CBM_DOCLINK_JAVA_THROWS); + ASSERT_EQ(jt_token(r, "a.CustomError")->syntax, CBM_DOCLINK_JAVA_THROWS); + ASSERT_EQ(jt_token(r, "a.CustomError")->line, 14); + ASSERT_EQ(jt_token(r, "https://example.org/x")->syntax, CBM_DOCLINK_HREF); + ASSERT_EQ(jt_token(r, "https://example.org/x")->line, 11); + /* a format string is not the reference of {@value} */ + ASSERT_EQ(jt_token(r, "Helper#MASK")->syntax, CBM_DOCLINK_JAVA_VALUE); + /* a reference to the definition's own type parameter is still a token: + * the resolver, which knows the scope, calls it local */ + ASSERT_EQ(jt_token(r, "T")->line, 15); + /* not references: code, literal, HTML comment, CDATA, another tag whose + * name only starts like one, a quoted title, a bare {@value} */ + ASSERT_NULL(dm_find_token(r, "InCode")); + ASSERT_NULL(dm_find_token(r, "InLiteral")); + ASSERT_NULL(dm_find_token(r, "InComment")); + ASSERT_NULL(dm_find_token(r, "InCdata")); + ASSERT_NULL(dm_find_token(r, "NotATag")); + ASSERT_NULL(dm_find_token(r, "\"The")); + /* an inline tag that a block tag cuts off never closed: javadoc reports + * it, and it links nothing */ + ASSERT_NULL(dm_find_token(r, "Open")); + /* the field's doc is harvested once, on the Field (its package-level + * Variable twin carries the same doc) */ + ASSERT_EQ(dm_count_tokens(r, "Widget#MAX"), 1); + ASSERT_STR_EQ(jt_token(r, "Widget#MAX")->source_qn, "p.a.Widget.MAX"); + /* `@` at the start of a line inside {@code} is sample code, not a block + * tag, and what follows inside the block is still code */ + ASSERT_NULL(dm_find_token(r, "InSample")); + ASSERT_EQ(jt_token(r, "AfterSample")->line, 24); + cbm_free_result(r); + PASS(); +} + +TEST(doc_mentions_jvm_kotlin_tokens) { + const char *src = + "package k\n" /* 1 */ + "\n" /* 2 */ + "/**\n" /* 3 */ + " * A [Gadget], [the helper][Helper.run], [text](https://x.org), `[InCode]`.\n" /* 4 */ + " * ``double `tick` [InDouble]`` then [Outer.Inner] and [`quoted name`].\n" /* 5 */ + " * [label]: https://example.org\n" /* 6 */ + " * ![image][ImgLabel] \\[Escaped]\n" /* 7 */ + " * Nested /* comment [StillDoc] */ goes on to [AfterNested].\n" /* 8 */ + " *\n" /* 9 */ + " * [IndentedCode]\n" /* 10 */ + " *\n" /* 11 */ + " * ```\n" /* 12 */ + " * [Fenced]\n" /* 13 */ + " * ```\n" /* 14 */ + " * @param name the [name] of [T]\n" /* 15 */ + " * @property size the size\n" /* 16 */ + " * @see Gadget.spin\n" /* 17 */ + " * @see https://example.org\n" /* 18 */ + " * @throws IllegalStateException never\n" /* 19 */ + " * @exception CustomError worse\n" /* 20 */ + " * @sample k.samples.widgetSample\n" /* 21 */ + " * @return [Result] of [0] and [two words]\n" /* 22 */ + " */\n" /* 23 */ + "class Widget(val name: String, size: Int)\n"; /* 24 */ + CBMFileResult *r = dm_extract(src, CBM_LANG_KOTLIN, "k/Widget.kt"); + ASSERT_NOT_NULL(r); + const CBMDocLink *t = jt_token(r, "Gadget"); + ASSERT_EQ(t->syntax, CBM_DOCLINK_KT_KDOC_LINK); + ASSERT_EQ(t->line, 4); + ASSERT_EQ(t->def_line, 24); + ASSERT_STR_EQ(t->source_qn, "p.k.Widget.Widget"); + /* [text][Name]: the second bracket names the code */ + ASSERT_EQ(jt_token(r, "Helper.run")->syntax, CBM_DOCLINK_KT_KDOC_LINK); + ASSERT_NULL(dm_find_token(r, "the helper")); + ASSERT_EQ(jt_token(r, "Outer.Inner")->line, 5); + ASSERT_EQ(jt_token(r, "`quoted name`")->line, 5); + /* a block comment inside the doc is part of its text: Kotlin comments + * nest, and the doc goes on after it */ + ASSERT_EQ(jt_token(r, "StillDoc")->line, 8); + ASSERT_EQ(jt_token(r, "AfterNested")->line, 8); + ASSERT_EQ(jt_token(r, "Gadget.spin")->syntax, CBM_DOCLINK_KT_SEE); + ASSERT_EQ(jt_token(r, "IllegalStateException")->syntax, CBM_DOCLINK_KT_THROWS); + ASSERT_EQ(jt_token(r, "CustomError")->syntax, CBM_DOCLINK_KT_THROWS); + ASSERT_EQ(jt_token(r, "CustomError")->line, 20); + ASSERT_EQ(jt_token(r, "k.samples.widgetSample")->syntax, CBM_DOCLINK_KT_SAMPLE); + ASSERT_EQ(jt_token(r, "Result")->line, 22); + /* own parameter and type parameter: tokens, local to the resolver */ + ASSERT_EQ(jt_token(r, "name")->line, 15); + ASSERT_EQ(jt_token(r, "T")->line, 15); + /* not references: a Markdown link, code spans, a link definition, an + * HTML comment, an image's label, an escaped bracket, an indented and a + * fenced code block, a URL, and brackets that hold no name */ + ASSERT_NULL(dm_find_token(r, "text")); + ASSERT_NULL(dm_find_token(r, "InCode")); + ASSERT_NULL(dm_find_token(r, "InDouble")); + ASSERT_NULL(dm_find_token(r, "label")); + ASSERT_NULL(dm_find_token(r, "InComment")); + ASSERT_NULL(dm_find_token(r, "image")); + ASSERT_NULL(dm_find_token(r, "ImgLabel")); + ASSERT_NULL(dm_find_token(r, "Escaped")); + ASSERT_NULL(dm_find_token(r, "IndentedCode")); + ASSERT_NULL(dm_find_token(r, "Fenced")); + ASSERT_NULL(dm_find_token(r, "https://example.org")); + ASSERT_NULL(dm_find_token(r, "0")); + ASSERT_NULL(dm_find_token(r, "two words")); + ASSERT_NULL(dm_find_token(r, "size")); + cbm_free_result(r); + PASS(); +} + +/* ── extraction: hostile input ───────────────────────────────────── */ + +enum { JT_BIG = 200000, JT_MEGA = 1100000 }; + +/* Extract `head` + `count` copies of `unit` + `tail` as one doc comment on a + * class; the result, or NULL when memory ran out. */ +static CBMFileResult *jt_extract_repeated(CBMLanguage lang, const char *head, const char *unit, + int count, const char *tail) { + size_t ul = strlen(unit); + size_t cap = strlen(head) + (ul * (size_t)count) + strlen(tail) + 64; + char *src = malloc(cap); + if (!src) { + return NULL; + } + size_t w = (size_t)snprintf(src, cap, "/**\n * %s", head); + for (int i = 0; i < count; i++) { + memcpy(src + w, unit, ul); + w += ul; + } + snprintf(src + w, cap - w, "%s\n */\n%s", tail, + lang == CBM_LANG_JAVA ? "public class C {}\n" : "class C\n"); + CBMFileResult *r = dm_extract(src, lang, lang == CBM_LANG_JAVA ? "C.java" : "C.kt"); + free(src); + return r; +} + +TEST(doc_mentions_jvm_malformed_and_oversized) { + /* a reference longer than any name (here: over a megabyte) is kept just + * past the limit, which is how the resolver knows it was cut: it never + * resolves a prefix */ + CBMFileResult *r = jt_extract_repeated(CBM_LANG_JAVA, "{@link ", "A", JT_MEGA, "}"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 1); + ASSERT_GT(strlen(r->doc_links.items[0].raw), (size_t)CBM_DOCLINK_JVM_REF_MAX); + ASSERT(strlen(r->doc_links.items[0].raw) < (size_t)CBM_DOCLINK_JVM_REF_MAX + 16); + cbm_free_result(r); + r = jt_extract_repeated(CBM_LANG_KOTLIN, "[", "A", JT_MEGA, "]"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 1); + ASSERT_GT(strlen(r->doc_links.items[0].raw), (size_t)CBM_DOCLINK_JVM_REF_MAX); + ASSERT(strlen(r->doc_links.items[0].raw) < (size_t)CBM_DOCLINK_JVM_REF_MAX + 16); + cbm_free_result(r); + /* tags that never close, nested deeper than any text nests */ + r = jt_extract_repeated(CBM_LANG_JAVA, "", "{@link A ", JT_BIG, ""); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 0); + cbm_free_result(r); + /* ... and closed again: the innermost ones are references, no more than + * the parser keeps open at once, and nothing overflows */ + r = jt_extract_repeated(CBM_LANG_JAVA, "", "{@link A ", 100, "}}}}}}}}}}"); + ASSERT_NOT_NULL(r); + ASSERT(r->doc_links.count <= 10); + cbm_free_result(r); + /* brackets, backticks and comment openers by the hundred thousand on one + * line: one pass, no token */ + r = jt_extract_repeated(CBM_LANG_KOTLIN, "", "[", JT_BIG, ""); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 0); + cbm_free_result(r); + r = jt_extract_repeated(CBM_LANG_KOTLIN, "", "` `` ", JT_BIG, " [After]"); + ASSERT_NOT_NULL(r); + cbm_free_result(r); + r = jt_extract_repeated(CBM_LANG_KOTLIN, "[Before] ", "\n" /* 22 */ + "/// [lbl]: defined::Target\n" /* 23 */ + "/// [unused]: https://example.org/unused\n" /* 24 */ + "pub struct Documented;\n" /* 25 */ + "\n" /* 26 */ + "//// [FourSlashes]\n" /* 27 */ + "// [PlainComment]\n" /* 28 */ + "pub struct NotDocumented;\n" /* 29 */ + "\n" /* 30 */ + "/** Block doc with [`InBlock`]\n" /* 31 */ + " * and a second line [second::Line].\n" /* 32 */ + " */\n" /* 33 */ + "pub fn block_documented() {}\n"; /* 34 */ + CBMFileResult *r = dm_extract(src, CBM_LANG_RUST, "src/lib.rs"); + ASSERT_NOT_NULL(r); + + /* the file's own doc: marked, with the real line of every doc line (the + * attribute between them is no doc line) */ + const CBMDocLink *alpha = rs_token_at(r, "Alpha", 1); + ASSERT_NOT_NULL(alpha); + ASSERT_EQ(alpha->syntax, CBM_DOCLINK_RS_INTRA_DOC); + ASSERT_TRUE((alpha->flags & CBM_DOCLINK_FLAG_FILE) != 0); + const CBMDocLink *gamma = rs_token_at(r, "beta::Gamma", 3); + ASSERT_NOT_NULL(gamma); + ASSERT_TRUE((gamma->flags & CBM_DOCLINK_FLAG_FILE) != 0); + + /* an item's doc: inline, shortcut (backticks go), plain shortcut */ + const CBMDocLink *inl = rs_token_at(r, "alpha::Path", 5); + ASSERT_NOT_NULL(inl); + ASSERT_EQ(inl->syntax, CBM_DOCLINK_RS_INTRA_DOC); + ASSERT_EQ(inl->flags & CBM_DOCLINK_FLAG_FILE, 0); + ASSERT_STR_EQ(inl->source_qn, "p.src.lib.Documented"); + ASSERT_EQ(inl->def_line, 25); + ASSERT_NOT_NULL(rs_token_at(r, "Short", 5)); + ASSERT_NOT_NULL(rs_token_at(r, "plain::path", 5)); + ASSERT_EQ(rs_tokens_on(r, 5), 3); + + /* collapsed; a label with a definition takes the definition's destination */ + ASSERT_NOT_NULL(rs_token_at(r, "Coll", 6)); + const CBMDocLink *def = rs_token_at(r, "defined::Target", 6); + ASSERT_NOT_NULL(def); + ASSERT_EQ(def->syntax, CBM_DOCLINK_RS_LINK_DEF); + ASSERT_EQ(dm_count_tokens(r, "defined::Target"), 2); /* [see this][lbl] and [lbl] */ + ASSERT_EQ(rs_tokens_on(r, 6), 3); + + /* a URL is the core's href family; a relative file and a page anchor are + * not harvested */ + const CBMDocLink *url = rs_token_at(r, "https://example.org/a", 7); + ASSERT_NOT_NULL(url); + ASSERT_EQ(url->syntax, CBM_DOCLINK_HREF); + ASSERT_EQ(rs_tokens_on(r, 7), 1); + + /* the fragment is cut; an image and a footnote are no links */ + ASSERT_NOT_NULL(rs_token_at(r, "Frag", 8)); + ASSERT_EQ(rs_tokens_on(r, 8), 1); + + /* a code span, escaped brackets and prose hold no link */ + ASSERT_EQ(rs_tokens_on(r, 9), 0); + + /* disambiguators, suffixes and generics stay in the token */ + ASSERT_NOT_NULL(rs_token_at(r, "struct@Dis", 10)); + ASSERT_NOT_NULL(rs_token_at(r, "call()", 10)); + ASSERT_NOT_NULL(rs_token_at(r, "mac!", 10)); + ASSERT_NOT_NULL(rs_token_at(r, "Vec", 10)); + + /* task markers and an admonition are no links */ + ASSERT_EQ(rs_tokens_on(r, 11), 0); + ASSERT_EQ(rs_tokens_on(r, 12), 0); + ASSERT_EQ(rs_tokens_on(r, 13), 0); + + /* backticks make rustdoc keep what it would otherwise drop as prose; of + * nested brackets the inner pair is the link */ + ASSERT_NOT_NULL(rs_token_at(r, "weird(x)", 14)); + ASSERT_NOT_NULL(rs_token_at(r, "Inner", 14)); + ASSERT_EQ(rs_tokens_on(r, 14), 2); + + /* code blocks, an HTML comment and the definitions themselves */ + ASSERT_EQ(rs_tokens_on(r, 17), 0); + ASSERT_EQ(rs_tokens_on(r, 20), 0); + ASSERT_EQ(rs_tokens_on(r, 22), 0); + ASSERT_EQ(rs_tokens_on(r, 23), 0); + ASSERT_EQ(rs_tokens_on(r, 24), 0); + + /* `////` and `//` are comments, not documentation */ + ASSERT_NULL(dm_find_token(r, "FourSlashes")); + ASSERT_NULL(dm_find_token(r, "PlainComment")); + + /* a block doc, its decoration removed */ + const CBMDocLink *blk = rs_token_at(r, "InBlock", 31); + ASSERT_NOT_NULL(blk); + ASSERT_STR_EQ(blk->source_qn, "p.src.lib.block_documented"); + ASSERT_NOT_NULL(rs_token_at(r, "second::Line", 32)); + cbm_free_result(r); + PASS(); +} + +/* The doc of the first item is the item's, never the file's. */ +TEST(doc_mentions_rs_item_doc_is_not_file_doc) { + const char *src = "/// Outer doc of the first item: [`Target`].\n" + "pub struct First;\n"; + CBMFileResult *r = dm_extract(src, CBM_LANG_RUST, "src/first.rs"); + ASSERT_NOT_NULL(r); + const CBMDocLink *t = dm_find_token(r, "Target"); + ASSERT_NOT_NULL(t); + ASSERT_EQ(t->flags & CBM_DOCLINK_FLAG_FILE, 0); + ASSERT_STR_EQ(t->source_qn, "p.src.first.First"); + ASSERT_EQ(r->doc_links.count, 1); + cbm_free_result(r); + PASS(); +} + +/* Text nobody controls: nothing here may crash, hang or grow without bound. */ +TEST(doc_mentions_rs_untrusted_text) { + enum { DEEP = 20000, LONG = 1 << 20, LABEL = 700 }; + /* a megabyte destination, a megabyte label, a megabyte of opening brackets, + * a megabyte of backticks, and a label longer than a token carries */ + char *big = malloc((size_t)LONG * 4 + 4096); + ASSERT_NOT_NULL(big); + size_t w = (size_t)snprintf(big, 64, "/// [dest]("); + memset(big + w, 'a', LONG); + w += LONG; + w += (size_t)snprintf(big + w, 64, ")\n/// [`"); + memset(big + w, 'b', LONG); + w += LONG; + w += (size_t)snprintf(big + w, 64, "`]\n/// "); + memset(big + w, '[', LONG); + w += LONG; + w += (size_t)snprintf(big + w, 64, "\n/// ticks "); /* not at the line start: no fence */ + memset(big + w, '`', LONG); + w += LONG; + w += (size_t)snprintf(big + w, 64, "\n///\n/// [`"); + memset(big + w, 'c', LABEL); + w += LABEL; + snprintf(big + w, 64, "`]\npub struct Big;\n"); + CBMFileResult *r = dm_extract(big, CBM_LANG_RUST, "src/big.rs"); + ASSERT_NOT_NULL(r); + /* an over-long destination is one token, cut and marked: it can never + * parse, so no truncated prefix is ever resolved. A label of more than + * 999 characters is no link label at all (CommonMark). */ + ASSERT_EQ(r->doc_links.count, 2); + for (int i = 0; i < r->doc_links.count; i++) { + const char *raw = r->doc_links.items[i].raw; + size_t rl = strlen(raw); + CBMDocLinkRsPath path; + ASSERT_TRUE(rl < CBM_DOCLINK_RS_RAW_CAP); + ASSERT_TRUE(raw[0] == (i == 0 ? 'a' : 'c')); + ASSERT_STR_EQ(raw + rl - 3, "..."); + ASSERT_EQ(cbm_doclink_rs_parse_path(raw, rl, false, &path), CBM_DOCLINK_RS_PATH_MALFORMED); + } + cbm_free_result(r); + + /* deeply nested links, lists and block quotes */ + size_t pos = (size_t)snprintf(big, 64, "/// "); + for (int i = 0; i < DEEP; i++) { + big[pos++] = '['; + } + pos += (size_t)snprintf(big + pos, 64, "`Core`"); + for (int i = 0; i < DEEP; i++) { + big[pos++] = ']'; + } + pos += (size_t)snprintf(big + pos, 64, "\n/// "); + for (int i = 0; i < DEEP; i++) { + big[pos++] = '>'; + } + pos += (size_t)snprintf(big + pos, 64, " [`Quoted`]\n/// "); + for (int i = 0; i < DEEP; i++) { + big[pos++] = '-'; + big[pos++] = ' '; + } + snprintf(big + pos, 64, "[`Listed`]\npub struct Nested;\n"); + r = dm_extract(big, CBM_LANG_RUST, "src/nested.rs"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(dm_find_token(r, "Core")); + cbm_free_result(r); + + /* bytes that are no UTF-8, and a NUL inside the doc */ + const char bad[] = "/// [`Caf\xC3`] [`\xFF\xFE`] [`Ok`]\n/// [`Af\0ter`]\npub struct Bytes;\n"; + r = cbm_extract_file(bad, (int)sizeof(bad) - 1, CBM_LANG_RUST, "p", "src/bytes.rs", 0, NULL, + NULL); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(dm_find_token(r, "Ok")); + cbm_free_result(r); + free(big); + PASS(); +} + +/* ── extraction: the path grammar (rustdoc's preprocess_link) ────── */ + +TEST(doc_mentions_rs_parse_path) { + /* where the grammar decides what is harvested: text in plain brackets + * that can be no path is prose (no token); the same text in backticks, + * and a path that is broken, is a link rustdoc reports (a token, which + * the resolver turns into an `unparseable` row) */ + CBMFileResult *r = dm_extract("/// [a+b] [`c+d`] [bogus@Foo] [Vec<] [x y] [`x y`]\n" + "pub struct Grammar;\n", + CBM_LANG_RUST, "src/g.rs"); + ASSERT_NOT_NULL(r); + ASSERT_NULL(dm_find_token(r, "a+b")); + ASSERT_NOT_NULL(dm_find_token(r, "c+d")); + ASSERT_NOT_NULL(dm_find_token(r, "bogus@Foo")); + ASSERT_NOT_NULL(dm_find_token(r, "Vec<")); + ASSERT_NULL(dm_find_token(r, "x y")); + ASSERT_EQ(r->doc_links.count, 3); + cbm_free_result(r); + + struct { + const char *raw; + bool lenient; + CBMDocLinkRsPathStatus want; + int nsegs; + const char *last; /* last segment */ + int dis; + bool global; + } cases[] = { + {"Foo", false, CBM_DOCLINK_RS_PATH_OK, 1, "Foo", CBM_DOCLINK_RS_DIS_NONE, false}, + {"a::b::C", false, CBM_DOCLINK_RS_PATH_OK, 3, "C", CBM_DOCLINK_RS_DIS_NONE, false}, + {"::std::fmt", false, CBM_DOCLINK_RS_PATH_OK, 2, "fmt", CBM_DOCLINK_RS_DIS_NONE, true}, + {" crate::x ", false, CBM_DOCLINK_RS_PATH_OK, 2, "x", CBM_DOCLINK_RS_DIS_NONE, false}, + /* generics are stripped, as rustdoc does */ + {"Vec::new", false, CBM_DOCLINK_RS_PATH_OK, 2, "new", CBM_DOCLINK_RS_DIS_NONE, false}, + {"HashMap", false, CBM_DOCLINK_RS_PATH_OK, 1, "HashMap", CBM_DOCLINK_RS_DIS_NONE, + false}, + /* disambiguators and suffixes */ + {"struct@Foo", false, CBM_DOCLINK_RS_PATH_OK, 1, "Foo", CBM_DOCLINK_RS_DIS_STRUCT, false}, + {"fn@a::run", false, CBM_DOCLINK_RS_PATH_OK, 2, "run", CBM_DOCLINK_RS_DIS_FN, false}, + {"method@T::m", false, CBM_DOCLINK_RS_PATH_OK, 2, "m", CBM_DOCLINK_RS_DIS_FN, false}, + {"mod@x", false, CBM_DOCLINK_RS_PATH_OK, 1, "x", CBM_DOCLINK_RS_DIS_MOD, false}, + {"prim@str", false, CBM_DOCLINK_RS_PATH_OK, 1, "str", CBM_DOCLINK_RS_DIS_PRIM, false}, + {"type@X", false, CBM_DOCLINK_RS_PATH_OK, 1, "X", CBM_DOCLINK_RS_DIS_TYPE_NS, false}, + {"run()", false, CBM_DOCLINK_RS_PATH_OK, 1, "run", CBM_DOCLINK_RS_DIS_FN, false}, + {"vec!", false, CBM_DOCLINK_RS_PATH_OK, 1, "vec", CBM_DOCLINK_RS_DIS_MACRO_BANG, false}, + {"vec![]", false, CBM_DOCLINK_RS_PATH_OK, 1, "vec", CBM_DOCLINK_RS_DIS_MACRO_BANG, false}, + /* a raw identifier, a tuple field, a name that is no ASCII */ + {"r#type", false, CBM_DOCLINK_RS_PATH_OK, 1, "r#type", CBM_DOCLINK_RS_DIS_NONE, false}, + {"Pair::0", false, CBM_DOCLINK_RS_PATH_OK, 2, "0", CBM_DOCLINK_RS_DIS_NONE, false}, + {"Gr\xC3\xB6\xC3\x9F" + "e", + false, CBM_DOCLINK_RS_PATH_OK, 1, + "Gr\xC3\xB6\xC3\x9F" + "e", + CBM_DOCLINK_RS_DIS_NONE, false}, + /* rustdoc's spellings of primitive types that are no identifiers */ + {"!", false, CBM_DOCLINK_RS_PATH_OK, 1, "!", CBM_DOCLINK_RS_DIS_NONE, false}, + {"&mut", false, CBM_DOCLINK_RS_PATH_OK, 1, "&mut", CBM_DOCLINK_RS_DIS_NONE, false}, + {"*const", false, CBM_DOCLINK_RS_PATH_OK, 1, "*const", CBM_DOCLINK_RS_DIS_NONE, false}, + /* prose: no link at all */ + {"two words", true, CBM_DOCLINK_RS_PATH_NOT_LINK, 0, NULL, 0, false}, + {"a+b", true, CBM_DOCLINK_RS_PATH_NOT_LINK, 0, NULL, 0, false}, + {"!NOTE", false, CBM_DOCLINK_RS_PATH_NOT_LINK, 0, NULL, 0, false}, + {"", false, CBM_DOCLINK_RS_PATH_NOT_LINK, 0, NULL, 0, false}, + /* a link, but no path */ + {"a+b", false, CBM_DOCLINK_RS_PATH_MALFORMED, 0, NULL, 0, false}, + {"weird(x)", false, CBM_DOCLINK_RS_PATH_MALFORMED, 0, NULL, 0, false}, + {"Vec<", false, CBM_DOCLINK_RS_PATH_MALFORMED, 0, NULL, 0, false}, + {"a:b", false, CBM_DOCLINK_RS_PATH_MALFORMED, 0, NULL, 0, false}, + {"a::", false, CBM_DOCLINK_RS_PATH_MALFORMED, 0, NULL, 0, false}, + {"bogus@Foo", false, CBM_DOCLINK_RS_PATH_MALFORMED, 0, NULL, 0, false}, + {"::m", false, CBM_DOCLINK_RS_PATH_MALFORMED, 0, NULL, 0, false}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + CBMDocLinkRsPath p; + CBMDocLinkRsPathStatus got = + cbm_doclink_rs_parse_path(cases[i].raw, strlen(cases[i].raw), cases[i].lenient, &p); + bool ok = got == cases[i].want; + if (ok && got == CBM_DOCLINK_RS_PATH_OK) { + ok = p.nsegs == cases[i].nsegs && p.dis == cases[i].dis && + p.global == cases[i].global && strcmp(p.segs[p.nsegs - 1], cases[i].last) == 0; + } + if (!ok) { + printf(" [%s]: status %d segs %d dis %d\n", cases[i].raw, (int)got, p.nsegs, p.dis); + FAIL("path grammar"); + } + } + /* more segments than are carried, and a segment longer than is carried */ + char many[256] = "a"; + for (int i = 0; i < CBM_DOCLINK_RS_MAX_SEGS; i++) { + strcat(many, "::a"); + } + CBMDocLinkRsPath p; + ASSERT_EQ(cbm_doclink_rs_parse_path(many, strlen(many), false, &p), + CBM_DOCLINK_RS_PATH_MALFORMED); + char wide[CBM_DOCLINK_RS_SEG_CAP + 8]; + memset(wide, 'w', sizeof(wide) - 1); + wide[sizeof(wide) - 1] = '\0'; + ASSERT_EQ(cbm_doclink_rs_parse_path(wide, strlen(wide), false, &p), + CBM_DOCLINK_RS_PATH_MALFORMED); + PASS(); +} + +/* ── extraction: the scope blob ──────────────────────────────────── */ + +/* True when the blob holds exactly this line. */ +static bool rs_blob_has(const char *blob, const char *line) { + size_t n = strlen(line); + for (const char *p = blob; p && *p;) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len == n && memcmp(p, line, n) == 0) { + return true; + } + p = nl ? nl + 1 : NULL; + } + return false; +} + +TEST(doc_mentions_rs_scope_blob) { + const char *src = "use std::fmt::{self, Debug as Dbg};\n" /* 1 */ + "pub use crate::shapes::*;\n" /* 2 */ + "extern crate alloc as heap;\n" /* 3 */ + "mod shapes;\n" /* 4 */ + "#[cfg(test)]\n" /* 5 */ + "mod checks;\n" /* 6 */ + "#[path = \"odd/place.rs\"]\n" /* 7 */ + "pub(crate) mod odd;\n" /* 8 */ + "/// A widget: [`Gadget`], [`size`], [`T`].\n" /* 9 */ + "#[derive(Clone, serde::Serialize)]\n" /* 10 */ + "pub struct Widget {\n" /* 11 */ + " pub size: u32,\n" /* 12 */ + " inner: T,\n" /* 13 */ + "}\n" /* 14 */ + "pub enum Kind { Round, Flat }\n" /* 15 */ + "pub struct Pair(pub u32, pub u32);\n" /* 16 */ + "pub trait Draw {\n" /* 17 */ + " const SIDES: u32;\n" /* 18 */ + " type Out;\n" /* 19 */ + " fn draw(&self);\n" /* 20 */ + "}\n" /* 21 */ + "impl Widget {\n" /* 22 */ + " pub const UNIT: u32 = 1;\n" /* 23 */ + " /// Resize by [`factor`].\n" /* 24 */ + " pub fn resize(&mut self, factor: u32) {}\n" /* 25 */ + "}\n" /* 26 */ + "impl Draw for Widget {\n" /* 27 */ + " const SIDES: u32 = 0;\n" /* 28 */ + " type Out = ();\n" /* 29 */ + " fn draw(&self) {}\n" /* 30 */ + "}\n" /* 31 */ + "impl Draw for T {\n" /* 32 */ + " const SIDES: u32 = 0;\n" /* 33 */ + " type Out = ();\n" /* 34 */ + " fn draw(&self) {}\n" /* 35 */ + "}\n" /* 36 */ + "pub type Alias = shapes::Circle;\n" /* 37 */ + "pub const LIMIT: u32 = 3;\n" /* 38 */ + "pub static NAME: &str = \"\";\n" /* 39 */ + "pub(super) fn helper() {}\n" /* 40 */ + "#[macro_export]\n" /* 41 */ + "macro_rules! declare {\n" /* 42 */ + " ($n:ident) => { pub struct Knob; pub struct $n; };\n" /* 43 */ + "}\n" /* 44 */ + "declare!(Dial);\n" /* 45 */ + "lazy_static! { pub static ref TABLE: u32 = 0; }\n" /* 46 */ + "#[cfg(test)]\n" /* 47 */ + "mod tests {\n" /* 48 */ + " use super::*;\n" /* 49 */ + " fn helper() {}\n" /* 50 */ + "}\n"; /* 51 */ + CBMFileResult *r = dm_extract(src, CBM_LANG_RUST, "src/lib.rs"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(r->doc_scope); + const char *b = r->doc_scope; + ASSERT_TRUE(strncmp(b, CBM_DOCLINK_RS_SCOPE_TAG "\n", 4) == 0); + /* imports: a `self` import, a renamed one, a glob, an extern crate */ + ASSERT_TRUE(rs_blob_has(b, "U\t0\t-\ts\tfmt\tstd::fmt")); + ASSERT_TRUE(rs_blob_has(b, "U\t0\t-\tb\tDbg\tstd::fmt::Debug")); + ASSERT_TRUE(rs_blob_has(b, "U\t0\tp\tg\t-\tcrate::shapes")); + ASSERT_TRUE(rs_blob_has(b, "U\t0\t-\tx\theap\talloc")); + /* `mod x;`: plain, test-only, with a path */ + ASSERT_TRUE(rs_blob_has(b, "D\t0\t-\tshapes\t-")); + ASSERT_TRUE(rs_blob_has(b, "D\t0\t-t\tchecks\t-")); + ASSERT_TRUE(rs_blob_has(b, "D\t0\tc\todd\todd/place.rs")); + /* items with their node, members with theirs, derive names */ + ASSERT_TRUE(rs_blob_has(b, "T\t0\t11\t14\tp\tS\tWidget\tS=\tClone,Serialize")); + ASSERT_TRUE(rs_blob_has(b, "M\t12\td\tsize\tD:Widget.size")); + ASSERT_TRUE(rs_blob_has(b, "M\t13\td\tinner\tD:Widget.inner")); + ASSERT_TRUE(rs_blob_has(b, "T\t0\t15\t15\tp\tE\tKind\tE=\t-")); + ASSERT_TRUE(rs_blob_has(b, "M\t15\tv\tRound\t!")); /* a variant has no node */ + ASSERT_TRUE(rs_blob_has(b, "M\t0\td\t0\t!")); /* nor has a tuple field */ + ASSERT_TRUE(rs_blob_has(b, "T\t0\t17\t21\tp\tR\tDraw\tI=\t-")); + ASSERT_TRUE(rs_blob_has(b, "M\t19\tt\tOut\t!")); + ASSERT_TRUE(rs_blob_has(b, "M\t20\tf\tdraw\tM:Draw.draw")); + /* impls: inherent, of a trait, and for no named type */ + ASSERT_TRUE(rs_blob_has(b, "I\t0\t22\t26\tWidget\t-")); + ASSERT_TRUE(rs_blob_has(b, "M\t23\tc\tUNIT\t!")); /* an impl's constant has no node */ + ASSERT_TRUE(rs_blob_has(b, "M\t25\tf\tresize\tM:Widget.resize")); + ASSERT_TRUE(rs_blob_has(b, "I\t0\t27\t31\tWidget\tDraw")); + ASSERT_TRUE(rs_blob_has(b, "I\t0\t32\t36\t?\tDraw")); + ASSERT_TRUE(rs_blob_has(b, "T\t0\t37\t37\tp\tA\tAlias\tT=\tshapes::Circle")); + ASSERT_TRUE(rs_blob_has(b, "T\t0\t38\t38\tp\tC\tLIMIT\tV=\t-")); + ASSERT_TRUE(rs_blob_has(b, "T\t0\t39\t39\tp\tG\tNAME\tV=\t-")); + /* the module-level helper lost its node to the one in `mod tests` (the + * extractor flattens inline modules) */ + ASSERT_TRUE(rs_blob_has(b, "T\t0\t40\t40\ts\tF\thelper\t!\t-")); + ASSERT_TRUE(rs_blob_has(b, "T\t1\t50\t50\t-\tF\thelper\tF=\t-")); + ASSERT_TRUE(rs_blob_has(b, "N\t1\t0\t48\t51\t-t\ttests")); + ASSERT_TRUE(rs_blob_has(b, "U\t1\t-\tg\t-\tsuper")); + /* macros: an exported definition and what its body declares by name (the + * metavariable is no name); the invocation; the name written at it; a + * `static ref` of a macro body */ + ASSERT_TRUE(rs_blob_has(b, "T\t0\t42\t44\t-e\tM\tdeclare\t!\t-")); + ASSERT_TRUE(rs_blob_has(b, "W\tdeclare\tKnob")); + ASSERT_TRUE(rs_blob_has(b, "V\t0\tdeclare")); + ASSERT_TRUE(rs_blob_has(b, "Q\t0\tDial")); + ASSERT_TRUE(rs_blob_has(b, "V\t0\tlazy_static")); + ASSERT_TRUE(rs_blob_has(b, "Q\t0\tTABLE")); + /* the names local to a documented definition that carries references */ + ASSERT_TRUE(rs_blob_has(b, "P\t11\tT")); + ASSERT_TRUE(rs_blob_has(b, "P\t25\tT,factor")); + ASSERT_FALSE(rs_blob_has(b, "B")); + + /* the persisted form: no line numbers, no per-definition records */ + char *portable = cbm_doclink_portable_scope(b); + ASSERT_NOT_NULL(portable); + ASSERT_TRUE(rs_blob_has(portable, "T\t0\t0\t0\tp\tS\tWidget\tS=\tClone,Serialize")); + ASSERT_TRUE(rs_blob_has(portable, "M\t0\tf\tresize\tM:Widget.resize")); + ASSERT_TRUE(rs_blob_has(portable, "N\t1\t0\t0\t0\t-t\ttests")); + ASSERT_NULL(strstr(portable, "\nP\t")); + ASSERT_NULL(strstr(portable, "\nZ\t")); + cbm_free(CBM_MEM_CLASS_OTHER, portable); + cbm_free_result(r); + PASS(); +} + +/* A parse error: what follows it has no scope, unless the error stayed inside + * a function body with its delimiters paired. */ +TEST(doc_mentions_rs_scope_parse_errors) { + /* syntax the grammar does not know, inside a body: the items around it + * are where the tree puts them */ + const char *contained = "pub fn odd() {\n" + " let x = 1 @@ 2;\n" + "}\n" + "pub struct After;\n"; + CBMFileResult *r = dm_extract(contained, CBM_LANG_RUST, "src/a.rs"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(r->doc_scope); + ASSERT_TRUE(rs_blob_has(r->doc_scope, "T\t0\t4\t4\tp\tS\tAfter\tS=\t-")); + ASSERT_FALSE(rs_blob_has(r->doc_scope, "B")); + ASSERT_NULL(strstr(r->doc_scope, "\nE\t")); + cbm_free_result(r); + + /* an unpaired brace in a body can move every later item boundary: the + * parser gave up on the whole file, and nothing of it is trusted */ + const char *open = "pub struct Before;\n" + "pub fn half() {\n" + " if true {\n" + "}\n" + "pub struct Lost;\n"; + r = dm_extract(open, CBM_LANG_RUST, "src/b.rs"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(r->doc_scope); + ASSERT_TRUE(rs_blob_has(r->doc_scope, "E\t1")); + ASSERT_TRUE(rs_blob_has(r->doc_scope, "B")); + ASSERT_NULL(strstr(r->doc_scope, "\nT\t")); + cbm_free_result(r); + + /* an error between items: what stands before it keeps its scope, what + * follows is only a name the file may declare somewhere */ + const char *between = "pub struct One;\n" + "pub struct {\n" + "pub struct Two;\n"; + r = dm_extract(between, CBM_LANG_RUST, "src/c.rs"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(r->doc_scope); + ASSERT_TRUE(rs_blob_has(r->doc_scope, "T\t0\t1\t1\tp\tS\tOne\tS=\t-")); + ASSERT_TRUE(rs_blob_has(r->doc_scope, "E\t2")); + ASSERT_TRUE(rs_blob_has(r->doc_scope, "Q\t*\tTwo")); + ASSERT_TRUE(rs_blob_has(r->doc_scope, "B")); + cbm_free_result(r); + PASS(); +} + +TEST(doc_mentions_rs_manifest_blob) { + const char *toml = "[package]\n" + "name = \"acme-core\"\n" + "edition.workspace = true\n" + "build = \"gen/build.rs\"\n" + "autobins = false\n" + "\n" + "[lib]\n" + "name = \"acme\"\n" + "path = \"lib/root.rs\"\n" + "\n" + "[[bin]]\n" + "name = \"tool\"\n" + "path = \"cli/tool.rs\"\n" + "\n" + "[dependencies]\n" + "serde = \"1\"\n" + "util = { path = \"../util\" }\n" + "alias = { package = \"real-name\", path = \"../real\" }\n" + "shared.workspace = true\n" + "\n" + "[dev-dependencies.helper]\n" + "path = \"../helper\"\n" + "\n" + "[target.'cfg(unix)'.dependencies]\n" + "nix = \"0.29\"\n" + "\n" + "[workspace]\n" + "members = [\"crates/*\"]\n" + "\n" + "[workspace.package]\n" + "edition = \"2021\"\n" + "\n" + "[workspace.dependencies]\n" + "shared = { path = \"crates/shared\" }\n"; + CBMFileResult *r = dm_extract(toml, CBM_LANG_TOML, "crates/core/Cargo.toml"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(r->doc_scope); + const char *b = r->doc_scope; + ASSERT_TRUE(strncmp(b, CBM_DOCLINK_RS_SCOPE_TAG "\nC\n", 6) == 0); + ASSERT_TRUE(rs_blob_has(b, "K\tpackage\tname\tacme-core")); + ASSERT_TRUE(rs_blob_has(b, "K\tpackage\tedition.workspace\ttrue")); + ASSERT_TRUE(rs_blob_has(b, "K\tpackage\tbuild\tgen/build.rs")); + ASSERT_TRUE(rs_blob_has(b, "K\tpackage\tautobins\tfalse")); + ASSERT_TRUE(rs_blob_has(b, "K\tlib\tname\tacme")); + ASSERT_TRUE(rs_blob_has(b, "K\tlib\tpath\tlib/root.rs")); + ASSERT_TRUE(rs_blob_has(b, "A\tbin")); + ASSERT_TRUE(rs_blob_has(b, "a\tpath\tcli/tool.rs")); + ASSERT_TRUE(rs_blob_has(b, "X\td\tserde\t-\t-")); + ASSERT_TRUE(rs_blob_has(b, "X\td\tutil\tpath\t../util")); + ASSERT_TRUE(rs_blob_has(b, "X\td\talias\tpackage\treal-name")); + ASSERT_TRUE(rs_blob_has(b, "X\td\talias\tpath\t../real")); + ASSERT_TRUE(rs_blob_has(b, "X\td\tshared\tworkspace\ttrue")); + ASSERT_TRUE(rs_blob_has(b, "X\tv\thelper\tpath\t../helper")); + ASSERT_TRUE(rs_blob_has(b, "X\td\tnix\t-\t-")); + ASSERT_TRUE(rs_blob_has(b, "W")); + ASSERT_TRUE(rs_blob_has(b, "K\tworkspace.package\tedition\t2021")); + ASSERT_TRUE(rs_blob_has(b, "X\tw\tshared\tpath\tcrates/shared")); + cbm_free_result(r); + + /* any other TOML file has no scope */ + r = dm_extract("[package]\nname = \"x\"\n", CBM_LANG_TOML, "config/settings.toml"); + ASSERT_NOT_NULL(r); + ASSERT_NULL(r->doc_scope); + cbm_free_result(r); + PASS(); +} + +/* ── resolution: the fixture workspace ───────────────────────────── */ + +typedef struct { + const char *path; + const char *text; +} rs_fixture_file_t; + +static void rs_write_files(const char *root, const rs_fixture_file_t *files, size_t n) { + for (size_t i = 0; i < n; i++) { + th_write_file(TH_PATH(root, files[i].path), files[i].text); + } +} + +/* A workspace of five packages. `core` is the one under test; `util` and + * `extra` are path dependencies of it; `twin` has a lib and two binaries that + * share a module; `old` is an edition-2015 crate. */ +static void rs_write_workspace(const char *root) { + static const rs_fixture_file_t files[] = { + {"Cargo.toml", + "[workspace]\n" + "members = [\"crates/core\", \"crates/util\", \"crates/extra\", \"crates/twin\",\n" + " \"crates/old\"]\n" + "\n" + "[workspace.package]\n" + "edition = \"2021\"\n" + "\n" + "[workspace.dependencies]\n" + "util = { path = \"crates/util\" }\n"}, + + /* util: a path dependency, inherited through the workspace table */ + {"crates/util/Cargo.toml", "[package]\nname = \"util\"\nedition = \"2021\"\n"}, + {"crates/util/src/lib.rs", "//! Utilities.\n" + "mod inner;\n" + "pub use crate::inner::Hidden as Shown;\n" + "/// A tool.\n" + "pub struct Tool;\n" + "impl Tool {\n" + " /// Runs.\n" + " pub fn run(&self) {}\n" + "}\n"}, + {"crates/util/src/inner.rs", "/// Hidden away.\npub struct Hidden;\n"}, + + /* extra: its lib target is named unlike its package */ + {"crates/extra/Cargo.toml", "[package]\nname = \"acme-extra\"\nedition = \"2021\"\n" + "\n[lib]\nname = \"extra_lib\"\n"}, + {"crates/extra/src/lib.rs", "/// Extra.\npub struct Extra;\n"}, + + {"crates/core/Cargo.toml", "[package]\n" + "name = \"acme-core\"\n" + "edition.workspace = true\n" + "\n" + "[dependencies]\n" + "util.workspace = true\n" + "serde = \"1\"\n" + "acme-extra = { path = \"../extra\" }\n" + "Option = { package = \"acme-extra\", path = \"../extra\" }\n"}, + {"crates/core/src/lib.rs", + "//! Crate doc: [`Gadget`] and [shapes::Circle], also [the tool][tool].\n" /* 1 */ + "//! Rotten: [`NoSuchThing`].\n" /* 2 */ + "//!\n" + "//! [tool]: util::Tool\n" + "\n" + "pub mod shapes;\n" + "pub mod deep;\n" + "pub mod fake;\n" + "pub mod shadow;\n" + "pub mod levels;\n" + "pub mod char;\n" + "pub mod first;\n" + "pub mod chain;\n" + "pub mod strict;\n" + "pub mod broken;\n" + "#[cfg(test)]\n" + "mod tests_support;\n" + "#[path = \"elsewhere/odd.rs\"]\n" + "pub mod odd;\n" + "pub mod outer_inline {\n" + " pub mod leaf;\n" + " /// In an inline module.\n" + " pub struct InInline;\n" + "}\n" + "#[cfg(unix)]\n" + "#[path = \"imp_unix.rs\"]\n" + "mod imp;\n" + "#[cfg(windows)]\n" + "#[path = \"imp_win.rs\"]\n" + "mod imp;\n" + "\n" + "use fake::*;\n" + "pub use shapes::Circle;\n" + "use shapes::Circle as Disc;\n" + "use util::Tool as Hammer;\n" + "pub use paint_derive::Paintable;\n" + "\n" + "/// A gadget.\n" + "pub struct Gadget;\n" + "/// Own shape (the glob brings another one).\n" + "pub struct Shape;\n" + "/// A name that is no ASCII.\n" + "pub struct Gr\xC3\xB6\xC3\x9F" + "e;\n" + "/// A trait named like a derive macro.\n" + "pub trait Paintable {}\n" + "/// Both a function and a macro.\n" + "pub fn dual() {}\n" + "#[macro_export]\n" + "macro_rules! dual {\n" + " () => {};\n" + "}\n" + "/// Unix.\n" + "#[cfg(unix)]\n" + "pub fn plat() {}\n" + "/// Windows.\n" + "#[cfg(windows)]\n" + "pub fn plat() {}\n" + "/// One of two declarations under cfg.\n" + "#[cfg(unix)]\n" + "pub struct Twin;\n" + "/// The other.\n" + "#[cfg(not(unix))]\n" + "pub struct Twin;\n" + "/// Only for tests.\n" + "#[cfg(test)]\n" + "pub fn only_in_tests() {}\n" + "macro_rules! declare_knob {\n" + " () => {\n" + " pub struct Knob;\n" + " };\n" + "}\n" + "declare_knob!();\n" + "define_things! {\n" + " pub struct Made;\n" + "}\n" + "\n" + "/// [`Gadget`]\n" + "pub struct SrcShortcut;\n" + "/// [the circle](shapes::Circle)\n" + "pub struct SrcInline;\n" + "/// [`Circle`][]\n" + "pub struct SrcCollapsed;\n" + "/// [the hammer][ham]\n" + "///\n" + "/// [ham]: Hammer\n" + "pub struct SrcLinkDef;\n" + "/// [`Disc`]\n" + "pub struct SrcRenamed;\n" + "/// [site](https://example.org/docs) and [guide](../guide.md)\n" + "pub struct SrcUrl;\n" + "/// [`Shape`]\n" + "pub struct SrcOwnOverGlob;\n" + "/// [`OnlyInFake`] [`CrateWide`] [`Secret`]\n" + "pub struct SrcGlob;\n" + "/// [util::Tool::run] [extra_lib::Extra] [Option::Extra] [serde::Serialize]\n" + "/// [std::fmt::Debug] [util::Shown]\n" + "pub struct SrcExtern;\n" + "/// [`String`] [`u8`] [str::len] [`char`] [prim@char] [mod@char] [prim@Gadget]\n" + "pub struct SrcStd;\n" + "/// [crate::shapes::Kind] [self::Gadget] [crate]\n" + "pub struct SrcKeywords;\n" + "/// [Circle::area] [Circle::area()] [Circle::radius] [Circle::UNIT]\n" + "/// [shapes::Kind::Round] [shapes::Draw::draw] [Circle::draw] [shapes::Ring::area]\n" + "/// [shapes::Ring] [shapes::Pair] [shapes::Pair()] [shapes::Pair::0] [shapes::Bits]\n" + "/// [Circle::no_such] [Circle::clone] [shapes::Lamp::glow]\n" + "pub struct SrcMembers;\n" + "/// [`dual`] [`dual()`] [`dual!`] [fn@dual] [macro@dual]\n" + "pub struct SrcNamespaces;\n" + "/// [struct@Gadget] [enum@Gadget] [fn@Gadget] [type@Gadget] [`Paintable`] [`plat`]\n" + "/// [`Twin`]\n" + "pub struct SrcKinds;\n" + "/// [`NoSuchName`] [shapes::NoSuch] [`Gr\xC3\xB6\xC3\x9F" + "e`] [`\xC3\x9C" + "bel`]\n" + "/// [outer_inline] [outer_inline::InInline] [`Knob`] [`Made`]\n" + "/// [tests_support::Fixture] [`only_in_tests`] [`Circle::area(x)`] [Circle#a#b]\n" + "/// [imp::Thing]\n" + "pub struct SrcReasons;\n" + "/// [deep::pit::Hole] [shapes::round::Ball] [odd::Odd] [outer_inline::leaf::Leaf]\n" + "pub struct SrcModules;\n" + "/// [`count`] and [`T`] and [`Gadget`]\n" + "pub fn src_local(count: u32) {}\n"}, + {"crates/core/src/shapes.rs", + "pub mod round;\n" + "/// A circle. [`Self`] [`Self::area`]\n" + "#[derive(Clone)]\n" + "pub struct Circle {\n" + " /// The radius; see [`Circle::area`].\n" + " pub radius: f64,\n" + "}\n" + "impl Circle {\n" + " /// The unit.\n" + " pub const UNIT: f64 = 1.0;\n" + " /// Area from [`Self::radius`]; see [`Draw::draw`], [`Circle::draw`],\n" + " /// [`Circle::outline`], [`Self::UNIT`].\n" + " pub fn area(&self) -> f64 {\n" + " 0.0\n" + " }\n" + "}\n" + "/// Kinds.\n" + "pub enum Kind {\n" + " Round,\n" + " Flat,\n" + "}\n" + "/// Draws.\n" + "pub trait Draw {\n" + " /// Draw; see [`Self::outline`].\n" + " fn draw(&self);\n" + " /// Outline.\n" + " fn outline(&self) {}\n" + "}\n" + "impl Draw for Circle {\n" + " /// Draws a circle: [`Self::area`].\n" + " fn draw(&self) {}\n" + "}\n" + "/// Alias.\n" + "pub type Ring = Circle;\n" + "/// Tuple.\n" + "pub struct Pair(pub u32, pub u32);\n" + "/// Bits.\n" + "pub union Bits {\n" + " pub a: u32,\n" + " pub b: f32,\n" + "}\n" + "/// A field and a method of one name: the method is what a path names.\n" + "pub struct Lamp {\n" + " pub glow: u32,\n" + "}\n" + "impl Lamp {\n" + " /// Glows.\n" + " pub fn glow(&self) -> u32 {\n" + " self.glow\n" + " }\n" + "}\n"}, + {"crates/core/src/shapes/round.rs", + "/// A ball: [super::Circle] [super::super::Gadget] [`super::Kind`]\n" + "pub struct Ball;\n"}, + {"crates/core/src/deep/mod.rs", "pub mod pit;\n"}, + {"crates/core/src/deep/pit.rs", "/// A hole.\npub struct Hole;\n"}, + {"crates/core/src/fake.rs", "/// Decoy shape.\n" + "pub struct Shape;\n" + "/// Only here.\n" + "pub struct OnlyInFake;\n" + "/// Crate wide.\n" + "pub(crate) struct CrateWide;\n" + "struct Secret;\n"}, + {"crates/core/src/shadow.rs", "pub mod util;\n"}, + {"crates/core/src/shadow/util.rs", "/// The shadowing tool.\npub struct Tool;\n"}, + {"crates/core/src/levels.rs", "use crate::shadow::*;\n" + "/// Own Vec.\n" + "pub struct Vec;\n" + "/// [util::Tool] [`Vec`]\n" + "pub struct LevelGlob;\n"}, + {"crates/core/src/char.rs", "/// In a module named like a primitive type.\n" + "pub struct Unit;\n"}, + {"crates/core/src/first.rs", "/// [`crate::Gadget`]\npub struct First;\n"}, + {"crates/core/src/chain.rs", "mod a {\n" + " /// Deep.\n" + " pub struct Deep;\n" + "}\n" + "mod b {\n" + " pub use super::a::Deep as Mid;\n" + "}\n" + "pub use self::b::Mid as Top;\n" + "mod g1 {\n" + " pub use super::g2::*;\n" + "}\n" + "mod g2 {\n" + " /// Via globs.\n" + " pub struct ViaGlobs;\n" + "}\n" + "use self::g1::*;\n" + "mod c1 {\n" + " pub use super::c2::Loop;\n" + "}\n" + "mod c2 {\n" + " pub use super::c1::Loop;\n" + "}\n" + "/// [`Top`] [`ViaGlobs`] [c1::Loop]\n" + "pub struct ChainUser;\n"}, + {"crates/core/src/strict.rs", "use serde::*;\n" + "use util::Tool as Wrench;\n" + "/// [util::Tool] [`Wrench`]\n" + "pub struct StrictUser;\n"}, + {"crates/core/src/broken.rs", "/// Before the error: [`crate::Gadget`] [`Vec`]\n" + "pub struct Whole;\n" + "pub struct {\n" + "/// After the error: [`crate::Shape`]\n" + "pub struct Cut;\n"}, + /* declared by no `mod`: an orphan */ + {"crates/core/src/orphan.rs", "/// [crate::Gadget] [`String`] [serde::Serialize]\n" + "pub struct Orphan;\n"}, + {"crates/core/src/tests_support.rs", + "/// A fixture: [`crate::Gadget`] [`super::only_in_tests`]\n" + "pub struct Fixture;\n"}, + {"crates/core/src/elsewhere/odd.rs", "/// Odd.\npub struct Odd;\n"}, + {"crates/core/src/outer_inline/leaf.rs", "/// Leaf.\npub struct Leaf;\n"}, + {"crates/core/src/imp_unix.rs", "/// Unix thing.\npub struct Thing;\n"}, + {"crates/core/src/imp_win.rs", "/// Windows thing.\npub struct Thing;\n"}, + {"crates/core/tests/it.rs", "/// [acme_core::Gadget] [`acme_core::shapes::Circle::area`]\n" + "fn it_works() {}\n"}, + + /* twin: one module file under two crate roots, and a second binary */ + {"crates/twin/Cargo.toml", "[package]\nname = \"twin\"\nedition = \"2021\"\n"}, + {"crates/twin/src/lib.rs", "mod common;\n" + "/// Only in the lib.\n" + "pub struct Only;\n" + "/// In both roots.\n" + "pub struct Both;\n"}, + {"crates/twin/src/main.rs", "mod common;\n" + "/// In both roots.\n" + "pub struct Both;\n" + "fn main() {}\n"}, + {"crates/twin/src/common.rs", "/// [crate::Only] [crate::Both]\npub struct Shared;\n"}, + {"crates/twin/src/bin/tool.rs", "/// [twin::Only]\npub fn run_tool() {}\n"}, + + /* old: no edition key means 2015, where a `use` path starts at the root */ + {"crates/old/Cargo.toml", "[package]\nname = \"old\"\n"}, + {"crates/old/src/lib.rs", "mod a;\nmod b;\n"}, + {"crates/old/src/a.rs", "/// Thing.\npub struct Thing;\n"}, + {"crates/old/src/b.rs", "use a::Thing;\n/// [`Thing`]\npub struct UsesThing;\n"}, + }; + rs_write_files(root, files, sizeof(files) / sizeof(files[0])); +} + +/* One edge from the definition ending in `src` to the one ending in `tgt`, + * with every fragment of `want` (separated by '|') in its properties. */ +static bool rs_edge_is(const char *db, const char *src, const char *tgt, const char *want) { + char props[512]; + char frags[256]; + int n = 0; + dm_edge(db, src, tgt, props, sizeof(props), &n); + if (n != 1) { + printf(" edge %s -> %s: %d found\n", src, tgt, n); + return false; + } + snprintf(frags, sizeof(frags), "%s", want); + for (char *f = strtok(frags, "|"); f; f = strtok(NULL, "|")) { + if (!strstr(props, f)) { + printf(" edge %s -> %s: %s lacks %s\n", src, tgt, props, f); + return false; + } + } + return true; +} + +static bool rs_no_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); + if (n != 0) { + printf(" edge %s -> %s: %d found, none expected\n", src, tgt, n); + } + return n == 0; +} + +/* The unresolved row of `raw` in `rel` has this reason ("" for no row). */ +static bool rs_row_is(const char *db, const char *rel, const char *raw, const char *want) { + char reason[64]; + dm_row(db, rel, raw, reason, sizeof(reason), NULL, 0); + if (strcmp(reason, want) != 0) { + printf(" row %s [%s]: \"%s\", expected \"%s\"\n", rel, raw, reason, want); + return false; + } + return true; +} + +#define RS_SYNTAX_INTRA "\"syntax\":\"intra_doc\"" +#define RS_SYNTAX_DEF "\"syntax\":\"link_def\"" +#define RS_EXACT "\"tier\":\"exact\"" +#define RS_UNIQUE "\"tier\":\"unique\"" + +/* One indexed copy of the workspace for the resolver tests. */ +typedef struct { + char tmp[256]; + char db[512]; +} rs_ws_t; + +static bool rs_ws_open(rs_ws_t *ws) { + snprintf(ws->tmp, sizeof(ws->tmp), "/tmp/cbm_dmrs_XXXXXX"); + if (!cbm_mkdtemp(ws->tmp)) { + return false; + } + rs_write_workspace(ws->tmp); + snprintf(ws->db, sizeof(ws->db), "%s/ws.db", ws->tmp); + return dm_index(ws->tmp, ws->db, NULL) == 0; +} + +static void rs_ws_close(rs_ws_t *ws) { + dm_unlink_db(ws->db); + th_rmtree(ws->tmp); +} + +static const char RS_LIB[] = "crates/core/src/lib.rs"; + +/* The two families, both tiers, and what is no reference at all. */ +TEST(doc_mentions_rs_families_and_tiers) { + rs_ws_t ws; + ASSERT_TRUE(rs_ws_open(&ws)); + const char *db = ws.db; + char syntax[64]; + char reason[64]; + /* intra_doc: the destination is written at the link */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcShortcut", "lib.Gadget", + "\"via\":\"doc_comment\"|" RS_SYNTAX_INTRA "|" RS_UNIQUE + "|\"count\":1")); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcInline", "shapes.Circle", RS_SYNTAX_INTRA "|" RS_EXACT)); + /* a name bound by a named import is an exact naming */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcCollapsed", "shapes.Circle", RS_SYNTAX_INTRA "|" RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcRenamed", "shapes.Circle", RS_EXACT)); + /* link_def: the destination comes from a link reference definition */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcLinkDef", "util.src.lib.Tool", RS_SYNTAX_DEF "|" RS_EXACT)); + /* a URL is the core's href family; a relative file is not harvested */ + dm_row(db, RS_LIB, "https://example.org/docs", reason, sizeof(reason), syntax, sizeof(syntax)); + ASSERT_STR_EQ(reason, "external"); + ASSERT_STR_EQ(syntax, "href"); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "../guide.md", "")); + ASSERT_EQ(dm_mentions_from(db, "lib.SrcUrl"), 0); + /* the definition's own parameter and type parameter: no edge, no row */ + ASSERT_TRUE(rs_edge_is(db, "lib.src_local", "lib.Gadget", RS_UNIQUE)); + ASSERT_EQ(dm_mentions_from(db, "lib.src_local"), 1); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "count", "")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "T", "")); + rs_ws_close(&ws); + PASS(); +} + +/* A file's own doc: the File node is the source. */ +TEST(doc_mentions_rs_file_level_docs) { + rs_ws_t ws; + ASSERT_TRUE(rs_ws_open(&ws)); + const char *db = ws.db; + const char *file = "core.src.lib.rs.__file__"; + ASSERT_TRUE( + rs_edge_is(db, file, "core.src.lib.Gadget", RS_SYNTAX_INTRA "|" RS_UNIQUE "|\"line\":1")); + ASSERT_TRUE(rs_edge_is(db, file, "shapes.Circle", RS_SYNTAX_INTRA "|" RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, file, "util.src.lib.Tool", RS_SYNTAX_DEF "|" RS_EXACT)); + ASSERT_EQ(dm_mentions_from(db, file), 3); + /* an unresolved one: a row with the file's path and the reference's line */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "NoSuchThing", "missing")); + ASSERT_EQ(dm_count(db, "SELECT line FROM doc_link_unresolved WHERE raw = 'NoSuchThing'"), 2); + /* the outer doc of a file's first item belongs to the item */ + ASSERT_TRUE(rs_edge_is(db, "first.First", "core.src.lib.Gadget", RS_EXACT)); + ASSERT_EQ(dm_mentions_from(db, "first.rs.__file__"), 0); + rs_ws_close(&ws); + PASS(); +} + +/* The lookup levels, each with a decoy one level further out. */ +TEST(doc_mentions_rs_lookup_levels) { + rs_ws_t ws; + ASSERT_TRUE(rs_ws_open(&ws)); + const char *db = ws.db; + /* level 1 before level 2: the module's own item, not the glob's */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcOwnOverGlob", "core.src.lib.Shape", RS_UNIQUE)); + ASSERT_TRUE(rs_no_edge(db, "lib.SrcOwnOverGlob", "fake.Shape")); + /* level 2: a glob import brings in what the importing module can see */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcGlob", "fake.OnlyInFake", RS_UNIQUE)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcGlob", "fake.CrateWide", RS_UNIQUE)); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "Secret", "missing")); /* private to `fake` */ + /* level 2 before level 3: the module a glob brings in, not the crate of + * the extern prelude named like it */ + ASSERT_TRUE(rs_edge_is(db, "levels.LevelGlob", "shadow.util.Tool", RS_EXACT)); + ASSERT_TRUE(rs_no_edge(db, "levels.LevelGlob", "util.src.lib.Tool")); + /* level 1 before level 4: the module's own `Vec`, not the prelude's */ + ASSERT_TRUE(rs_edge_is(db, "levels.LevelGlob", "levels.Vec", RS_UNIQUE)); + /* level 3: a path dependency (through the workspace table), a lib target + * named unlike its package, and a re-export of the dependency */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcExtern", "util.src.lib.Tool.run", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcExtern", "extra.src.lib.Extra", RS_EXACT "|\"count\":2")); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcExtern", "util.src.inner.Hidden", RS_EXACT)); + /* level 3 before level 4: a dependency renamed `Option` is that crate, + * not the prelude's type (the second of the two references above) */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "Option::Extra", "")); + /* a dependency without a path, and the standard library */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "serde::Serialize", "external")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "std::fmt::Debug", "external")); + /* level 4: the std prelude and the primitive types */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "String", "external")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "u8", "external")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "str::len", "external")); + /* a module named like a primitive type: rustdoc wants a disambiguator */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "char", "ambiguous")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "prim@char", "external")); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcStd", "core.src.char", RS_UNIQUE)); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "prim@Gadget", "missing")); + /* a doc-link path does not look past a glob of something outside the + * repository (its names would silently win); an import path does */ + ASSERT_TRUE(rs_row_is(db, "crates/core/src/strict.rs", "util::Tool", "external")); + ASSERT_TRUE(rs_edge_is(db, "strict.StrictUser", "util.src.lib.Tool", RS_EXACT)); + rs_ws_close(&ws); + PASS(); +} + +/* `crate`, `self`, `super`, `Self`, and what a path walks through. */ +TEST(doc_mentions_rs_paths_and_modules) { + rs_ws_t ws; + ASSERT_TRUE(rs_ws_open(&ws)); + const char *db = ws.db; + ASSERT_TRUE(rs_edge_is(db, "lib.SrcKeywords", "shapes.Kind", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcKeywords", "core.src.lib.Gadget", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcKeywords", "core.src.lib", RS_EXACT)); /* the root module */ + /* `super`, twice: the file that declares the module, then the crate root */ + ASSERT_TRUE(rs_edge_is(db, "round.Ball", "shapes.Circle", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "round.Ball", "core.src.lib.Gadget", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "round.Ball", "shapes.Kind", RS_EXACT)); + /* `Self`: the documented type (a reference to itself is no edge), the + * impl's self type, the trait */ + ASSERT_TRUE(rs_edge_is(db, "shapes.Circle", "shapes.Circle.area", RS_EXACT)); + ASSERT_TRUE(rs_no_edge(db, "shapes.Circle", "shapes.Circle")); + ASSERT_TRUE(rs_row_is(db, "crates/core/src/shapes.rs", "Self", "")); + ASSERT_TRUE(rs_edge_is(db, "shapes.Circle.area", "shapes.Circle.radius", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "shapes.Draw.draw", "shapes.Draw.outline", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "shapes.Circle.draw", "shapes.Circle.area", RS_EXACT)); + /* a field's doc */ + ASSERT_TRUE(rs_edge_is(db, "shapes.Circle.radius", "shapes.Circle.area", RS_EXACT)); + /* where `mod x;` files are: beside a mod.rs, under the directory of any + * other file, at a `#[path]`, under an inline module's directory */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcModules", "deep.pit.Hole", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcModules", "shapes.round.Ball", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcModules", "elsewhere.odd.Odd", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcModules", "outer_inline.leaf.Leaf", RS_EXACT)); + /* two files for one module name under different cfg */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "imp::Thing", "ambiguous")); + /* imports: a chain of renaming re-exports, a chain of globs, a cycle */ + ASSERT_TRUE(rs_edge_is(db, "chain.ChainUser", "chain.Deep", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "chain.ChainUser", "chain.ViaGlobs", RS_UNIQUE)); + ASSERT_TRUE(rs_row_is(db, "crates/core/src/chain.rs", "c1::Loop", "graph_gap")); + /* one module file under two crate roots: what one root declares binds, + * what both declare is ambiguous */ + ASSERT_TRUE(rs_edge_is(db, "common.Shared", "twin.src.lib.Only", RS_EXACT)); + ASSERT_TRUE(rs_row_is(db, "crates/twin/src/common.rs", "crate::Both", "ambiguous")); + /* a package's other targets see its lib by name */ + ASSERT_TRUE(rs_edge_is(db, "bin.tool.run_tool", "twin.src.lib.Only", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "tests.it.it_works", "core.src.lib.Gadget", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "tests.it.it_works", "shapes.Circle.area", RS_EXACT)); + /* edition 2015: a `use` path starts at the crate root */ + ASSERT_TRUE(rs_edge_is(db, "old.src.b.UsesThing", "old.src.a.Thing", RS_EXACT)); + rs_ws_close(&ws); + PASS(); +} + +/* Associated items, namespaces, disambiguators. */ +TEST(doc_mentions_rs_members_and_kinds) { + rs_ws_t ws; + ASSERT_TRUE(rs_ws_open(&ws)); + const char *db = ws.db; + /* a method, with and without `()`, and through a type alias: one edge */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcMembers", "shapes.Circle.area", RS_EXACT "|\"count\":3")); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcMembers", "shapes.Circle.radius", RS_EXACT)); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Field' AND " + "qualified_name LIKE '%.shapes.Circle.radius'"), + 1); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcMembers", "shapes.Draw.draw", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcMembers", "shapes.Ring", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcMembers", "shapes.Pair", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcMembers", "shapes.Bits", RS_EXACT)); + /* a field and a method of one name: the method (rustdoc's order) */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcMembers", "shapes.Lamp.glow", RS_EXACT)); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Method' AND " + "qualified_name LIKE '%.shapes.Lamp.glow'"), + 1); + /* an item of a trait impl needs its trait in scope where the link is + * written: not in lib.rs, but in shapes.rs, where a default item of the + * trait is found through the impl as well */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "Circle::draw", "missing")); + ASSERT_TRUE(rs_edge_is(db, "shapes.Circle.area", "shapes.Circle.draw", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "shapes.Circle.area", "shapes.Draw.outline", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "shapes.Circle.area", "shapes.Draw.draw", RS_EXACT)); + /* declared, but without a graph node */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "Circle::UNIT", "graph_gap")); /* impl constant */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "shapes::Kind::Round", "graph_gap")); /* variant */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "shapes::Pair::0", "graph_gap")); /* tuple field */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "Circle::clone", "graph_gap")); /* #[derive(Clone)] */ + /* not declared at all; a tuple struct is no function */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "Circle::no_such", "missing")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "shapes::Pair()", "missing")); + /* a function and a macro of one name */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "dual", "ambiguous")); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcNamespaces", "core.src.lib.dual", "\"count\":2")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "dual!", "graph_gap")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "macro@dual", "graph_gap")); + /* a disambiguator of another kind is no match */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcKinds", "core.src.lib.Gadget", "\"count\":2")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "enum@Gadget", "missing")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "fn@Gadget", "missing")); + /* a trait and a derive macro of one name are the trait */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcKinds", "core.src.lib.Paintable", RS_UNIQUE)); + /* declared twice under cfg: one definition with its variants on one node, + * so the item binds */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "plat", "")); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcKinds", "core.src.lib.plat", RS_UNIQUE)); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcKinds", "core.src.lib.Twin", RS_UNIQUE)); + rs_ws_close(&ws); + PASS(); +} + +/* Every reason, with the cases that are easy to mislabel. */ +TEST(doc_mentions_rs_reasons) { + rs_ws_t ws; + ASSERT_TRUE(rs_ws_open(&ws)); + const char *db = ws.db; + /* missing: nothing of that name where the language looks */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "NoSuchName", "missing")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "shapes::NoSuch", "missing")); + /* a name that is no ASCII is a name like any other */ + ASSERT_TRUE(rs_edge_is(db, "lib.SrcReasons", + "lib.Gr\xC3\xB6\xC3\x9F" + "e", + RS_UNIQUE)); + ASSERT_TRUE(rs_row_is(db, RS_LIB, + "\xC3\x9C" + "bel", + "missing")); + /* graph_gap: an inline module has no node, its items have */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "outer_inline", "graph_gap")); + ASSERT_TRUE(rs_edge_is(db, "lib.SrcReasons", "lib.InInline", RS_EXACT)); + /* graph_gap: declared by a macro, in its body or at the invocation */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "Knob", "graph_gap")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "Made", "graph_gap")); + /* test_only_target: product code never binds test code ... */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "tests_support::Fixture", "test_only_target")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "only_in_tests", "test_only_target")); + /* ... test code binds both */ + ASSERT_TRUE(rs_edge_is(db, "tests_support.Fixture", "core.src.lib.Gadget", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(db, "tests_support.Fixture", "lib.only_in_tests", RS_EXACT)); + /* unparseable: a link whose destination is no path */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "Circle::area(x)", "unparseable")); + ASSERT_TRUE(rs_row_is(db, RS_LIB, "Circle#a#b", "unparseable")); + /* ambiguous */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "imp::Thing", "ambiguous")); + /* external */ + ASSERT_TRUE(rs_row_is(db, RS_LIB, "serde::Serialize", "external")); + /* a file with a parse error: what stands before the error resolves what + * it can name for sure; a name the file does not show is a gap, not the + * prelude's; what follows the error has no scope */ + ASSERT_TRUE(rs_edge_is(db, "broken.Whole", "core.src.lib.Gadget", RS_EXACT)); + ASSERT_TRUE(rs_row_is(db, "crates/core/src/broken.rs", "Vec", "graph_gap")); + ASSERT_TRUE(rs_row_is(db, "crates/core/src/broken.rs", "crate::Shape", "graph_gap")); + /* a file no crate root reaches: its `crate` is not known, its package is */ + ASSERT_TRUE(rs_row_is(db, "crates/core/src/orphan.rs", "crate::Gadget", "graph_gap")); + ASSERT_TRUE(rs_row_is(db, "crates/core/src/orphan.rs", "String", "external")); + ASSERT_TRUE(rs_row_is(db, "crates/core/src/orphan.rs", "serde::Serialize", "external")); + /* no reason outside the core's set, and none left as an unknown one */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM doc_link_unresolved WHERE reason NOT IN " + "('missing','ambiguous','external','test_only_target','graph_gap'," + "'unparseable')"), + 0); + rs_ws_close(&ws); + PASS(); +} + +/* ── resolution: structure nobody controls ───────────────────────── */ + +/* Deep nesting, long chains, cycles and crowds: every one resolves or ends + * in a row; none may hang, crash or pick something at random. */ +TEST(doc_mentions_rs_untrusted_structure) { + enum { DEPTH = 600, CHAIN = 100, CROWD = 3000, RING = 40, BUF = 1 << 20 }; + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dmrs_un_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char *src = malloc(BUF); + ASSERT_NOT_NULL(src); + th_write_file(TH_PATH(tmp, "Cargo.toml"), "[package]\nname = \"wild\"\nedition = \"2021\"\n"); + th_write_file(TH_PATH(tmp, "src/lib.rs"), + "pub mod nest;\npub mod globs;\npub mod ring;\npub mod crowd;\n" + "/// The root's target.\npub struct Top;\n"); + + /* inline modules nested DEPTH deep; the innermost names the root through + * `crate`, and through more `super`s than a path carries */ + size_t w = 0; + for (int i = 0; i < DEPTH; i++) { + w += (size_t)snprintf(src + w, BUF - w, "pub mod m {\n"); + } + w += (size_t)snprintf(src + w, BUF - w, "/// [crate::Top] ["); + for (int i = 0; i < CBM_DOCLINK_RS_MAX_SEGS + 4; i++) { + w += (size_t)snprintf(src + w, BUF - w, "super::"); + } + w += (size_t)snprintf(src + w, BUF - w, "Top]\npub struct Innermost;\n"); + for (int i = 0; i < DEPTH; i++) { + w += (size_t)snprintf(src + w, BUF - w, "}\n"); + } + th_write_file(TH_PATH(tmp, "src/nest.rs"), src); + + /* a chain of re-exporting globs longer than is followed, and a short one */ + w = (size_t)snprintf(src, BUF, "use self::g0::*;\nuse self::s0::*;\n"); + for (int i = 0; i < CHAIN; i++) { + w += (size_t)snprintf(src + w, BUF - w, "mod g%d { pub use super::g%d::*; }\n", i, i + 1); + } + w += (size_t)snprintf(src + w, BUF - w, + "mod g%d { /// Far.\n pub struct Far; }\n" + "mod s0 { pub use super::s1::*; }\n" + "mod s1 { /// Near.\n pub struct Near; }\n" + "/// [`Far`] [`Near`]\npub struct GlobUser;\n", + CHAIN); + th_write_file(TH_PATH(tmp, "src/globs.rs"), src); + + /* a ring of imports, each waiting for the next */ + w = 0; + for (int i = 0; i < RING; i++) { + w += (size_t)snprintf(src + w, BUF - w, "mod r%d { pub use super::r%d::Round; }\n", i, + (i + 1) % RING); + } + w += (size_t)snprintf(src + w, BUF - w, "/// [r0::Round]\npub struct RingUser;\n"); + th_write_file(TH_PATH(tmp, "src/ring.rs"), src); + + /* thousands of declarations of one name, and of impls of one type */ + w = 0; + for (int i = 0; i < CROWD; i++) { + w += (size_t)snprintf(src + w, BUF - w, "#[cfg(c%d)]\npub fn many() {}\n", i); + } + w += (size_t)snprintf(src + w, BUF - w, "/// One type, many impls.\npub struct Hub;\n"); + for (int i = 0; i < CROWD; i++) { + w += (size_t)snprintf(src + w, BUF - w, + "impl From<[u8; %d]> for Hub {\n" + " fn from(_: [u8; %d]) -> Self { Hub }\n}\n", + i, i); + } + w += (size_t)snprintf(src + w, BUF - w, + "/// [`many`] [`Hub::from`] [`Hub`]\n" + "pub struct CrowdUser;\n"); + th_write_file(TH_PATH(tmp, "src/crowd.rs"), src); + free(src); + + char db[512]; + snprintf(db, sizeof(db), "%s/wild.db", tmp); + ASSERT_EQ(dm_index(tmp, db, NULL), 0); + /* depth is no limit for `crate`; a path of more segments than are carried + * is no path */ + ASSERT_TRUE(rs_edge_is(db, "nest.Innermost", "src.lib.Top", RS_EXACT)); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM doc_link_unresolved WHERE rel_path = " + "'src/nest.rs' AND reason = 'unparseable'"), + 1); + /* a glob chain that is followed, and one that is not: a gap, never a miss */ + ASSERT_TRUE(rs_edge_is(db, "globs.GlobUser", "globs.Near", RS_UNIQUE)); + ASSERT_TRUE(rs_row_is(db, "src/globs.rs", "Far", "graph_gap")); + /* a cycle of imports is not followed */ + ASSERT_TRUE(rs_row_is(db, "src/ring.rs", "r0::Round", "graph_gap")); + /* a crowd of one name is ambiguous, whatever its size */ + ASSERT_TRUE(rs_row_is(db, "src/crowd.rs", "many", "ambiguous")); + ASSERT_TRUE(rs_row_is(db, "src/crowd.rs", "Hub::from", "ambiguous")); + ASSERT_TRUE(rs_edge_is(db, "crowd.CrowdUser", "crowd.Hub", RS_UNIQUE)); + dm_unlink_db(db); + th_rmtree(tmp); + PASS(); +} + +/* ── which scope changes are repairable file by file ─────────────── */ + +static int rs_delta(const char *before, const char *after, char *names, size_t cap) { + return dm_scope_delta(CBM_LANG_RUST, "src/s.rs", before, after, names, cap); +} + +#define RS_D_USE "use std::fmt;\n" +#define RS_D_STRUCT "pub struct W {\n pub size: u32,\n pub name: u32,\n}\n" +#define RS_D_IMPL_OPEN "impl W {\n" +#define RS_D_RUN " pub fn run(&self) {}\n" +#define RS_D_STOP " pub fn stop(&self) {}\n" +#define RS_D_LIMIT " pub const LIMIT: u32 = 1;\n" +#define RS_D_IMPL_CLOSE "}\n" +#define RS_D_ENUM "pub enum Kind {\n A,\n B,\n}\n" +#define RS_D_FREE "pub fn free() {}\n" +#define RS_D_OTHER "pub struct Other;\n" +#define RS_D_IMPL RS_D_IMPL_OPEN RS_D_RUN RS_D_STOP RS_D_LIMIT RS_D_IMPL_CLOSE +#define RS_D_TAIL RS_D_ENUM RS_D_FREE RS_D_OTHER + +TEST(doc_mentions_rs_scope_delta_rules) { + const char *base = RS_D_USE RS_D_STRUCT RS_D_IMPL RS_D_TAIL; + struct { + const char *what; + const char *after; + int want; + const char *names; + } cases[] = { + {"unchanged", base, CBM_DOCLINK_DELTA_LOCAL, ""}, + /* the persisted scope carries no line number, no body and no doc */ + {"lines moved", "// moved\n\n" RS_D_USE RS_D_STRUCT RS_D_IMPL RS_D_TAIL, + CBM_DOCLINK_DELTA_LOCAL, ""}, + {"body edit", + RS_D_USE RS_D_STRUCT RS_D_IMPL_OPEN + " pub fn run(&self) {\n let _ = 1;\n }\n" RS_D_STOP RS_D_LIMIT RS_D_IMPL_CLOSE + RS_D_TAIL, + CBM_DOCLINK_DELTA_LOCAL, ""}, + {"doc edit", + RS_D_USE "/// Now documented: [`Other`], [`size`].\n" RS_D_STRUCT RS_D_IMPL RS_D_TAIL, + CBM_DOCLINK_DELTA_LOCAL, ""}, + /* a member's own visibility is no part of what a path can name */ + {"method made private", + RS_D_USE RS_D_STRUCT RS_D_IMPL_OPEN + " fn run(&self) {}\n" RS_D_STOP RS_D_LIMIT RS_D_IMPL_CLOSE RS_D_TAIL, + CBM_DOCLINK_DELTA_LOCAL, ""}, + /* a removed member is reported by name */ + {"method removed", + RS_D_USE RS_D_STRUCT RS_D_IMPL_OPEN RS_D_RUN RS_D_LIMIT RS_D_IMPL_CLOSE RS_D_TAIL, + CBM_DOCLINK_DELTA_LOCAL, "stop"}, + {"field removed", RS_D_USE "pub struct W {\n pub size: u32,\n}\n" RS_D_IMPL RS_D_TAIL, + CBM_DOCLINK_DELTA_LOCAL, "name"}, + {"constant removed", + RS_D_USE RS_D_STRUCT RS_D_IMPL_OPEN RS_D_RUN RS_D_STOP RS_D_IMPL_CLOSE RS_D_TAIL, + CBM_DOCLINK_DELTA_LOCAL, "LIMIT"}, + {"two members removed", + RS_D_USE "pub struct W {\n pub size: u32,\n}\n" RS_D_IMPL_OPEN RS_D_RUN RS_D_LIMIT + RS_D_IMPL_CLOSE RS_D_TAIL, + CBM_DOCLINK_DELTA_LOCAL, "name,stop"}, + /* everything else can re-route references of files with no edge here */ + {"method added", + RS_D_USE RS_D_STRUCT RS_D_IMPL_OPEN RS_D_RUN RS_D_STOP RS_D_LIMIT + " pub fn more(&self) {}\n" RS_D_IMPL_CLOSE RS_D_TAIL, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"method renamed", + RS_D_USE RS_D_STRUCT RS_D_IMPL_OPEN + " pub fn go(&self) {}\n" RS_D_STOP RS_D_LIMIT RS_D_IMPL_CLOSE RS_D_TAIL, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"members reordered", + RS_D_USE RS_D_STRUCT RS_D_IMPL_OPEN RS_D_STOP RS_D_RUN RS_D_LIMIT RS_D_IMPL_CLOSE + RS_D_TAIL, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + /* a variant can be imported under another name: no name finds the + * references that depended on it */ + {"variant removed", + RS_D_USE RS_D_STRUCT RS_D_IMPL "pub enum Kind {\n A,\n}\n" RS_D_FREE RS_D_OTHER, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + /* ... and so can every item */ + {"function removed", RS_D_USE RS_D_STRUCT RS_D_IMPL RS_D_ENUM RS_D_OTHER, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"type removed", RS_D_USE RS_D_STRUCT RS_D_IMPL RS_D_ENUM RS_D_FREE, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"type added", RS_D_USE RS_D_STRUCT RS_D_IMPL RS_D_TAIL "pub struct New;\n", + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"item made private", RS_D_USE RS_D_STRUCT RS_D_IMPL RS_D_ENUM RS_D_FREE "struct Other;\n", + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"item made test-only", + RS_D_USE RS_D_STRUCT RS_D_IMPL RS_D_ENUM RS_D_FREE "#[cfg(test)]\n" RS_D_OTHER, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"derive added", RS_D_USE "#[derive(Clone)]\n" RS_D_STRUCT RS_D_IMPL RS_D_TAIL, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"import added", RS_D_USE "use std::io;\n" RS_D_STRUCT RS_D_IMPL RS_D_TAIL, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"import removed", RS_D_STRUCT RS_D_IMPL RS_D_TAIL, CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"module declared", RS_D_USE "mod extra;\n" RS_D_STRUCT RS_D_IMPL RS_D_TAIL, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"impl header changed", + RS_D_USE RS_D_STRUCT + "impl fmt::Debug for W {\n" RS_D_RUN RS_D_STOP RS_D_LIMIT RS_D_IMPL_CLOSE RS_D_TAIL, + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + /* an impl is more than its members: through it a type takes the + * default items of a trait, which live in another file */ + {"impl removed", RS_D_USE RS_D_STRUCT RS_D_TAIL, CBM_DOCLINK_DELTA_GLOBAL, NULL}, + /* a parse error where there was none */ + {"parse error", RS_D_USE RS_D_STRUCT RS_D_IMPL RS_D_TAIL "pub struct {\n", + CBM_DOCLINK_DELTA_GLOBAL, NULL}, + {"file deleted", NULL, CBM_DOCLINK_DELTA_GLOBAL, NULL}, + }; + char names[256]; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + int got = rs_delta(base, cases[i].after, names, sizeof(names)); + if (got != cases[i].want || (cases[i].names && strcmp(names, cases[i].names) != 0)) { + printf(" %s: delta %d [%s], want %d [%s]\n", cases[i].what, got, names, cases[i].want, + cases[i].names ? cases[i].names : "any"); + FAIL("scope delta"); + } + } + /* a member whose name the incremental route cannot look for in a row + * (it splits rows at every byte that is no ASCII letter, digit or `_`) */ + const char *wide = "pub struct W {\n pub gr\xC3\xB6\xC3\x9F" + "e: u32,\n pub b: u32,\n}\n"; + ASSERT_EQ(rs_delta(wide, "pub struct W {\n pub b: u32,\n}\n", names, sizeof(names)), + CBM_DOCLINK_DELTA_GLOBAL); + /* a raw identifier is reported as the name a reference writes */ + ASSERT_EQ(rs_delta("pub struct W {\n pub r#type: u32,\n pub b: u32,\n}\n", + "pub struct W {\n pub b: u32,\n}\n", names, sizeof(names)), + CBM_DOCLINK_DELTA_LOCAL); + ASSERT_STR_EQ(names, "type"); + /* a scope that appears is as global as one that leaves */ + ASSERT_EQ(rs_delta(NULL, base, names, sizeof(names)), CBM_DOCLINK_DELTA_GLOBAL); + + /* a manifest: only what the resolver reads of it counts, and every such + * change is global (it re-scopes the files of its package) */ + const char *toml = "[package]\nname = \"p\"\nedition = \"2021\"\n\n" + "[dependencies]\nserde = \"1\"\nutil = { path = \"../util\" }\n"; + struct { + const char *what; + const char *after; + int want; + } manifests[] = { + {"unchanged", toml, CBM_DOCLINK_DELTA_LOCAL}, + {"comment and version", + "# a comment\n[package]\nname = \"p\"\nedition = \"2021\"\nversion = \"2.0.0\"\n\n" + "[dependencies]\nserde = \"2\"\nutil = { path = \"../util\" }\n", + CBM_DOCLINK_DELTA_LOCAL}, + {"dependency added", + "[package]\nname = \"p\"\nedition = \"2021\"\n\n" + "[dependencies]\nserde = \"1\"\nutil = { path = \"../util\" }\nmore = \"1\"\n", + CBM_DOCLINK_DELTA_GLOBAL}, + {"path changed", + "[package]\nname = \"p\"\nedition = \"2021\"\n\n" + "[dependencies]\nserde = \"1\"\nutil = { path = \"../other\" }\n", + CBM_DOCLINK_DELTA_GLOBAL}, + {"edition changed", + "[package]\nname = \"p\"\nedition = \"2018\"\n\n" + "[dependencies]\nserde = \"1\"\nutil = { path = \"../util\" }\n", + CBM_DOCLINK_DELTA_GLOBAL}, + }; + for (size_t i = 0; i < sizeof(manifests) / sizeof(manifests[0]); i++) { + int got = dm_scope_delta(CBM_LANG_TOML, "Cargo.toml", toml, manifests[i].after, names, + sizeof(names)); + if (got != manifests[i].want) { + printf(" manifest %s: delta %d, want %d\n", manifests[i].what, got, manifests[i].want); + FAIL("manifest scope delta"); + } + } + PASS(); +} + +/* ── incremental == full ─────────────────────────────────────────── */ + +static const char RS_INC_LIB[] = "//! The crate: [`Target`].\n" + "mod a;\n" + "mod b;\n" + "mod user;\n" + "mod rows;\n" + "pub use a::Target;\n" + "use b::Other;\n"; +static const char RS_INC_LIB_DOC[] = "//! The crate: [`Target`] and [`Other`].\n" + "mod a;\n" + "mod b;\n" + "mod user;\n" + "mod rows;\n" + "pub use a::Target;\n" + "use b::Other;\n"; +#define RS_INC_A_HEAD "/// The target.\npub struct Target;\nimpl Target {\n" +#define RS_INC_A_GO " /// Go.\n pub fn go(&self) {}\n" +#define RS_INC_A_STOP " /// Stop.\n pub fn stop(&self) {}\n" +/* two impl blocks with a method of one name: the later one owns the node */ +#define RS_INC_A_TWIN1 \ + "impl Target {\n /// Twin.\n pub fn twin(&self) {}\n /// Kept.\n" \ + " pub fn keep(&self) {}\n}\n" +#define RS_INC_A_KEEP "impl Target {\n /// Kept.\n pub fn keep(&self) {}\n}\n" +#define RS_INC_A_TWIN2 "impl Target {\n /// Twin.\n pub fn twin(&self) {}\n}\n" +#define RS_INC_A_DUP "/// A duplicate.\npub struct Dup;\n" +static const char RS_INC_A[] = + RS_INC_A_HEAD RS_INC_A_GO RS_INC_A_STOP "}\n" RS_INC_A_TWIN1 RS_INC_A_TWIN2 RS_INC_A_DUP; +static const char RS_INC_A_BODY[] = + RS_INC_A_HEAD " /// Go.\n pub fn go(&self) {\n let _ = 1;\n }\n" RS_INC_A_STOP + "}\n" RS_INC_A_TWIN1 RS_INC_A_TWIN2 RS_INC_A_DUP; +static const char RS_INC_A_NO_STOP[] = RS_INC_A_HEAD + " /// Go.\n pub fn go(&self) {\n let _ = 1;\n }\n}\n" RS_INC_A_TWIN1 + RS_INC_A_TWIN2 RS_INC_A_DUP; +static const char RS_INC_A_ONE_TWIN[] = RS_INC_A_HEAD + " /// Go.\n pub fn go(&self) {\n let _ = 1;\n }\n}\n" RS_INC_A_KEEP + RS_INC_A_TWIN2 RS_INC_A_DUP; +static const char RS_INC_A_RENAMED[] = + "/// The target.\npub struct Renamed;\nimpl Renamed {\n" RS_INC_A_GO "}\n" RS_INC_A_DUP; +static const char RS_INC_B[] = "/// A duplicate.\npub struct Dup;\n/// Other.\npub struct Other;\n"; +static const char RS_INC_B_NO_DUP[] = "/// Other.\npub struct Other;\n"; +static const char RS_INC_USER[] = + "use crate::a::*;\n" + "use crate::b::*;\n" + "/// [`Target`] [`Target::go`] [`Target::stop`] [`Dup`] [`Missing`]\n" + "pub struct User;\n"; +static const char RS_INC_USER_DOC[] = + "use crate::a::*;\n" + "use crate::b::*;\n" + "/// [`Target`] [`Target::go`] [`Target::stop`] [`Dup`] [`Missing`] [`Other`]\n" + "pub struct User;\n"; +/* its only reference is unresolved: it has no edge into the file it names */ +static const char RS_INC_ROWS[] = "/// [crate::a::Target::twin]\npub struct Rows;\n"; + +TEST(doc_mentions_rs_incremental_equals_full) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dmrs_inc_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[400]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + th_write_file(TH_PATH(repo, "Cargo.toml"), "[package]\nname = \"inc\"\nedition = \"2021\"\n"); + th_write_file(TH_PATH(repo, "src/lib.rs"), RS_INC_LIB); + th_write_file(TH_PATH(repo, "src/a.rs"), RS_INC_A); + th_write_file(TH_PATH(repo, "src/b.rs"), RS_INC_B); + th_write_file(TH_PATH(repo, "src/user.rs"), RS_INC_USER); + th_write_file(TH_PATH(repo, "src/rows.rs"), RS_INC_ROWS); + char inc_db[512]; + char full_db[512]; + snprintf(inc_db, sizeof(inc_db), "%s/inc.db", tmp); + snprintf(full_db, sizeof(full_db), "%s/full.db", tmp); + ASSERT_EQ(dm_index(repo, inc_db, NULL), 0); + const char *file = "src.lib.rs.__file__"; + const char *user_rs = "src/user.rs"; + /* the preconditions the steps below move away from */ + ASSERT_TRUE(rs_edge_is(inc_db, file, "a.Target", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(inc_db, "user.User", "a.Target", RS_UNIQUE)); + ASSERT_TRUE(rs_edge_is(inc_db, "user.User", "a.Target.go", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(inc_db, "user.User", "a.Target.stop", RS_EXACT)); + ASSERT_TRUE(rs_row_is(inc_db, user_rs, "Dup", "ambiguous")); /* a::Dup and b::Dup */ + ASSERT_TRUE(rs_row_is(inc_db, user_rs, "Missing", "missing")); + ASSERT_TRUE(rs_row_is(inc_db, "src/rows.rs", "crate::a::Target::twin", "ambiguous")); + + /* a doc edit re-extracts the file alone */ + th_write_file(TH_PATH(repo, "src/user.rs"), RS_INC_USER_DOC); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "doc edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0); + ASSERT_TRUE(rs_edge_is(inc_db, "user.User", "b.Other", RS_UNIQUE)); + + /* a reference in the FILE's own doc, added by an edit of that doc alone */ + th_write_file(TH_PATH(repo, "src/lib.rs"), RS_INC_LIB_DOC); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "file doc edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), + 0); + ASSERT_TRUE(rs_edge_is(inc_db, file, "b.Other", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(inc_db, file, "a.Target", RS_EXACT)); + + /* a body edit in a file others point into: its scope is unchanged, the + * edges into it stand (from an item and from a file's own doc) */ + th_write_file(TH_PATH(repo, "src/a.rs"), RS_INC_A_BODY); + ASSERT_EQ( + dm_step(repo, inc_db, full_db, "target body edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), + 0); + ASSERT_TRUE(rs_edge_is(inc_db, "user.User", "a.Target.go", RS_EXACT)); + ASSERT_TRUE(rs_edge_is(inc_db, file, "a.Target", RS_EXACT)); + + /* a removed member: the file with an edge to it gets a row instead */ + th_write_file(TH_PATH(repo, "src/a.rs"), RS_INC_A_NO_STOP); + ASSERT_EQ( + dm_step(repo, inc_db, full_db, "member removed", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0); + ASSERT_TRUE(rs_no_edge(inc_db, "user.User", "a.Target.stop")); + ASSERT_TRUE(rs_row_is(inc_db, user_rs, "Target::stop", "missing")); + + /* ... and a file with NO edge into the changed one, whose row names the + * removed member, is resolved again: two candidates shrink to one */ + th_write_file(TH_PATH(repo, "src/a.rs"), RS_INC_A_ONE_TWIN); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "twin removed", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), + 0); + ASSERT_TRUE(rs_edge_is(inc_db, "rows.Rows", "a.Target.twin", RS_EXACT)); + + /* a removed item is not repaired file by file: one of two ambiguous + * candidates goes, the other binds */ + th_write_file(TH_PATH(repo, "src/b.rs"), RS_INC_B_NO_DUP); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "item removed", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0); + ASSERT_TRUE(rs_edge_is(inc_db, "user.User", "a.Dup", RS_UNIQUE)); + + /* renaming a target */ + th_write_file(TH_PATH(repo, "src/a.rs"), RS_INC_A_RENAMED); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "rename", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0); + ASSERT_TRUE(rs_row_is(inc_db, user_rs, "Target", "missing")); + ASSERT_TRUE(rs_row_is(inc_db, "src/lib.rs", "Target", "graph_gap")); /* `pub use a::Target` */ + ASSERT_TRUE(rs_edge_is(inc_db, file, "b.Other", RS_EXACT)); + + /* the manifest scopes every file of its package */ + th_write_file( + TH_PATH(repo, "Cargo.toml"), + "[package]\nname = \"inc\"\nedition = \"2021\"\n\n[dependencies]\nserde = \"1\"\n"); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "manifest", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0); + + /* deleting a file others point into */ + ASSERT_EQ(unlink(TH_PATH(repo, "src/b.rs")), 0); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "delete file", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0); + ASSERT_TRUE(rs_no_edge(inc_db, "user.User", "b.Other")); + ASSERT_EQ(dm_mentions_from(inc_db, file), 0); + + dm_unlink_db(inc_db); + dm_unlink_db(full_db); + th_rmtree(tmp); + PASS(); +} + +/* ── the parallel path == the sequential path ────────────────────── */ + +enum { RS_RING = 64 }; /* above MIN_FILES_FOR_PARALLEL */ + +/* Every module documents its successor (through `crate`), a shared target (by + * a glob, by a path into its methods) and a name nothing declares; the root + * documents the hub in its own doc. */ +static void rs_write_ring_fixture(const char *repo) { + char path[512]; + char body[1024]; + char lib[8192]; + size_t w = (size_t)snprintf(lib, sizeof(lib), "//! The ring's [`hub::Hub`].\npub mod hub;\n"); + for (int i = 0; i < RS_RING; i++) { + w += (size_t)snprintf(lib + w, sizeof(lib) - w, "pub mod m%02d;\n", i); + snprintf(path, sizeof(path), "%s/src/m%02d.rs", repo, i); + snprintf(body, sizeof(body), + "use crate::hub::*;\n" + "/// Next [crate::m%02d::C%02d], shared [`Hub`], [`Hub::run`], [`Nothing%02d`].\n" + "pub struct C%02d;\n", + (i + 1) % RS_RING, (i + 1) % RS_RING, i, i); + th_write_file(path, body); + } + th_write_file(TH_PATH(repo, "src/lib.rs"), lib); + th_write_file(TH_PATH(repo, "src/hub.rs"), "/// The hub.\n" + "pub struct Hub;\n" + "impl Hub {\n" + " /// Runs.\n" + " pub fn run(&self) {}\n" + "}\n"); + th_write_file(TH_PATH(repo, "Cargo.toml"), "[package]\nname = \"ring\"\nedition = \"2021\"\n"); +} + +TEST(doc_mentions_rs_parallel_equals_sequential) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dmrs_par_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[400]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + rs_write_ring_fixture(repo); + char par_db[512]; + char seq_db[512]; + snprintf(par_db, sizeof(par_db), "%s/par.db", tmp); + snprintf(seq_db, sizeof(seq_db), "%s/seq.db", tmp); + ASSERT_EQ(dm_workers_agree(repo, par_db, seq_db), 0); + /* per module: its successor, the hub, the hub's method; the root: the hub */ + ASSERT_EQ(dm_count(par_db, "SELECT COUNT(*) FROM edges WHERE type = 'MENTIONS'"), + RS_RING * 3 + 1); + /* per module: the undeclared name */ + ASSERT_EQ(dm_count(par_db, "SELECT COUNT(*) FROM doc_link_unresolved"), RS_RING); + ASSERT_EQ(dm_count(par_db, "SELECT COUNT(*) FROM doc_link_unresolved WHERE reason = 'missing'"), + RS_RING); + dm_unlink_db(par_db); + dm_unlink_db(seq_db); + th_rmtree(tmp); + PASS(); +} + +SUITE(doc_mentions_rs) { + dm_ship_held_families(); + RUN_TEST(doc_mentions_rs_tokens); + RUN_TEST(doc_mentions_rs_item_doc_is_not_file_doc); + RUN_TEST(doc_mentions_rs_untrusted_text); + RUN_TEST(doc_mentions_rs_parse_path); + RUN_TEST(doc_mentions_rs_scope_blob); + RUN_TEST(doc_mentions_rs_scope_parse_errors); + RUN_TEST(doc_mentions_rs_manifest_blob); + RUN_TEST(doc_mentions_rs_families_and_tiers); + RUN_TEST(doc_mentions_rs_file_level_docs); + RUN_TEST(doc_mentions_rs_lookup_levels); + RUN_TEST(doc_mentions_rs_paths_and_modules); + RUN_TEST(doc_mentions_rs_members_and_kinds); + RUN_TEST(doc_mentions_rs_reasons); + RUN_TEST(doc_mentions_rs_untrusted_structure); + RUN_TEST(doc_mentions_rs_scope_delta_rules); + RUN_TEST(doc_mentions_rs_incremental_equals_full); + RUN_TEST(doc_mentions_rs_parallel_equals_sequential); +} diff --git a/tests/test_doc_mentions_ts.c b/tests/test_doc_mentions_ts.c new file mode 100644 index 000000000..350416479 --- /dev/null +++ b/tests/test_doc_mentions_ts.c @@ -0,0 +1,360 @@ +/* + * test_doc_mentions_ts.c -- TypeScript and JavaScript doc-comment references + * (JSDoc and TSDoc {@link}, {@linkcode}, @see). + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include "test_doc_mentions_helpers.h" + +#include "cbm.h" +#include "doclink.h" + +#include +#include +#include + +/* ── extraction ──────────────────────────────────────────────────── */ + +static const char *const TS_WIDGET = + "import { Helper, gadget as g } from \"./helper\";\n" /* 1 */ + "import * as ns from \"./helper\";\n" /* 2 */ + "import Def from \"./def\";\n" /* 3 */ + "\n" /* 4 */ + "/**\n" /* 5 */ + " * The widget. See {@link Helper} and {@link Widget#spin | spin}, also\n" /* 6 */ + " * {@linkcode ns.Helper}. Not `{@link Hidden}` nor {@linkplain Plain}.\n" /* 7 */ + " * ```ts\n" /* 8 */ + " * {@link Fenced}\n" /* 9 */ + " * ```\n" /* 10 */ + " * @see Gadget.\n" /* 11 */ + " * @see {@link Other}\n" /* 12 */ + " * @see https://example.org/w\n" /* 13 */ + " * @see aria-colcount for details\n" /* 14 */ + " */\n" /* 15 */ + "export class Widget {\n" /* 16 */ + " /**\n" /* 17 */ + " * Spins n times, see {@link n} and {@link Widget.size}.\n" /* 18 */ + " */\n" /* 19 */ + " spin(n: number): void {}\n" /* 20 */ + "}\n" /* 21 */ + "\n" /* 22 */ + "export namespace Space {\n" /* 23 */ + " export namespace Inner {\n" /* 24 */ + " export function deep() {}\n" /* 25 */ + " }\n" /* 26 */ + "}\n" /* 27 */ + "function hidden() {}\n" /* 28 */ + "export * from \"./a\";\n" /* 29 */ + "export * as all from \"./b\";\n" /* 30 */ + "export { x as y } from \"./c\";\n" /* 31 */ + "export { hidden as shown };\n" /* 32 */ + "export default Widget;\n"; /* 33 */ + +static bool ts_token_is(const CBMFileResult *r, const char *raw, uint32_t line, int syntax) { + if (dm_count_tokens(r, raw) != 1) { + printf(" token %s: %d found\n", raw, dm_count_tokens(r, raw)); + return false; + } + const CBMDocLink *t = dm_find_token(r, raw); + if (t->line != line || t->syntax != syntax) { + printf(" token %s: line %u syntax %d\n", raw, t->line, t->syntax); + return false; + } + return true; +} + +TEST(doc_mentions_ts_tokens) { + CBMFileResult *r = dm_extract(TS_WIDGET, CBM_LANG_TYPESCRIPT, "src/widget.ts"); + ASSERT_NOT_NULL(r); + ASSERT_TRUE(ts_token_is(r, "Helper", 6, CBM_DOCLINK_TS_LINK)); + ASSERT_TRUE(ts_token_is(r, "Widget#spin", 6, CBM_DOCLINK_TS_LINK)); + ASSERT_TRUE(ts_token_is(r, "ns.Helper", 7, CBM_DOCLINK_TS_LINKCODE)); + ASSERT_TRUE(ts_token_is(r, "Gadget", 11, CBM_DOCLINK_TS_SEE)); + /* an inline tag in an @see line is the tag's, once */ + ASSERT_TRUE(ts_token_is(r, "Other", 12, CBM_DOCLINK_TS_LINK)); + ASSERT_TRUE(ts_token_is(r, "https://example.org/w", 13, CBM_DOCLINK_HREF)); + ASSERT_TRUE(ts_token_is(r, "Widget.size", 18, CBM_DOCLINK_TS_LINK)); + /* a code span, {@linkplain}, a fenced block, prose, the method's own + * parameter: no token */ + ASSERT_EQ(dm_count_tokens(r, "Hidden"), 0); + ASSERT_EQ(dm_count_tokens(r, "Plain"), 0); + ASSERT_EQ(dm_count_tokens(r, "Fenced"), 0); + ASSERT_EQ(dm_count_tokens(r, "aria-colcount"), 0); + ASSERT_EQ(dm_count_tokens(r, "n"), 0); + ASSERT_EQ(r->doc_links.count, 7); + cbm_free_result(r); + PASS(); +} + +static bool ts_has_line(const char *blob, const char *line) { + size_t n = strlen(line); + for (const char *p = blob; p && *p;) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len == n && memcmp(p, line, n) == 0) { + return true; + } + p = nl ? nl + 1 : NULL; + } + printf(" no line: %s\n", line); + return false; +} + +TEST(doc_mentions_ts_scope_blob) { + CBMFileResult *r = dm_extract(TS_WIDGET, CBM_LANG_TYPESCRIPT, "src/widget.ts"); + ASSERT_NOT_NULL(r); + const char *s = r->doc_scope; + ASSERT_NOT_NULL(s); + ASSERT(strncmp(s, "ts1\n", 4) == 0); + ASSERT(ts_has_line(s, "I\tHelper\tn\t./helper\tHelper")); + ASSERT(ts_has_line(s, "I\tg\tn\t./helper\tgadget")); + ASSERT(ts_has_line(s, "I\tns\ts\t./helper\t-")); + ASSERT(ts_has_line(s, "I\tDef\td\t./def\t-")); + ASSERT(ts_has_line(s, "D\tWidget\te")); + ASSERT(ts_has_line(s, "D\tSpace\te")); + ASSERT(ts_has_line(s, "D\thidden\t-")); + ASSERT(ts_has_line(s, "N\tSpace\t23\t27")); + ASSERT(ts_has_line(s, "N\tSpace.Inner\t24\t26")); + ASSERT(ts_has_line(s, "A\t./a")); + ASSERT(ts_has_line(s, "S\tall\t./b")); + ASSERT(ts_has_line(s, "F\ty\t./c\tx")); + ASSERT(ts_has_line(s, "L\tshown\thidden")); + ASSERT(ts_has_line(s, "L\tdefault\tWidget")); + ASSERT(ts_has_line(s, "M")); + /* the persisted blob keeps no line */ + char *p = cbm_doclink_ts_portable_scope(s); + ASSERT_NOT_NULL(p); + ASSERT(ts_has_line(p, "N\tSpace")); + ASSERT(ts_has_line(p, "N\tSpace.Inner")); + ASSERT(ts_has_line(p, "D\tWidget\te")); + cbm_free(CBM_MEM_CLASS_OTHER, p); + cbm_free_result(r); + PASS(); +} + +/* ── resolution ──────────────────────────────────────────────────── */ + +static void ts_write_repo(const char *repo) { + th_write_file(TH_PATH(repo, "src/helper.ts"), "/** A helper. */\n" + "export class Helper {\n" + " run(): void {}\n" + "}\n" + "export function gadget() {}\n" + "function internal() {}\n"); + th_write_file(TH_PATH(repo, "src/def.ts"), "export default class Def {}\n"); + th_write_file(TH_PATH(repo, "src/barrel/index.ts"), + "export * from \"../helper\";\nexport * from \"./more\";\n" + "export function inBarrel() {}\n"); + th_write_file(TH_PATH(repo, "src/barrel/more.ts"), "export function extra() {}\n"); + th_write_file(TH_PATH(repo, "src/dup1.ts"), "export function twin() {}\n"); + th_write_file(TH_PATH(repo, "src/dup2.ts"), "export function twin() {}\n"); + th_write_file(TH_PATH(repo, "src/twins.ts"), + "export * from \"./dup1\";\nexport * from \"./dup2\";\n"); + /* functions declared in a function body: visible there, under file-level + * names in the graph */ + th_write_file(TH_PATH(repo, "src/closure.ts"), + "export function outer() {\n" + " /** Calls {@link helperInside} and {@link uniqueInside}. */\n" + " function inside() {}\n" + " function helperInside() {}\n" + " function uniqueInside() {}\n" + "}\n" + "export function other() {\n" + " function helperInside() {}\n" + "}\n"); + /* a script: no import, no export, so its names are global */ + th_write_file(TH_PATH(repo, "src/global.js"), "function legacyGlobal() {}\n"); + /* a tsconfig path alias and a workspace package */ + th_write_file(TH_PATH(repo, "tsconfig.json"), + "{ \"compilerOptions\": { \"paths\": { \"@lib/*\": [\"src/lib/*\"] } } }\n"); + th_write_file(TH_PATH(repo, "src/lib/aliased.ts"), "export function aliased() {}\n"); + th_write_file(TH_PATH(repo, "packages/util/package.json"), + "{ \"name\": \"@acme/util\", \"main\": \"src/index.ts\" }\n"); + th_write_file(TH_PATH(repo, "packages/util/src/index.ts"), "export function utilFn() {}\n"); + th_write_file(TH_PATH(repo, "src/widget.ts"), + "import { aliased } from \"@lib/aliased\";\n" + "import { utilFn } from \"@acme/util\";\n" + "import { inBarrel } from \"./barrel\";\n" + "import { Helper, gadget as g } from \"./helper\";\n" + "import * as ns from \"./helper\";\n" + "import Def from \"./def\";\n" + "import { extra } from \"./barrel\";\n" + "import { twin } from \"./twins\";\n" + "import { Thing } from \"some-package\";\n" + "import { Gone } from \"./nowhere\";\n" + "\n" + "/**\n" + " * Links: {@link Helper}, {@link Helper.run}, {@link g}, {@link ns.gadget},\n" + " * {@link Def}, {@link extra}, {@link twin}, {@link Thing}, {@link Gone},\n" + " * {@link Promise}, {@link legacyGlobal}, {@link Nope}, {@link spin},\n" + " * {@link Space.Inner.deep}, {@link Helper#missingMember}, {@link aliased},\n" + " * {@link utilFn}, {@link inBarrel}.\n" + " */\n" + "export class Widget {\n" + " spin(): void {}\n" + " /** Turns, see {@link this.spin}. */\n" + " turn(): void {}\n" + "}\n" + "\n" + "export namespace Space {\n" + " export namespace Inner {\n" + " /** Deep, see {@link deep2}. */\n" + " export function deep() {}\n" + " export function deep2() {}\n" + " }\n" + "}\n"); +} + +static bool ts_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); + if (n != 1) { + printf(" edge %s -> %s: %d found\n", src, tgt, n); + return false; + } + return true; +} + +static bool ts_row(const char *db, const char *raw, const char *reason) { + char got[64]; + dm_row(db, "src/widget.ts", raw, got, sizeof(got), NULL, 0); + if (strcmp(got, reason) != 0) { + printf(" row [%s]: \"%s\", expected \"%s\"\n", raw, got, reason); + return false; + } + return true; +} + +TEST(doc_mentions_ts_resolution) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlts_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(db, sizeof(db), "%s/g.db", tmp); + ts_write_repo(repo); + ASSERT_EQ(dm_index(repo, db, NULL), 0); + const char *w = "src.widget.Widget"; + /* imports: named, aliased, a member of one, a namespace, a default */ + ASSERT_TRUE(ts_edge(db, w, "src.helper.Helper")); + ASSERT_TRUE(ts_edge(db, w, "src.helper.Helper.run")); + ASSERT_TRUE(ts_edge(db, w, "src.helper.gadget")); + ASSERT_TRUE(ts_edge(db, w, "src.def.Def")); + /* through an export * barrel; two barrels that answer differently */ + ASSERT_TRUE(ts_edge(db, w, "src.barrel.more.extra")); + ASSERT_TRUE(ts_row(db, "twin", "ambiguous")); + /* a package, a relative import the index does not hold, the standard + * library */ + ASSERT_TRUE(ts_row(db, "Thing", "external")); + ASSERT_TRUE(ts_row(db, "Gone", "not_indexed")); + ASSERT_TRUE(ts_row(db, "Promise", "external")); + /* a script's global; nothing anywhere */ + ASSERT_TRUE(ts_edge(db, w, "src.global.legacyGlobal")); + ASSERT_TRUE(ts_row(db, "Nope", "missing")); + /* the class's own member; a path through namespaces */ + ASSERT_TRUE(ts_edge(db, w, "src.widget.Widget.spin")); + ASSERT_TRUE(ts_edge(db, w, "src.widget.Space.Inner.deep")); + /* a class member without a node of its own */ + ASSERT_TRUE(ts_row(db, "Helper#missingMember", "graph_gap")); + /* a tsconfig path alias; a workspace package's entry module */ + ASSERT_TRUE(ts_edge(db, w, "src.lib.aliased.aliased")); + ASSERT_TRUE(ts_edge(db, w, "packages.util.src.utilFn")); + /* a declaration of an index file (its QN has no `index`) */ + ASSERT_TRUE(ts_edge(db, w, "src.barrel.inBarrel")); + /* this. is the container's; a namespace's own names first */ + ASSERT_TRUE(ts_edge(db, "src.widget.Widget.turn", "src.widget.Widget.spin")); + ASSERT_TRUE(ts_edge(db, "src.widget.Space.Inner.deep", "src.widget.Space.Inner.deep2")); + /* a function body's own declaration binds; two of one name share one node */ + ASSERT_TRUE(ts_edge(db, "src.closure.inside", "src.closure.uniqueInside")); + char reason[64]; + dm_row(db, "src/closure.ts", "helperInside", reason, sizeof(reason), NULL, 0); + ASSERT_STR_EQ(reason, "graph_gap"); + th_cleanup(tmp); + PASS(); +} + +/* ── incremental == full; one worker == several ──────────────────── */ + +TEST(doc_mentions_ts_incremental_equals_full) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlts_inc_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char inc_db[512]; + char full_db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(inc_db, sizeof(inc_db), "%s/inc.db", tmp); + snprintf(full_db, sizeof(full_db), "%s/full.db", tmp); + th_write_file(TH_PATH(repo, "src/lib.ts"), "export function a() {}\nexport function b() {}\n"); + th_write_file(TH_PATH(repo, "src/use.ts"), "import { a } from \"./lib\";\n" + "/** Uses {@link a}. */\n" + "export function use() {}\n"); + ASSERT_EQ(dm_index(repo, inc_db, NULL), 0); + ASSERT_TRUE(ts_edge(inc_db, "src.use.use", "src.lib.a")); + + /* a doc edit: the file is repaired alone */ + th_write_file(TH_PATH(repo, "src/use.ts"), "import { a } from \"./lib\";\n" + "/** Uses {@link a}, and {@link Nope}. */\n" + "export function use() {}\n"); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "doc edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0); + + /* a body edit in the target file: its scope is unchanged */ + th_write_file(TH_PATH(repo, "src/lib.ts"), + "export function a() { return 1; }\nexport function b() {}\n"); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "body edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0); + ASSERT_TRUE(ts_edge(inc_db, "src.use.use", "src.lib.a")); + + /* an export goes: the declaration's record changes, a full run */ + th_write_file(TH_PATH(repo, "src/lib.ts"), "function a() {}\nexport function b() {}\n"); + ASSERT_EQ(dm_step(repo, inc_db, full_db, "export removed", CBM_INCREMENTAL_ROUTE_FORCED_FULL), + 0); + char props[512]; + int n = 0; + dm_edge(inc_db, "src.use.use", "src.lib.a", props, sizeof(props), &n); + ASSERT_EQ(n, 0); + th_cleanup(tmp); + PASS(); +} + +TEST(doc_mentions_ts_workers_agree) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlts_par_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char par_db[512]; + char seq_db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(par_db, sizeof(par_db), "%s/par.db", tmp); + snprintf(seq_db, sizeof(seq_db), "%s/seq.db", tmp); + enum { MODULES = 60 }; /* past the parallel threshold */ + for (int i = 0; i < MODULES; i++) { + char rel[64]; + char src[1024]; + int next = (i + 1) % MODULES; + snprintf(rel, sizeof(rel), "src/m%02d.ts", i); + snprintf(src, sizeof(src), + "import { F%02d, K%02d } from \"./m%02d\";\n" + "/** F%02d; see {@link F%02d}, {@link K%02d.run}, {@link K%02d#gone},\n" + " * {@link Promise}, {@link Nope} and {@link F%02d}. */\n" + "export function F%02d() {}\n" + "export class K%02d {\n run() {}\n}\n", + next, next, next, i, next, next, next, i, i, i); + th_write_file(TH_PATH(repo, rel), src); + } + ASSERT_EQ(dm_workers_agree(repo, par_db, seq_db), 0); + ASSERT_TRUE(ts_edge(par_db, "src.m00.F00", "src.m01.K01.run")); + th_cleanup(tmp); + PASS(); +} + +SUITE(doc_mentions_ts) { + dm_ship_held_families(); + RUN_TEST(doc_mentions_ts_tokens); + RUN_TEST(doc_mentions_ts_scope_blob); + RUN_TEST(doc_mentions_ts_resolution); + RUN_TEST(doc_mentions_ts_incremental_equals_full); + RUN_TEST(doc_mentions_ts_workers_agree); +} diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 1431cefe7..4c62bddc7 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -8610,6 +8610,64 @@ TEST(doc_perl_pod_section_by_sub_name) { PASS(); } +/* A Perl file's module doc (its File node's docstring) is the POD that no + * sub's doc holds: each run as it stands, joined by a blank line, so that a + * reference is read once. */ +TEST(doc_perl_module_doc_is_the_pod_no_sub_holds) { + CBMFileResult *r = extract("package Knob;\n" + "\n" + "=head1 NAME\n" + "\n" + "Knob - see L.\n" + "\n" + "=cut\n" + "\n" + "sub turn { return 1; }\n" + "\n" + "1;\n" + "__END__\n" + "\n" + "=head1 METHODS\n" + "\n" + "The knob's methods.\n" + "\n" + "=head2 turn\n" + "\n" + "Turns the knob once.\n" + "\n" + "=head2 spin\n" + "\n" + "Not a sub here.\n" + "\n" + "=cut\n", + CBM_LANG_PERL, "t", "Knob.pm"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(r->module_doc, "=head1 NAME\n\nKnob - see L.\n\n=cut\n\n" + "=head1 METHODS\n\nThe knob's methods.\n\n" + "=head2 spin\n\nNot a sub here.\n\n=cut"); + const CBMDefinition *d = doc_def(r, "Function", "turn"); + ASSERT_NOT_NULL(d); + ASSERT_STR_EQ(d->docstring, "=head2 turn\n\nTurns the knob once."); + cbm_free_result(r); + + /* all of the POD is a sub's: no module doc */ + r = extract("package Knob;\n" + "sub turn { return 1; }\n" + "1;\n" + "__END__\n" + "\n" + "=head2 turn\n" + "\n" + "Turns.\n" + "\n" + "=cut\n", + CBM_LANG_PERL, "t", "Knob.pm"); + ASSERT_NOT_NULL(r); + ASSERT_NULL(r->module_doc); + cbm_free_result(r); + PASS(); +} + TEST(doc_long_comment_kept_whole) { /* One 12-line block comment well past the old 500-byte cut, kept byte for * byte. */ @@ -10847,6 +10905,7 @@ SUITE(extraction) { RUN_TEST(doc_kotlin_kdoc_folded_after_imports); RUN_TEST(doc_c_typedef_struct); RUN_TEST(doc_perl_pod_section_by_sub_name); + RUN_TEST(doc_perl_module_doc_is_the_pod_no_sub_holds); RUN_TEST(doc_long_comment_kept_whole); RUN_TEST(doc_divider_only_run_is_not_doc); RUN_TEST(extract_ts_decorators_survive_interleaved_comment); diff --git a/tests/test_grammar_labels.c b/tests/test_grammar_labels.c index 7246e5b95..f2e58074d 100644 --- a/tests/test_grammar_labels.c +++ b/tests/test_grammar_labels.c @@ -242,7 +242,7 @@ static const LabelGolden LABEL_GOLDENS[] = { {"astro", "Module:1,Variable:1"}, {"templ", "Class:1,Module:1"}, {"typst", "Module:1"}, - {"mermaid", "Module:1"}, + {"mermaid", "Diagram:1,DiagramElement:2,Module:1"}, /* the fixture's flowchart */ {NULL, NULL}, }; diff --git a/tests/test_main.c b/tests/test_main.c index 145e96ef6..2bee46fef 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1825,6 +1825,15 @@ extern void suite_doc_links_md(void); extern void suite_doc_links_pdf(void); extern void suite_doc_links_rst(void); extern void suite_doc_links_adoc(void); +extern void suite_doc_mentions_go(void); +extern void suite_doc_mentions_jvm(void); +extern void suite_doc_mentions_rs(void); +extern void suite_doc_mentions_perl(void); +extern void suite_doc_mentions_ts(void); +extern void suite_doc_mentions_php(void); +extern void suite_doc_mentions_c(void); +extern void suite_diagrams(void); +extern void suite_openapi(void); extern void suite_infrascan(void); extern void suite_cli(void); extern void suite_agent_clients(void); @@ -2296,6 +2305,15 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(doc_links_pdf); RUN_SELECTED_SUITE(doc_links_rst); RUN_SELECTED_SUITE(doc_links_adoc); + RUN_SELECTED_SUITE(doc_mentions_go); + RUN_SELECTED_SUITE(doc_mentions_jvm); + RUN_SELECTED_SUITE(doc_mentions_rs); + RUN_SELECTED_SUITE(doc_mentions_perl); + RUN_SELECTED_SUITE(doc_mentions_ts); + RUN_SELECTED_SUITE(doc_mentions_php); + RUN_SELECTED_SUITE(doc_mentions_c); + RUN_SELECTED_SUITE(diagrams); + RUN_SELECTED_SUITE(openapi); /* Infrastructure scanning */ RUN_SELECTED_SUITE(infrascan); diff --git a/tests/test_openapi.c b/tests/test_openapi.c new file mode 100644 index 000000000..acc336fed --- /dev/null +++ b/tests/test_openapi.c @@ -0,0 +1,242 @@ +/* + * test_openapi.c -- OpenAPI / Swagger documents: each operation a Route named + * as code names it, each operationId a reference to its handler (openapi.c). + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include "test_doc_mentions_helpers.h" +#include "cbm.h" +#include "discover/discover.h" +#include +#include +#include + +static const CBMDefinition *oa_route(const CBMFileResult *r, const char *qn) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->qualified_name && strcmp(d->qualified_name, qn) == 0) { + return d; + } + } + printf(" no definition %s\n", qn); + return NULL; +} + +static const char *oa_prop(const CBMDefinition *d, const char *key) { + for (int i = 0; d && d->extra_props && d->extra_props[i]; i += 2) { + if (strcmp(d->extra_props[i], key) == 0) { + return d->extra_props[i + 1]; + } + } + return NULL; +} + +static int oa_count(const CBMFileResult *r, const char *label) { + int n = 0; + for (int i = 0; i < r->defs.count; i++) { + n += r->defs.items[i].label && strcmp(r->defs.items[i].label, label) == 0; + } + return n; +} + +static int oa_link(const CBMFileResult *r, const char *source_qn, const char *raw) { + int n = 0; + for (int i = 0; i < r->doc_links.count; i++) { + const CBMDocLink *l = &r->doc_links.items[i]; + n += l->syntax == CBM_DOCLINK_OPENAPI_OPERATION_ID && + strcmp(l->source_qn, source_qn) == 0 && strcmp(l->raw, raw) == 0; + } + if (n != 1) { + printf(" link %s -> %s: %d found\n", source_qn, raw, n); + } + return n; +} + +static const char OA_YAML[] = "openapi: 3.0.0\n" + "info:\n" + " title: Users\n" + "paths:\n" + " /users/{id}:\n" + " x-openapi-router-controller: app.api.users\n" + " get:\n" + " operationId: get_user\n" + " delete:\n" + " operationId: deleteUser\n" + " x-openapi-router-controller: app.admin\n" + " /users:\n" + " post:\n" + " operationId: \"app.api.users.create_user\"\n" + " parameters: []\n" + " /health:\n" + " get:\n" + " summary: no id\n"; + +TEST(openapi_yaml) { + CBMFileResult *r = dm_extract(OA_YAML, CBM_LANG_YAML, "api/openapi.yaml"); + ASSERT_NOT_NULL(r); + const CBMDefinition *g = oa_route(r, "__route__GET__/users/{}"); + ASSERT_NOT_NULL(g); + ASSERT_STR_EQ(g->label, "Route"); + ASSERT_STR_EQ(g->name, "/users/{id}"); + ASSERT_STR_EQ(oa_prop(g, "method"), "GET"); + ASSERT_STR_EQ(oa_prop(g, "source"), "openapi"); + ASSERT_STR_EQ(oa_prop(g, "operation_id"), "get_user"); + ASSERT_NOT_NULL(oa_route(r, "__route__DELETE__/users/{}")); + ASSERT_NOT_NULL(oa_route(r, "__route__POST__/users")); + ASSERT_NULL(oa_prop(oa_route(r, "__route__GET__/health"), "operation_id")); + ASSERT_EQ(oa_count(r, "Route"), 4); /* `parameters` is no operation */ + /* the controller names the module of a bare id; a dotted id is whole */ + ASSERT_EQ(oa_link(r, "__route__GET__/users/{}", "app.api.users.get_user"), 1); + ASSERT_EQ(oa_link(r, "__route__DELETE__/users/{}", "app.admin.deleteUser"), 1); + ASSERT_EQ(oa_link(r, "__route__POST__/users", "app.api.users.create_user"), 1); + ASSERT_EQ(r->doc_links.count, 3); + cbm_free_result(r); + + /* YAML that describes no API is none */ + r = dm_extract("paths:\n /x:\n get:\n operationId: x\n", CBM_LANG_YAML, "conf.yaml"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(oa_count(r, "Route"), 0); + cbm_free_result(r); + PASS(); +} + +TEST(openapi_json) { + static const char spec[] = + "{\"swagger\": \"2.0\", \"info\": {\"title\": \"Pets\"},\n" + " \"paths\": {\"/pets\": {\"get\": {\"operationId\": \"listPets\"},\n" + " \"post\": {\"operationId\": \"pets.create\"}}}}\n"; + /* openapi.json: its operations, not a node per key */ + CBMFileResult *r = dm_extract(spec, CBM_LANG_OPENAPI, "openapi.json"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(oa_route(r, "__route__GET__/pets")); + ASSERT_EQ(oa_link(r, "__route__GET__/pets", "listPets"), 1); + ASSERT_EQ(oa_link(r, "__route__POST__/pets", "pets.create"), 1); + ASSERT_EQ(oa_count(r, "Variable"), 0); + cbm_free_result(r); + /* a JSON file of another name that describes an API */ + r = dm_extract(spec, CBM_LANG_JSON, "spec/pets-api.json"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(oa_count(r, "Route"), 2); + cbm_free_result(r); + /* the document names it */ + ASSERT_EQ(cbm_language_for_filename("openapi.json"), CBM_LANG_OPENAPI); + ASSERT_EQ(cbm_language_for_filename("swagger.json"), CBM_LANG_OPENAPI); + PASS(); +} + +/* MENTIONS edges from the Route `route_qn` to a node whose QN ends with `tgt`. */ +static int oa_db_mentions(const char *db, const char *route_qn, const char *tgt) { + sqlite3 *h = NULL; + int n = 0; + if (sqlite3_open_v2(db, &h, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(h); + return -1; + } + sqlite3_stmt *st = NULL; + const char *sql = "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 = 'MENTIONS' " + "AND s.qualified_name = ?1 AND t.qualified_name LIKE '%' || ?2"; + if (sqlite3_prepare_v2(h, sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, route_qn, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, tgt, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) { + n = sqlite3_column_int(st, 0); + } + } + sqlite3_finalize(st); + sqlite3_close(h); + return n; +} + +/* MENTIONS edges from the Route `route_qn` whose properties hold `part`. */ +static int oa_db_mentions_props(const char *db, const char *route_qn, const char *part) { + sqlite3 *h = NULL; + int n = 0; + if (sqlite3_open_v2(db, &h, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(h); + return -1; + } + sqlite3_stmt *st = NULL; + const char *sql = "SELECT count(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "WHERE e.type = 'MENTIONS' AND s.qualified_name = ?1 " + "AND e.properties LIKE '%' || ?2 || '%'"; + if (sqlite3_prepare_v2(h, sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, route_qn, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, part, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) { + n = sqlite3_column_int(st, 0); + } + } + sqlite3_finalize(st); + sqlite3_close(h); + return n; +} + +static int oa_db_nodes(const char *db, const char *label, const char *qn) { + sqlite3 *h = NULL; + int n = 0; + if (sqlite3_open_v2(db, &h, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(h); + return -1; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(h, "SELECT count(*) FROM nodes WHERE label = ?1 AND qualified_name = ?2", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, label, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, qn, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) { + n = sqlite3_column_int(st, 0); + } + } + sqlite3_finalize(st); + sqlite3_close(h); + return n; +} + +TEST(openapi_graph) { + dm_ship_held_families(); /* the operationId family is held until its audit */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_oa_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(db, sizeof(db), "%s/g.db", tmp); + th_write_file(TH_PATH(repo, "app/api/users.py"), + "def get_user(id):\n return id\n\n\ndef create_user():\n return 1\n"); + th_write_file(TH_PATH(repo, "api/openapi.yaml"), OA_YAML); + th_write_file(TH_PATH(repo, "openapi.json"), + "{\"openapi\": \"3.1.0\", \"paths\": {\"/pets\": {\"get\": " + "{\"operationId\": \"listPets\"}}, \"/dup\": {\"get\": " + "{\"operationId\": \"dup\"}}, \"/none\": {\"get\": " + "{\"operationId\": \"noSuchHandler\"}}}}\n"); + th_write_file(TH_PATH(repo, "app/pets.py"), "def listPets():\n return []\n\n\ndef dup():\n" + " return 1\n"); + th_write_file(TH_PATH(repo, "app/other.py"), "def dup():\n return 2\n"); + ASSERT_EQ(dm_index(repo, db, NULL), 0); + ASSERT_EQ(oa_db_nodes(db, "Route", "__route__GET__/users/{}"), 1); + ASSERT_EQ(oa_db_nodes(db, "Route", "__route__GET__/pets"), 1); + /* a dotted operationId names its handler */ + ASSERT_EQ(oa_db_mentions(db, "__route__GET__/users/{}", "users.get_user"), 1); + ASSERT_EQ(oa_db_mentions(db, "__route__POST__/users", "users.create_user"), 1); + /* through the API document, whichever resolver read its file (a YAML one) */ + ASSERT_EQ(oa_db_mentions_props(db, "__route__POST__/users", "\"via\":\"openapi\""), 1); + ASSERT_EQ(oa_db_mentions_props(db, "__route__POST__/users", "\"tier\":\"exact\""), 1); + ASSERT_EQ(oa_db_mentions_props(db, "__route__GET__/pets", "\"tier\":\"unique\""), 1); + /* a bare id names the one function of that name */ + ASSERT_EQ(oa_db_mentions(db, "__route__GET__/pets", "pets.listPets"), 1); + /* a module with no such function; a bare id two functions carry; one none does */ + ASSERT_EQ(oa_db_mentions(db, "__route__DELETE__/users/{}", ""), 0); + ASSERT_EQ(oa_db_mentions(db, "__route__GET__/dup", ""), 0); + ASSERT_EQ(oa_db_mentions(db, "__route__GET__/none", ""), 0); + th_cleanup(tmp); + cbm_doclink_test_reset_ships(); + PASS(); +} + +SUITE(openapi) { + RUN_TEST(openapi_yaml); + RUN_TEST(openapi_json); + RUN_TEST(openapi_graph); +} diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index ddf7bc5a5..7684868e4 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -18948,11 +18948,12 @@ TEST(pipeline_semantic_edges_carry_p) { PASS(); } -/* The p a stored candidate row must carry: the curve of the row's kind and - * position (read from its evidence) at its score, two decimals; -1 for an - * unknown kind or position. *is_function: a function candidate (top 5). */ -static double doc_row_want(const char *evidence, cbm_sem_doc_format_t fmt, float score, - bool *is_function) { +/* The p a stored candidate row of a section embedding diagrams of family fam + * must carry: the curve of the row's kind and position (read from its + * evidence) at its score, two decimals; -1 for an unknown kind or position. + * *is_function: a function candidate (top 5). */ +static double doc_row_want_family(const char *evidence, cbm_sem_doc_format_t fmt, + cbm_sem_diag_family_t fam, float score, bool *is_function) { static const struct { const char *name; cbm_sem_doc_kind_t kind; @@ -18973,8 +18974,14 @@ static double doc_row_want(const char *evidence, cbm_sem_doc_format_t fmt, float } *is_function = k == 0; return k < 0 || p < 0 ? -1.0 - : cbm_sem_p_2dp(cbm_sem_doc_calibrated_p(fmt, KINDS[k].kind, - POSITIONS[p].pos, score)); + : cbm_sem_p_2dp(cbm_sem_doc_section_p(fmt, fam, KINDS[k].kind, + POSITIONS[p].pos, score)); +} + +/* doc_row_want_family for a section without a diagram. */ +static double doc_row_want(const char *evidence, cbm_sem_doc_format_t fmt, float score, + bool *is_function) { + return doc_row_want_family(evidence, fmt, CBM_SEM_DIAG_NONE, score, is_function); } /* Count a section's stored doc -> code candidates (section QN containing @@ -19219,6 +19226,133 @@ TEST(pipeline_doc_candidates_skip_exact_links_and_use_format_curves) { PASS(); } +/* The rows of a section (its QN contains heading): their count; *bad the + * rows off the curve of the section's diagram family fam; *own the rows + * whose p differs from a section without a diagram. */ +static int doc_family_rows(cbm_store_t *s, const char *heading, cbm_sem_diag_family_t fam, int *bad, + int *own) { + sqlite3_stmt *st = NULL; + *bad = 0; + *own = 0; + if (sqlite3_prepare_v2(cbm_store_get_db(s), + "SELECT score, p, evidence FROM doc_link_candidates " + "WHERE instr(section_qn, ?1) > 0", + -1, &st, NULL) != SQLITE_OK) { + return -1; + } + sqlite3_bind_text(st, 1, heading, -1, SQLITE_TRANSIENT); + int n = 0; + while (sqlite3_step(st) == SQLITE_ROW) { + float score = (float)sqlite3_column_double(st, 0); + double p = sqlite3_column_double(st, 1); + const char *ev = (const char *)sqlite3_column_text(st, 2); + bool is_function = false; + double want = doc_row_want_family(ev, CBM_SEM_DOC_FMT_MARKDOWN, fam, score, &is_function); + *bad += want <= 0.0 || p != want; + *own += want != doc_row_want(ev, CBM_SEM_DOC_FMT_MARKDOWN, score, &is_function); + n++; + } + sqlite3_finalize(st); + return n; +} + +/* The diagram_kind of the Diagram a section (its QN contains heading) + * DEFINES, "" without one. */ +static void doc_section_diagram_kind(cbm_store_t *s, const char *heading, char *out, + size_t out_sz) { + sqlite3_stmt *st = NULL; + out[0] = '\0'; + if (sqlite3_prepare_v2(cbm_store_get_db(s), + "SELECT json_extract(d.properties, '$.diagram_kind') FROM edges e " + "JOIN nodes a ON a.id = e.source_id JOIN nodes d ON d.id = e.target_id " + "WHERE e.type = 'DEFINES' AND a.label = 'Section' " + "AND d.label = 'Diagram' AND instr(a.qualified_name, ?1) > 0", + -1, &st, NULL) != SQLITE_OK) { + return; + } + sqlite3_bind_text(st, 1, heading, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW && sqlite3_column_text(st, 0)) { + snprintf(out, out_sz, "%s", (const char *)sqlite3_column_text(st, 0)); + } + sqlite3_finalize(st); +} + +/* A Markdown section that embeds a diagram takes its diagram family's judged + * curve (DIAG-C): a sequence diagram is structural, a flowchart is prose; a + * section without a diagram keeps the Markdown curve. Some rows of each + * diagram section must carry a p the Markdown curve would not give. */ +TEST(pipeline_doc_candidates_diagram_family_curves) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_docdiag_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + static const char *const subjects[] = {"mail", "cache", "queue", "user", "server", "client"}; + for (size_t i = 0; i < sizeof(subjects) / sizeof(subjects[0]); i++) { + write_py_family(tmp, subjects[i]); + } + char docs[512]; + snprintf(docs, sizeof(docs), "%s/docs", tmp); + ASSERT_TRUE(cbm_mkdir_p(docs, 0755)); + write_temp_file( + tmp, "docs/mail.md", + "# Mail loading\n\n" + "Load the mail config from disk and validate every mail field before the " + "parsed mail settings are returned; missing mail fields fail the validation.\n\n" + "```mermaid\nsequenceDiagram\n" + " participant Loader\n participant Validator\n" + " Loader->>Validator: validate mail config\n```\n\n" + "# Mail writing\n\n" + "Write the mail config back to disk after validating it, so the written " + "mail config file always carries every mail field it needs.\n\n" + "```mermaid\nflowchart TD\n" + " validate[validate mail config] --> write[write mail config]\n```\n\n" + "# Mail parsing\n\n" + "Parse the mail config file and return the parsed mail settings; the mail " + "config is loaded from disk and every mail field is validated first.\n"); + char db[512]; + snprintf(db, sizeof(db), "%s/docdiag.db", tmp); + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + char seq_kind[32]; + char flow_kind[32]; + char none_kind[32]; + doc_section_diagram_kind(s, "Mail-loading", seq_kind, sizeof(seq_kind)); + doc_section_diagram_kind(s, "Mail-writing", flow_kind, sizeof(flow_kind)); + doc_section_diagram_kind(s, "Mail-parsing", none_kind, sizeof(none_kind)); + int seq_bad = 0; + int seq_own = 0; + int flow_bad = 0; + int flow_own = 0; + int none_bad = 0; + int none_own = 0; + int seq = doc_family_rows(s, "Mail-loading", CBM_SEM_DIAG_STRUCTURAL, &seq_bad, &seq_own); + int flow = doc_family_rows(s, "Mail-writing", CBM_SEM_DIAG_PROSE, &flow_bad, &flow_own); + int none = doc_family_rows(s, "Mail-parsing", CBM_SEM_DIAG_NONE, &none_bad, &none_own); + cbm_store_close(s); + th_rmtree(tmp); + cbm_pipeline_incremental_test_reset_faults(); + ASSERT_STR_EQ(seq_kind, "sequence"); + ASSERT_STR_EQ(flow_kind, "flowchart"); + ASSERT_STR_EQ(none_kind, ""); + ASSERT_EQ(cbm_sem_diag_family("mermaid", seq_kind), CBM_SEM_DIAG_STRUCTURAL); + ASSERT_EQ(cbm_sem_diag_family("mermaid", flow_kind), CBM_SEM_DIAG_PROSE); + ASSERT_GT(seq, 0); + ASSERT_EQ(seq_bad, 0); + ASSERT_GT(seq_own, 0); + ASSERT_GT(flow, 0); + ASSERT_EQ(flow_bad, 0); + ASSERT_GT(flow_own, 0); + ASSERT_GT(none, 0); + ASSERT_EQ(none_bad, 0); + ASSERT_EQ(none_own, 0); + PASS(); +} + /* Lines of a CBM_SEM_DOC_SIGNALS dump whose section file, kind, position, * home and target file equal the given values (NULL: any). */ static int doc_signal_count(const char *dump, const char *sec_file, const char *kind, @@ -20312,6 +20446,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_doc_candidates_published_and_kept_by_delta); RUN_TEST(pipeline_doc_candidates_skip_exact_links_and_use_format_curves); RUN_TEST(pipeline_doc_candidates_home_position_and_kinds); + RUN_TEST(pipeline_doc_candidates_diagram_family_curves); RUN_TEST(pipeline_cpp_static_factory_pointer_receiver_issue1153); RUN_TEST(pipeline_cpp_static_factory_reference_receiver_issue1153); RUN_TEST(pipeline_cpp_static_factory_unique_ptr_receiver_issue1153); diff --git a/tests/test_semantic.c b/tests/test_semantic.c index f1ebd09f7..654c1b536 100644 --- a/tests/test_semantic.c +++ b/tests/test_semantic.c @@ -611,6 +611,86 @@ TEST(sem_doc_calibrated_p_bands) { PASS(); } +/* A section's diagram family (DIAG-C): Mermaid and PlantUML structural kinds, + * any other Mermaid or PlantUML kind is prose, any other format a graph. */ +TEST(sem_diag_family_by_format_and_kind) { + ASSERT_EQ(cbm_sem_diag_family("mermaid", "sequence"), CBM_SEM_DIAG_STRUCTURAL); + ASSERT_EQ(cbm_sem_diag_family("mermaid", "class"), CBM_SEM_DIAG_STRUCTURAL); + ASSERT_EQ(cbm_sem_diag_family("mermaid", "er"), CBM_SEM_DIAG_STRUCTURAL); + ASSERT_EQ(cbm_sem_diag_family("mermaid", "c4_deployment"), CBM_SEM_DIAG_STRUCTURAL); + ASSERT_EQ(cbm_sem_diag_family("mermaid", "architecture"), CBM_SEM_DIAG_STRUCTURAL); + ASSERT_EQ(cbm_sem_diag_family("mermaid", "flowchart"), CBM_SEM_DIAG_PROSE); + ASSERT_EQ(cbm_sem_diag_family("mermaid", "state"), CBM_SEM_DIAG_PROSE); + ASSERT_EQ(cbm_sem_diag_family("mermaid", "usecase"), CBM_SEM_DIAG_PROSE); + ASSERT_EQ(cbm_sem_diag_family("mermaid", NULL), CBM_SEM_DIAG_PROSE); + ASSERT_EQ(cbm_sem_diag_family("plantuml", "usecase"), CBM_SEM_DIAG_STRUCTURAL); + ASSERT_EQ(cbm_sem_diag_family("plantuml", "component"), CBM_SEM_DIAG_STRUCTURAL); + ASSERT_EQ(cbm_sem_diag_family("plantuml", "c4"), CBM_SEM_DIAG_STRUCTURAL); + ASSERT_EQ(cbm_sem_diag_family("plantuml", "activity"), CBM_SEM_DIAG_PROSE); + ASSERT_EQ(cbm_sem_diag_family("plantuml", "er"), CBM_SEM_DIAG_PROSE); + ASSERT_EQ(cbm_sem_diag_family("dot", "digraph"), CBM_SEM_DIAG_GRAPH); + ASSERT_EQ(cbm_sem_diag_family("d2", "d2"), CBM_SEM_DIAG_GRAPH); + ASSERT_EQ(cbm_sem_diag_family("", "sequence"), CBM_SEM_DIAG_NONE); + ASSERT_EQ(cbm_sem_diag_family(NULL, NULL), CBM_SEM_DIAG_NONE); + PASS(); +} + +/* A Markdown section that embeds a diagram (DIAG-C): function candidates at + * or inside the home and local extras carry the family's judged curve, from + * the first band; outside functions, files, other formats, graph diagrams + * and sections without a diagram keep cbm_sem_doc_calibrated_p. */ +TEST(sem_doc_section_p_diagram_families) { + const cbm_sem_doc_format_t md = CBM_SEM_DOC_FMT_MARKDOWN; + const cbm_sem_doc_kind_t fn = CBM_SEM_DOC_KIND_FUNCTION; + const cbm_sem_doc_kind_t lx = CBM_SEM_DOC_KIND_LOCAL; + const cbm_sem_doc_pos_t global = CBM_SEM_DOC_POS_GLOBAL; + const cbm_sem_doc_pos_t local = CBM_SEM_DOC_POS_LOCAL; + const cbm_sem_doc_pos_t outside = CBM_SEM_DOC_POS_OUTSIDE; + const cbm_sem_diag_family_t prose = CBM_SEM_DIAG_PROSE; + const cbm_sem_diag_family_t st = CBM_SEM_DIAG_STRUCTURAL; + ASSERT_FLOAT_EQ(cbm_sem_doc_section_p(md, prose, fn, global, 0.199F), 0.0F, 0.0); + ASSERT_FLOAT_EQ(cbm_sem_doc_section_p(md, prose, fn, global, 0.20F), 0.24F, 0.0); + ASSERT_FLOAT_EQ(cbm_sem_doc_section_p(md, prose, fn, local, 0.30F), 0.28F, 0.0); + ASSERT_FLOAT_EQ(cbm_sem_doc_section_p(md, prose, fn, global, 0.40F), 0.45F, 0.0); + ASSERT_FLOAT_EQ(cbm_sem_doc_section_p(md, prose, lx, local, 0.20F), 0.275F, 0.0); + ASSERT_FLOAT_EQ(cbm_sem_doc_section_p(md, st, fn, local, 0.25F), 0.32F, 0.0); + ASSERT_FLOAT_EQ(cbm_sem_doc_section_p(md, st, fn, global, 0.35F), 0.464F, 0.0); + ASSERT_FLOAT_EQ(cbm_sem_doc_section_p(md, st, fn, local, 0.90F), 0.567F, 0.0); + ASSERT_FLOAT_EQ(cbm_sem_doc_section_p(md, st, lx, local, 0.50F), 0.778F, 0.0); + ASSERT_FLOAT_EQ(cbm_sem_doc_section_p(md, st, lx, local, 0.199F), 0.0F, 0.0); + /* stored as two decimals: 0.275 -> 0.28, 0.464 -> 0.46, 0.567 -> 0.57 */ + ASSERT_FLOAT_EQ(cbm_sem_p_2dp(cbm_sem_doc_section_p(md, prose, lx, local, 0.5F)), 0.28, 1e-12); + ASSERT_FLOAT_EQ(cbm_sem_p_2dp(cbm_sem_doc_section_p(md, st, fn, local, 0.35F)), 0.46, 1e-12); + static const cbm_sem_doc_kind_t KINDS[] = {CBM_SEM_DOC_KIND_FUNCTION, CBM_SEM_DOC_KIND_LOCAL, + CBM_SEM_DOC_KIND_FILE}; + for (int fam = 0; fam < CBM_SEM_DIAG_COUNT; fam++) { + for (size_t k = 0; k < sizeof(KINDS) / sizeof(KINDS[0]); k++) { + for (int pos = 0; pos < CBM_SEM_DOC_POS_COUNT; pos++) { + float last = 0.0F; + for (int milli = 0; milli <= 1000; milli++) { + float s = (float)milli / 1000.0F; + cbm_sem_doc_pos_t ps = (cbm_sem_doc_pos_t)pos; + float p = + cbm_sem_doc_section_p(md, (cbm_sem_diag_family_t)fam, KINDS[k], ps, s); + ASSERT_TRUE(p >= last); + last = p; + bool own = (fam == prose || fam == st) && ((KINDS[k] == fn && pos != outside) || + (KINDS[k] == lx && pos == local)); + if (!own) { + ASSERT_FLOAT_EQ(p, cbm_sem_doc_calibrated_p(md, KINDS[k], ps, s), 0.0); + } + ASSERT_FLOAT_EQ( + cbm_sem_doc_section_p(CBM_SEM_DOC_FMT_RST, (cbm_sem_diag_family_t)fam, + KINDS[k], ps, s), + cbm_sem_doc_calibrated_p(CBM_SEM_DOC_FMT_RST, KINDS[k], ps, s), 0.0); + } + } + } + } + ASSERT_FLOAT_EQ(cbm_sem_doc_section_p(md, CBM_SEM_DIAG_COUNT, fn, global, 0.25F), 0.0F, 0.0); + PASS(); +} + /* p is stored and shown at two decimals, half up, whatever the float * representation of the judged value (0.225F is 0.22499999...). */ TEST(sem_p_two_decimals_half_up) { @@ -841,6 +921,8 @@ SUITE(semantic) { RUN_TEST(sem_admit_best_first_keeps_the_best); RUN_TEST(sem_calibrated_p_bands); RUN_TEST(sem_doc_calibrated_p_bands); + RUN_TEST(sem_diag_family_by_format_and_kind); + RUN_TEST(sem_doc_section_p_diagram_families); RUN_TEST(sem_doc_format_by_extension); RUN_TEST(sem_p_two_decimals_half_up); RUN_TEST(sem_json_str_honours_escapes);