From a1190afbb0f2dae89179a2862c1749cdfa1b13bd Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 10 Oct 2026 11:07:37 +0200 Subject: [PATCH] doc-links(java, kotlin): Javadoc and KDoc references become MENTIONS edges, held until their audit Java's {@link}, {@linkplain}, @see, @throws and {@value}, and Kotlin's [Name] links, @see, @throws and @sample, bound the way javadoc and Dokka search for a member (one resolver for both languages, so a Kotlin doc can name a Java type and back). The leg (doclink_jvm.c extracts the references and each file's scope; doc_links_jvm.c resolves them) is ported onto the restacked core: family rows carry their edge type, resolve() takes the per-file state (none here). All nine families are held (ships=false) until their held-out audit passes; the suite ships them through the test seam. The graph moved under the leg since it was written: a Java field's node is named by the field alone (no longer by its whole declarator, `MAX = 10`), and a field named like a method of its class has its own node, .#field, where the method used to take the name. The tests follow: the expected field names lose the initializer, a changed initializer is a local change repaired file by file (it renamed the node before, which forced a full run), and a field that shares its accessor's name is now an edge target and a source (doc_mentions_jvm_java_families_and_reasons, doc_mentions_jvm_node_ownership), where both were graph_gap rows. Measured with the leg's binary (1078036f) and this one on the same input: - elasticsearch subset (libs/ + server/src/main at f3dc4b0bd3a0, 5,956 files): no edge lost; 66 new edges and 7 edges moved onto a field's own #field node; 81 graph_gap rows gone, no new row, no reason changed. A seeded sample of 8 of the gone rows read against the source: every new edge is right (a field named like its accessor, enum constants, a field's own doc as the source). - Exposed (Kotlin, c419caaab61f): identical, 896 edges and 371 rows. Signed-off-by: Martin Vogel --- Makefile.cbm | 4 +- internal/cbm/doclink.c | 10 + internal/cbm/doclink.h | 34 + internal/cbm/doclink_jvm.c | 2983 +++++++++++++++++++ src/pipeline/doc_links.c | 1 + src/pipeline/doc_links.h | 1 + src/pipeline/doc_links_jvm.c | 4599 +++++++++++++++++++++++++++++ tests/test_doc_mentions_helpers.h | 10 +- tests/test_doc_mentions_jvm.c | 3669 +++++++++++++++++++++++ tests/test_main.c | 2 + 10 files changed, 11310 insertions(+), 3 deletions(-) create mode 100644 internal/cbm/doclink_jvm.c create mode 100644 src/pipeline/doc_links_jvm.c create mode 100644 tests/test_doc_mentions_jvm.c diff --git a/Makefile.cbm b/Makefile.cbm index bee0f531d..29f5896d0 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -387,6 +387,7 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/pdf/pdf_tables.c \ $(CBM_DIR)/pdf/pdf_unicode.c \ $(CBM_DIR)/doclink_go.c \ + $(CBM_DIR)/doclink_jvm.c \ $(CBM_DIR)/sql_values.c \ $(CBM_DIR)/helpers.c \ $(CBM_DIR)/cpp_branch_views.c \ @@ -514,6 +515,7 @@ PIPELINE_SRCS = \ src/pipeline/doc_links_rst.c \ src/pipeline/doc_links_adoc.c \ src/pipeline/doc_links_go.c \ + src/pipeline/doc_links_jvm.c \ src/pipeline/doc_links_msbuild.c \ src/pipeline/pass_route_nodes.c \ src/pipeline/pass_enrichment.c \ @@ -776,7 +778,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_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_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 0d8c1e753..6e58f34c2 100644 --- a/internal/cbm/doclink.c +++ b/internal/cbm/doclink.c @@ -190,6 +190,16 @@ static const doclink_lang_t DOCLINK_LANGS[] = { .scan_scope = cbm_doclink_gomod_scan_scope, .scope_tag = CBM_DOCLINK_GO_SCOPE_TAG, .portable_scope = cbm_doclink_go_portable_scope}, + {.lang = CBM_LANG_JAVA, + .parse_doc = cbm_doclink_java_parse_doc, + .scan_scope = cbm_doclink_java_scan_scope, + .scope_tag = CBM_DOCLINK_JVM_SCOPE_TAG, + .portable_scope = cbm_doclink_jvm_portable_scope}, + {.lang = CBM_LANG_KOTLIN, + .parse_doc = cbm_doclink_kotlin_parse_doc, + .scan_scope = cbm_doclink_kotlin_scan_scope, + .scope_tag = CBM_DOCLINK_JVM_SCOPE_TAG, + .portable_scope = cbm_doclink_jvm_portable_scope}, }; static const doclink_lang_t *doclink_lang(CBMLanguage lang) { diff --git a/internal/cbm/doclink.h b/internal/cbm/doclink.h index ab97ac0f4..c3a94cca3 100644 --- a/internal/cbm/doclink.h +++ b/internal/cbm/doclink.h @@ -85,6 +85,16 @@ X(CBM_DOCLINK_CS_INHERITDOC, CBM_LANG_CSHARP, "inheritdoc", false, true, "MENTIONS") \ /* go (doclink_go.c): [Name] doc links; held until its held-out audit passes */ \ X(CBM_DOCLINK_GO_DOC_LINK, CBM_LANG_GO, "doc_link", false, false, "MENTIONS") \ + /* java and kotlin (doclink_jvm.c): Javadoc and KDoc; held until their audit passes */ \ + X(CBM_DOCLINK_JAVA_LINK, CBM_LANG_JAVA, "link", false, false, "MENTIONS") \ + X(CBM_DOCLINK_JAVA_LINKPLAIN, CBM_LANG_JAVA, "linkplain", false, false, "MENTIONS") \ + X(CBM_DOCLINK_JAVA_SEE, CBM_LANG_JAVA, "see", false, false, "MENTIONS") \ + X(CBM_DOCLINK_JAVA_THROWS, CBM_LANG_JAVA, "throws", false, false, "MENTIONS") \ + X(CBM_DOCLINK_JAVA_VALUE, CBM_LANG_JAVA, "value", false, false, "MENTIONS") \ + X(CBM_DOCLINK_KT_KDOC_LINK, CBM_LANG_KOTLIN, "kdoc_link", false, false, "MENTIONS") \ + X(CBM_DOCLINK_KT_SEE, CBM_LANG_KOTLIN, "see", false, false, "MENTIONS") \ + X(CBM_DOCLINK_KT_THROWS, CBM_LANG_KOTLIN, "throws", false, false, "MENTIONS") \ + X(CBM_DOCLINK_KT_SAMPLE, CBM_LANG_KOTLIN, "sample", 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") \ @@ -427,5 +437,29 @@ bool cbm_doclink_go_parse_ref(const char *raw, size_t len, CBMDocLinkGoRef *out) /* The Go scope blob (Go files and go.mod files) starts with this tag line. */ #define CBM_DOCLINK_GO_SCOPE_TAG "go1" +/* ── Java and Kotlin (doclink_jvm.c) ───────────────────────────────── */ + +void cbm_doclink_java_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line); +void cbm_doclink_kotlin_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line); +const char *cbm_doclink_java_scan_scope(CBMExtractCtx *ctx); +const char *cbm_doclink_kotlin_scan_scope(CBMExtractCtx *ctx); +char *cbm_doclink_jvm_portable_scope(const char *scope); + +/* Normalize one Java parameter type as written in a declaration or in a + * Javadoc `#member(...)` list: annotations, `final`, type arguments, package + * and outer-type qualifiers and a trailing parameter name are dropped, + * `...` becomes `[]`, array brackets stay. "?" when nothing is left. Writes a + * NUL-terminated string into `out` (truncated at cap) and returns its + * length. */ +size_t cbm_doclink_java_norm_type(const char *in, size_t len, char *out, size_t cap); + +/* Java and Kotlin files share one scope tag: one resolver indexes both. */ +#define CBM_DOCLINK_JVM_SCOPE_TAG "jvm1" + +/* The longest reference a Java or Kotlin token may carry: a longer one is cut + * just past this length at extraction and reported as unparseable. */ +#define CBM_DOCLINK_JVM_REF_MAX 1000 #endif /* CBM_DOCLINK_H */ diff --git a/internal/cbm/doclink_jvm.c b/internal/cbm/doclink_jvm.c new file mode 100644 index 000000000..b33d37f75 --- /dev/null +++ b/internal/cbm/doclink_jvm.c @@ -0,0 +1,2983 @@ +/* + * doclink_jvm.c — Java and Kotlin doc-comment references and the JVM doc-link + * scope. + * + * Tokens. + * Javadoc: `{@link R}` / `{@link R label}`, `{@linkplain R label}`, + * `{@value R#F}` (a bare `{@value}` names the documented field itself and is + * no token), `@see R` at the start of a block-tag line (`@see ` is + * a URL, `@see "text"` is prose), `@throws T` / `@exception T`. Nothing in + * `{@code}`, `{@literal}`, `{@snippet}` or an HTML comment is a reference. + * KDoc: `[Name]` and `[text][Name]` in the comment text (not `[text](url)`, + * not an image, not a `[label]: url` definition, not in a code span or a + * fenced or indented code block), `@see Name`, `@throws T` / `@exception T`, + * `@sample qualified.name`. The subject of `@param` / `@property` is the + * definition's own parameter, never a reference. + * Only block doc comments (slash, two stars) are read: that is what javadoc + * and Dokka read. + * + * Scope blob (one record per line, tab-separated, first line "jvm1"; records + * in document order, a member after its type): + * L lang j Java, k Kotlin + * P package "" when the file declares none + * I kind alias target import: s single (alias = the simple + * name it binds), w on-demand / star, + * m Java static single (alias = member, + * target = type), n Java static + * on-demand (target = type) + * T start end kind path tparams supers slot own pnames + * type: c class, i interface, e enum, + * r record, a annotation, o object, + * C companion object, t typealias; '!' + * after the kind when a parse error + * hides some of its members. path is + * the dotted LOGICAL path (Outer.Inner), + * tparams ','-joined, supers '|'-joined + * as written without type arguments + * ('?' for one that is no name) + * M start kind flags owner name tparams sig recv slot own pnames + * member: c callable, v value. flags: K + * constructor, p constructor property / + * record component, n enum constant, i + * implicit (declared by no text of its + * own). owner is the index of its T + * record ('-' for a Kotlin top-level + * declaration), sig the '|'-joined + * normalized parameter types ('-' for a + * value), recv the receiver type of a + * Kotlin extension + * Q name a type that is declared, but could not + * be placed (parse error) + * X from to lines whose declarations could not be + * placed + * slot / own say where the extractor put the declaration's node: slot is the + * qualified name of its definition relative to the file's module ('=' when it + * is the logical path, '-' when the declaration has no definition), own is 1 + * when that definition is the one the graph keeps for the qualified name + * inside this file. Both are read off the extractor's own definitions (same + * start line, name and kind), so this file models neither the qualified-name + * scheme nor its collisions: Kotlin flattens nested types and puts class + * properties at module level, Java names a field after its whole declarator + * (`MAX = 10`), overloads share one name, and whatever the scheme does next is + * followed without a change here. + * pnames (the parameter names of a callable, the primary-constructor + * parameters of a Kotlin class) and all line numbers serve the file's own + * references only: the persisted form drops them. + * + * Everything is a pure function of the file's bytes. Nesting is the tree's + * while the tree has no parse error; otherwise it is checked against the + * braces (see "Braces"). + */ +#include "doclink.h" + +#include "arena.h" +#include "foundation/constants.h" +#include "foundation/mem_core.h" +#include "helpers.h" +#include "tree_sitter/api.h" + +#include +#include +#include +#include + +enum { + JV_SB_INIT = 1024, + JV_MAX_DEPTH = 128, /* nested type declarations */ + JV_NAME_MAX = 255, /* one identifier */ + JV_TYPE_MAX = 512, /* one written type */ + JV_SLOT_MAX = 512, /* a definition's name relative to its module */ + JV_UINT_DIGITS = 16, + JV_ITEMS_INIT = 64, + JV_BRACES_INIT = 1024, + JV_CHAR_LITERAL_MAX = 12, /* a quoted unicode escape */ + JV_QUOTES = 3, /* """ */ + JV_UTF8_TAIL_MAX = 3, +}; + +/* ── Small arena string builder ──────────────────────────────────── */ + +typedef struct { + CBMArena *a; + char *buf; + size_t len; + size_t cap; + bool failed; +} jv_sb_t; + +static void jv_reserve(jv_sb_t *sb, size_t extra) { + if (sb->failed || sb->len + extra + SKIP_ONE <= sb->cap) { + return; + } + size_t ncap = sb->cap ? sb->cap : JV_SB_INIT; + while (ncap < sb->len + extra + SKIP_ONE) { + ncap *= PAIR_LEN; + } + char *grown = (char *)cbm_arena_alloc(sb->a, ncap); + if (!grown) { + sb->failed = true; + return; + } + if (sb->len > 0) { + memcpy(grown, sb->buf, sb->len); + } + sb->buf = grown; + sb->cap = ncap; +} + +static void jv_putn(jv_sb_t *sb, const char *s, size_t n) { + jv_reserve(sb, n); + if (sb->failed) { + return; + } + memcpy(sb->buf + sb->len, s, n); + sb->len += n; + sb->buf[sb->len] = '\0'; +} + +static void jv_puts(jv_sb_t *sb, const char *s) { + jv_putn(sb, s, strlen(s)); +} + +static void jv_putc(jv_sb_t *sb, char c) { + jv_putn(sb, &c, SKIP_ONE); +} + +static void jv_putu(jv_sb_t *sb, uint32_t v) { + char tmp[JV_UINT_DIGITS]; + int n = snprintf(tmp, sizeof(tmp), "%u", v); + if (n > 0) { + jv_putn(sb, tmp, (size_t)n); + } +} + +/* ── Characters ──────────────────────────────────────────────────── */ + +static bool jv_blank(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v'; +} + +/* Identifier characters: Java and Kotlin identifiers are Unicode, so every + * byte of a multi-byte character counts. */ +static bool jv_ident_start(char c) { + unsigned char u = (unsigned char)c; + return isalpha(u) || c == '_' || c == '$' || u >= 0x80; +} + +static bool jv_ident_char(char c) { + unsigned char u = (unsigned char)c; + return isalnum(u) || c == '_' || c == '$' || u >= 0x80; +} + +/* ── Doc text ────────────────────────────────────────────────────── */ + +/* The text of the block doc comments in `doc` (slash, two stars) without its + * decoration: every line break stays (a position's line is the doc's first + * line plus the breaks before it), each continuation line loses its leading + * blanks, its asterisks and one blank. Line comments and plain block comments + * between doc comments leave only their line breaks. `nesting`: block + * comments nest (Kotlin), so a comment written inside the doc is part of its + * text and its end is not the doc's. */ +static char *jv_clean_doc(CBMArena *a, const char *doc, bool nesting, size_t *out_len) { + size_t n = strlen(doc); + char *out = (char *)cbm_arena_alloc(a, n + SKIP_ONE); + if (!out) { + return NULL; + } + size_t w = 0; + size_t i = 0; + bool in_doc = false; + size_t inner = 0; /* comments open inside the doc */ + while (i < n) { + char c = doc[i]; + if (!in_doc) { + if (c == '/' && doc[i + 1] == '*' && doc[i + 2] == '*' && doc[i + 3] != '/') { + in_doc = true; + inner = 0; + i += 3; + while (doc[i] == '*' && doc[i + 1] != '/') { + i++; + } + continue; + } + if (c == '\n') { + out[w++] = '\n'; + } + i++; + continue; + } + if (nesting && c == '/' && doc[i + 1] == '*') { + inner++; + out[w++] = '/'; + out[w++] = '*'; + i += 2; + continue; + } + if (c == '*' && doc[i + 1] == '/') { + if (inner > 0) { + inner--; + out[w++] = '*'; + out[w++] = '/'; + } else { + in_doc = false; + } + i += 2; + continue; + } + if (c == '\n') { + out[w++] = '\n'; + i++; + while (doc[i] == ' ' || doc[i] == '\t' || doc[i] == '\r') { + i++; + } + if (doc[i] == '*' && doc[i + 1] != '/') { + while (doc[i] == '*' && doc[i + 1] != '/') { + i++; + } + if (doc[i] == ' ') { + i++; + } + } + continue; + } + if (c != '\r') { + out[w++] = c; + } + i++; + } + out[w] = '\0'; + *out_len = w; + return out; +} + +typedef struct { + CBMExtractCtx *ctx; + const CBMDefinition *def; + const char *t; /* the cleaned doc text */ + size_t n; +} jv_doc_t; + +/* Push the reference s[0, len) written on source line `line`: whitespace runs + * become one blank, control characters go, and a reference longer than any + * name is cut (on a character boundary) just past CBM_DOCLINK_JVM_REF_MAX, + * which is how the resolver knows it was oversized. */ +static void jv_push(jv_doc_t *d, const char *s, size_t len, uint32_t line, int syntax) { + size_t cap = (size_t)CBM_DOCLINK_JVM_REF_MAX + JV_UTF8_TAIL_MAX + SKIP_ONE; + size_t take = len < cap ? len : cap; + char *raw = (char *)cbm_arena_alloc(d->ctx->arena, take + SKIP_ONE); + if (!raw) { + return; + } + size_t w = 0; + bool space = false; + for (size_t i = 0; i < len && w < take; i++) { + unsigned char c = (unsigned char)s[i]; + if (c <= ' ') { + space = w > 0; + continue; + } + if (space && w + SKIP_ONE < take) { + raw[w++] = ' '; + } + space = false; + raw[w++] = (char)c; + } + if (w == take && len > take) { + /* cut: do not leave half a multi-byte character behind */ + while (w > 0 && ((unsigned char)raw[w - SKIP_ONE] & 0xC0) == 0x80) { + w--; + } + if (w > 0 && (unsigned char)raw[w - SKIP_ONE] >= 0xC0) { + w--; + } + } + raw[w] = '\0'; + if (w == 0) { + return; + } + CBMDocLink link = { + .source_qn = d->def->qualified_name, + .raw = raw, + .line = line, + .def_line = d->def->start_line, + .syntax = (uint16_t)syntax, + }; + cbm_doclinks_push(&d->ctx->result->doc_links, d->ctx->arena, link); +} + +/* Index just past the group opened at s[i] by `open` (nesting groups of the + * same kind), or n when it does not close. */ +static size_t jv_group_end(const char *s, size_t n, size_t i, char open, char close) { + int depth = 0; + for (size_t k = i; k < n; k++) { + if (s[k] == open) { + depth++; + } else if (s[k] == close) { + depth--; + if (depth == 0) { + return k + SKIP_ONE; + } + } + } + return n; +} + +static bool jv_word_is(const char *s, size_t len, const char *word) { + return strlen(word) == len && memcmp(s, word, len) == 0; +} + +/* ── Javadoc ─────────────────────────────────────────────────────── */ + +/* Length of the Javadoc reference at s: a (qualified) type, `#member`, + * `module/`, type arguments, array brackets and a parameter list. A parameter + * list that does not close takes the rest (the reference is malformed, and + * the resolver says so). */ +static size_t jv_java_ref_len(const char *s, size_t n) { + size_t j = 0; + while (j < n) { + char c = s[j]; + if (jv_ident_char(c) || c == '.' || c == '#' || c == '/') { + j++; + } else if (c == '<') { + size_t e = jv_group_end(s, n, j, '<', '>'); + if (e >= n && (n == 0 || s[n - SKIP_ONE] != '>')) { + break; + } + j = e; + } else if (c == '[' && j + SKIP_ONE < n && s[j + SKIP_ONE] == ']') { + j += PAIR_LEN; + } else { + break; + } + } + if (j < n && s[j] == '(') { + j = jv_group_end(s, n, j, '(', ')'); + } + return j; +} + +/* The reference at s[0, n): what the doc tool reads as one reference. Text + * glued to it (`Foo,`, `http://x`) makes the whole word the (malformed) + * reference. Returns its length, 0 when there is none. */ +static size_t jv_java_ref(const char *s, size_t n) { + size_t r = jv_java_ref_len(s, n); + if (r < n && !jv_blank(s[r])) { + while (r < n && !jv_blank(s[r])) { + r++; + } + } + return r; +} + +/* The href of an `` at s[0, n): pushed as a URL. */ +static void jv_java_href(jv_doc_t *d, const char *s, size_t n, uint32_t line) { + static const char key[] = "href"; + for (size_t i = 0; i + sizeof(key) <= n && s[i] != '>'; i++) { + if (memcmp(s + i, key, sizeof(key) - SKIP_ONE) != 0) { + continue; + } + size_t j = i + sizeof(key) - SKIP_ONE; + while (j < n && (s[j] == ' ' || s[j] == '\t')) { + j++; + } + if (j >= n || s[j] != '=') { + continue; + } + j++; + while (j < n && (s[j] == ' ' || s[j] == '\t')) { + j++; + } + if (j >= n || (s[j] != '"' && s[j] != '\'')) { + return; + } + char quote = s[j]; + size_t v = j + SKIP_ONE; + size_t e = v; + while (e < n && s[e] != quote) { + e++; + } + if (e < n && e > v) { + jv_push(d, s + v, e - v, line, CBM_DOCLINK_HREF); + } + return; + } +} + +/* `@see ...` / `@throws ...`: the rest of the tag's line starts at `i`. */ +static void jv_java_block_tag(jv_doc_t *d, size_t i, uint32_t line, bool see) { + const char *t = d->t; + while (i < d->n && (t[i] == ' ' || t[i] == '\t')) { + i++; + } + size_t eol = i; + while (eol < d->n && t[eol] != '\n') { + eol++; + } + if (i >= eol) { + return; + } + if (see && t[i] == '<') { + jv_java_href(d, t + i, eol - i, line); + return; + } + if (t[i] == '"' || t[i] == '{') { + return; /* a quoted title; an inline tag is found by the text scan */ + } + size_t r = jv_java_ref(t + i, eol - i); + if (r > 0) { + jv_push(d, t + i, r, line, see ? CBM_DOCLINK_JAVA_SEE : CBM_DOCLINK_JAVA_THROWS); + } +} + +/* An inline tag `{@name body}` whose body is t[b, e) and starts on `line`: + * its reference. */ +static void jv_java_inline(jv_doc_t *d, size_t b, size_t e, uint32_t line, int syntax) { + const char *t = d->t; + for (; b < e && jv_blank(t[b]); b++) { + line += t[b] == '\n'; + } + if (syntax == CBM_DOCLINK_JAVA_VALUE && b < e && t[b] == '"') { + /* {@value "format" ref}: the format string is not the reference */ + size_t q = b + SKIP_ONE; + while (q < e && t[q] != '"') { + line += t[q] == '\n'; + q++; + } + for (b = q < e ? q + SKIP_ONE : e; b < e && jv_blank(t[b]); b++) { + line += t[b] == '\n'; + } + } + size_t r = jv_java_ref(t + b, e - b); + if (r > 0) { + jv_push(d, t + b, r, line, syntax); + } +} + +/* An open inline tag. javadoc ends it at the brace that balances its own + * `{`; braces outside inline tags are plain text and balance nothing. */ +typedef struct { + int syntax; /* a link family, JV_TAG_OTHER, or JV_TAG_TEXT */ + size_t body; /* first byte after the tag's name */ + uint32_t line; /* the line of that byte */ + int depth; /* plain braces open inside it */ +} jv_tag_t; + +enum { + JV_TAG_OTHER = -1, /* another inline tag: its body is scanned */ + JV_TAG_TEXT = -2, /* code, literal, snippet: the body is text, not markup */ + JV_TAG_NEST_MAX = 32, +}; + +/* The kind of the inline tag `{@name`; `next` is the character after the + * name (a tag named `linkage` is not `link`). */ +static int jv_java_tag_kind(const char *name, size_t len, char next) { + if (jv_word_is(name, len, "code") || jv_word_is(name, len, "literal") || + jv_word_is(name, len, "snippet")) { + return JV_TAG_TEXT; + } + if (!jv_blank(next) && next != '}') { + return JV_TAG_OTHER; + } + if (jv_word_is(name, len, "link")) { + return CBM_DOCLINK_JAVA_LINK; + } + if (jv_word_is(name, len, "linkplain")) { + return CBM_DOCLINK_JAVA_LINKPLAIN; + } + if (jv_word_is(name, len, "value")) { + return CBM_DOCLINK_JAVA_VALUE; + } + return JV_TAG_OTHER; +} + +/* Past an HTML comment or a CDATA section that starts at t[i] (the doc tool + * reads no markup in either); i itself when none starts there. Line breaks + * passed are added to *line. One that never ends takes the rest. */ +static size_t jv_skip_hidden(const jv_doc_t *d, size_t i, uint32_t *line) { + static const struct { + const char *open; + const char *close; + } kinds[] = {{""}, {""}}; + for (size_t k = 0; k < sizeof(kinds) / sizeof(kinds[0]); k++) { + size_t ol = strlen(kinds[k].open); + if (d->n - i < ol || memcmp(d->t + i, kinds[k].open, ol) != 0) { + continue; + } + const char *close = strstr(d->t + i + ol, kinds[k].close); + size_t past = close ? (size_t)(close - d->t) + strlen(kinds[k].close) : d->n; + for (size_t p = i; p < past; p++) { + *line += d->t[p] == '\n'; + } + return past; + } + return i; +} + +void cbm_doclink_java_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line) { + /* A field is also emitted as a package-level Variable with the same doc: + * the Field carries the references. A module declaration is no code + * definition (its "class" is named like a package). */ + if (!def->label || (strcmp(def->label, "Variable") == 0 && def->parent_class) || + (strcmp(def->label, "Class") == 0 && strchr(def->name, '.'))) { + return; + } + CBMArena *scratch = ctx->scratch ? ctx->scratch : ctx->arena; + jv_doc_t d = {.ctx = ctx, .def = def}; + d.t = jv_clean_doc(scratch, doc, false, &d.n); + if (!d.t) { + return; + } + const char *t = d.t; + size_t n = d.n; + jv_tag_t tags[JV_TAG_NEST_MAX]; + int sp = 0; /* open inline tags */ + uint32_t line = doc_line; + size_t i = 0; + bool bol = true; /* only blanks since the line began */ + while (i < n) { + char c = t[i]; + if (c == '\n') { + line++; + bol = true; + i++; + continue; + } + if (bol && (c == ' ' || c == '\t')) { + i++; + continue; + } + bool in_text = sp > 0 && tags[sp - SKIP_ONE].syntax == JV_TAG_TEXT; + if (bol && !in_text && c == '@' && i + SKIP_ONE < n && + isalpha((unsigned char)t[i + SKIP_ONE])) { + /* A block tag. javadoc ends an open {@link ..} (or any inline tag + * whose content is markup) at it: that tag never closed and is no + * link. Inside {@code}, {@literal} and {@snippet} an `@` at the + * start of a line is text (annotations in sample code). */ + size_t e = i + SKIP_ONE; + while (e < n && isalpha((unsigned char)t[e])) { + e++; + } + const char *name = t + i + SKIP_ONE; + size_t len = e - i - SKIP_ONE; + bool gap = e >= n || jv_blank(t[e]); + if (gap && jv_word_is(name, len, "see")) { + jv_java_block_tag(&d, e, line, true); + } else if (gap && + (jv_word_is(name, len, "throws") || jv_word_is(name, len, "exception"))) { + jv_java_block_tag(&d, e, line, false); + } + sp = 0; + bol = false; + i = e; + continue; + } + bol = false; + bool text = in_text; + if (c == '<' && sp == 0) { + size_t past = jv_skip_hidden(&d, i, &line); + if (past > i) { + i = past; + continue; + } + } + if (c == '{' && !text && i + SKIP_ONE < n && t[i + SKIP_ONE] == '@' && + sp < JV_TAG_NEST_MAX) { + size_t ne = i + PAIR_LEN; + while (ne < n && isalpha((unsigned char)t[ne])) { + ne++; + } + tags[sp++] = (jv_tag_t){.syntax = jv_java_tag_kind(t + i + PAIR_LEN, ne - i - PAIR_LEN, + ne < n ? t[ne] : '}'), + .body = ne, + .line = line}; + i = ne; + continue; + } + if (c == '{' && sp > 0) { + tags[sp - SKIP_ONE].depth++; + } else if (c == '}' && sp > 0) { + if (tags[sp - SKIP_ONE].depth > 0) { + tags[sp - SKIP_ONE].depth--; + } else { + jv_tag_t done = tags[--sp]; + if (done.syntax > CBM_DOCLINK_NONE) { + jv_java_inline(&d, done.body, i, done.line, done.syntax); + } + } + } + i++; + } +} + +/* ── KDoc ────────────────────────────────────────────────────────── */ + +/* Length of the KDoc link target at s[0, n): a qualified name whose segments + * are identifiers or backticked names. 0 when s is not exactly one. */ +static size_t jv_kdoc_target_len(const char *s, size_t n) { + size_t i = 0; + for (;;) { + if (i < n && s[i] == '`') { + size_t e = i + SKIP_ONE; + while (e < n && s[e] != '`' && s[e] != '\n') { + e++; + } + if (e >= n || s[e] != '`' || e == i + SKIP_ONE) { + return 0; + } + i = e + SKIP_ONE; + } else { + if (i >= n || !jv_ident_start(s[i]) || s[i] == '$') { + return 0; + } + while (i < n && jv_ident_char(s[i]) && s[i] != '$') { + i++; + } + } + if (i < n && s[i] == '.') { + i++; + continue; + } + return i == n ? i : 0; + } +} + +/* A `[...]` group opening at `i` on the line that ends at `eol`: the index of + * its `]`, or eol when it holds a bracket or does not close on the line. */ +static size_t jv_kdoc_bracket_close(const char *t, size_t i, size_t eol) { + for (size_t k = i + SKIP_ONE; k < eol; k++) { + if (t[k] == ']') { + return k; + } + if (t[k] == '[') { + return eol; + } + } + return eol; +} + +/* One line of KDoc text. */ +typedef struct { + size_t first; /* its first byte that is no blank */ + size_t eol; /* its end */ + uint32_t line; /* its source line */ +} jv_kline_t; + +/* `[Name]` / `[text][Name]` at `i`. Returns the index to continue from. */ +static size_t jv_kdoc_link(jv_doc_t *d, size_t i, const jv_kline_t *ln) { + const char *t = d->t; + size_t eol = ln->eol; + size_t close = jv_kdoc_bracket_close(t, i, eol); + if (close >= eol || close == i + SKIP_ONE) { + return i + SKIP_ONE; + } + size_t b = i + SKIP_ONE; + size_t e = close; + size_t next = close + SKIP_ONE; + if (next < eol && t[next] == '(') { + return next; /* [text](url): a Markdown link, not a code reference */ + } + if (next < eol && t[next] == '[') { + size_t close2 = jv_kdoc_bracket_close(t, next, eol); + if (close2 >= eol) { + return next; + } + if (close2 > next + SKIP_ONE) { + b = next + SKIP_ONE; /* [text][Name] */ + e = close2; + } + next = close2 + SKIP_ONE; + } else if (next < eol && t[next] == ':' && i == ln->first) { + return next; /* [label]: url -- a link definition */ + } + while (b < e && (t[b] == ' ' || t[b] == '\t')) { + b++; + } + while (e > b && (t[e - SKIP_ONE] == ' ' || t[e - SKIP_ONE] == '\t')) { + e--; + } + if (jv_kdoc_target_len(t + b, e - b) > 0) { + jv_push(d, t + b, e - b, ln->line, CBM_DOCLINK_KT_KDOC_LINK); + } + return next; +} + +/* The subject of `@see` / `@throws` / `@sample` starting at `i`: one word. + * Returns the index to continue the line's text scan from. */ +static size_t jv_kdoc_subject(jv_doc_t *d, size_t i, const jv_kline_t *ln, int syntax) { + const char *t = d->t; + size_t eol = ln->eol; + while (i < eol && (t[i] == ' ' || t[i] == '\t')) { + i++; + } + size_t e = i; + while (e < eol && t[e] != ' ' && t[e] != '\t') { + e++; + } + if (e == i) { + return i; + } + size_t b = i; + size_t w = e; + if (t[b] == '[') { + size_t close = jv_kdoc_bracket_close(t, b, eol); + if (close >= eol) { + return e; + } + b++; + w = close; + e = close + SKIP_ONE; + } else { + while (w > b && strchr(".,;:", t[w - SKIP_ONE])) { + w--; + } + } + bool url = + t[b] == '<' || (w - b > 4 && memcmp(t + b, "http", 4) == 0 && memchr(t + b, ':', w - b)); + if (w > b && !url) { + jv_push(d, t + b, w - b, ln->line, syntax); + } + return e; +} + +static int jv_kdoc_tag_syntax(const char *name, size_t len) { + if (jv_word_is(name, len, "see")) { + return CBM_DOCLINK_KT_SEE; + } + if (jv_word_is(name, len, "throws") || jv_word_is(name, len, "exception")) { + return CBM_DOCLINK_KT_THROWS; + } + if (jv_word_is(name, len, "sample")) { + return CBM_DOCLINK_KT_SAMPLE; + } + return CBM_DOCLINK_NONE; +} + +/* Just past the code span that the run of `len` backticks ending before `run` + * opens: the next run of exactly that many. 0 when the line has none (the + * backticks are then text). */ +static size_t jv_kdoc_span_end(const char *t, size_t run, size_t len, size_t eol) { + size_t k = run; + while (k < eol) { + if (t[k] != '`') { + k++; + continue; + } + size_t r = k; + while (r < eol && t[r] == '`') { + r++; + } + if (r - k == len) { + return r; + } + k = r; + } + return 0; +} + +/* The text of one KDoc line from `i`: code spans and HTML comments are + * skipped, bracket links pushed. *in_comment: an HTML comment is open (it may + * have begun on an earlier line). */ +static void jv_kdoc_text(jv_doc_t *d, size_t i, const jv_kline_t *ln, bool *in_comment) { + const char *t = d->t; + size_t eol = ln->eol; + /* A run of n backticks was looked for once and the rest of the line has no + * second one: a later run of n is text as well. Longer runs than a fence + * are not looked for (they would be searched once per length). */ + bool no_span[JV_QUOTES + SKIP_ONE] = {false}; + while (i < eol) { + char c = t[i]; + if (*in_comment) { + if (c == '-' && eol - i >= 3 && t[i + 1] == '-' && t[i + 2] == '>') { + *in_comment = false; + i += 3; + } else { + i++; + } + } else if (c == '\\') { + i += PAIR_LEN; + } else if (c == '`') { + size_t run = i; + while (run < eol && t[run] == '`') { + run++; + } + size_t len = run - i; + size_t past = + (len > JV_QUOTES || no_span[len]) ? 0 : jv_kdoc_span_end(t, run, len, eol); + if (past == 0 && len <= JV_QUOTES) { + no_span[len] = true; + } + i = past ? past : run; + } else if (c == '<' && eol - i >= 4 && memcmp(t + i, " \n" /* 8 */ + " * {@linkage NotATag} {@link Open\n" /* 9 */ + " * @see Gadget#spin()\n" /* 10 */ + " * @see site\n" /* 11 */ + " * @see \"The Book\"\n" /* 12 */ + " * @throws IllegalStateException never\n" /* 13 */ + " * @exception a.CustomError worse\n" /* 14 */ + " * @param the type {@link T}\n" /* 15 */ + " * @return {@value} nothing, {@value \"%x\" Helper#MASK}\n" /* 16 */ + " */\n" /* 17 */ + "public class Widget {\n" /* 18 */ + " /** Max {@value}. {@link Widget#MAX} */\n" /* 19 */ + " public static final int MAX = 10;\n" /* 20 */ + " /**\n" /* 21 */ + " *
{@code\n"                                                    /* 22 */
+        "     * @Override {@link InSample}\n"                                     /* 23 */
+        "     * }
after {@link AfterSample}\n" /* 24 */ + " */\n" /* 25 */ + " void sample() {}\n" /* 26 */ + "}\n"; + CBMFileResult *r = dm_extract(src, CBM_LANG_JAVA, "a/Widget.java"); + ASSERT_NOT_NULL(r); + /* every family, with the line the reference is written on */ + const CBMDocLink *t = jt_token(r, "Gadget"); + ASSERT_EQ(t->syntax, CBM_DOCLINK_JAVA_LINK); + ASSERT_EQ(t->line, 4); + ASSERT_EQ(t->def_line, 18); + ASSERT_STR_EQ(t->source_qn, "p.a.Widget"); + ASSERT_EQ(dm_count_tokens(r, "Gadget#spin()"), 2); + ASSERT_EQ(jt_token(r, "Gadget#spin()")->syntax, CBM_DOCLINK_JAVA_LINKPLAIN); + /* the label is not part of the reference; blanks inside it are one */ + ASSERT_EQ(jt_token(r, "Helper#run(int, String)")->line, 5); + ASSERT_EQ(jt_token(r, "#MAX")->syntax, CBM_DOCLINK_JAVA_VALUE); + ASSERT_EQ(jt_token(r, "Wrapped#name")->line, 7); + ASSERT_EQ(jt_token(r, "IllegalStateException")->syntax, CBM_DOCLINK_JAVA_THROWS); + ASSERT_EQ(jt_token(r, "a.CustomError")->syntax, CBM_DOCLINK_JAVA_THROWS); + ASSERT_EQ(jt_token(r, "a.CustomError")->line, 14); + ASSERT_EQ(jt_token(r, "https://example.org/x")->syntax, CBM_DOCLINK_HREF); + ASSERT_EQ(jt_token(r, "https://example.org/x")->line, 11); + /* a format string is not the reference of {@value} */ + ASSERT_EQ(jt_token(r, "Helper#MASK")->syntax, CBM_DOCLINK_JAVA_VALUE); + /* a reference to the definition's own type parameter is still a token: + * the resolver, which knows the scope, calls it local */ + ASSERT_EQ(jt_token(r, "T")->line, 15); + /* not references: code, literal, HTML comment, CDATA, another tag whose + * name only starts like one, a quoted title, a bare {@value} */ + ASSERT_NULL(dm_find_token(r, "InCode")); + ASSERT_NULL(dm_find_token(r, "InLiteral")); + ASSERT_NULL(dm_find_token(r, "InComment")); + ASSERT_NULL(dm_find_token(r, "InCdata")); + ASSERT_NULL(dm_find_token(r, "NotATag")); + ASSERT_NULL(dm_find_token(r, "\"The")); + /* an inline tag that a block tag cuts off never closed: javadoc reports + * it, and it links nothing */ + ASSERT_NULL(dm_find_token(r, "Open")); + /* the field's doc is harvested once, on the Field (its package-level + * Variable twin carries the same doc) */ + ASSERT_EQ(dm_count_tokens(r, "Widget#MAX"), 1); + ASSERT_STR_EQ(jt_token(r, "Widget#MAX")->source_qn, "p.a.Widget.MAX"); + /* `@` at the start of a line inside {@code} is sample code, not a block + * tag, and what follows inside the block is still code */ + ASSERT_NULL(dm_find_token(r, "InSample")); + ASSERT_EQ(jt_token(r, "AfterSample")->line, 24); + cbm_free_result(r); + PASS(); +} + +TEST(doc_mentions_jvm_kotlin_tokens) { + const char *src = + "package k\n" /* 1 */ + "\n" /* 2 */ + "/**\n" /* 3 */ + " * A [Gadget], [the helper][Helper.run], [text](https://x.org), `[InCode]`.\n" /* 4 */ + " * ``double `tick` [InDouble]`` then [Outer.Inner] and [`quoted name`].\n" /* 5 */ + " * [label]: https://example.org\n" /* 6 */ + " * ![image][ImgLabel] \\[Escaped]\n" /* 7 */ + " * Nested /* comment [StillDoc] */ goes on to [AfterNested].\n" /* 8 */ + " *\n" /* 9 */ + " * [IndentedCode]\n" /* 10 */ + " *\n" /* 11 */ + " * ```\n" /* 12 */ + " * [Fenced]\n" /* 13 */ + " * ```\n" /* 14 */ + " * @param name the [name] of [T]\n" /* 15 */ + " * @property size the size\n" /* 16 */ + " * @see Gadget.spin\n" /* 17 */ + " * @see https://example.org\n" /* 18 */ + " * @throws IllegalStateException never\n" /* 19 */ + " * @exception CustomError worse\n" /* 20 */ + " * @sample k.samples.widgetSample\n" /* 21 */ + " * @return [Result] of [0] and [two words]\n" /* 22 */ + " */\n" /* 23 */ + "class Widget(val name: String, size: Int)\n"; /* 24 */ + CBMFileResult *r = dm_extract(src, CBM_LANG_KOTLIN, "k/Widget.kt"); + ASSERT_NOT_NULL(r); + const CBMDocLink *t = jt_token(r, "Gadget"); + ASSERT_EQ(t->syntax, CBM_DOCLINK_KT_KDOC_LINK); + ASSERT_EQ(t->line, 4); + ASSERT_EQ(t->def_line, 24); + ASSERT_STR_EQ(t->source_qn, "p.k.Widget.Widget"); + /* [text][Name]: the second bracket names the code */ + ASSERT_EQ(jt_token(r, "Helper.run")->syntax, CBM_DOCLINK_KT_KDOC_LINK); + ASSERT_NULL(dm_find_token(r, "the helper")); + ASSERT_EQ(jt_token(r, "Outer.Inner")->line, 5); + ASSERT_EQ(jt_token(r, "`quoted name`")->line, 5); + /* a block comment inside the doc is part of its text: Kotlin comments + * nest, and the doc goes on after it */ + ASSERT_EQ(jt_token(r, "StillDoc")->line, 8); + ASSERT_EQ(jt_token(r, "AfterNested")->line, 8); + ASSERT_EQ(jt_token(r, "Gadget.spin")->syntax, CBM_DOCLINK_KT_SEE); + ASSERT_EQ(jt_token(r, "IllegalStateException")->syntax, CBM_DOCLINK_KT_THROWS); + ASSERT_EQ(jt_token(r, "CustomError")->syntax, CBM_DOCLINK_KT_THROWS); + ASSERT_EQ(jt_token(r, "CustomError")->line, 20); + ASSERT_EQ(jt_token(r, "k.samples.widgetSample")->syntax, CBM_DOCLINK_KT_SAMPLE); + ASSERT_EQ(jt_token(r, "Result")->line, 22); + /* own parameter and type parameter: tokens, local to the resolver */ + ASSERT_EQ(jt_token(r, "name")->line, 15); + ASSERT_EQ(jt_token(r, "T")->line, 15); + /* not references: a Markdown link, code spans, a link definition, an + * HTML comment, an image's label, an escaped bracket, an indented and a + * fenced code block, a URL, and brackets that hold no name */ + ASSERT_NULL(dm_find_token(r, "text")); + ASSERT_NULL(dm_find_token(r, "InCode")); + ASSERT_NULL(dm_find_token(r, "InDouble")); + ASSERT_NULL(dm_find_token(r, "label")); + ASSERT_NULL(dm_find_token(r, "InComment")); + ASSERT_NULL(dm_find_token(r, "image")); + ASSERT_NULL(dm_find_token(r, "ImgLabel")); + ASSERT_NULL(dm_find_token(r, "Escaped")); + ASSERT_NULL(dm_find_token(r, "IndentedCode")); + ASSERT_NULL(dm_find_token(r, "Fenced")); + ASSERT_NULL(dm_find_token(r, "https://example.org")); + ASSERT_NULL(dm_find_token(r, "0")); + ASSERT_NULL(dm_find_token(r, "two words")); + ASSERT_NULL(dm_find_token(r, "size")); + cbm_free_result(r); + PASS(); +} + +/* ── extraction: hostile input ───────────────────────────────────── */ + +enum { JT_BIG = 200000, JT_MEGA = 1100000 }; + +/* Extract `head` + `count` copies of `unit` + `tail` as one doc comment on a + * class; the result, or NULL when memory ran out. */ +static CBMFileResult *jt_extract_repeated(CBMLanguage lang, const char *head, const char *unit, + int count, const char *tail) { + size_t ul = strlen(unit); + size_t cap = strlen(head) + (ul * (size_t)count) + strlen(tail) + 64; + char *src = malloc(cap); + if (!src) { + return NULL; + } + size_t w = (size_t)snprintf(src, cap, "/**\n * %s", head); + for (int i = 0; i < count; i++) { + memcpy(src + w, unit, ul); + w += ul; + } + snprintf(src + w, cap - w, "%s\n */\n%s", tail, + lang == CBM_LANG_JAVA ? "public class C {}\n" : "class C\n"); + CBMFileResult *r = dm_extract(src, lang, lang == CBM_LANG_JAVA ? "C.java" : "C.kt"); + free(src); + return r; +} + +TEST(doc_mentions_jvm_malformed_and_oversized) { + /* a reference longer than any name (here: over a megabyte) is kept just + * past the limit, which is how the resolver knows it was cut: it never + * resolves a prefix */ + CBMFileResult *r = jt_extract_repeated(CBM_LANG_JAVA, "{@link ", "A", JT_MEGA, "}"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 1); + ASSERT_GT(strlen(r->doc_links.items[0].raw), (size_t)CBM_DOCLINK_JVM_REF_MAX); + ASSERT(strlen(r->doc_links.items[0].raw) < (size_t)CBM_DOCLINK_JVM_REF_MAX + 16); + cbm_free_result(r); + r = jt_extract_repeated(CBM_LANG_KOTLIN, "[", "A", JT_MEGA, "]"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 1); + ASSERT_GT(strlen(r->doc_links.items[0].raw), (size_t)CBM_DOCLINK_JVM_REF_MAX); + ASSERT(strlen(r->doc_links.items[0].raw) < (size_t)CBM_DOCLINK_JVM_REF_MAX + 16); + cbm_free_result(r); + /* tags that never close, nested deeper than any text nests */ + r = jt_extract_repeated(CBM_LANG_JAVA, "", "{@link A ", JT_BIG, ""); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 0); + cbm_free_result(r); + /* ... and closed again: the innermost ones are references, no more than + * the parser keeps open at once, and nothing overflows */ + r = jt_extract_repeated(CBM_LANG_JAVA, "", "{@link A ", 100, "}}}}}}}}}}"); + ASSERT_NOT_NULL(r); + ASSERT(r->doc_links.count <= 10); + cbm_free_result(r); + /* brackets, backticks and comment openers by the hundred thousand on one + * line: one pass, no token */ + r = jt_extract_repeated(CBM_LANG_KOTLIN, "", "[", JT_BIG, ""); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->doc_links.count, 0); + cbm_free_result(r); + r = jt_extract_repeated(CBM_LANG_KOTLIN, "", "` `` ", JT_BIG, " [After]"); + ASSERT_NOT_NULL(r); + cbm_free_result(r); + r = jt_extract_repeated(CBM_LANG_KOTLIN, "[Before] ", "