diff --git a/Makefile.cbm b/Makefile.cbm index 4cfb47783..ffb0cb0e7 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -392,6 +392,7 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/doclink_perl.c \ $(CBM_DIR)/doclink_ts.c \ $(CBM_DIR)/doclink_php.c \ + $(CBM_DIR)/doclink_c.c \ $(CBM_DIR)/sql_values.c \ $(CBM_DIR)/helpers.c \ $(CBM_DIR)/cpp_branch_views.c \ @@ -524,6 +525,7 @@ PIPELINE_SRCS = \ 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/doc_links_msbuild.c \ src/pipeline/pass_route_nodes.c \ src/pipeline/pass_enrichment.c \ @@ -786,7 +788,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_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_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_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/doclink.c b/internal/cbm/doclink.c index cb65126b1..18a65cef1 100644 --- a/internal/cbm/doclink.c +++ b/internal/cbm/doclink.c @@ -152,6 +152,8 @@ typedef struct { /* A document language: the whole file is the documentation, so one scan * of its bytes replaces parse_doc, the file-level doc and the scope. */ void (*scan_file)(CBMExtractCtx *ctx); + void (*loose_docs)(CBMExtractCtx *ctx, const CBMDefinition *file_def, + cbm_doclink_taken_fn taken); } doclink_lang_t; static const doclink_lang_t DOCLINK_LANGS[] = { @@ -234,6 +236,17 @@ static const doclink_lang_t DOCLINK_LANGS[] = { .scan_scope = cbm_doclink_ts_scan_scope, .scope_tag = CBM_DOCLINK_TS_SCOPE_TAG, .portable_scope = cbm_doclink_ts_portable_scope}, + /* C, and the .h files C++ parses (the hooks skip every other file) */ + {.lang = CBM_LANG_C, + .parse_doc = cbm_doclink_c_parse_doc, + .scan_scope = cbm_doclink_c_scan_scope, + .scope_tag = CBM_DOCLINK_C_SCOPE_TAG, + .loose_docs = cbm_doclink_c_loose_docs}, + {.lang = CBM_LANG_CPP, + .parse_doc = cbm_doclink_c_parse_doc, + .scan_scope = cbm_doclink_c_scan_scope, + .scope_tag = CBM_DOCLINK_C_SCOPE_TAG, + .loose_docs = cbm_doclink_c_loose_docs}, {.lang = CBM_LANG_PHP, .parse_doc = cbm_doclink_php_parse_doc, .scan_scope = cbm_doclink_php_scan_scope, @@ -359,12 +372,6 @@ static doc_line_ent_t *doc_info_of(CBMExtractCtx *ctx, const char *doc) { return NULL; } -/* A doc not built by doc_run_text keeps the definition's own line. */ -static uint32_t doc_line_of(CBMExtractCtx *ctx, const char *doc) { - const doc_line_ent_t *info = doc_info_of(ctx, doc); - return info ? info->line : 0; -} - /* ── Persisted scope ─────────────────────────────────────────────── */ char *cbm_doclink_portable_scope(const char *scope) { @@ -461,6 +468,55 @@ static doclink_twin_key_t *twin_keys(CBMExtractCtx *ctx, const char *label, int return keys; } +/* True when a doc taken from a definition, or the file's own doc, starts on + * one of the lines [first, last]. */ +static bool doclink_taken(const CBMExtractCtx *ctx, uint32_t first, uint32_t last) { + const doc_line_map_t *m = (const doc_line_map_t *)ctx->doc_lines; + for (int i = 0; m && i < m->count; i++) { + if (m->items[i].parsed && m->items[i].line >= first && m->items[i].line <= last) { + return true; + } + } + return false; +} + +/* The file's own doc, then the language's loose docs: their references belong + * to the file. The parser gets a definition-shaped stand-in for the file; what + * it pushes is marked, and the resolving half takes the File node as the + * source. No qualified name for that node is computed on this side. */ +static void doclink_file_pass(CBMExtractCtx *ctx, const doclink_lang_t *L) { + const char *file_doc = ctx->result->module_doc; + bool has_doc = file_doc && file_doc[0]; + if (!has_doc && !L->loose_docs) { + return; + } + CBMDocLinkArray *arr = &ctx->result->doc_links; + int first = arr->count; + CBMDefinition file_def = { + .name = ctx->rel_path, + .qualified_name = ctx->module_qn ? ctx->module_qn : "", + .label = "File", + .file_path = ctx->rel_path, + .start_line = SKIP_ONE, + .end_line = SKIP_ONE, + .docstring = file_doc, + }; + if (has_doc) { + doc_line_ent_t *info = doc_info_of(ctx, file_doc); + uint32_t doc_line = info ? info->line : 0; + L->parse_doc(ctx, &file_def, file_doc, doc_line ? doc_line : file_def.start_line); + if (info) { + info->parsed = true; + } + } + if (L->loose_docs) { + L->loose_docs(ctx, &file_def, doclink_taken); + } + for (int i = first; i < arr->count; i++) { + arr->items[i].flags |= CBM_DOCLINK_FLAG_FILE; + } +} + void cbm_doclinks_extract(CBMExtractCtx *ctx) { if (!ctx || !ctx->result) { return; @@ -520,29 +576,7 @@ void cbm_doclinks_extract(CBMExtractCtx *ctx) { info->parsed = true; } } - /* The file's own doc: its references belong to the file. The parser gets - * a definition-shaped stand-in for the file; what it pushes is marked, and - * the resolving half takes the File node as the source. No qualified name - * for that node is computed on this side. */ - const char *file_doc = ctx->result->module_doc; - if (file_doc && file_doc[0]) { - CBMDocLinkArray *arr = &ctx->result->doc_links; - int first = arr->count; - CBMDefinition file_def = { - .name = ctx->rel_path, - .qualified_name = ctx->module_qn ? ctx->module_qn : "", - .label = "File", - .file_path = ctx->rel_path, - .start_line = SKIP_ONE, - .end_line = SKIP_ONE, - .docstring = file_doc, - }; - uint32_t doc_line = doc_line_of(ctx, file_doc); - L->parse_doc(ctx, &file_def, file_doc, doc_line ? doc_line : file_def.start_line); - for (int i = first; i < arr->count; i++) { - arr->items[i].flags |= CBM_DOCLINK_FLAG_FILE; - } - } + doclink_file_pass(ctx, L); if (L->scan_scope) { ctx->result->doc_scope = L->scan_scope(ctx); } diff --git a/internal/cbm/doclink.h b/internal/cbm/doclink.h index 8e3313009..cce493488 100644 --- a/internal/cbm/doclink.h +++ b/internal/cbm/doclink.h @@ -22,6 +22,10 @@ * identical on full and incremental runs. Optional. * scope_tag, the blob's tag line and its persisted form (line * portable_scope numbers dropped). Optional. + * loose_docs after the definitions and the file's own doc: the + * file's other doc comments (C: a prototype's doc, + * which the graph keeps no node for); what it pushes + * belongs to the file. Optional. * scan_file instead of parse_doc, for a DOCUMENT language whose * text is the documentation (Markdown): one pass over * the file's bytes pushes every reference, with the @@ -106,6 +110,9 @@ X(CBM_DOCLINK_TS_SEE, CBM_LANG_TYPESCRIPT, "see", false, false, "MENTIONS") \ /* php (doclink_php.c): PHPDoc @see and {@see}; held until their audit passes */ \ X(CBM_DOCLINK_PHP_SEE, CBM_LANG_PHP, "see", false, false, "MENTIONS") \ + /* c (doclink_c.c): Doxygen @ref and @see in .c and .h files; held until their audit */ \ + X(CBM_DOCLINK_C_REF, CBM_LANG_C, "doxygen_ref", false, false, "MENTIONS") \ + X(CBM_DOCLINK_C_SEE, CBM_LANG_C, "see", false, false, "MENTIONS") \ /* markdown (doclink_md.c): what a document's text names explicitly */ \ X(CBM_DOCLINK_MD_LINK, CBM_LANG_MARKDOWN, "link", false, true, "MENTIONS") \ X(CBM_DOCLINK_MD_PATH, CBM_LANG_MARKDOWN, "path", false, true, "MENTIONS") \ @@ -591,6 +598,26 @@ size_t cbm_doclink_php_qualify(char *out, size_t cap, const char *ns, size_t ns_ const cbm_doclink_php_use_t *uses, int nuses, const char *ref, size_t n); +/* ── C (doclink_c.c) ───────────────────────────────────────────────── */ + +/* Asked by a loose_docs hook for a doc comment on lines [first, last]: true + * when a definition's doc or the file's own doc there was already taken. */ +typedef bool (*cbm_doclink_taken_fn)(const CBMExtractCtx *ctx, uint32_t first, uint32_t last); + +void cbm_doclink_c_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line); +void cbm_doclink_c_loose_docs(CBMExtractCtx *ctx, const CBMDefinition *file_def, + cbm_doclink_taken_fn taken); +const char *cbm_doclink_c_scan_scope(CBMExtractCtx *ctx); + +/* C files and the headers C++ parses share one scope tag: one resolver + * indexes them. The blob holds no line: it is its own persisted form. */ +#define CBM_DOCLINK_C_SCOPE_TAG "c1" + +/* The longest reference a C token keeps; a longer one is cut just past this + * length and the resolver reports it as unparseable. */ +enum { CBM_DOCLINK_C_REF_MAX = 256 }; + /* ── Perl POD (doclink_perl.c) ─────────────────────────────────────── */ void cbm_doclink_perl_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, diff --git a/internal/cbm/doclink_c.c b/internal/cbm/doclink_c.c new file mode 100644 index 000000000..fa8ad5c22 --- /dev/null +++ b/internal/cbm/doclink_c.c @@ -0,0 +1,760 @@ +/* + * doclink_c.c — C doc-comment references (Doxygen) and the C doc-link scope. + * + * Files. Only .c and .h files: a .h is parsed as C++, which does not change + * that; other C++ and Objective-C sources are not part of this leg. + * + * Docs. The four Doxygen comment styles: blocks opening with slash-star-star + * or slash-star-bang, and runs of lines opening with three slashes or with + * slash-slash-bang (a `<` after the opener marks a member's trailing doc). A + * definition's stored doc goes through cbm_doclink_c_parse_doc. Every other + * Doxygen block of the file outside function bodies (a prototype's doc in a + * header, which the graph keeps no node for; a group's text) goes through + * cbm_doclink_c_loose_docs, and its references belong to the file. + * + * Tokens: + * - `@ref name` or `\ref name`, an optional quoted label after it: family + * doxygen_ref; + * - the names listed after `@see`, `\see`, `@sa` or `\sa` (the paragraph up + * to an empty line or the next command at the start of a line) that have + * the shape of a symbol: `name`, `name()`, `::name`: family see. An + * explicit @ref inside such a paragraph is a doxygen_ref token. + * Text inside \code, \verbatim, \dot, \msc or \startuml blocks, a Markdown + * fence or a code span holds none, nor does the word after \c, \p, \a, \b, \e + * or \em. A URL is the core's href family. + * + * Scope blob, one record per line after the tag line "c1" (tab-separated): + * D kind link name qn parent a definition the graph has a node for: + * kind f (function), v (variable), t (type: typedef, + * struct, union or enum), e (enum constant), r (a + * struct or union field), m (object-like macro), M + * (function-like macro); link x (visible in every file: + * external linkage, or a type, constant or macro of a + * header) or s (this file only: static, a type, + * constant or macro of a .c file, a field); qn the + * node's qualified name; parent the enum of a constant + * or the struct of a field ("-" for none) + * A name a Doxygen page, group, anchor or section the file's + * docs define + * The blob holds no line, so it is its own persisted form. + */ +#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 + +enum { C_OPENER_LEN = 3, C_FENCE_LEN = 3, C_CMD_MAX = 32 }; + +/* True for a .c or .h file: the only files this leg reads. */ +static bool c_leg_file(const char *rel_path) { + size_t n = rel_path ? strlen(rel_path) : 0; + return n > PAIR_LEN && rel_path[n - PAIR_LEN] == '.' && + (rel_path[n - SKIP_ONE] == 'c' || rel_path[n - SKIP_ONE] == 'h'); +} + +/* ── Doc lines ───────────────────────────────────────────────────── */ + +typedef struct { + const char *p; + size_t n; + uint32_t line; +} c_line_t; + +static bool c_has(const char *p, size_t n, const char *lit) { + size_t l = strlen(lit); + return n >= l && memcmp(p, lit, l) == 0; +} + +/* One raw line of a doc comment without its comment marks. */ +static c_line_t c_strip_line(const char *s, const char *le, bool first, bool *block, + uint32_t line) { + while (s < le && (*s == ' ' || *s == '\t')) { + s++; + } + size_t n = (size_t)(le - s); + if (first && (c_has(s, n, "/**") || c_has(s, n, "/*!"))) { + *block = true; + s += C_OPENER_LEN; + } else if (!*block && (c_has(s, n, "///") || c_has(s, n, "//!"))) { + s += C_OPENER_LEN; + } else if (*block && s < le && *s == '*' && !(s + SKIP_ONE < le && s[SKIP_ONE] == '/')) { + s++; + } + if (s < le && *s == '<') { + s++; /* a member's trailing doc */ + } + const char *t = le; + while (t > s && (t[-SKIP_ONE] == ' ' || t[-SKIP_ONE] == '\t' || t[-SKIP_ONE] == '\r')) { + t--; + } + if (*block && t - s >= PAIR_LEN && t[-SKIP_ONE] == '/' && t[-PAIR_LEN] == '*') { + t -= PAIR_LEN; + while (t > s && (t[-SKIP_ONE] == ' ' || t[-SKIP_ONE] == '\t')) { + t--; + } + } + while (s < t && (*s == ' ' || *s == '\t')) { + s++; + } + return (c_line_t){s, (size_t)(t - s), line}; +} + +/* The doc's lines without their comment marks. */ +static int c_doc_lines(CBMArena *a, const char *doc, uint32_t doc_line, c_line_t **out) { + int cap = SKIP_ONE; + for (const char *p = doc; *p; p++) { + cap += *p == '\n'; + } + c_line_t *lines = (c_line_t *)cbm_arena_alloc(a, (size_t)cap * sizeof(c_line_t)); + if (!lines) { + return 0; + } + int n = 0; + bool block = false; + for (const char *p = doc; n < cap;) { + const char *le = strchr(p, '\n'); + if (!le) { + le = p + strlen(p); + } + lines[n] = c_strip_line(p, le, n == 0, &block, doc_line + (uint32_t)n); + n++; + if (!*le) { + break; + } + p = le + SKIP_ONE; + } + *out = lines; + return n; +} + +/* ── Tokens ──────────────────────────────────────────────────────── */ + +typedef struct { + CBMExtractCtx *ctx; + const CBMDefinition *def; + bool see; /* inside an @see paragraph */ + int verbatim; /* inside a code block: the index of its end command, else -1 */ + bool fence; +} c_emit_t; + +static bool c_ident_start(char c) { + return isalpha((unsigned char)c) || c == '_'; +} + +static bool c_ident_char(char c) { + return isalnum((unsigned char)c) || c == '_'; +} + +static bool c_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 c_push(const c_emit_t *em, const char *ref, size_t n, int syntax, uint32_t line) { + if (n == 0) { + return; + } + if (n > CBM_DOCLINK_C_REF_MAX) { + n = CBM_DOCLINK_C_REF_MAX + SKIP_ONE; /* clipped: the resolver calls it unparseable */ + } + if (c_is_url(ref, n)) { + syntax = CBM_DOCLINK_HREF; + } + 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); +} + +/* Mark the bytes of a line that sit in a code span or a quoted label. */ +static void c_mask(const c_line_t *ln, bool *mask) { + for (size_t i = 0; i < ln->n;) { + char q = ln->p[i]; + if (q != '`' && q != '"') { + i++; + continue; + } + size_t j = i + SKIP_ONE; + while (j < ln->n && ln->p[j] != q) { + j++; + } + if (j >= ln->n) { + i++; /* unpaired */ + continue; + } + for (size_t k = i; k <= j; k++) { + mask[k] = true; + } + i = j + SKIP_ONE; + } +} + +/* A command at p[i] (`\word` or `@word`, not inside a word or an address): + * the word's length, else 0. */ +static size_t c_command_at(const char *p, size_t i, size_t n) { + if ((p[i] != '\\' && p[i] != '@') || + (i > 0 && + (c_ident_char(p[i - SKIP_ONE]) || p[i - SKIP_ONE] == '\\' || p[i - SKIP_ONE] == '@'))) { + return 0; + } + size_t k = i + SKIP_ONE; + while (k < n && isalpha((unsigned char)p[k])) { + k++; + } + return k - i - SKIP_ONE; +} + +static bool c_word_is(const char *w, size_t n, const char *lit) { + return strlen(lit) == n && memcmp(w, lit, n) == 0; +} + +static bool c_inline_cmd(const char *w, size_t n) { + return c_word_is(w, n, "c") || c_word_is(w, n, "p") || c_word_is(w, n, "a") || + c_word_is(w, n, "b") || c_word_is(w, n, "e") || c_word_is(w, n, "em"); +} + +static const char *const C_BLOCK_OPEN[] = {"code", "verbatim", "dot", "msc", "startuml", NULL}; +static const char *const C_BLOCK_CLOSE[] = {"endcode", "endverbatim", "enddot", + "endmsc", "enduml", NULL}; + +static int c_block_index(const char *const *list, const char *w, size_t n) { + for (int i = 0; list[i]; i++) { + if (c_word_is(w, n, list[i])) { + return i; + } + } + return CBM_NOT_FOUND; +} + +/* The end of the next non-space position of p[j, n). */ +static size_t c_skip_space(const char *p, size_t j, size_t n) { + while (j < n && isspace((unsigned char)p[j])) { + j++; + } + return j; +} + +/* `@ref name`: the name after the command (sentence punctuation dropped, + * `()` kept); returns the position after it. */ +static size_t c_ref(const c_emit_t *em, const c_line_t *ln, size_t j, const bool *mask) { + j = c_skip_space(ln->p, j, ln->n); + size_t k = j; + while (k < ln->n && !isspace((unsigned char)ln->p[k]) && ln->p[k] != '"' && !mask[k]) { + k++; + } + size_t e = k; + while (e > j && strchr(".,;:", ln->p[e - SKIP_ONE])) { + e--; + } + if (e > j && ln->p[e - SKIP_ONE] == ')' && !(e - j >= PAIR_LEN && ln->p[e - PAIR_LEN] == '(')) { + e--; /* a closing parenthesis of the prose around it */ + } + c_push(em, ln->p + j, e - j, CBM_DOCLINK_C_REF, ln->line); + return k; +} + +/* A word of an @see paragraph: a token when it has the shape of a symbol. */ +static void c_see_word(const c_emit_t *em, const char *w, size_t n, uint32_t line) { + while (n > 0 && strchr(".,;:", w[n - SKIP_ONE])) { + n--; + } + if (c_is_url(w, n)) { + c_push(em, w, n, CBM_DOCLINK_HREF, line); + return; + } + size_t i = (n >= PAIR_LEN && w[0] == ':' && w[SKIP_ONE] == ':') ? PAIR_LEN : 0; + if (i >= n || !c_ident_start(w[i])) { + return; + } + while (i < n && c_ident_char(w[i])) { + i++; + } + if (i + PAIR_LEN == n && w[i] == '(' && w[i + SKIP_ONE] == ')') { + i = n; + } + if (i == n) { + c_push(em, w, n, CBM_DOCLINK_C_SEE, line); + } +} + +/* One line outside code blocks: commands, and the words of an @see + * paragraph. */ +static void c_line(c_emit_t *em, const c_line_t *ln, const bool *mask) { + size_t i = 0; + bool line_start = true; + while (i < ln->n) { + if (mask[i] || isspace((unsigned char)ln->p[i]) || ln->p[i] == ',') { + i++; + continue; + } + size_t cl = c_command_at(ln->p, i, ln->n); + if (cl > 0) { + const char *w = ln->p + i + SKIP_ONE; + size_t j = i + SKIP_ONE + cl; + if (c_word_is(w, cl, "ref")) { + i = c_ref(em, ln, j, mask); + } else if (c_word_is(w, cl, "see") || c_word_is(w, cl, "sa")) { + em->see = true; + i = j; + } else if (c_inline_cmd(w, cl)) { + j = c_skip_space(ln->p, j, ln->n); + while (j < ln->n && !isspace((unsigned char)ln->p[j])) { + j++; /* the word it formats is text */ + } + i = j; + } else { + int open = c_block_index(C_BLOCK_OPEN, w, cl); + if (open >= 0) { + em->verbatim = open; + return; + } + if (line_start) { + em->see = false; /* a new paragraph */ + } + i = j; + } + line_start = false; + continue; + } + size_t k = i; + while (k < ln->n && !isspace((unsigned char)ln->p[k]) && ln->p[k] != ',' && !mask[k] && + c_command_at(ln->p, k, ln->n) == 0) { + k++; + } + if (k == i) { + k = i + SKIP_ONE; + } else if (em->see) { + c_see_word(em, ln->p + i, k - i, ln->line); + } + line_start = false; + i = k; + } +} + +/* The end command of the open code block on this line, if any. */ +static bool c_closes_block(const c_line_t *ln, int open) { + for (size_t i = 0; i < ln->n; i++) { + size_t cl = c_command_at(ln->p, i, ln->n); + if (cl && c_word_is(ln->p + i + SKIP_ONE, cl, C_BLOCK_CLOSE[open])) { + return true; + } + } + return false; +} + +static void c_harvest(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line) { + CBMArena *a = ctx->scratch ? ctx->scratch : ctx->arena; + c_line_t *lines = NULL; + int n = c_doc_lines(a, doc, doc_line, &lines); + c_emit_t em = {ctx, def, false, CBM_NOT_FOUND, false}; + for (int i = 0; i < n; i++) { + const c_line_t *ln = &lines[i]; + if (em.verbatim >= 0) { + if (c_closes_block(ln, em.verbatim)) { + em.verbatim = CBM_NOT_FOUND; + } + continue; + } + if (ln->n >= C_FENCE_LEN && + (memcmp(ln->p, "```", C_FENCE_LEN) == 0 || memcmp(ln->p, "~~~", C_FENCE_LEN) == 0)) { + em.fence = !em.fence; + continue; + } + if (em.fence) { + continue; + } + if (ln->n == 0) { + em.see = false; /* an empty line ends a paragraph */ + continue; + } + bool *mask = (bool *)cbm_arena_calloc(a, ln->n + SKIP_ONE); + if (!mask) { + return; + } + c_mask(ln, mask); + c_line(&em, ln, mask); + } +} + +void cbm_doclink_c_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line) { + if (!ctx || !ctx->result || !def || !doc || !def->qualified_name || + !c_leg_file(ctx->rel_path)) { + return; + } + c_harvest(ctx, def, doc, doc_line); +} + +/* ── Loose docs ──────────────────────────────────────────────────── */ + +static bool c_kind(TSNode n, const char *k) { + return !ts_node_is_null(n) && strcmp(ts_node_type(n), k) == 0; +} + +/* A comment node's Doxygen style: 'b' a block, 'l' a line, 0 none. */ +static char c_doc_style(const char *src, TSNode n) { + uint32_t b = ts_node_start_byte(n); + uint32_t e = ts_node_end_byte(n); + const char *p = src + b; + size_t len = e - b; + if (c_has(p, len, "/**/") || c_has(p, len, "/***")) { + return 0; + } + if (c_has(p, len, "/**") || c_has(p, len, "/*!")) { + return 'b'; + } + if (c_has(p, len, "////")) { + return 0; + } + return (c_has(p, len, "///") || c_has(p, len, "//!")) ? 'l' : 0; +} + +typedef struct { + CBMExtractCtx *ctx; + cbm_doclink_taken_fn taken; + const CBMDefinition *file_def; + uint32_t start_byte; /* a pending run of line comments */ + uint32_t end_byte; + uint32_t first; + uint32_t last; + bool pending; +} c_loose_t; + +static void c_loose_emit(c_loose_t *lw, uint32_t sb, uint32_t eb, uint32_t first, uint32_t last) { + if (lw->taken(lw->ctx, first, last)) { + return; /* a definition's doc: harvested with the definition */ + } + CBMArena *a = lw->ctx->scratch ? lw->ctx->scratch : lw->ctx->arena; + char *doc = cbm_arena_strndup(a, lw->ctx->source + sb, eb - sb); + if (doc) { + c_harvest(lw->ctx, lw->file_def, doc, first); + } +} + +static void c_loose_flush(c_loose_t *lw) { + if (lw->pending) { + c_loose_emit(lw, lw->start_byte, lw->end_byte, lw->first, lw->last); + lw->pending = false; + } +} + +static void c_loose_comment(c_loose_t *lw, TSNode n) { + char style = c_doc_style(lw->ctx->source, n); + uint32_t row = ts_node_start_point(n).row + SKIP_ONE; + if (style == 'l' && lw->pending && row == lw->last + SKIP_ONE) { + lw->end_byte = ts_node_end_byte(n); /* the run goes on */ + lw->last = row; + return; + } + c_loose_flush(lw); + if (style == 'l') { + lw->pending = true; + lw->start_byte = ts_node_start_byte(n); + lw->end_byte = ts_node_end_byte(n); + lw->first = row; + lw->last = row; + } else if (style == 'b') { + c_loose_emit(lw, ts_node_start_byte(n), ts_node_end_byte(n), row, + ts_node_end_point(n).row + SKIP_ONE); + } +} + +void cbm_doclink_c_loose_docs(CBMExtractCtx *ctx, const CBMDefinition *file_def, + cbm_doclink_taken_fn taken) { + if (!ctx || !ctx->result || !file_def || !taken || ts_node_is_null(ctx->root) || + !c_leg_file(ctx->rel_path)) { + return; + } + c_loose_t lw = {.ctx = ctx, .taken = taken, .file_def = file_def}; + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (bool more = ts_tree_cursor_goto_first_child(&cur); more;) { + TSNode n = ts_tree_cursor_current_node(&cur); + if (c_kind(n, "comment")) { + c_loose_comment(&lw, n); + } else if (ts_node_is_named(n)) { + c_loose_flush(&lw); /* code between: the run ends */ + } + /* Doxygen reads no comment inside a function body */ + if (!c_kind(n, "compound_statement") && 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); + c_loose_flush(&lw); +} + +/* ── Scope blob ──────────────────────────────────────────────────── */ + +typedef struct { + CBMArena *a; + char *buf; + size_t len; + size_t cap; + bool oom; +} c_sb_t; + +static void c_sb_put(c_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 c_sb_field(c_sb_t *sb, const char *s, size_t n) { + c_sb_put(sb, "\t", SKIP_ONE); + for (size_t i = 0; i < n; i++) { + char c = (s[i] == '\t' || s[i] == '\n' || s[i] == '\r') ? ' ' : s[i]; + c_sb_put(sb, &c, SKIP_ONE); + } +} + +/* The name a declarator declares (through pointers, arrays, functions and + * parentheses). */ +static TSNode c_declarator_name(TSNode d) { + for (int guard = 0; guard < CBM_SZ_32 && !ts_node_is_null(d); guard++) { + if (c_kind(d, "identifier") || c_kind(d, "type_identifier")) { + return d; + } + TSNode inner = ts_node_child_by_field_name(d, TS_FIELD("declarator")); + if (ts_node_is_null(inner)) { + inner = ts_node_named_child(d, 0); /* a parenthesized declarator */ + } + d = inner; + } + return (TSNode){0}; +} + +static bool c_is_static(const char *src, TSNode decl) { + uint32_t nc = ts_node_named_child_count(decl); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(decl, i); + if (!c_kind(c, "storage_class_specifier")) { + continue; + } + uint32_t b = ts_node_start_byte(c); + uint32_t e = ts_node_end_byte(c); + if (e - b == CBM_SZ_6 && memcmp(src + b, "static", CBM_SZ_6) == 0) { + return true; + } + } + return false; +} + +/* The names a static declaration or definition declares. */ +static void c_static_names(CBMExtractCtx *ctx, CBMHashTable *names, TSNode decl) { + if (!c_is_static(ctx->source, decl)) { + return; + } + TSTreeCursor cur = ts_tree_cursor_new(decl); + for (bool more = ts_tree_cursor_goto_first_child(&cur); more; + more = ts_tree_cursor_goto_next_sibling(&cur)) { + const char *field = ts_tree_cursor_current_field_name(&cur); + if (!field || strcmp(field, "declarator") != 0) { + continue; + } + TSNode name = c_declarator_name(ts_tree_cursor_current_node(&cur)); + if (ts_node_is_null(name)) { + continue; + } + uint32_t b = ts_node_start_byte(name); + char *key = cbm_arena_strndup(ctx->arena, ctx->source + b, ts_node_end_byte(name) - b); + if (key) { + cbm_ht_set(names, key, (void *)(uintptr_t)SKIP_ONE); + } + } + ts_tree_cursor_delete(&cur); +} + +/* The static names of the file: top-level declarations, also inside + * preprocessor conditionals and extern "C" blocks. */ +static void c_collect_static(CBMExtractCtx *ctx, CBMHashTable *names) { + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (bool more = ts_tree_cursor_goto_first_child(&cur); more;) { + TSNode n = ts_tree_cursor_current_node(&cur); + bool container = c_kind(n, "preproc_if") || c_kind(n, "preproc_ifdef") || + c_kind(n, "preproc_else") || c_kind(n, "preproc_elif") || + c_kind(n, "preproc_elifdef") || c_kind(n, "linkage_specification") || + c_kind(n, "declaration_list"); + if (c_kind(n, "function_definition") || c_kind(n, "declaration")) { + c_static_names(ctx, names, n); + } + if (container && 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); +} + +static char c_def_kind(const CBMDefinition *d) { + const char *l = d->label; + if (strcmp(l, "Function") == 0) { + return 'f'; + } + if (strcmp(l, "Variable") == 0) { + return d->parent_class ? 'e' : 'v'; + } + if (strcmp(l, "Field") == 0) { + return 'r'; + } + if (strcmp(l, "Macro") == 0) { + return d->signature ? 'M' : 'm'; + } + if (strcmp(l, "Type") == 0 || strcmp(l, "Class") == 0 || strcmp(l, "Enum") == 0) { + return 't'; + } + return 0; +} + +/* `\page name`, `\defgroup name` ... in one doc text: A records. */ +static void c_anchors(c_sb_t *sb, const char *doc) { + static const char *const cmds[] = {"page", "defgroup", "addtogroup", "anchor", + "section", "subsection", "subsubsection", NULL}; + size_t n = strlen(doc); + for (size_t i = 0; i < n; i++) { + size_t cl = c_command_at(doc, i, n); + if (!cl) { + continue; + } + bool hit = false; + for (int k = 0; cmds[k] && !hit; k++) { + hit = c_word_is(doc + i + SKIP_ONE, cl, cmds[k]); + } + size_t j = i + SKIP_ONE + cl; + if (!hit || j >= n || (doc[j] != ' ' && doc[j] != '\t')) { + continue; + } + j = c_skip_space(doc, j, n); + size_t k = j; + while (k < n && (c_ident_char(doc[k]) || doc[k] == '.' || doc[k] == '-')) { + k++; + } + if (k > j) { + c_sb_put(sb, "\nA", PAIR_LEN); + c_sb_field(sb, doc + j, k - j); + } + i = k; + } +} + +/* The anchors every Doxygen comment of the file defines. */ +static void c_scope_anchors(CBMExtractCtx *ctx, c_sb_t *sb) { + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (bool more = ts_tree_cursor_goto_first_child(&cur); more;) { + TSNode n = ts_tree_cursor_current_node(&cur); + if (c_kind(n, "comment") && c_doc_style(ctx->source, n)) { + uint32_t b = ts_node_start_byte(n); + char *text = cbm_arena_strndup(sb->a, ctx->source + b, ts_node_end_byte(n) - b); + if (text) { + c_anchors(sb, text); + } + } + if (!c_kind(n, "compound_statement") && 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); +} + +const char *cbm_doclink_c_scan_scope(CBMExtractCtx *ctx) { + if (!ctx || !ctx->result || ts_node_is_null(ctx->root) || !c_leg_file(ctx->rel_path)) { + return NULL; + } + c_sb_t sb = {.a = ctx->arena}; + c_sb_put(&sb, CBM_DOCLINK_C_SCOPE_TAG, strlen(CBM_DOCLINK_C_SCOPE_TAG)); + CBMHashTable *statics = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_32); + CBMHashTable *seen = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_64); + if (!statics || !seen) { + cbm_ht_free(statics); + cbm_ht_free(seen); + return NULL; + } + c_collect_static(ctx, statics); + size_t pl = strlen(ctx->rel_path); + bool header = ctx->rel_path[pl - SKIP_ONE] == 'h'; + const CBMDefArray *defs = &ctx->result->defs; + for (int i = 0; i < defs->count && !sb.oom; i++) { + const CBMDefinition *d = &defs->items[i]; + char kind = (d->label && d->name && d->qualified_name) ? c_def_kind(d) : 0; + if (!kind || cbm_ht_get(seen, d->qualified_name)) { + continue; /* one node per qualified name (#if variants) */ + } + cbm_ht_set(seen, d->qualified_name, (void *)(uintptr_t)SKIP_ONE); + bool object = kind == 'f' || kind == 'v'; + char link = object ? (cbm_ht_get(statics, d->name) ? 's' : 'x') + : (header && kind != 'r' ? 'x' : 's'); + c_sb_put(&sb, "\nD", PAIR_LEN); + c_sb_field(&sb, &kind, SKIP_ONE); + c_sb_field(&sb, &link, SKIP_ONE); + c_sb_field(&sb, d->name, strlen(d->name)); + c_sb_field(&sb, d->qualified_name, strlen(d->qualified_name)); + const char *parent = + (kind == 'e' || kind == 'r') && d->parent_class ? d->parent_class : "-"; + c_sb_field(&sb, parent, strlen(parent)); + } + cbm_ht_free(statics); + cbm_ht_free(seen); + c_scope_anchors(ctx, &sb); + return sb.oom ? NULL : sb.buf; +} diff --git a/src/pipeline/doc_links.c b/src/pipeline/doc_links.c index 802665a35..a71aefb5c 100644 --- a/src/pipeline/doc_links.c +++ b/src/pipeline/doc_links.c @@ -48,7 +48,7 @@ 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_go_resolver, &cbm_doclink_jvm_resolver, &cbm_doclink_rs_resolver, &cbm_doclink_perl_resolver, - &cbm_doclink_ts_resolver, &cbm_doclink_php_resolver, + &cbm_doclink_ts_resolver, &cbm_doclink_php_resolver, &cbm_doclink_c_resolver, }; enum { DOCLINK_RESOLVER_COUNT = sizeof(DOCLINK_RESOLVERS) / sizeof(DOCLINK_RESOLVERS[0]) }; diff --git a/src/pipeline/doc_links.h b/src/pipeline/doc_links.h index 11ab9ec34..9ae1e07c7 100644 --- a/src/pipeline/doc_links.h +++ b/src/pipeline/doc_links.h @@ -301,6 +301,7 @@ 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); diff --git a/src/pipeline/doc_links_c.c b/src/pipeline/doc_links_c.c new file mode 100644 index 000000000..044d69852 --- /dev/null +++ b/src/pipeline/doc_links_c.c @@ -0,0 +1,481 @@ +/* + * 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; + } +} + +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, +}; diff --git a/tests/test_doc_mentions_c.c b/tests/test_doc_mentions_c.c new file mode 100644 index 000000000..b4fe96e84 --- /dev/null +++ b/tests/test_doc_mentions_c.c @@ -0,0 +1,341 @@ +/* + * 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")); + + /* a becomes static: without a scope_delta hook that is 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_helpers.h b/tests/test_doc_mentions_helpers.h index eb32af893..95e5f0503 100644 --- a/tests/test_doc_mentions_helpers.h +++ b/tests/test_doc_mentions_helpers.h @@ -442,6 +442,7 @@ static const int DM_HELD_FAMILIES[] = { CBM_DOCLINK_RS_LINK_DEF, CBM_DOCLINK_PERL_POD_L, CBM_DOCLINK_TS_LINK, CBM_DOCLINK_TS_LINKCODE, CBM_DOCLINK_TS_SEE, CBM_DOCLINK_PHP_SEE, + CBM_DOCLINK_C_REF, CBM_DOCLINK_C_SEE, }; static inline void dm_ship_held_families(void) { diff --git a/tests/test_main.c b/tests/test_main.c index cff0ee468..de3d80508 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1831,6 +1831,7 @@ 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_infrascan(void); extern void suite_cli(void); extern void suite_agent_clients(void); @@ -2308,6 +2309,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(doc_mentions_perl); RUN_SELECTED_SUITE(doc_mentions_ts); RUN_SELECTED_SUITE(doc_mentions_php); + RUN_SELECTED_SUITE(doc_mentions_c); /* Infrastructure scanning */ RUN_SELECTED_SUITE(infrascan);