From 196ca71cda6000d2597107636d9bb7b5aeb250e3 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 10 Oct 2026 15:43:40 +0200 Subject: [PATCH] doc-links(ts): JSDoc and TSDoc links become MENTIONS edges, held until their audit A TypeScript, TSX or JavaScript doc comment (a block comment opening with a slash and two stars) names code with {@link Ref}, {@linkcode Ref} (each with an optional `|` label) and `@see Ref` at the start of a tag line that holds no inline tag. doclink_ts.c harvests them (code spans and fenced blocks hold none, {@linkplain} is not harvested, a hyphenated word after @see is prose, a reference naming the declaration's own parameter is dropped, a URL is the core's href family) and writes each file's scope: imports, exports, export * barrels, top-level declarations, namespace bodies and functions or classes declared inside a function body. doc_links_ts.c resolves a reference as the TypeScript checker sees it from the documented declaration, in one index over TS, TSX and JS files: 1. the namespaces around the reference, innermost first; 2. a function or class declared in the enclosing function body (the graph gives it a file-level name: two of one name in one file share one node, a graph gap); 3. the file's own top-level declarations; 4. its imports, followed through export { a as b }, export ... from and export * barrels (cycle guard, depth bound; two barrels that answer differently are ambiguous); a relative specifier resolves with the usual extension and index rules, a bare one is external; 5. the standard library's global names: external; 6. the top-level declarations of script files (no import, no export): one binds, several are ambiguous; 7. the members of the documented class or interface, `this.` included. The other segments walk members or a module's exports. A member declared without a node of its own is a graph gap; test code binds only from test code. There is no scope_delta hook yet: an export change is a full run. The families are held (ships=false) until their held-out audit passes; the suite ships them through the test seam. Proof: the TypeScript compiler (perf-bench/typescript b465fdbfe175), ship build, against the field test's prototype records (cascade results left out): 69 edges; 57 of the prototype's 61 pairs bound alike. The 4 others are doc blocks without a definition of their own (on an `if` statement, a local `var`, an enum member, a free-standing @typedef block), which the core does not harvest. Of the 12 edges the prototype has no pair for, 4 are the same targets the prototype resolved without recording a source node, and 8 bind PropertyDescriptor to the repository's own src/lib/es5.d.ts, which the prototype called an external library global (kept: the repository declares it). Unresolved rows agree with the prototype's reasons apart from the documented reason split (not_in_scope -> missing/ambiguous). Known limit: package.json and tsconfig.json are not indexed (discovery ignores them), so workspace packages and tsconfig `paths` stay external. Tests (doc_mentions_ts): tokens with their lines; the scope blob and its persisted form; every resolution rule, including a function-body declaration that binds and two of one name that do not; incremental equals full (a doc edit and a body edit repair one file, an export change is a full run); 60 modules give the same edges and rows with one worker and several. Each rule red with it reverted (the body declaration's visibility and its line check, barrel ambiguity, the parameter rule, code spans, the library globals). Signed-off-by: Martin Vogel --- Makefile.cbm | 4 +- internal/cbm/doclink.c | 17 + internal/cbm/doclink.h | 18 + internal/cbm/doclink_ts.c | 905 +++++++++++++++++++++++++ src/pipeline/doc_links.c | 1 + src/pipeline/doc_links.h | 1 + src/pipeline/doc_links_ts.c | 1021 +++++++++++++++++++++++++++++ tests/test_doc_mentions_helpers.h | 2 + tests/test_doc_mentions_ts.c | 343 ++++++++++ tests/test_main.c | 2 + 10 files changed, 2313 insertions(+), 1 deletion(-) create mode 100644 internal/cbm/doclink_ts.c create mode 100644 src/pipeline/doc_links_ts.c create mode 100644 tests/test_doc_mentions_ts.c diff --git a/Makefile.cbm b/Makefile.cbm index b8aa4f71e..b6341756c 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -390,6 +390,7 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/doclink_jvm.c \ $(CBM_DIR)/doclink_rs.c \ $(CBM_DIR)/doclink_perl.c \ + $(CBM_DIR)/doclink_ts.c \ $(CBM_DIR)/sql_values.c \ $(CBM_DIR)/helpers.c \ $(CBM_DIR)/cpp_branch_views.c \ @@ -520,6 +521,7 @@ PIPELINE_SRCS = \ 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_msbuild.c \ src/pipeline/pass_route_nodes.c \ src/pipeline/pass_enrichment.c \ @@ -782,7 +784,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_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_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 4957e5489..7231a2308 100644 --- a/internal/cbm/doclink.c +++ b/internal/cbm/doclink.c @@ -217,6 +217,23 @@ static const doclink_lang_t DOCLINK_LANGS[] = { .parse_doc = cbm_doclink_perl_parse_doc, .scan_scope = cbm_doclink_perl_scan_scope, .scope_tag = CBM_DOCLINK_PERL_SCOPE_TAG}, + /* TypeScript, TSX and JavaScript: one scope, since their files import + * each other */ + {.lang = CBM_LANG_TYPESCRIPT, + .parse_doc = cbm_doclink_ts_parse_doc, + .scan_scope = cbm_doclink_ts_scan_scope, + .scope_tag = CBM_DOCLINK_TS_SCOPE_TAG, + .portable_scope = cbm_doclink_ts_portable_scope}, + {.lang = CBM_LANG_TSX, + .parse_doc = cbm_doclink_ts_parse_doc, + .scan_scope = cbm_doclink_ts_scan_scope, + .scope_tag = CBM_DOCLINK_TS_SCOPE_TAG, + .portable_scope = cbm_doclink_ts_portable_scope}, + {.lang = CBM_LANG_JAVASCRIPT, + .parse_doc = cbm_doclink_ts_parse_doc, + .scan_scope = cbm_doclink_ts_scan_scope, + .scope_tag = CBM_DOCLINK_TS_SCOPE_TAG, + .portable_scope = cbm_doclink_ts_portable_scope}, }; static const doclink_lang_t *doclink_lang(CBMLanguage lang) { diff --git a/internal/cbm/doclink.h b/internal/cbm/doclink.h index 479d104ff..c1d868da5 100644 --- a/internal/cbm/doclink.h +++ b/internal/cbm/doclink.h @@ -100,6 +100,10 @@ X(CBM_DOCLINK_RS_LINK_DEF, CBM_LANG_RUST, "link_def", false, false, "MENTIONS") \ /* perl (doclink_perl.c): POD L<> codes; held until their audit passes */ \ X(CBM_DOCLINK_PERL_POD_L, CBM_LANG_PERL, "pod_L", false, false, "MENTIONS") \ + /* typescript and javascript (doclink_ts.c): JSDoc/TSDoc; held until their audit */ \ + X(CBM_DOCLINK_TS_LINK, CBM_LANG_TYPESCRIPT, "link", false, false, "MENTIONS") \ + X(CBM_DOCLINK_TS_LINKCODE, CBM_LANG_TYPESCRIPT, "linkcode", false, false, "MENTIONS") \ + X(CBM_DOCLINK_TS_SEE, CBM_LANG_TYPESCRIPT, "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") \ @@ -537,6 +541,20 @@ typedef enum { * are read. */ CBMDocLinkRsPathStatus cbm_doclink_rs_parse_path(const char *raw, size_t len, bool lenient, CBMDocLinkRsPath *out); +/* ── TypeScript and JavaScript (doclink_ts.c) ──────────────────────── */ + +void cbm_doclink_ts_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line); +const char *cbm_doclink_ts_scan_scope(CBMExtractCtx *ctx); +char *cbm_doclink_ts_portable_scope(const char *scope); + +/* TS, TSX and JS files share one scope tag: one resolver indexes them all. */ +#define CBM_DOCLINK_TS_SCOPE_TAG "ts1" + +/* The longest reference a TS/JS token keeps; a longer one is cut just past + * this length and the resolver reports it as unparseable. */ +enum { CBM_DOCLINK_TS_REF_MAX = 512 }; + /* ── 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_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/src/pipeline/doc_links.c b/src/pipeline/doc_links.c index c912ac9e0..f01fff7c7 100644 --- a/src/pipeline/doc_links.c +++ b/src/pipeline/doc_links.c @@ -48,6 +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, }; 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 e0ca5820f..f21049792 100644 --- a/src/pipeline/doc_links.h +++ b/src/pipeline/doc_links.h @@ -299,6 +299,7 @@ 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; /* 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_ts.c b/src/pipeline/doc_links_ts.c new file mode 100644 index 000000000..db007f37e --- /dev/null +++ b/src/pipeline/doc_links_ts.c @@ -0,0 +1,1021 @@ +/* + * 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, every bare one is external (package.json and + * tsconfig.json are not indexed); + * 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/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 +#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; + 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 */ + 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_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; +} + +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; + 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); + 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->module_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 module specifier written in file `from` names: -1 with + * *external for a bare specifier (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'))))) { + *external = true; + return CBM_NOT_FOUND; + } + 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; + } + 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; +} + +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->module_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); +} + +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, +}; diff --git a/tests/test_doc_mentions_helpers.h b/tests/test_doc_mentions_helpers.h index f0e2312e1..b6d502ddb 100644 --- a/tests/test_doc_mentions_helpers.h +++ b/tests/test_doc_mentions_helpers.h @@ -440,6 +440,8 @@ static const int DM_HELD_FAMILIES[] = { 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_TS_LINKCODE, + CBM_DOCLINK_TS_SEE, }; static inline void dm_ship_held_families(void) { diff --git a/tests/test_doc_mentions_ts.c b/tests/test_doc_mentions_ts.c new file mode 100644 index 000000000..32581e10d --- /dev/null +++ b/tests/test_doc_mentions_ts.c @@ -0,0 +1,343 @@ +/* + * 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"); + 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"); + th_write_file(TH_PATH(repo, "src/widget.ts"), + "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}.\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")); + /* 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: without a scope_delta hook that is 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_main.c b/tests/test_main.c index 8c2c814d1..2db5d8184 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1829,6 +1829,7 @@ 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_infrascan(void); extern void suite_cli(void); extern void suite_agent_clients(void); @@ -2304,6 +2305,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(doc_mentions_jvm); RUN_SELECTED_SUITE(doc_mentions_rs); RUN_SELECTED_SUITE(doc_mentions_perl); + RUN_SELECTED_SUITE(doc_mentions_ts); /* Infrastructure scanning */ RUN_SELECTED_SUITE(infrascan);