diff --git a/Makefile.cbm b/Makefile.cbm index ffb0cb0e7..4a3e45d59 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -393,6 +393,9 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/doclink_ts.c \ $(CBM_DIR)/doclink_php.c \ $(CBM_DIR)/doclink_c.c \ + $(CBM_DIR)/diagram.c \ + $(CBM_DIR)/route_path.c \ + $(CBM_DIR)/openapi.c \ $(CBM_DIR)/sql_values.c \ $(CBM_DIR)/helpers.c \ $(CBM_DIR)/cpp_branch_views.c \ @@ -526,6 +529,7 @@ PIPELINE_SRCS = \ src/pipeline/doc_links_ts.c \ src/pipeline/doc_links_php.c \ src/pipeline/doc_links_c.c \ + src/pipeline/pass_diagrams.c \ src/pipeline/doc_links_msbuild.c \ src/pipeline/pass_route_nodes.c \ src/pipeline/pass_enrichment.c \ @@ -788,7 +792,7 @@ TEST_DISCOVER_SRCS = \ TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c -TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_pipeline_conventions.c tests/test_pipeline_frozen.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_doc_mentions.c tests/test_doc_links_md.c tests/test_doc_links_pdf.c tests/test_doc_links_rst.c tests/test_doc_links_adoc.c tests/test_doc_mentions_go.c tests/test_doc_mentions_jvm.c tests/test_doc_mentions_rs.c tests/test_doc_mentions_perl.c tests/test_doc_mentions_ts.c tests/test_doc_mentions_php.c tests/test_doc_mentions_c.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c +TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_pipeline_conventions.c tests/test_pipeline_frozen.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_doc_mentions.c tests/test_doc_links_md.c tests/test_doc_links_pdf.c tests/test_doc_links_rst.c tests/test_doc_links_adoc.c tests/test_doc_mentions_go.c tests/test_doc_mentions_jvm.c tests/test_doc_mentions_rs.c tests/test_doc_mentions_perl.c tests/test_doc_mentions_ts.c tests/test_doc_mentions_php.c tests/test_doc_mentions_c.c tests/test_diagrams.c tests/test_openapi.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c TEST_WATCHER_SRCS = tests/test_watcher.c diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index b9e3237b2..d60c9a9bd 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -277,6 +277,11 @@ void cbm_channels_push(CBMChannelArray *arr, CBMArena *a, CBMChannel ch) { arr->items[arr->count++] = ch; } +void cbm_diagram_arrows_push(CBMDiagramArrowArray *arr, CBMArena *a, CBMDiagramArrow da) { + GROW_ARRAY(arr, a); + arr->items[arr->count++] = da; +} + // --- String input reader (for parse_with_options) --- typedef struct { @@ -2837,6 +2842,26 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C .module_qn = result->module_qn, }; cbm_adoc_extract_document(&adoc_ctx); + cbm_extract_diagrams(&adoc_ctx); /* after the Sections they hang under */ + cbm_index_mark_done(rel_path); + return result; + } + /* Diagram files have no grammar either: each format is read from the text + * (diagram.c). */ + if (language == CBM_LANG_DIAGRAM) { + result->module_qn = cbm_fqn_module_source_lang(a, project, rel_path, language); + CBMExtractCtx diagram_ctx = { + .arena = a, + .scratch = scratch, + .result = result, + .source = source, + .source_len = source_len, + .language = language, + .project = project, + .rel_path = rel_path, + .module_qn = result->module_qn, + }; + cbm_extract_diagram_file(&diagram_ctx); cbm_index_mark_done(rel_path); return result; } @@ -3021,6 +3046,12 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C // Channel detection (Socket.IO / EventEmitter) — JS/TS only. cbm_extract_channels(&ctx); + // Diagrams: blocks of documents and diagram files (self-gated by language). + cbm_extract_diagrams(&ctx); + + // OpenAPI operations as Routes (self-gated: API description documents). + cbm_extract_openapi(&ctx); + // K8s / Kustomize semantic pass (additional structured extraction for YAML-based infra files). if (ctx.language == CBM_LANG_KUSTOMIZE || ctx.language == CBM_LANG_K8S) { cbm_extract_k8s(&ctx); diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 6cc5d4743..7d3bf15a0 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -211,6 +211,10 @@ typedef enum { CBM_LANG_CHIALISP, // Chialisp (.clsp/.clib/.clinc — Chia smart-coin s-expression language) CBM_LANG_PDF, // PDF documents: the text layer, read without a grammar (doc_pdf.c) CBM_LANG_ASCIIDOC, // AsciiDoc documents, read without a grammar (doc_adoc.c) + CBM_LANG_OPENAPI, // openapi.json / swagger.json: the JSON grammar, no node per key; their + // operations are Routes (openapi.c) + CBM_LANG_DIAGRAM, // diagram files (PlantUML, DOT, D2, draw.io, Excalidraw), read + // without a grammar (diagram.c) CBM_LANG_COUNT } CBMLanguage; @@ -612,6 +616,23 @@ typedef struct { int cap; } CBMChannelArray; +/* One arrow of a diagram (diagram.c): from one DiagramElement definition of + * the file to another, written as a DIAGRAM_ARROW edge. */ +typedef struct { + const char *from_qn; + const char *to_qn; + const char *label; /* the arrow's text, NULL for none */ + const char *style; /* what the arrow means as its format writes it: "flow", + * "message", "inherits", "composition", ... */ + uint32_t line; +} CBMDiagramArrow; + +typedef struct { + CBMDiagramArrow *items; + int count; + int cap; +} CBMDiagramArrowArray; + typedef struct { CBMFieldType *items; int count; @@ -661,6 +682,7 @@ typedef struct CBMFileResult { CBMStringRefArray string_refs; // URL/config string literals from AST CBMInfraBindingArray infra_bindings; // topic→URL pairs from IaC configs CBMChannelArray channels; // Socket.IO / EventEmitter pub/sub participation + CBMDiagramArrowArray diagram_arrows; // arrows between the file's DiagramElement defs CBMFieldTypeArray field_types; // Python: annotated instance fields (#1277) const char *module_qn; // module qualified name @@ -1122,6 +1144,7 @@ void cbm_infrabinding_push(CBMInfraBindingArray *arr, CBMArena *a, CBMInfraBindi void cbm_impltrait_push(CBMImplTraitArray *arr, CBMArena *a, CBMImplTrait it); void cbm_resolvedcall_push(CBMResolvedCallArray *arr, CBMArena *a, CBMResolvedCall rc); void cbm_channels_push(CBMChannelArray *arr, CBMArena *a, CBMChannel ch); +void cbm_diagram_arrows_push(CBMDiagramArrowArray *arr, CBMArena *a, CBMDiagramArrow da); // --- Sub-extractor entry points --- @@ -1140,6 +1163,26 @@ void cbm_extract_type_refs(CBMExtractCtx *ctx); void cbm_extract_env_accesses(CBMExtractCtx *ctx); void cbm_extract_type_assigns(CBMExtractCtx *ctx); void cbm_extract_channels(CBMExtractCtx *ctx); +/* Diagrams (diagram.c): a Diagram definition per diagram -- a Markdown, reST + * or AsciiDoc block, or a diagram file -- with a DiagramElement definition per + * element and the arrows between them. */ +void cbm_extract_diagrams(CBMExtractCtx *ctx); +/* A diagram file (CBM_LANG_DIAGRAM): its format comes from its extension. */ +void cbm_extract_diagram_file(CBMExtractCtx *ctx); + +/* OpenAPI / Swagger documents (openapi.c): a Route definition per operation and + * a doc link per operationId. Self-gated: openapi.json / swagger.json, and YAML + * or JSON files whose root says `openapi` or `swagger`. */ +void cbm_extract_openapi(CBMExtractCtx *ctx); + +/* Canonicalize route-path parameter placeholders (":id", "{id}", "", + * "${...}") to a single "{}" token so that client call sites, server handlers + * and OpenAPI operations rendezvous on the same Route QN regardless of + * framework syntax. Parameter names are intentionally discarded ("/u/{id}" and + * "/u/{slug}" both canonicalize to "/u/{}"). The result never exceeds the + * input length, so out_sz >= strlen(in) + 1 always suffices. Returns out. + * (route_path.c) */ +const char *cbm_route_canon_path(const char *in, char *out, size_t out_sz); // Single-pass unified extraction (replaces the 7 calls above except defs+imports). void cbm_extract_unified(CBMExtractCtx *ctx); diff --git a/internal/cbm/diagram.c b/internal/cbm/diagram.c new file mode 100644 index 000000000..228c3ca67 --- /dev/null +++ b/internal/cbm/diagram.c @@ -0,0 +1,3544 @@ +/* + * diagram.c — diagrams as graph nodes: a Diagram definition per diagram and a + * DiagramElement definition per element, with the arrows between elements + * (CBMDiagramArrow, written as DIAGRAM_ARROW edges by pass_diagrams.c). + * + * Where diagrams come from: + * - a fenced block of a Markdown file whose info string names a diagram + * language (mermaid; plantuml, puml, uml; dot, graphviz; d2): the Diagram + * hangs under the Section the block is written in (the last heading above + * it), or under the file before any heading; + * - a Mermaid file (.mmd, .mermaid): one Diagram, under the file. + * + * Qualified names: the parent's (a Section's, else the file's module QN) + + * "#diagram-", k counting the diagrams of that parent from 1; an element's + * is the diagram's + "." + its id (dots and blanks as `_`). + * + * A Diagram's docstring is the text its elements and arrows carry (their + * labels), which is what a reader of the diagram reads; its properties hold + * the format and the diagram kind. An element's name is its label (its id + * when it has none); its properties hold the id and the element kind. An + * element named only by an arrow is an element too. + * + * Formats (one parser each, line by line, no grammar): Mermaid flowchart and + * graph, sequenceDiagram, classDiagram, erDiagram, stateDiagram, C4 (Context, + * Container, Component, Dynamic, Deployment) and architecture-beta. Every + * other Mermaid kind (gantt, pie, journey, mindmap, ...) is a Diagram without + * elements. + */ +#include "cbm.h" +#include "doclink.h" + +#include "arena.h" +#include "helpers.h" +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/mem_core.h" +#include "tree_sitter/api.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +enum { + DG_DOC_MAX = 2048, /* a Diagram's docstring */ + DG_LABEL_MAX = 256, /* an element's or an arrow's label */ + DG_ID_MAX = 128, + DG_ID_HEAD = DG_ID_MAX - 18, /* a long id's head: `~` and 16 hex digits follow */ + DG_KIND_MAX = 32, + DG_ARGS_MAX = 8, +}; + +static const uint64_t DG_FNV_OFFSET = 0xcbf29ce484222325ULL; /* FNV-1a 64 */ +static const uint64_t DG_FNV_PRIME = 0x100000001b3ULL; +enum { DG_UTF8_CONT_MASK = 0xC0, DG_UTF8_CONT = 0x80 }; /* a continuation byte: 10xxxxxx */ + +/* ── The builder ─────────────────────────────────────────────────── */ + +typedef struct { + CBMExtractCtx *ctx; + const char *qn; /* the diagram's */ + const char *format; + char kind[DG_KIND_MAX]; + const char *element_kind; /* what an element named only by an arrow is */ + int def_index; /* the Diagram definition, in ctx->result->defs */ + uint32_t line0; /* the source line of the diagram's text */ + CBMHashTable *ids; /* element id -> its definition's index + 1 */ + char doc[DG_DOC_MAX]; /* the labels, for the docstring */ + size_t doc_len; + bool oom; +} dg_t; + +typedef struct { + const char *p; + size_t n; +} dg_str_t; + +static dg_str_t dg_s(const char *p, size_t n) { + return (dg_str_t){p, n}; +} + +static dg_str_t dg_trim(dg_str_t s) { + while (s.n && isspace((unsigned char)s.p[0])) { + s.p++; + s.n--; + } + while (s.n && isspace((unsigned char)s.p[s.n - SKIP_ONE])) { + s.n--; + } + return s; +} + +/* Quotes and Markdown-string backticks around a label dropped. */ +static dg_str_t dg_unquote(dg_str_t s) { + s = dg_trim(s); + if (s.n >= PAIR_LEN && (s.p[0] == '"' || s.p[0] == '\'' || s.p[0] == '`') && + s.p[s.n - SKIP_ONE] == s.p[0]) { + s.p++; + s.n -= PAIR_LEN; + } + if (s.n >= PAIR_LEN && s.p[0] == '`' && s.p[s.n - SKIP_ONE] == '`') { + s.p++; + s.n -= PAIR_LEN; + } + return dg_trim(s); +} + +static bool dg_eq(dg_str_t s, const char *lit) { + return strlen(lit) == s.n && memcmp(s.p, lit, s.n) == 0; +} + +static bool dg_starts(dg_str_t s, const char *lit) { + size_t l = strlen(lit); + return s.n >= l && memcmp(s.p, lit, l) == 0; +} + +/* A label's text: tags (`
`), the line breaks DOT, PlantUML and D2 write + * as `\n` (also `\l`, `\r`, `\t`) and blank runs as one space, at most cap-1 + * bytes. */ +static size_t dg_label_text(dg_str_t s, char *out, size_t cap) { + size_t w = 0; + bool space = false; + for (size_t i = 0; i < s.n && w + SKIP_ONE < cap; i++) { + char c = s.p[i]; + if (c == '\\' && i + SKIP_ONE < s.n && strchr("nlrt", s.p[i + SKIP_ONE]) && + s.p[i + SKIP_ONE]) { + i++; + space = true; + continue; + } + if (c == '<') { + const char *gt = memchr(s.p + i, '>', s.n - i); + if (gt) { + i = (size_t)(gt - s.p); + space = true; + continue; + } + } + if (isspace((unsigned char)c)) { + space = true; + continue; + } + if (space && w) { + out[w++] = ' '; + } + space = false; + out[w++] = c; + } + out[w] = '\0'; + return w; +} + +static void dg_doc_add(dg_t *dg, const char *text, size_t n) { + if (!n || dg->doc_len + n + PAIR_LEN >= sizeof(dg->doc)) { + return; + } + if (dg->doc_len) { + memcpy(dg->doc + dg->doc_len, "; ", PAIR_LEN); + dg->doc_len += PAIR_LEN; + } + memcpy(dg->doc + dg->doc_len, text, n); + dg->doc_len += n; + dg->doc[dg->doc_len] = '\0'; +} + +static const char *dg_dup(dg_t *dg, const char *s, size_t n) { + char *c = cbm_arena_strndup(dg->ctx->arena, s, n); + dg->oom = dg->oom || !c; + return c; +} + +static const char **dg_props(dg_t *dg, const char *k1, const char *v1, const char *k2, + const char *v2) { + const char **kv = (const char **)cbm_arena_alloc(dg->ctx->arena, CBM_SZ_5 * sizeof(char *)); + if (!kv) { + dg->oom = true; + return NULL; + } + kv[0] = k1; + kv[SKIP_ONE] = v1; + kv[PAIR_LEN] = k2; + kv[CBM_SZ_3] = v2; + kv[CBM_SZ_4] = NULL; + return kv; +} + +/* An element id as a QN segment: dots, blanks, backslashes, quotes and `#` + * (QN suffix syntax) as `_`. */ +static const char *dg_element_qn(dg_t *dg, dg_str_t id) { + char seg[DG_ID_MAX]; + size_t n = id.n < sizeof(seg) - SKIP_ONE ? id.n : sizeof(seg) - SKIP_ONE; + for (size_t i = 0; i < n; i++) { + char c = id.p[i]; + seg[i] = + (c == '.' || c == '\\' || c == '"' || c == '#' || isspace((unsigned char)c)) ? '_' : c; + } + seg[n] = '\0'; + char *qn = cbm_arena_sprintf(dg->ctx->arena, "%s.%s", dg->qn, seg); + dg->oom = dg->oom || !qn; + return qn; +} + +/* An element id as its table key and QN source. An id too long for one (a + * DOT node named by a sentence) keeps its head, cut at a character boundary, + * and the FNV-1a 64 of the whole: still one key per id. */ +static size_t dg_id_key(dg_str_t id, char *key) { + if (id.n < DG_ID_MAX) { + memcpy(key, id.p, id.n); + key[id.n] = '\0'; + return id.n; + } + uint64_t h = DG_FNV_OFFSET; + for (size_t i = 0; i < id.n; i++) { + h = (h ^ (unsigned char)id.p[i]) * DG_FNV_PRIME; + } + size_t head = DG_ID_HEAD; + while (head > 0 && ((unsigned char)id.p[head] & DG_UTF8_CONT_MASK) == DG_UTF8_CONT) { + head--; /* not inside a UTF-8 sequence */ + } + int n = snprintf(key, DG_ID_MAX, "%.*s~%016llx", (int)head, id.p, (unsigned long long)h); + return n > 0 ? (size_t)n : 0; +} + +/* An explicit link written on an element (or, for a PlantUML !include, on the + * diagram): a doc-link record the pipeline resolves as a Markdown link. An + * in-page anchor, a data URI or a script links nothing. */ +static void dg_link(dg_t *dg, const char *source_qn, dg_str_t raw, int syntax, uint32_t line) { + raw = dg_unquote(raw); + if (!source_qn || !raw.n || raw.p[0] == '#' || dg_starts(raw, "data:") || + dg_starts(raw, "javascript:")) { + return; + } + CBMDocLink link = { + .source_qn = source_qn, + .raw = dg_dup(dg, raw.p, raw.n), + .line = line, + .def_line = line, + .syntax = (uint16_t)syntax, + }; + if (link.raw) { + cbm_doclinks_push(&dg->ctx->result->doc_links, dg->ctx->arena, link); + } +} + +/* The element `id` (made on first sight), its label set when it has none. */ +static const char *dg_element(dg_t *dg, dg_str_t id, dg_str_t label, const char *kind, + uint32_t line) { + id = dg_trim(id); + if (!id.n || dg->oom) { + return NULL; + } + char key[DG_ID_MAX]; + size_t kn = dg_id_key(id, key); + char text[DG_LABEL_MAX]; + size_t tl = dg_label_text(dg_unquote(label), text, sizeof(text)); + CBMDefArray *defs = &dg->ctx->result->defs; + void *v = cbm_ht_get(dg->ids, key); + if (v) { + CBMDefinition *d = &defs->items[(uintptr_t)v - SKIP_ONE]; + if (tl && d->name == d->extra_props[SKIP_ONE]) { /* still named by its id */ + d->name = dg_dup(dg, text, tl); + dg_doc_add(dg, text, tl); + } + return d->qualified_name; + } + const char *qn = dg_element_qn(dg, dg_s(key, kn)); + const char *idc = dg_dup(dg, id.p, id.n); + if (!qn || !idc) { + return NULL; + } + CBMDefinition def; + memset(&def, 0, sizeof(def)); + def.name = tl ? dg_dup(dg, text, tl) : idc; + def.qualified_name = qn; + def.label = "DiagramElement"; + def.file_path = dg->ctx->rel_path; + def.start_line = line; + def.end_line = line; + def.parent_class = dg->qn; + /* the kind may live in a parser buffer (a JSON value, a draw.io cell) */ + const char *kindc = dg_dup(dg, kind, strlen(kind)); + def.extra_props = dg_props(dg, "element_id", idc, "element_kind", kindc); + if (dg->oom || !def.name) { + return NULL; + } + cbm_defs_push(defs, dg->ctx->arena, def); + const char *k = dg_dup(dg, key, kn); + if (k) { + cbm_ht_set(dg->ids, k, (void *)(uintptr_t)defs->count); + } + if (tl) { + dg_doc_add(dg, text, tl); + } + return qn; +} + +static void dg_arrow(dg_t *dg, dg_str_t from, dg_str_t to, dg_str_t label, const char *style, + uint32_t line) { + const char *kind = dg->element_kind ? dg->element_kind : "node"; + const char *a = dg_element(dg, from, dg_s(NULL, 0), kind, line); + const char *b = dg_element(dg, to, dg_s(NULL, 0), kind, line); + if (!a || !b) { + return; + } + char text[DG_LABEL_MAX]; + size_t tl = dg_label_text(dg_unquote(label), text, sizeof(text)); + CBMDiagramArrow arrow = {.from_qn = a, + .to_qn = b, + .label = tl ? dg_dup(dg, text, tl) : NULL, + .style = style, + .line = line}; + if (!dg->oom) { + cbm_diagram_arrows_push(&dg->ctx->result->diagram_arrows, dg->ctx->arena, arrow); + } + if (tl) { + dg_doc_add(dg, text, tl); + } +} + +/* ── Lines ───────────────────────────────────────────────────────── */ + +typedef struct { + const char *p; + const char *end; + uint32_t line; +} dg_lines_t; + +/* The next line (no newline), false at the end. */ +static bool dg_next_line(dg_lines_t *it, dg_str_t *out, uint32_t *line) { + if (it->p >= it->end) { + return false; + } + const char *nl = memchr(it->p, '\n', (size_t)(it->end - it->p)); + const char *le = nl ? nl : it->end; + *out = dg_s(it->p, (size_t)(le - it->p)); + if (out->n && out->p[out->n - SKIP_ONE] == '\r') { + out->n--; + } + *line = it->line++; + it->p = nl ? nl + SKIP_ONE : it->end; + return true; +} + +/* A Mermaid line without its `%%` comment. */ +static dg_str_t dg_mermaid_strip(dg_str_t s) { + for (size_t i = 0; i + SKIP_ONE < s.n; i++) { + if (s.p[i] == '%' && s.p[i + SKIP_ONE] == '%') { + s.n = i; + break; + } + } + return dg_trim(s); +} + +/* ── Mermaid: flowchart ──────────────────────────────────────────── */ + +static bool dg_id_char(char c) { + return isalnum((unsigned char)c) || c == '_' || c == '-' || (unsigned char)c >= 0x80; +} + +/* The closing bracket of a node shape opened at s[i]: the shape's text in + * *label, the position after it. */ +static size_t dg_shape(dg_str_t s, size_t i, dg_str_t *label) { + static const char *const opens[] = {"(((", "([", "[[", "[(", "((", "{{", "[/", + "[\\", "[", "(", "{", ">", NULL}; + static const char *const closes[] = {")))", "])", "]]", ")]", "))", "}}", "/]", + "\\]", "]", ")", "}", "]", NULL}; + for (int k = 0; opens[k]; k++) { + size_t ol = strlen(opens[k]); + if (i + ol > s.n || memcmp(s.p + i, opens[k], ol) != 0) { + continue; + } + size_t cl = strlen(closes[k]); + bool quoted = false; + for (size_t j = i + ol; j + cl <= s.n; j++) { + if (s.p[j] == '"') { + quoted = !quoted; + } + if (!quoted && memcmp(s.p + j, closes[k], cl) == 0) { + *label = dg_s(s.p + i + ol, j - i - ol); + return j + cl; + } + } + /* `[/text\]` and `[\text/]` close with the other slash */ + if (k == CBM_SZ_6 || k == CBM_SZ_7) { + const char *alt = k == CBM_SZ_6 ? "\\]" : "/]"; + for (size_t j = i + ol; j + PAIR_LEN <= s.n; j++) { + if (memcmp(s.p + j, alt, PAIR_LEN) == 0) { + *label = dg_s(s.p + i + ol, j - i - ol); + return j + PAIR_LEN; + } + } + } + return i; + } + return i; +} + +/* A node at s[i]: its id and label; the position after it (i when none). */ +static size_t dg_flow_node(dg_str_t s, size_t i, dg_str_t *id, dg_str_t *label) { + size_t j = i; + while (j < s.n && dg_id_char(s.p[j])) { + if (s.p[j] == '-' && j + SKIP_ONE < s.n && + (s.p[j + SKIP_ONE] == '-' || s.p[j + SKIP_ONE] == '.' || s.p[j + SKIP_ONE] == '>')) { + break; /* an edge starts */ + } + j++; + } + if (j == i) { + return i; + } + *id = dg_s(s.p + i, j - i); + *label = dg_s(NULL, 0); + if (j < s.n && s.p[j] == '@' && j + SKIP_ONE < s.n && s.p[j + SKIP_ONE] == '{') { + const char *close = memchr(s.p + j, '}', s.n - j); + if (close) { + dg_str_t body = dg_s(s.p + j + PAIR_LEN, (size_t)(close - s.p) - j - PAIR_LEN); + const char *lab = NULL; + for (size_t k = 0; k + CBM_SZ_6 <= body.n && !lab; k++) { + lab = memcmp(body.p + k, "label:", CBM_SZ_6) == 0 ? body.p + k + CBM_SZ_6 : NULL; + } + if (lab) { + const char *comma = memchr(lab, ',', (size_t)(body.p + body.n - lab)); + *label = dg_s(lab, (size_t)((comma ? comma : body.p + body.n) - lab)); + } + return (size_t)(close - s.p) + SKIP_ONE; + } + } + size_t k = dg_shape(s, j, label); + if (k > j) { + j = k; + } + if (j + CBM_SZ_3 <= s.n && memcmp(s.p + j, ":::", CBM_SZ_3) == 0) { + j += CBM_SZ_3; + while (j < s.n && dg_id_char(s.p[j])) { + j++; + } + } + return j; +} + +static size_t dg_skip_ws(dg_str_t s, size_t i) { + while (i < s.n && isspace((unsigned char)s.p[i])) { + i++; + } + return i; +} + +static bool dg_link_char(char c) { + return c == '-' || c == '=' || c == '.' || c == '>' || c == '<' || c == '~'; +} + +/* An edge at s[i]: its style and label; the position after it (i when none). */ +static size_t dg_flow_edge(dg_str_t s, size_t i, const char **style, dg_str_t *label) { + size_t j = i; + if (j < s.n && (s.p[j] == '<' || s.p[j] == 'x' || s.p[j] == 'o') && j + SKIP_ONE < s.n && + (s.p[j + SKIP_ONE] == '-' || s.p[j + SKIP_ONE] == '=')) { + j++; + } + size_t start = j; + while (j < s.n && dg_link_char(s.p[j])) { + j++; + } + size_t run = j - start; + if (run < PAIR_LEN) { + return i; + } + if (j < s.n && (s.p[j] == 'x' || s.p[j] == 'o') && + (j + SKIP_ONE >= s.n || !dg_id_char(s.p[j + SKIP_ONE]))) { + j++; /* an arrowhead x or o */ + } + dg_str_t op = dg_s(s.p + i, j - i); + *label = dg_s(NULL, 0); + bool head = + memchr(op.p, '>', op.n) || op.p[op.n - SKIP_ONE] == 'x' || op.p[op.n - SKIP_ONE] == 'o'; + if (!head && (dg_eq(op, "--") || dg_eq(op, "==") || dg_eq(op, "-."))) { + /* `-- text -->`, `== text ==>`, `-. text .->`: the label runs to the + * closing link, which opens with `--`, `==` or `.-` */ + char c0 = op.p[0] == '-' && op.p[SKIP_ONE] == '.' ? '.' : op.p[0]; + char c1 = op.p[0] == '-' && op.p[SKIP_ONE] == '.' ? '-' : op.p[SKIP_ONE]; + for (size_t k = j; k + SKIP_ONE < s.n; k++) { + if (s.p[k] == c0 && s.p[k + SKIP_ONE] == c1) { + size_t e = k; + while (e < s.n && dg_link_char(s.p[e])) { + e++; + } + *label = dg_s(s.p + j, k - j); + op = dg_s(s.p + i, e - i); + j = e; + break; + } + } + } + bool dotted = memchr(op.p, '.', op.n) != NULL; + bool thick = memchr(op.p, '=', op.n) != NULL; + bool both = op.p[0] == '<' && memchr(op.p + SKIP_ONE, '>', op.n - SKIP_ONE); + head = memchr(op.p, '>', op.n) != NULL; + *style = both ? "bidirectional" : dotted ? "dotted" : thick ? "thick" : head ? "arrow" : "line"; + j = dg_skip_ws(s, j); + if (j < s.n && s.p[j] == '|') { + const char *bar = memchr(s.p + j + SKIP_ONE, '|', s.n - j - SKIP_ONE); + if (bar) { + *label = dg_s(s.p + j + SKIP_ONE, (size_t)(bar - s.p) - j - SKIP_ONE); + j = (size_t)(bar - s.p) + SKIP_ONE; + } + } + return j; +} + +enum { DG_GROUP_MAX = 16 }; + +typedef struct { + dg_str_t ids[DG_GROUP_MAX]; + int n; +} dg_group_t; + +/* `A & B[label]`: the nodes of a group, made; the position after it. */ +static size_t dg_flow_group(dg_t *dg, dg_str_t s, size_t i, uint32_t line, dg_group_t *g) { + g->n = 0; + for (;;) { + i = dg_skip_ws(s, i); + dg_str_t id; + dg_str_t label; + size_t j = dg_flow_node(s, i, &id, &label); + if (j == i) { + return i; + } + dg_element(dg, id, label, "node", line); + if (g->n < DG_GROUP_MAX) { + g->ids[g->n++] = id; + } + i = dg_skip_ws(s, j); + if (i < s.n && s.p[i] == '&') { + i++; + continue; + } + return i; + } +} + +/* `click A "url"`, `click A href "url"` (a class diagram's `link A "url"`): + * the element's link; a callback (`click A call f()`) links nothing. */ +static void dg_mermaid_click(dg_t *dg, dg_str_t s, uint32_t line) { + size_t kw = dg_starts(s, "click ") ? CBM_SZ_6 : CBM_SZ_5; + dg_str_t rest = dg_trim(dg_s(s.p + kw, s.n - kw)); + size_t j = 0; + while (j < rest.n && !isspace((unsigned char)rest.p[j])) { + j++; + } + dg_str_t tail = dg_trim(dg_s(rest.p + j, rest.n - j)); + if (dg_starts(tail, "href ")) { + tail = dg_trim(dg_s(tail.p + CBM_SZ_5, tail.n - CBM_SZ_5)); + } + const char *close = + tail.n && tail.p[0] == '"' ? memchr(tail.p + SKIP_ONE, '"', tail.n - SKIP_ONE) : NULL; + if (!close) { + return; + } + const char *qn = dg_element(dg, dg_s(rest.p, j), dg_s(NULL, 0), + dg->element_kind ? dg->element_kind : "node", line); + dg_link(dg, qn, dg_s(tail.p + SKIP_ONE, (size_t)(close - tail.p) - SKIP_ONE), + CBM_DOCLINK_DG_MERMAID_CLICK, line); +} + +static void dg_flow_statement(dg_t *dg, dg_str_t s, uint32_t line) { + if (dg_starts(s, "click ")) { + dg_mermaid_click(dg, s, line); + return; + } + static const char *const skip[] = {"classDef ", "class ", "style ", "linkStyle ", + "click ", "direction ", "end", NULL}; + for (int k = 0; skip[k]; k++) { + if (dg_starts(s, skip[k])) { + return; + } + } + if (dg_starts(s, "subgraph")) { + /* `subgraph id [title]`, `subgraph id[title]`, or `subgraph Some title` + * (the title is its id too) */ + dg_str_t rest = dg_trim(dg_s(s.p + CBM_SZ_8, s.n - CBM_SZ_8)); + dg_str_t id; + dg_str_t label; + size_t j = dg_flow_node(rest, 0, &id, &label); + if (j == 0) { + return; + } + size_t k = dg_skip_ws(rest, j); + if (!label.n && k < rest.n && rest.p[k] == '[') { + dg_shape(rest, k, &label); + } else if (!label.n && k < rest.n) { + id = rest; /* a title of several words */ + label = rest; + } + dg_element(dg, id, label, "subgraph", line); + return; + } + dg_group_t left; + size_t i = dg_flow_group(dg, s, 0, line, &left); + while (i < s.n && left.n) { + const char *style = NULL; + dg_str_t label; + size_t j = dg_flow_edge(s, i, &style, &label); + if (j == i) { + return; + } + dg_group_t right; + i = dg_flow_group(dg, s, j, line, &right); + for (int a = 0; a < left.n; a++) { + for (int b = 0; b < right.n; b++) { + dg_arrow(dg, left.ids[a], right.ids[b], label, style, line); + } + } + left = right; + } +} + +static void dg_flowchart(dg_t *dg, dg_lines_t *it) { + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + size_t start = 0; + bool quoted = false; + int depth = 0; + for (size_t i = 0; i <= ln.n; i++) { + char c = i < ln.n ? ln.p[i] : ';'; + quoted = c == '"' ? !quoted : quoted; + depth += !quoted && (c == '[' || c == '(' || c == '{'); + depth -= !quoted && (c == ']' || c == ')' || c == '}') && depth > 0; + if (c == ';' && !quoted && depth == 0) { + dg_str_t st = dg_trim(dg_s(ln.p + start, i - start)); + if (st.n) { + dg_flow_statement(dg, st, line); + } + start = i + SKIP_ONE; + } + } + } +} + +/* ── Mermaid: sequence ───────────────────────────────────────────── */ + +/* A message line `A->>+B: text`: its arrow; false when it has none. */ +static bool dg_sequence_message(dg_t *dg, dg_str_t ln, uint32_t line) { + static const char *const arrows[] = {"<<-->>", "<<->>", "-->>", "->>", "--x", "-x", + "--)", "-)", "-->", "->", NULL}; + const char *colon = memchr(ln.p, ':', ln.n); + dg_str_t head = colon ? dg_s(ln.p, (size_t)(colon - ln.p)) : ln; + for (int k = 0; arrows[k]; k++) { + size_t al = strlen(arrows[k]); + for (size_t i = 0; i + al <= head.n; i++) { + if (memcmp(head.p + i, arrows[k], al) != 0) { + continue; + } + dg_str_t to = dg_trim(dg_s(head.p + i + al, head.n - i - al)); + while (to.n && (to.p[0] == '+' || to.p[0] == '-')) { + to.p++; + to.n--; + } + dg_str_t label = colon + ? dg_s(colon + SKIP_ONE, (size_t)(ln.p + ln.n - colon) - SKIP_ONE) + : dg_s(NULL, 0); + bool reply = arrows[k][0] == '-' && arrows[k][SKIP_ONE] == '-'; + dg_arrow(dg, dg_s(head.p, i), to, label, reply ? "reply" : "message", line); + return true; + } + } + return false; +} + +static void dg_sequence(dg_t *dg, dg_lines_t *it) { + static const char participant[] = "participant "; + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + if (dg_starts(ln, "create ")) { + ln = dg_trim(dg_s(ln.p + CBM_SZ_7, ln.n - CBM_SZ_7)); + } + bool actor = dg_starts(ln, "actor "); + if (actor || dg_starts(ln, participant)) { + size_t kl = actor ? CBM_SZ_6 : sizeof(participant) - SKIP_ONE; + dg_str_t rest = dg_trim(dg_s(ln.p + kl, ln.n - kl)); + dg_str_t id = rest; + dg_str_t label = dg_s(NULL, 0); + for (size_t i = 0; i + CBM_SZ_4 <= rest.n; i++) { + if (memcmp(rest.p + i, " as ", CBM_SZ_4) == 0) { + id = dg_s(rest.p, i); + label = dg_s(rest.p + i + CBM_SZ_4, rest.n - i - CBM_SZ_4); + break; + } + } + dg_element(dg, id, label, actor ? "actor" : "participant", line); + continue; + } + (void)dg_sequence_message(dg, ln, line); + } +} + +/* ── Mermaid: class and ER ───────────────────────────────────────── */ + +typedef struct { + const char *op; + const char *style; + bool to_left; /* the arrow points at the left side (child -> parent) */ +} dg_rel_t; + +static const dg_rel_t DG_CLASS_RELS[] = { + {"<|--|>", "inheritance", false}, + {"<|--", "inheritance", true}, + {"--|>", "inheritance", false}, + {"<|..", "realization", true}, + {"..|>", "realization", false}, + {"*--", "composition", false}, + {"--*", "composition", true}, + {"o--", "aggregation", false}, + {"--o", "aggregation", true}, + {"<--", "association", true}, + {"-->", "association", false}, + {"<..", "dependency", true}, + {"..>", "dependency", false}, + {"--", "link", false}, + {"..", "link", false}, + {NULL, NULL, false}, +}; + +/* `A "1" op "*" B : label`: the relation of a class line, false when none. */ +static bool dg_class_relation(dg_t *dg, dg_str_t ln, uint32_t line) { + const char *colon = NULL; + bool quoted = false; + for (size_t i = 0; i < ln.n && !colon; i++) { + quoted = ln.p[i] == '"' ? !quoted : quoted; + colon = !quoted && ln.p[i] == ':' ? ln.p + i : NULL; + } + dg_str_t head = colon ? dg_s(ln.p, (size_t)(colon - ln.p)) : ln; + for (int k = 0; DG_CLASS_RELS[k].op; k++) { + size_t ol = strlen(DG_CLASS_RELS[k].op); + for (size_t i = 0; i + ol <= head.n; i++) { + if (memcmp(head.p + i, DG_CLASS_RELS[k].op, ol) != 0) { + continue; + } + dg_str_t l = dg_trim(dg_s(head.p, i)); + dg_str_t r = dg_trim(dg_s(head.p + i + ol, head.n - i - ol)); + if (l.n && l.p[l.n - SKIP_ONE] == '"') { /* a cardinality */ + const char *q = memchr(l.p, '"', l.n - SKIP_ONE); + l = q ? dg_trim(dg_s(l.p, (size_t)(q - l.p))) : l; + } + if (r.n && r.p[0] == '"') { + const char *q = memchr(r.p + SKIP_ONE, '"', r.n - SKIP_ONE); + r = q ? dg_trim(dg_s(q + SKIP_ONE, (size_t)(r.p + r.n - q) - SKIP_ONE)) : r; + } + if (!l.n || !r.n) { + return false; + } + dg_str_t label = colon + ? dg_s(colon + SKIP_ONE, (size_t)(ln.p + ln.n - colon) - SKIP_ONE) + : dg_s(NULL, 0); + dg_element(dg, l, dg_s(NULL, 0), "class", line); + dg_element(dg, r, dg_s(NULL, 0), "class", line); + if (DG_CLASS_RELS[k].to_left) { + dg_arrow(dg, r, l, label, DG_CLASS_RELS[k].style, line); + } else { + dg_arrow(dg, l, r, label, DG_CLASS_RELS[k].style, line); + } + return true; + } + } + return false; +} + +/* The name of `class Name~T~["Label"] {`, its label in *label. */ +static dg_str_t dg_class_decl(dg_str_t rest, dg_str_t *label) { + size_t j = 0; + while (j < rest.n && (dg_id_char(rest.p[j]) || rest.p[j] == '.')) { + j++; + } + dg_str_t name = dg_s(rest.p, j); + *label = dg_s(NULL, 0); + const char *lb = memchr(rest.p + j, '[', rest.n - j); + const char *rb = lb ? memchr(lb, ']', (size_t)(rest.p + rest.n - lb)) : NULL; + if (lb && rb) { + *label = dg_s(lb + SKIP_ONE, (size_t)(rb - lb) - SKIP_ONE); + } + return name; +} + +static void dg_classdiagram(dg_t *dg, dg_lines_t *it) { + dg_str_t ln; + uint32_t line = 0; + int body = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + if (body > 0) { + body -= ln.n && ln.p[0] == '}'; + continue; + } + if (dg_starts(ln, "class ")) { + dg_str_t label; + dg_str_t name = dg_class_decl(dg_trim(dg_s(ln.p + CBM_SZ_6, ln.n - CBM_SZ_6)), &label); + dg_element(dg, name, label, "class", line); + body += ln.n && ln.p[ln.n - SKIP_ONE] == '{'; + continue; + } + if (dg_starts(ln, "click ") || dg_starts(ln, "link ")) { + dg_mermaid_click(dg, ln, line); + continue; + } + if (dg_starts(ln, "namespace ") || dg_starts(ln, "note") || dg_starts(ln, "<<") || + dg_starts(ln, "style ") || dg_starts(ln, "cssClass") || dg_starts(ln, "classDef") || + dg_starts(ln, "direction") || dg_starts(ln, "link ") || dg_starts(ln, "callback ") || + dg_starts(ln, "click ") || dg_eq(ln, "}")) { + continue; + } + if (!dg_class_relation(dg, ln, line)) { + const char *colon = memchr(ln.p, ':', ln.n); + if (colon) { /* `Name : +member`: the class, no member */ + dg_element(dg, dg_s(ln.p, (size_t)(colon - ln.p)), dg_s(NULL, 0), "class", line); + } + } + } +} + +static bool dg_er_card(char c) { + return c == '|' || c == 'o' || c == '}' || c == '{'; +} + +static void dg_erdiagram(dg_t *dg, dg_lines_t *it) { + dg_str_t ln; + uint32_t line = 0; + int body = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + if (body > 0) { + body -= ln.n && ln.p[0] == '}'; + continue; + } + const char *colon = memchr(ln.p, ':', ln.n); + dg_str_t head = colon ? dg_s(ln.p, (size_t)(colon - ln.p)) : ln; + bool done = false; + for (size_t i = 0; i + PAIR_LEN <= head.n && !done; i++) { + bool solid = head.p[i] == '-' && head.p[i + SKIP_ONE] == '-'; + bool dashed = head.p[i] == '.' && head.p[i + SKIP_ONE] == '.'; + if (!(solid || dashed) || i == 0 || !dg_er_card(head.p[i - SKIP_ONE])) { + continue; + } + size_t a = i; + while (a > 0 && dg_er_card(head.p[a - SKIP_ONE])) { + a--; + } + size_t b = i + PAIR_LEN; + while (b < head.n && dg_er_card(head.p[b])) { + b++; + } + dg_str_t l = dg_trim(dg_s(head.p, a)); + dg_str_t r = dg_trim(dg_s(head.p + b, head.n - b)); + dg_str_t label = colon + ? dg_s(colon + SKIP_ONE, (size_t)(ln.p + ln.n - colon) - SKIP_ONE) + : dg_s(NULL, 0); + dg_element(dg, l, dg_s(NULL, 0), "entity", line); + dg_element(dg, r, dg_s(NULL, 0), "entity", line); + dg_arrow(dg, l, r, label, solid ? "identifying" : "non_identifying", line); + done = true; + } + if (!done && ln.n && ln.p[ln.n - SKIP_ONE] == '{') { + dg_str_t name = dg_trim(dg_s(ln.p, ln.n - SKIP_ONE)); + dg_str_t label = dg_s(NULL, 0); + const char *lb = memchr(name.p, '[', name.n); + if (lb && name.p[name.n - SKIP_ONE] == ']') { + label = dg_s(lb + SKIP_ONE, (size_t)(name.p + name.n - lb) - PAIR_LEN); + name = dg_trim(dg_s(name.p, (size_t)(lb - name.p))); + } + dg_element(dg, name, label, "entity", line); + body++; + } + } +} + +/* ── Mermaid: state ──────────────────────────────────────────────── */ + +enum { DG_STATE_DEPTH = 8 }; + +/* A state of a transition: `[*]` is the start (left) or the end (right) of + * the composite state it is written in, `composite` (empty at the top). */ +static dg_str_t dg_state_id(dg_str_t s, bool left, dg_str_t composite, char *buf, size_t cap) { + s = dg_trim(s); + if (!dg_eq(s, "[*]")) { + return s; + } + const char *end = left ? "start" : "end"; + int n = composite.n ? snprintf(buf, cap, "%.*s_%s", (int)composite.n, composite.p, end) + : snprintf(buf, cap, "%s", end); + return n > 0 && (size_t)n < cap ? dg_s(buf, (size_t)n) : dg_s(end, strlen(end)); +} + +static void dg_statediagram(dg_t *dg, dg_lines_t *it) { + dg_str_t ln; + uint32_t line = 0; + dg_str_t stack[DG_STATE_DEPTH]; + int depth = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + if (dg_eq(ln, "}")) { + depth -= depth > 0; + continue; + } + if (dg_starts(ln, "state ")) { + dg_str_t rest = dg_trim(dg_s(ln.p + CBM_SZ_6, ln.n - CBM_SZ_6)); + if (rest.n && rest.p[0] == '"') { + const char *q = memchr(rest.p + SKIP_ONE, '"', rest.n - SKIP_ONE); + if (q) { + dg_str_t label = dg_s(rest.p + SKIP_ONE, (size_t)(q - rest.p) - SKIP_ONE); + dg_str_t after = + dg_trim(dg_s(q + SKIP_ONE, (size_t)(rest.p + rest.n - q) - SKIP_ONE)); + if (dg_starts(after, "as ")) { + dg_element(dg, dg_trim(dg_s(after.p + CBM_SZ_3, after.n - CBM_SZ_3)), label, + "state", line); + } + } + continue; + } + size_t j = 0; + while (j < rest.n && dg_id_char(rest.p[j])) { + j++; + } + dg_element(dg, dg_s(rest.p, j), dg_s(NULL, 0), "state", line); + if (rest.n && rest.p[rest.n - SKIP_ONE] == '{') { + if (depth < DG_STATE_DEPTH) { + stack[depth] = dg_s(rest.p, j); + } + depth++; + } + continue; + } + const char *arrow = NULL; + for (size_t i = 0; i + CBM_SZ_3 <= ln.n && !arrow; i++) { + arrow = memcmp(ln.p + i, "-->", CBM_SZ_3) == 0 ? ln.p + i : NULL; + } + if (!arrow) { + continue; + } + dg_str_t rest = dg_s(arrow + CBM_SZ_3, (size_t)(ln.p + ln.n - arrow) - CBM_SZ_3); + const char *colon = memchr(rest.p, ':', rest.n); + dg_str_t to = colon ? dg_s(rest.p, (size_t)(colon - rest.p)) : rest; + dg_str_t label = colon + ? dg_s(colon + SKIP_ONE, (size_t)(rest.p + rest.n - colon) - SKIP_ONE) + : dg_s(NULL, 0); + dg_str_t composite = + depth > 0 && depth <= DG_STATE_DEPTH ? stack[depth - SKIP_ONE] : dg_s(NULL, 0); + char from_buf[DG_ID_MAX]; + char to_buf[DG_ID_MAX]; + dg_arrow(dg, + dg_state_id(dg_s(ln.p, (size_t)(arrow - ln.p)), true, composite, from_buf, + sizeof(from_buf)), + dg_state_id(to, false, composite, to_buf, sizeof(to_buf)), label, "transition", + line); + } +} + +/* ── Mermaid: C4 ─────────────────────────────────────────────────── */ + +/* The arguments of `Name(a, "b", c)`: up to DG_ARGS_MAX, quotes dropped. */ +static int dg_args(dg_str_t s, dg_str_t *args) { + const char *open = memchr(s.p, '(', s.n); + if (!open) { + return 0; + } + int n = 0; + bool quoted = false; + const char *start = open + SKIP_ONE; + for (const char *p = start; p < s.p + s.n && n < DG_ARGS_MAX; p++) { + quoted = *p == '"' ? !quoted : quoted; + if (!quoted && (*p == ',' || *p == ')')) { + args[n++] = dg_unquote(dg_s(start, (size_t)(p - start))); + start = p + SKIP_ONE; + if (*p == ')') { + break; + } + } + } + return n; +} + +/* A C4 macro line `Person(id, "Label", ...)` or `Rel(a, b, "label")`: false + * when the line is none. */ +static bool dg_c4_line(dg_t *dg, dg_str_t ln, uint32_t line) { + size_t j = 0; + while (j < ln.n && (isalnum((unsigned char)ln.p[j]) || ln.p[j] == '_')) { + j++; + } + if (j == 0 || j >= ln.n || ln.p[j] != '(') { + return false; + } + { + dg_str_t fn = dg_s(ln.p, j); + dg_str_t args[DG_ARGS_MAX]; + int na = dg_args(ln, args); + if (dg_starts(fn, "Rel") || dg_starts(fn, "BiRel")) { + int base = dg_eq(fn, "RelIndex") ? SKIP_ONE : 0; /* RelIndex(i, from, to, ...) */ + if (na >= base + PAIR_LEN) { + dg_str_t label = na > base + PAIR_LEN ? args[base + PAIR_LEN] : dg_s(NULL, 0); + dg_arrow(dg, args[base], args[base + SKIP_ONE], label, + dg_starts(fn, "BiRel") ? "bidirectional" : "relation", line); + } + return true; + } + if (dg_starts(fn, "Update") || dg_starts(fn, "Add") || dg_starts(fn, "Lay_") || + dg_eq(fn, "title") || na < SKIP_ONE) { + return true; + } + char kind[DG_KIND_MAX]; + size_t kl = fn.n < sizeof(kind) - SKIP_ONE ? fn.n : sizeof(kind) - SKIP_ONE; + for (size_t k = 0; k < kl; k++) { + kind[k] = (char)tolower((unsigned char)fn.p[k]); + } + kind[kl] = '\0'; + const char *kc = dg_dup(dg, kind, kl); + dg_element(dg, args[0], na > SKIP_ONE ? args[SKIP_ONE] : dg_s(NULL, 0), kc ? kc : "c4", + line); + } + return true; +} + +static void dg_c4(dg_t *dg, dg_lines_t *it) { + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(it, &ln, &line)) { + (void)dg_c4_line(dg, dg_mermaid_strip(ln), line); + } +} + +/* ── Mermaid: architecture ───────────────────────────────────────── */ + +/* `id(icon)[Label] in group`: the id; the label in *label. */ +static dg_str_t dg_arch_decl(dg_str_t rest, dg_str_t *label) { + size_t j = 0; + while (j < rest.n && dg_id_char(rest.p[j])) { + j++; + } + dg_str_t id = dg_s(rest.p, j); + *label = dg_s(NULL, 0); + const char *lb = memchr(rest.p + j, '[', rest.n - j); + const char *rb = lb ? memchr(lb, ']', (size_t)(rest.p + rest.n - lb)) : NULL; + if (lb && rb) { + *label = dg_s(lb + SKIP_ONE, (size_t)(rb - lb) - SKIP_ONE); + } + return id; +} + +/* `id{group}:R` or `R:id`: the id. */ +static dg_str_t dg_arch_end(dg_str_t s) { + s = dg_trim(s); + const char *colon = memchr(s.p, ':', s.n); + if (colon) { + dg_str_t a = dg_s(s.p, (size_t)(colon - s.p)); + dg_str_t b = dg_s(colon + SKIP_ONE, (size_t)(s.p + s.n - colon) - SKIP_ONE); + s = a.n > SKIP_ONE ? a : b; /* the side is one letter */ + } + const char *brace = memchr(s.p, '{', s.n); + return dg_trim(brace ? dg_s(s.p, (size_t)(brace - s.p)) : s); +} + +static void dg_architecture(dg_t *dg, dg_lines_t *it) { + static const char *const decls[] = {"group ", "service ", "junction ", NULL}; + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + bool decl = false; + for (int k = 0; decls[k] && !decl; k++) { + if (!dg_starts(ln, decls[k])) { + continue; + } + size_t kl = strlen(decls[k]); + dg_str_t label; + dg_str_t id = dg_arch_decl(dg_trim(dg_s(ln.p + kl, ln.n - kl)), &label); + char kind[DG_KIND_MAX]; + snprintf(kind, sizeof(kind), "%.*s", (int)(kl - SKIP_ONE), decls[k]); + dg_element(dg, id, label, dg_dup(dg, kind, kl - SKIP_ONE), line); + decl = true; + } + if (decl) { + continue; + } + static const char *const ops[] = {"<-->", "-->", "<--", "--", NULL}; + for (int k = 0; ops[k]; k++) { + size_t ol = strlen(ops[k]); + const char *at = NULL; + for (size_t i = 0; i + ol <= ln.n && !at; i++) { + at = memcmp(ln.p + i, ops[k], ol) == 0 ? ln.p + i : NULL; + } + if (!at) { + continue; + } + dg_str_t l = dg_arch_end(dg_s(ln.p, (size_t)(at - ln.p))); + dg_str_t r = dg_arch_end(dg_s(at + ol, (size_t)(ln.p + ln.n - at) - ol)); + if (k == PAIR_LEN) { /* <-- points left */ + dg_arrow(dg, r, l, dg_s(NULL, 0), "arrow", line); + } else { + dg_arrow(dg, l, r, dg_s(NULL, 0), + k == 0 ? "bidirectional" + : k == 1 ? "arrow" + : "line", + line); + } + break; + } + } +} + +/* ── Mermaid: the header ─────────────────────────────────────────── */ + +typedef void (*dg_parser_fn)(dg_t *dg, dg_lines_t *it); + +typedef struct { + const char *keyword; + const char *kind; + const char *element_kind; /* of an element named only by an arrow */ + dg_parser_fn parse; +} dg_kind_t; + +static const dg_kind_t DG_MERMAID_KINDS[] = { + {"flowchart", "flowchart", "node", dg_flowchart}, + {"graph", "flowchart", "node", dg_flowchart}, + {"sequenceDiagram", "sequence", "participant", dg_sequence}, + {"classDiagram", "class", "class", dg_classdiagram}, + {"erDiagram", "er", "entity", dg_erdiagram}, + {"stateDiagram", "state", "state", dg_statediagram}, + {"C4Context", "c4_context", "c4", dg_c4}, + {"C4Container", "c4_container", "c4", dg_c4}, + {"C4Component", "c4_component", "c4", dg_c4}, + {"C4Dynamic", "c4_dynamic", "c4", dg_c4}, + {"C4Deployment", "c4_deployment", "c4", dg_c4}, + {"architecture", "architecture", "service", dg_architecture}, + {NULL, NULL, NULL, NULL}, +}; + +/* A `title Text` line of the diagram (or `pie title Text` in its header), + * when the front matter named none. */ +static dg_str_t dg_title_line(dg_lines_t it, dg_str_t head) { + for (size_t i = 0; i + CBM_SZ_7 <= head.n; i++) { + if (memcmp(head.p + i, " title ", CBM_SZ_7) == 0) { + return dg_unquote(dg_s(head.p + i + CBM_SZ_7, head.n - i - CBM_SZ_7)); + } + } + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(&it, &ln, &line)) { + ln = dg_mermaid_strip(ln); + if (dg_starts(ln, "title ")) { + return dg_unquote(dg_s(ln.p + CBM_SZ_6, ln.n - CBM_SZ_6)); + } + } + return dg_s(NULL, 0); +} + +/* The first line that says what the diagram is: front matter (`---` ... + * `---`, its `title:` kept) and `%%` lines skipped. A title of the diagram's + * own replaces one given from outside (a caption); none keeps it. */ +static dg_str_t dg_mermaid_header(dg_lines_t *it, dg_str_t *title) { + dg_str_t ln; + uint32_t line = 0; + bool front = false; + while (dg_next_line(it, &ln, &line)) { + dg_str_t t = dg_trim(ln); + if (dg_eq(t, "---")) { + front = !front; + continue; + } + if (front) { + if (dg_starts(t, "title:")) { + *title = dg_unquote(dg_s(t.p + CBM_SZ_6, t.n - CBM_SZ_6)); + } + continue; + } + t = dg_mermaid_strip(t); + if (t.n) { + return t; + } + } + return dg_s(NULL, 0); +} + +static void dg_mermaid(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + dg_str_t head = dg_mermaid_header(it, title); + dg_str_t own = dg_title_line(*it, head); + *title = own.n ? own : *title; + size_t j = 0; + while (j < head.n && !isspace((unsigned char)head.p[j]) && head.p[j] != ';') { + j++; + } + dg_str_t word = dg_s(head.p, j); + for (int k = 0; DG_MERMAID_KINDS[k].keyword; k++) { + if (dg_starts(word, DG_MERMAID_KINDS[k].keyword)) { + snprintf(dg->kind, sizeof(dg->kind), "%s", DG_MERMAID_KINDS[k].kind); + dg->element_kind = DG_MERMAID_KINDS[k].element_kind; + DG_MERMAID_KINDS[k].parse(dg, it); + return; + } + } + snprintf(dg->kind, sizeof(dg->kind), "%.*s", + (int)(word.n < DG_KIND_MAX - SKIP_ONE ? word.n : DG_KIND_MAX - SKIP_ONE), word.p); +} + +/* ── PlantUML ────────────────────────────────────────────────────── */ + +/* Declarations whose `{ ... }` body holds members, not elements. */ +static const char *const DG_PUML_CLASSLIKE[] = { + "abstract class", "abstract", "class", "interface", "enum", "annotation", "protocol", + "struct", "exception", "object", "map", "metaclass", NULL}; +/* Declarations whose `{ ... }` body holds further elements. */ +static const char *const DG_PUML_ELEMENTS[] = { + "participant", "actor", "boundary", "control", "database", "collections", "queue", + "usecase", "component", "node", "cloud", "frame", "folder", "package", + "rectangle", "artifact", "file", "storage", "card", "agent", "stack", + "hexagon", "person", "process", "state", "entity", "namespace", NULL}; + +static bool dg_word_at(dg_str_t s, const char *word) { + size_t l = strlen(word); + return s.n > l && memcmp(s.p, word, l) == 0 && (s.p[l] == ' ' || s.p[l] == '\t'); +} + +/* An operand of a PlantUML line: `[Comp]`, `(use case)`, `:actor:`, `"text"`, + * an id, or `[*]`; its id in *id; the position after it (i when none). */ +static size_t dg_puml_operand(dg_str_t s, size_t i, dg_str_t *id, dg_str_t *label) { + i = dg_skip_ws(s, i); + *label = dg_s(NULL, 0); + if (i + CBM_SZ_3 <= s.n && memcmp(s.p + i, "[*]", CBM_SZ_3) == 0) { + *id = dg_s(s.p + i, CBM_SZ_3); + return i + CBM_SZ_3; + } + static const char opens[] = "[(:\""; + static const char closes[] = "]):\""; + const char *o = i < s.n ? strchr(opens, s.p[i]) : NULL; + if (o && s.p[i]) { + char close = closes[o - opens]; + const char *c = memchr(s.p + i + SKIP_ONE, close, s.n - i - SKIP_ONE); + if (!c) { + return i; + } + *id = dg_s(s.p + i + SKIP_ONE, (size_t)(c - s.p) - i - SKIP_ONE); + *label = *id; + return (size_t)(c - s.p) + SKIP_ONE; + } + size_t j = i; + while (j < s.n && (dg_id_char(s.p[j]) || s.p[j] == '.' || s.p[j] == ':' || s.p[j] == '$')) { + if (s.p[j] == '-' || s.p[j] == ':') { /* an arrow or a label starts */ + break; + } + j++; + } + *id = dg_s(s.p + i, j - i); + return j; +} + +/* What a PlantUML arrow means, and whether it points at its left side. */ +static const char *dg_puml_arrow_style(dg_str_t op, bool sequence, bool *to_left) { + *to_left = false; + bool lt = memchr(op.p, '<', op.n) != NULL; + bool gt = memchr(op.p, '>', op.n) != NULL; + bool dotted = memchr(op.p, '.', op.n) != NULL; + bool inherit = false; + for (size_t i = 0; i + SKIP_ONE < op.n; i++) { + if (op.p[i] == '<' && op.p[i + SKIP_ONE] == '|') { + inherit = true; + *to_left = true; + } + if (op.p[i] == '|' && op.p[i + SKIP_ONE] == '>') { + inherit = true; + } + } + if (inherit) { + return dotted ? "realization" : "inheritance"; + } + if (op.p[0] == '+' || op.p[op.n - SKIP_ONE] == '+') { /* an inner class */ + *to_left = op.p[0] == '+'; /* nested -> its container, as `A ..+ B` reads */ + return "nesting"; + } + if (op.p[0] == '*' || op.p[op.n - SKIP_ONE] == '*') { + *to_left = op.p[op.n - SKIP_ONE] == '*'; + return "composition"; + } + if (op.p[0] == 'o' || op.p[op.n - SKIP_ONE] == 'o') { + *to_left = op.p[op.n - SKIP_ONE] == 'o'; + return "aggregation"; + } + if (lt && gt) { + return "bidirectional"; + } + *to_left = lt; + if (sequence) { + return memchr(op.p, '-', op.n) && op.n >= PAIR_LEN && op.p[SKIP_ONE] == '-' ? "reply" + : "message"; + } + return dotted ? (gt || lt ? "dependency" : "dotted") : (gt || lt ? "arrow" : "line"); +} + +static bool dg_puml_arrow_char(char c) { + return c == '-' || c == '.' || c == '<' || c == '>' || c == '|' || c == '*' || c == 'o' || + c == '#' || c == '+' || c == '^' || c == '/' || c == '\\' || c == 'x' || c == '[' || + c == ']' || c == ',' || c == '=' || isalpha((unsigned char)c); +} + +/* `A "1" -up-> "n" B : label`: false when the line holds no relation. */ +static bool dg_puml_relation(dg_t *dg, dg_str_t ln, uint32_t line, bool sequence) { + dg_str_t lid; + dg_str_t llab; + size_t i = dg_puml_operand(ln, 0, &lid, &llab); + if (!lid.n) { + return false; + } + i = dg_skip_ws(ln, i); + if (i < ln.n && ln.p[i] == '"') { /* a cardinality */ + const char *q = memchr(ln.p + i + SKIP_ONE, '"', ln.n - i - SKIP_ONE); + i = q ? dg_skip_ws(ln, (size_t)(q - ln.p) + SKIP_ONE) : i; + } + size_t a = i; + while (i < ln.n && !isspace((unsigned char)ln.p[i]) && dg_puml_arrow_char(ln.p[i])) { + i++; + } + dg_str_t op = dg_s(ln.p + a, i - a); + bool arrow = false; + for (size_t k = 0; k + SKIP_ONE < op.n && !arrow; k++) { + arrow = (op.p[k] == '-' || op.p[k] == '.') && + (op.p[k + SKIP_ONE] == '-' || op.p[k + SKIP_ONE] == '.' || + op.p[k + SKIP_ONE] == '>' || op.p[k + SKIP_ONE] == '|' || + op.p[k + SKIP_ONE] == '[' || isalpha((unsigned char)op.p[k + SKIP_ONE])); + arrow = + arrow || (op.p[k] == '<' && (op.p[k + SKIP_ONE] == '-' || op.p[k + SKIP_ONE] == '.')); + } + if (!arrow) { + return false; + } + i = dg_skip_ws(ln, i); + if (i < ln.n && ln.p[i] == '"') { + const char *q = memchr(ln.p + i + SKIP_ONE, '"', ln.n - i - SKIP_ONE); + i = q ? (size_t)(q - ln.p) + SKIP_ONE : i; + } + dg_str_t rid; + dg_str_t rlab; + i = dg_puml_operand(ln, i, &rid, &rlab); + if (!rid.n) { + return false; + } + i = dg_skip_ws(ln, i); + dg_str_t label = + i < ln.n && ln.p[i] == ':' ? dg_s(ln.p + i + SKIP_ONE, ln.n - i - SKIP_ONE) : dg_s(NULL, 0); + dg_element(dg, lid, llab, dg->element_kind, line); + dg_element(dg, rid, rlab, dg->element_kind, line); + bool to_left = false; + const char *style = dg_puml_arrow_style(op, sequence, &to_left); + if (to_left) { + dg_arrow(dg, rid, lid, label, style, line); + } else { + dg_arrow(dg, lid, rid, label, style, line); + } + return true; +} + +/* `[[url]]`, `[[url label]]`, `[[url{tooltip}]]` in a PlantUML line: the URL. */ +static dg_str_t dg_puml_url(dg_str_t ln) { + for (size_t i = 0; i + SKIP_ONE < ln.n; i++) { + if (ln.p[i] == '[' && ln.p[i + SKIP_ONE] == '[') { + size_t e = i + PAIR_LEN; + while (e < ln.n && ln.p[e] != ']' && ln.p[e] != '{' && + !isspace((unsigned char)ln.p[e])) { + e++; + } + return dg_s(ln.p + i + PAIR_LEN, e - i - PAIR_LEN); + } + } + return dg_s(NULL, 0); +} + +/* `!include path` (also _many, _once, url): the diagram's link to the file it + * takes in; `` names none of the repo's. `url of X is [[url]]`: X's. */ +static bool dg_puml_link_line(dg_t *dg, dg_str_t ln, uint32_t line) { + if (dg_starts(ln, "!include")) { + size_t i = CBM_SZ_8; + while (i < ln.n && !isspace((unsigned char)ln.p[i])) { + i++; + } + dg_str_t arg = dg_trim(dg_s(ln.p + i, ln.n - i)); + const char *bang = memchr(arg.p, '!', arg.n); /* `file!2`: a block of it */ + arg = bang ? dg_trim(dg_s(arg.p, (size_t)(bang - arg.p))) : arg; + if (arg.n && arg.p[0] != '<') { + dg_link(dg, dg->qn, arg, CBM_DOCLINK_DG_PLANTUML_INCLUDE, line); + } + return true; + } + if (dg_starts(ln, "url of ") || dg_starts(ln, "url for ")) { + size_t kw = dg_starts(ln, "url of ") ? CBM_SZ_7 : CBM_SZ_8; + dg_str_t rest = dg_s(ln.p + kw, ln.n - kw); + size_t j = 0; + while (j + CBM_SZ_4 <= rest.n && memcmp(rest.p + j, " is ", CBM_SZ_4) != 0) { + j++; + } + if (j + CBM_SZ_4 <= rest.n) { + const char *qn = + dg_element(dg, dg_unquote(dg_s(rest.p, j)), dg_s(NULL, 0), dg->element_kind, line); + dg_link(dg, qn, dg_puml_url(rest), CBM_DOCLINK_DG_PLANTUML_URL, line); + } + return true; + } + return false; +} + +/* `kw "Label" as Id`, `kw Id as "Label"`, `kw Id`, `kw [Label] as Id`: an + * element. *body is set when its `{` opens a member body. */ +static bool dg_puml_decl(dg_t *dg, dg_str_t ln, uint32_t line, bool *members) { + const char *kw = NULL; + bool classlike = false; + for (int k = 0; DG_PUML_CLASSLIKE[k] && !kw; k++) { + if (dg_word_at(ln, DG_PUML_CLASSLIKE[k])) { + kw = DG_PUML_CLASSLIKE[k]; + classlike = true; + } + } + for (int k = 0; DG_PUML_ELEMENTS[k] && !kw; k++) { + kw = dg_word_at(ln, DG_PUML_ELEMENTS[k]) ? DG_PUML_ELEMENTS[k] : NULL; + } + if (!kw) { + return false; + } + dg_str_t rest = dg_trim(dg_s(ln.p + strlen(kw), ln.n - strlen(kw))); + dg_str_t id; + dg_str_t label; + size_t j = dg_puml_operand(rest, 0, &id, &label); + dg_str_t after = dg_trim(dg_s(rest.p + j, rest.n - j)); + if (dg_starts(after, "as ")) { + dg_str_t aid; + dg_str_t alab; + dg_puml_operand(dg_trim(dg_s(after.p + CBM_SZ_3, after.n - CBM_SZ_3)), 0, &aid, &alab); + if (label.n || !alab.n) { + label = label.n ? label : id; + id = aid; + } else { + label = alab; /* `kw Id as "Label"` */ + } + } + *members = classlike && rest.n && rest.p[rest.n - SKIP_ONE] == '{'; + const char *kind = strcmp(kw, "abstract class") == 0 ? "abstract" : kw; + const char *qn = dg_element(dg, id, label, kind, line); + dg_link(dg, qn, dg_puml_url(after), CBM_DOCLINK_DG_PLANTUML_URL, line); + return true; +} + +/* What a PlantUML diagram is: the strongest kind any of its lines tells (the + * packages around classes make no component diagram). */ +enum { + PK_C4, + PK_CLASS, + PK_SEQUENCE, + PK_USECASE, + PK_ACTOR, /* an actor alone: a sequence, unless use cases say otherwise */ + PK_STATE, + PK_ACTIVITY, + PK_COMPONENT, + PK_COUNT +}; + +static const char *dg_puml_kind(dg_lines_t it) { + static const char *const names[PK_COUNT] = {"c4", "class", "sequence", "usecase", + "sequence", "state", "activity", "component"}; + bool seen[PK_COUNT] = {false}; + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(&it, &ln, &line)) { + ln = dg_trim(ln); + seen[PK_C4] |= dg_starts(ln, "Person(") || dg_starts(ln, "System(") || + dg_starts(ln, "Container(") || dg_starts(ln, "Component(") || + dg_starts(ln, "Rel("); + for (int k = 0; DG_PUML_CLASSLIKE[k]; k++) { + seen[PK_CLASS] |= dg_word_at(ln, DG_PUML_CLASSLIKE[k]); + } + seen[PK_SEQUENCE] |= dg_word_at(ln, "participant") || dg_word_at(ln, "boundary") || + dg_word_at(ln, "control") || dg_word_at(ln, "collections"); + seen[PK_USECASE] |= dg_word_at(ln, "usecase") || (ln.n && ln.p[0] == '('); + seen[PK_ACTOR] |= dg_word_at(ln, "actor"); + seen[PK_STATE] |= dg_word_at(ln, "state") || dg_starts(ln, "[*]"); + seen[PK_ACTIVITY] |= + dg_eq(ln, "start") || (ln.n && ln.p[0] == ':' && ln.p[ln.n - SKIP_ONE] == ';'); + seen[PK_COMPONENT] |= dg_word_at(ln, "component") || dg_word_at(ln, "node") || + dg_word_at(ln, "package") || dg_word_at(ln, "database") || + (ln.n && ln.p[0] == '['); + } + for (int k = 0; k < PK_COUNT; k++) { + if (seen[k]) { + return names[k]; + } + } + return "uml"; +} + +static bool dg_c4_line(dg_t *dg, dg_str_t ln, uint32_t line); + +static void dg_plantuml(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + const char *kind = dg_puml_kind(*it); + snprintf(dg->kind, sizeof(dg->kind), "%s", kind); + bool sequence = strcmp(kind, "sequence") == 0; + dg->element_kind = sequence ? "participant" + : strcmp(kind, "class") == 0 ? "class" + : strcmp(kind, "usecase") == 0 ? "usecase" + : strcmp(kind, "state") == 0 ? "state" + : strcmp(kind, "component") == 0 ? "component" + : "node"; + if (strcmp(kind, "activity") == 0) { + return; /* an activity's steps are prose: the Diagram alone */ + } + dg_str_t ln; + uint32_t line = 0; + int members = 0; + bool comment = false; + while (dg_next_line(it, &ln, &line)) { + ln = dg_trim(ln); + if (comment) { + comment = !(ln.n >= PAIR_LEN && ln.p[ln.n - PAIR_LEN] == '\'' && + ln.p[ln.n - SKIP_ONE] == '/'); + continue; + } + if (dg_starts(ln, "/'")) { + comment = !(ln.n >= CBM_SZ_4 && ln.p[ln.n - PAIR_LEN] == '\'' && + ln.p[ln.n - SKIP_ONE] == '/'); + continue; + } + if (members > 0) { + members += ln.n && ln.p[ln.n - SKIP_ONE] == '{'; + members -= ln.n && ln.p[0] == '}'; + continue; + } + if (dg_puml_link_line(dg, ln, line)) { + continue; + } + if (!ln.n || ln.p[0] == '\'' || ln.p[0] == '@' || ln.p[0] == '!' || dg_eq(ln, "}") || + dg_starts(ln, "skinparam") || dg_starts(ln, "hide ") || dg_starts(ln, "show ") || + dg_starts(ln, "note ") || dg_starts(ln, "end note") || dg_starts(ln, "legend") || + dg_starts(ln, "header") || dg_starts(ln, "footer") || dg_starts(ln, "left to right") || + dg_starts(ln, "top to bottom") || dg_starts(ln, "autonumber") || + dg_starts(ln, "activate") || dg_starts(ln, "deactivate")) { + continue; + } + if (dg_starts(ln, "title ")) { + if (!title->n) { + *title = dg_unquote(dg_s(ln.p + CBM_SZ_6, ln.n - CBM_SZ_6)); + } + continue; + } + if (strcmp(kind, "c4") == 0 && dg_c4_line(dg, ln, line)) { + continue; + } + bool body = false; + if (dg_puml_decl(dg, ln, line, &body)) { + members += body; + continue; + } + (void)dg_puml_relation(dg, ln, line, sequence); + } +} + +/* ── Graphviz DOT ────────────────────────────────────────────────── */ + +typedef enum { + DT_END = 0, + DT_ID, + DT_LBRACE, + DT_RBRACE, + DT_LBRACK, + DT_RBRACK, + DT_SEMI, + DT_COMMA, + DT_EQ, + DT_COLON, + DT_EDGE, +} dt_kind_t; + +typedef struct { + dt_kind_t k; + dg_str_t s; + uint32_t line; +} dt_tok_t; + +typedef struct { + const char *p; + const char *end; + uint32_t line; + dt_tok_t tok; /* the current token */ +} dt_lex_t; + +static void dt_skip(dt_lex_t *lx) { + for (;;) { + while (lx->p < lx->end && isspace((unsigned char)*lx->p)) { + lx->line += *lx->p == '\n'; + lx->p++; + } + if (lx->p + SKIP_ONE < lx->end && lx->p[0] == '/' && lx->p[SKIP_ONE] == '/') { + while (lx->p < lx->end && *lx->p != '\n') { + lx->p++; + } + continue; + } + if (lx->p < lx->end && lx->p[0] == '#') { + while (lx->p < lx->end && *lx->p != '\n') { + lx->p++; + } + continue; + } + if (lx->p + SKIP_ONE < lx->end && lx->p[0] == '/' && lx->p[SKIP_ONE] == '*') { + lx->p += PAIR_LEN; + while (lx->p + SKIP_ONE < lx->end && !(lx->p[0] == '*' && lx->p[SKIP_ONE] == '/')) { + lx->line += *lx->p == '\n'; + lx->p++; + } + lx->p = lx->p + PAIR_LEN <= lx->end ? lx->p + PAIR_LEN : lx->end; + continue; + } + return; + } +} + +static void dt_next(dt_lex_t *lx) { + dt_skip(lx); + lx->tok = (dt_tok_t){DT_END, dg_s(NULL, 0), lx->line}; + if (lx->p >= lx->end) { + return; + } + char c = *lx->p; + static const char single[] = "{}[];,=:"; + static const dt_kind_t kinds[] = {DT_LBRACE, DT_RBRACE, DT_LBRACK, DT_RBRACK, + DT_SEMI, DT_COMMA, DT_EQ, DT_COLON}; + const char *hit = strchr(single, c); + if (c && hit) { + lx->tok.k = kinds[hit - single]; + lx->p++; + return; + } + if (c == '-' && lx->p + SKIP_ONE < lx->end && + (lx->p[SKIP_ONE] == '>' || lx->p[SKIP_ONE] == '-')) { + lx->tok.k = DT_EDGE; + lx->tok.s = dg_s(lx->p, PAIR_LEN); + lx->p += PAIR_LEN; + return; + } + if (c == '"') { + const char *q = lx->p + SKIP_ONE; + while (q < lx->end && *q != '"') { + lx->line += *q == '\n'; + q += *q == '\\' && q + SKIP_ONE < lx->end ? PAIR_LEN : SKIP_ONE; + } + lx->tok.k = DT_ID; + lx->tok.s = dg_s(lx->p + SKIP_ONE, (size_t)(q - lx->p) - SKIP_ONE); + lx->p = q < lx->end ? q + SKIP_ONE : lx->end; + return; + } + if (c == '<') { /* an HTML string */ + int depth = 0; + const char *q = lx->p; + for (; q < lx->end; q++) { + depth += *q == '<'; + depth -= *q == '>'; + lx->line += *q == '\n'; + if (depth == 0) { + break; + } + } + lx->tok.k = DT_ID; + lx->tok.s = dg_s(lx->p + SKIP_ONE, (size_t)(q - lx->p) - (q > lx->p ? SKIP_ONE : 0)); + lx->p = q < lx->end ? q + SKIP_ONE : lx->end; + return; + } + const char *q = lx->p; + while (q < lx->end && (isalnum((unsigned char)*q) || *q == '_' || *q == '.' || + (unsigned char)*q >= 0x80 || (*q == '-' && q == lx->p))) { + q++; + } + if (q == lx->p) { + lx->p++; /* a stray byte */ + dt_next(lx); + return; + } + lx->tok.k = DT_ID; + lx->tok.s = dg_s(lx->p, (size_t)(q - lx->p)); + lx->p = q; +} + +/* `[a=b, c=d]` lists: the value of `label`, when one is set; url (when given) + * gets the value of `URL` or `href`. */ +static dg_str_t dt_attrs(dt_lex_t *lx, dg_str_t *url) { + dg_str_t label = dg_s(NULL, 0); + while (lx->tok.k == DT_LBRACK) { + dt_next(lx); + while (lx->tok.k != DT_RBRACK && lx->tok.k != DT_END) { + if (lx->tok.k == DT_ID) { + bool is_label = dg_eq(lx->tok.s, "label"); + bool is_url = dg_eq(lx->tok.s, "URL") || dg_eq(lx->tok.s, "href"); + dt_next(lx); + if (lx->tok.k == DT_EQ) { + dt_next(lx); + if (lx->tok.k == DT_ID && is_label) { + label = lx->tok.s; + } else if (lx->tok.k == DT_ID && is_url && url) { + *url = lx->tok.s; + } + } + } + dt_next(lx); + } + dt_next(lx); + } + return label; +} + +enum { DT_SET_MAX = 64, DT_DEPTH_MAX = 32 }; + +typedef struct { + dg_str_t ids[DT_SET_MAX]; + int n; +} dt_set_t; + +static void dt_stmts(dg_t *dg, dt_lex_t *lx, bool directed, dt_set_t *all, int depth, + dg_str_t *title); + +/* An edge operand: a node id (its port dropped) or a subgraph's nodes. */ +static bool dt_operand(dg_t *dg, dt_lex_t *lx, bool directed, dt_set_t *out, int depth, + dg_str_t *title) { + out->n = 0; + if (lx->tok.k == DT_ID && dg_eq(lx->tok.s, "subgraph")) { + dt_next(lx); + if (lx->tok.k == DT_ID) { + dt_next(lx); + } + } + if (lx->tok.k == DT_LBRACE) { + dt_next(lx); + dt_stmts(dg, lx, directed, out, depth + SKIP_ONE, title); + if (lx->tok.k == DT_RBRACE) { + dt_next(lx); + } + return true; + } + if (lx->tok.k != DT_ID) { + return false; + } + out->ids[out->n++] = lx->tok.s; + dt_next(lx); + while (lx->tok.k == DT_COLON) { /* a port, a compass point */ + dt_next(lx); + if (lx->tok.k == DT_ID) { + dt_next(lx); + } + } + return true; +} + +static void dt_stmt(dg_t *dg, dt_lex_t *lx, bool directed, dt_set_t *all, int depth, + dg_str_t *title) { + uint32_t line = lx->tok.line; + if (lx->tok.k == DT_ID && + (dg_eq(lx->tok.s, "graph") || dg_eq(lx->tok.s, "node") || dg_eq(lx->tok.s, "edge"))) { + dt_next(lx); + (void)dt_attrs(lx, NULL); + return; + } + dt_set_t left; + if (!dt_operand(dg, lx, directed, &left, depth, title)) { + dt_next(lx); + return; + } + if (lx->tok.k == DT_EQ) { /* a graph attribute */ + dt_next(lx); + if (lx->tok.k == DT_ID && left.n && dg_eq(left.ids[0], "label") && depth == 0 && + !title->n) { + *title = lx->tok.s; + } + dt_next(lx); + return; + } + if (lx->tok.k != DT_EDGE) { + dg_str_t url = dg_s(NULL, 0); + dg_str_t label = dt_attrs(lx, &url); + for (int i = 0; i < left.n; i++) { + dg_link(dg, dg_element(dg, left.ids[i], label, "node", line), url, + CBM_DOCLINK_DG_DOT_URL, line); + if (all->n < DT_SET_MAX) { + all->ids[all->n++] = left.ids[i]; + } + } + return; + } + /* an edge chain: a -> b -> {c d} [label=x] */ + dt_set_t chain[DT_DEPTH_MAX]; + int nc = 0; + chain[nc++] = left; + while (lx->tok.k == DT_EDGE && nc < DT_DEPTH_MAX) { + dt_next(lx); + if (!dt_operand(dg, lx, directed, &chain[nc], depth, title)) { + break; + } + nc++; + } + dg_str_t label = dt_attrs(lx, NULL); + for (int k = 0; k < nc; k++) { + for (int i = 0; i < chain[k].n; i++) { + dg_element(dg, chain[k].ids[i], dg_s(NULL, 0), "node", line); + if (all->n < DT_SET_MAX) { + all->ids[all->n++] = chain[k].ids[i]; + } + } + } + for (int k = 0; k + SKIP_ONE < nc; k++) { + for (int i = 0; i < chain[k].n; i++) { + for (int j = 0; j < chain[k + SKIP_ONE].n; j++) { + dg_arrow(dg, chain[k].ids[i], chain[k + SKIP_ONE].ids[j], label, + directed ? "arrow" : "line", line); + } + } + } +} + +static void dt_stmts(dg_t *dg, dt_lex_t *lx, bool directed, dt_set_t *all, int depth, + dg_str_t *title) { + if (depth > DT_DEPTH_MAX) { + return; + } + while (lx->tok.k != DT_RBRACE && lx->tok.k != DT_END && !dg->oom) { + if (lx->tok.k == DT_SEMI || lx->tok.k == DT_COMMA) { + dt_next(lx); + continue; + } + dt_stmt(dg, lx, directed, all, depth, title); + } +} + +static void dg_dot(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + dt_lex_t lx = {it->p, it->end, it->line, {DT_END, {NULL, 0}, 0}}; + dt_next(&lx); + if (lx.tok.k == DT_ID && dg_eq(lx.tok.s, "strict")) { + dt_next(&lx); + } + bool directed = lx.tok.k == DT_ID && dg_eq(lx.tok.s, "digraph"); + snprintf(dg->kind, sizeof(dg->kind), "%s", directed ? "digraph" : "graph"); + dg->element_kind = "node"; + while (lx.tok.k != DT_LBRACE && lx.tok.k != DT_END) { + dt_next(&lx); + } + if (lx.tok.k == DT_END) { + return; + } + dt_next(&lx); + dt_set_t all = {.n = 0}; + dt_stmts(dg, &lx, directed, &all, 0, title); +} + +/* ── D2 ──────────────────────────────────────────────────────────── */ + +enum { D2_DEPTH_MAX = 16, D2_PATH_MAX = 512, D2_DELIM_MAX = 8 }; + +/* Keys that set an attribute of the shape they are written in. */ +static bool d2_reserved(dg_str_t key) { + static const char *const words[] = { + "shape", "style", "icon", "near", "direction", "tooltip", + "link", "width", "height", "class", "classes", "vars", + "label", "constraint", "top", "left", "grid-rows", "grid-columns", + "grid-gap", "layers", "scenarios", "steps", "source-arrowhead", "target-arrowhead", + "filled", "font-size", NULL}; + for (int k = 0; words[k]; k++) { + if (dg_eq(key, words[k])) { + return true; + } + } + return false; +} + +/* The containers a line is written in: the path of ids, and how far it + * reached at each enclosing level (restored by the closing brace). */ +typedef struct { + char path[D2_PATH_MAX]; + size_t lens[D2_DEPTH_MAX]; + int depth; + int skip_depth; /* inside a style, vars or connection block; -1 for none */ +} d2_scope_t; + +/* One level deeper: into the container `id`, or (no id) into a block that + * keeps the path. */ +static void d2_push(d2_scope_t *sc, dg_str_t id) { + if (sc->depth < D2_DEPTH_MAX) { + sc->lens[sc->depth] = strlen(sc->path); + if (id.n) { + char next[D2_PATH_MAX]; + snprintf(next, sizeof(next), "%.*s", (int)id.n, id.p); + memcpy(sc->path, next, sizeof(next)); + } + } + sc->depth++; +} + +static void d2_pop(d2_scope_t *sc) { + if (sc->depth == 0) { + return; + } + if (sc->skip_depth == sc->depth) { + sc->skip_depth = -1; + } + sc->depth--; + if (sc->depth < D2_DEPTH_MAX) { + sc->path[sc->lens[sc->depth]] = '\0'; + } +} + +/* The full id of a reference written inside the containers `path`. */ +static dg_str_t d2_id(dg_str_t ref, const char *path, char *buf, size_t cap) { + ref = dg_unquote(ref); + if (!path[0]) { + return ref; + } + int n = snprintf(buf, cap, "%s.%.*s", path, (int)ref.n, ref.p); + return n > 0 && (size_t)n < cap ? dg_s(buf, (size_t)n) : ref; +} + +/* A line without its `#` comment; a block string (`|`) ends the scan, its + * text is no comment. */ +static dg_str_t d2_strip_comment(dg_str_t ln) { + bool quoted = false; + for (size_t i = 0; i < ln.n; i++) { + quoted = ln.p[i] == '"' ? !quoted : quoted; + if (quoted) { + continue; + } + if (ln.p[i] == '|') { + break; + } + if (ln.p[i] == '#') { + return dg_trim(dg_s(ln.p, i)); + } + } + return ln; +} + +/* The first `:` outside quotes, NULL for none. */ +static const char *d2_colon(dg_str_t ln) { + bool quoted = false; + for (size_t i = 0; i < ln.n; i++) { + quoted = ln.p[i] == '"' ? !quoted : quoted; + if (!quoted && ln.p[i] == ':') { + return ln.p + i; + } + } + return NULL; +} + +/* A block string value (`|md ...|`, `||...||`, `|||...|||`, |`...`|): its + * text, read on through the lines it spans, and what follows its closing + * delimiter on that line. False when value opens none. */ +static bool d2_block(dg_lines_t *it, dg_str_t value, dg_str_t *text, dg_str_t *after) { + size_t pipes = 0; + while (pipes < value.n && value.p[pipes] == '|') { + pipes++; + } + if (pipes == 0 || pipes + PAIR_LEN > D2_DELIM_MAX) { + return false; + } + char close[D2_DELIM_MAX]; + size_t cl = 0; + size_t open = pipes; + if (open < value.n && value.p[open] == '`') { + close[cl++] = '`'; + open++; + } + for (size_t i = 0; i < pipes; i++) { + close[cl++] = '|'; + } + while (open < value.n && !isspace((unsigned char)value.p[open])) { /* the language tag */ + open++; + } + const char *from = value.p + open; + const char *at = NULL; + for (const char *q = from; q + cl <= it->end && !at; q++) { + at = memcmp(q, close, cl) == 0 ? q : NULL; + } + at = at ? at : it->end; + *text = dg_s(from, (size_t)(at - from)); + const char *rest = at + (at < it->end ? cl : 0); + const char *eol = memchr(rest, '\n', (size_t)(it->end - rest)); + eol = eol ? eol : it->end; + *after = dg_trim(dg_s(rest, (size_t)(eol - rest))); + dg_str_t skipped; + uint32_t l = 0; + while (it->p <= at && dg_next_line(it, &skipped, &l)) { /* the lines the block spans */ + } + return true; +} + +/* A block string's label: its first line of text, tags and heading marks + * dropped. */ +static size_t d2_block_label(dg_str_t text, char *out, size_t cap) { + dg_lines_t li = {text.p, text.p + text.n, 0}; + dg_str_t ln; + uint32_t l = 0; + while (dg_next_line(&li, &ln, &l)) { + size_t n = dg_label_text(ln, out, cap); + size_t k = 0; + while (k < n && (out[k] == '#' || out[k] == ' ')) { + k++; + } + if (k < n) { + memmove(out, out + k, n - k + SKIP_ONE); + return n - k; + } + } + out[0] = '\0'; + return 0; +} + +/* A value that is a block string read whole: the label it gives in buf, its + * text added to the diagram's docstring, and whether a `{` follows it. */ +static dg_str_t d2_value(dg_t *dg, dg_lines_t *it, dg_str_t value, char *buf, size_t cap, + bool *opens) { + dg_str_t text; + dg_str_t after; + if (!value.n || value.p[0] != '|' || !d2_block(it, value, &text, &after)) { + return value; + } + *opens = after.n && after.p[after.n - SKIP_ONE] == '{'; + size_t n = d2_block_label(text, buf, cap); + char all[DG_LABEL_MAX]; + size_t an = dg_label_text(text, all, sizeof(all)); + if (an > n) { + dg_doc_add(dg, all, an); + } + return dg_s(buf, n); +} + +/* A connection chain `a -> b <- c: label`: true when the line is one. */ +static bool d2_connection(dg_t *dg, dg_lines_t *it, dg_str_t ln, const char *path, uint32_t line, + bool *opens) { + static const char *const ops[] = {"<->", "->", "<-", "--", NULL}; + dg_str_t parts[DT_SET_MAX]; + const char *styles[DT_SET_MAX]; + int np = 0; + const char *colon = NULL; + bool quoted = false; + size_t start = 0; + for (size_t i = 0; i < ln.n && np + SKIP_ONE < DT_SET_MAX; i++) { + quoted = ln.p[i] == '"' ? !quoted : quoted; + if (quoted) { + continue; + } + if (ln.p[i] == ':') { + colon = ln.p + i; + break; + } + for (int k = 0; ops[k]; k++) { + size_t l = strlen(ops[k]); + if (i + l <= ln.n && memcmp(ln.p + i, ops[k], l) == 0) { + parts[np] = dg_trim(dg_s(ln.p + start, i - start)); + styles[np++] = k == 0 ? "bidirectional" + : k == 1 ? "arrow" + : k == PAIR_LEN ? "<" + : "line"; + start = i + l; + i += l - SKIP_ONE; + break; + } + } + } + if (np == 0) { + return false; + } + size_t end = colon ? (size_t)(colon - ln.p) : ln.n; + parts[np] = dg_trim(dg_s(ln.p + start, end - start)); + dg_str_t label = colon + ? dg_trim(dg_s(colon + SKIP_ONE, (size_t)(ln.p + ln.n - colon) - SKIP_ONE)) + : dg_s(NULL, 0); + char buf[DG_LABEL_MAX]; + label = d2_value(dg, it, label, buf, sizeof(buf), opens); + if (label.n && label.p[label.n - SKIP_ONE] == '{') { + label = dg_trim(dg_s(label.p, label.n - SKIP_ONE)); + } + for (int k = 0; k < np; k++) { + char ba[D2_PATH_MAX]; + char bb[D2_PATH_MAX]; + dg_str_t a = d2_id(parts[k], path, ba, sizeof(ba)); + dg_str_t b = d2_id(parts[k + SKIP_ONE], path, bb, sizeof(bb)); + if (!a.n || !b.n) { + continue; + } + if (strcmp(styles[k], "<") == 0) { + dg_arrow(dg, b, a, label, "arrow", line); + } else { + dg_arrow(dg, a, b, label, styles[k], line); + } + } + return true; +} + +/* The first reserved segment of a dotted key: its index, -1 for none. */ +static int d2_reserved_segment(dg_str_t key, size_t *seg_start, size_t *seg_len) { + int index = 0; + size_t s = 0; + bool quoted = false; + for (size_t i = 0; i <= key.n; i++) { + quoted = i < key.n && key.p[i] == '"' ? !quoted : quoted; + if (i < key.n && (quoted || key.p[i] != '.')) { + continue; + } + if (d2_reserved(dg_trim(dg_s(key.p + s, i - s)))) { + *seg_start = s; + *seg_len = i - s; + return index; + } + index++; + s = i + SKIP_ONE; + } + return -1; +} + +/* `key: value` (or `key: value {`) that is no connection. */ +static void d2_key(dg_t *dg, d2_scope_t *sc, dg_lines_t *it, dg_str_t ln, uint32_t line, bool opens, + dg_str_t *title) { + const char *colon = d2_colon(ln); + dg_str_t key = dg_trim(colon ? dg_s(ln.p, (size_t)(colon - ln.p)) + : dg_s(ln.p, ln.n - (opens ? SKIP_ONE : 0))); + dg_str_t value = colon + ? dg_trim(dg_s(colon + SKIP_ONE, (size_t)(ln.p + ln.n - colon) - SKIP_ONE)) + : dg_s(NULL, 0); + char vbuf[DG_LABEL_MAX]; + value = d2_value(dg, it, value, vbuf, sizeof(vbuf), &opens); + if (value.n && value.p[value.n - SKIP_ONE] == '{') { + value = dg_trim(dg_s(value.p, value.n - SKIP_ONE)); + } + size_t rs = 0; + size_t rl = 0; + int r = d2_reserved_segment(key, &rs, &rl); + if (r >= 0) { + /* an attribute: `label` names the shape it is set on */ + bool label = dg_eq(dg_trim(dg_s(key.p + rs, rl)), "label") && rs + rl == key.n; + char buf[D2_PATH_MAX]; + dg_str_t owner = r == 0 ? dg_s(sc->path, strlen(sc->path)) + : d2_id(dg_s(key.p, rs - SKIP_ONE), sc->path, buf, sizeof(buf)); + bool link = dg_eq(dg_trim(dg_s(key.p + rs, rl)), "link") && rs + rl == key.n; + if (label && owner.n && value.n) { + dg_element(dg, owner, value, "shape", line); + } else if (link && owner.n && value.n) { + dg_link(dg, dg_element(dg, owner, dg_s(NULL, 0), "shape", line), value, + CBM_DOCLINK_DG_D2_LINK, line); + } else if (label && value.n && r == 0) { /* the board's own label */ + const char *t = dg_dup(dg, value.p, value.n); + *title = t ? dg_s(t, value.n) : *title; + } + if (opens) { + d2_push(sc, dg_s(NULL, 0)); + sc->skip_depth = sc->depth; + } + return; + } + char buf[D2_PATH_MAX]; + dg_str_t id = d2_id(key, sc->path, buf, sizeof(buf)); + dg_element(dg, id, value, opens ? "container" : "shape", line); + if (opens) { + d2_push(sc, id); + } +} + +static void dg_d2(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + snprintf(dg->kind, sizeof(dg->kind), "%s", "d2"); + dg->element_kind = "shape"; + d2_scope_t sc; + memset(&sc, 0, sizeof(sc)); + sc.skip_depth = -1; + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(it, &ln, &line)) { + ln = d2_strip_comment(dg_trim(ln)); + if (!ln.n) { + continue; + } + if (dg_eq(ln, "}")) { + d2_pop(&sc); + continue; + } + bool opens = ln.p[ln.n - SKIP_ONE] == '{'; + if (sc.skip_depth >= 0 || ln.p[0] == '(' || ln.p[0] == '|' || ln.p[0] == '*' || + ln.p[0] == '.' || ln.p[0] == '@') { + if (opens) { + d2_push(&sc, dg_s(NULL, 0)); + } + continue; + } + if (d2_connection(dg, it, ln, sc.path, line, &opens)) { + if (opens) { + d2_push(&sc, dg_s(NULL, 0)); + sc.skip_depth = sc.depth; /* a connection's attribute block */ + } + continue; + } + d2_key(dg, &sc, it, ln, line, opens, title); + } +} + +/* ── XML attributes (draw.io) ────────────────────────────────────── */ + +/* The value of attribute `name` in the tag text [p, end), entities + * decoded, into out; false when the tag has none. */ +static bool dg_xml_attr(const char *p, const char *end, const char *name, char *out, size_t cap) { + size_t nl = strlen(name); + for (const char *q = p; q + nl + PAIR_LEN < end; q++) { + if (memcmp(q, name, nl) != 0 || q[nl] != '=' || + (q[nl + SKIP_ONE] != '"' && q[nl + SKIP_ONE] != '\'') || + (q > p && !isspace((unsigned char)q[-SKIP_ONE]))) { + continue; + } + char quote = q[nl + SKIP_ONE]; + const char *v = q + nl + PAIR_LEN; + size_t w = 0; + for (; v < end && *v != quote && w + SKIP_ONE < cap; v++) { + if (*v != '&') { + out[w++] = *v; + continue; + } + static const struct { + const char *ent; + char c; + } ents[] = {{"<", '<'}, {">", '>'}, {"&", '&'}, {""", '"'}, + {"'", '\''}, {" ", ' '}, {" ", ' '}, {" ", ' '}, + {" ", ' '}, {NULL, 0}}; + bool hit = false; + for (int k = 0; ents[k].ent && !hit; k++) { + size_t el = strlen(ents[k].ent); + if (v + el <= end && memcmp(v, ents[k].ent, el) == 0) { + out[w++] = ents[k].c; + v += el - SKIP_ONE; + hit = true; + } + } + if (!hit) { + out[w++] = '&'; + } + } + out[w] = '\0'; + return true; + } + out[0] = '\0'; + return false; +} + +enum { DG_DRAWIO_CELLS = 4096, DG_ATTR_MAX = 4096 }; + +/* The text of a draw.io label: its HTML entities once more (an HTML label is + * escaped twice), its tags dropped. */ +static size_t dg_drawio_text(const char *html, char *out, size_t cap) { + char tmp[DG_ATTR_MAX]; + size_t w = 0; + for (const char *v = html; *v && w + SKIP_ONE < sizeof(tmp); v++) { + static const struct { + const char *ent; + char c; + } ents[] = {{"<", '<'}, {">", '>'}, {"&", '&'}, + {""", '"'}, {" ", ' '}, {NULL, 0}}; + bool hit = false; + for (int k = 0; ents[k].ent && !hit && *v == '&'; k++) { + size_t el = strlen(ents[k].ent); + if (strncmp(v, ents[k].ent, el) == 0) { + tmp[w++] = ents[k].c; + v += el - SKIP_ONE; + hit = true; + } + } + if (!hit) { + tmp[w++] = *v; + } + } + tmp[w] = '\0'; + /* a label cut short can end inside a tag: drop it, not show it */ + char *lt = strrchr(tmp, '<'); + if (lt && !strchr(lt, '>') && + (isalpha((unsigned char)lt[SKIP_ONE]) || lt[SKIP_ONE] == '/' || lt[SKIP_ONE] == '!')) { + *lt = '\0'; + w = (size_t)(lt - tmp); + } + return dg_label_text(dg_s(tmp, w), out, cap); +} + +typedef struct { + char id[DG_ID_MAX]; + char label[DG_LABEL_MAX]; + char source[DG_ID_MAX]; + char target[DG_ID_MAX]; + char parent[DG_ID_MAX]; + bool vertex; + bool edge; + bool text; /* a free text box */ + bool edge_label; /* the label of the edge `parent` */ + bool dashed; + bool no_arrow; + char kind[DG_KIND_MAX]; + const char *link; /* the cell's `link` attribute, NULL for none */ +} dg_cell_t; + +/* The cell a `` tag (inside an `` wrapper: `wrap`) says. */ +static void dg_drawio_cell(CBMArena *a, const char *tag, const char *tag_end, const char *wrap, + const char *wrap_end, dg_cell_t *c) { + char buf[DG_ATTR_MAX]; + memset(c, 0, sizeof(*c)); + if (dg_xml_attr(wrap ? wrap : tag, wrap ? wrap_end : tag_end, "link", buf, sizeof(buf)) && + buf[0]) { + c->link = cbm_arena_strdup(a, buf); + } + const char *idp = wrap ? wrap : tag; + const char *ide = wrap ? wrap_end : tag_end; + dg_xml_attr(idp, ide, "id", c->id, sizeof(c->id)); + if (dg_xml_attr(wrap ? wrap : tag, wrap ? wrap_end : tag_end, wrap ? "label" : "value", buf, + sizeof(buf))) { + dg_drawio_text(buf, c->label, sizeof(c->label)); + } + c->vertex = dg_xml_attr(tag, tag_end, "vertex", buf, sizeof(buf)) && buf[0] == '1'; + c->edge = dg_xml_attr(tag, tag_end, "edge", buf, sizeof(buf)) && buf[0] == '1'; + dg_xml_attr(tag, tag_end, "source", c->source, sizeof(c->source)); + dg_xml_attr(tag, tag_end, "target", c->target, sizeof(c->target)); + dg_xml_attr(tag, tag_end, "parent", c->parent, sizeof(c->parent)); + if (dg_xml_attr(tag, tag_end, "style", buf, sizeof(buf))) { + c->text = strncmp(buf, "text;", CBM_SZ_5) == 0 || strncmp(buf, "text", CBM_SZ_4) == 0; + c->edge_label = strstr(buf, "edgeLabel") != NULL; + c->dashed = strstr(buf, "dashed=1") != NULL; + c->no_arrow = strstr(buf, "endArrow=none") != NULL; + const char *shape = strstr(buf, "shape="); + const char *k = shape ? shape + CBM_SZ_6 : buf; + size_t kl = 0; + while (k[kl] && k[kl] != ';' && k[kl] != '=' && kl + SKIP_ONE < sizeof(c->kind)) { + kl++; + } + if (k[kl] == '=' && !shape) { + kl = 0; /* the first entry is a setting, not a shape */ + } + memcpy(c->kind, k, kl); + c->kind[kl] = '\0'; + } + if (!c->kind[0]) { + snprintf(c->kind, sizeof(c->kind), "%s", "shape"); + } +} + +/* The elements and arrows of one draw.io page's mxGraphModel. */ +static void dg_drawio(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + (void)title; + snprintf(dg->kind, sizeof(dg->kind), "%s", "drawio"); + dg->element_kind = "shape"; + const char *p = it->p; + const char *end = it->end; + dg_cell_t *cells = + (dg_cell_t *)cbm_arena_alloc(dg->ctx->arena, DG_DRAWIO_CELLS * sizeof(dg_cell_t)); + if (!cells) { + dg->oom = true; + return; + } + int n = 0; + for (const char *q = p; q < end && n < DG_DRAWIO_CELLS;) { + const char *lt = memchr(q, '<', (size_t)(end - q)); + if (!lt) { + break; + } + const char *gt = memchr(lt, '>', (size_t)(end - lt)); + if (!gt) { + break; + } + bool object = (size_t)(end - lt) > CBM_SZ_8 && (memcmp(lt, "', (size_t)(end - cell)); + if (cend) { + dg_drawio_cell(dg->ctx->arena, cell, cend, lt, gt, &cells[n++]); + q = cend + SKIP_ONE; + continue; + } + } + } else if ((size_t)(end - lt) > CBM_SZ_7 && memcmp(lt, "ctx->arena, lt, gt, NULL, NULL, &cells[n++]); + } + q = gt + SKIP_ONE; + } + uint32_t line = dg->line0; + for (int i = 0; i < n; i++) { /* the shapes with text */ + dg_cell_t *c = &cells[i]; + if (c->vertex && !c->edge_label && c->label[0]) { + if (c->text) { + dg_doc_add(dg, c->label, strlen(c->label)); + } else { + const char *qn = dg_element(dg, dg_s(c->id, strlen(c->id)), + dg_s(c->label, strlen(c->label)), c->kind, line); + dg_link(dg, qn, dg_s(c->link, c->link ? strlen(c->link) : 0), + CBM_DOCLINK_DG_DRAWIO_LINK, line); + } + } + } + for (int i = 0; i < n; i++) { /* the connections */ + dg_cell_t *c = &cells[i]; + if (!c->edge || !c->source[0] || !c->target[0]) { + continue; + } + const char *label = c->label; + for (int k = 0; k < n && !label[0]; k++) { + if (cells[k].edge_label && strcmp(cells[k].parent, c->id) == 0) { + label = cells[k].label; + } + } + if (!cbm_ht_get(dg->ids, c->source) || !cbm_ht_get(dg->ids, c->target)) { + continue; /* an end without text names nothing */ + } + dg_arrow(dg, dg_s(c->source, strlen(c->source)), dg_s(c->target, strlen(c->target)), + dg_s(label, strlen(label)), + c->dashed ? "dotted" + : c->no_arrow ? "line" + : "arrow", + line); + } +} + +/* ── Excalidraw ──────────────────────────────────────────────────── */ + +enum { DG_EX_MARGIN = 8 }; /* px: how near a loose arrow end comes to the shape it meets */ + +/* A shape of the scene: its box, and the label it gets. */ +typedef struct { + const char *id; + const char *type; + double x0; + double y0; + double x1; + double y1; + double area; + const char *label; + double label_x; /* a loose label's place: the topmost text inside wins */ + double label_y; + const char *link; /* the shape's `link`, NULL for none */ + bool fixed; /* bound text or a frame's name: no loose text replaces it */ + bool region; /* a box that can hold loose text and meet loose arrow ends */ + bool frame; /* a region around every other shape, the drawing's frame: loose + * arrow ends meet it at its border only */ +} ex_shape_t; + +static const char *dg_json_str(yyjson_val *obj, const char *key) { + yyjson_val *v = yyjson_obj_get(obj, key); + return yyjson_is_str(v) ? yyjson_get_str(v) : NULL; +} + +static double ex_num(yyjson_val *obj, const char *key) { + yyjson_val *v = yyjson_obj_get(obj, key); + return yyjson_is_num(v) ? yyjson_get_num(v) : 0.0; +} + +static bool ex_live(yyjson_val *e) { + return !yyjson_get_bool(yyjson_obj_get(e, "isDeleted")); +} + +static bool ex_is(const char *type, const char *const *kinds) { + for (int k = 0; kinds[k]; k++) { + if (strcmp(type, kinds[k]) == 0) { + return true; + } + } + return false; +} + +static const char *const EX_REGIONS[] = {"rectangle", "ellipse", "diamond", "frame", + "magicframe", "embeddable", "iframe", NULL}; +static const char *const EX_NOT_SHAPES[] = {"text", "arrow", "line", "freedraw", "selection", NULL}; + +static int ex_by_area(const void *a, const void *b) { + const ex_shape_t *x = (const ex_shape_t *)a; + const ex_shape_t *y = (const ex_shape_t *)b; + if (x->area != y->area) { + return x->area < y->area ? -1 : SKIP_ONE; + } + return strcmp(x->id, y->id); +} + +/* The smallest region whose box, grown by margin, holds (x, y): -1 for none. + * The shapes are sorted by area, so the first hit is the smallest. An arrow + * end (labelled) deep inside the drawing's frame points at something in the + * drawing, not at the frame: none. */ +static int ex_region_at(const ex_shape_t *s, int n, double x, double y, double margin, + bool labelled) { + for (int i = 0; i < n; i++) { + if (!s[i].region || (labelled && !s[i].label)) { + continue; + } + if (x >= s[i].x0 - margin && x <= s[i].x1 + margin && y >= s[i].y0 - margin && + y <= s[i].y1 + margin) { + bool deep = x > s[i].x0 + margin && x < s[i].x1 - margin && y > s[i].y0 + margin && + y < s[i].y1 - margin; + return labelled && s[i].frame && deep ? -1 : i; + } + } + return -1; +} + +/* A loose text: the label of the smallest region it sits in (the topmost + * text of several), else the diagram's docstring. */ +static void ex_loose_text(dg_t *dg, ex_shape_t *s, int n, yyjson_val *e, const char *text) { + double x = ex_num(e, "x"); + double y = ex_num(e, "y"); + int i = ex_region_at(s, n, x + ex_num(e, "width") / PAIR_LEN, + y + ex_num(e, "height") / PAIR_LEN, 0.0, false); + if (i < 0 || s[i].fixed) { + dg_doc_add(dg, text, strlen(text)); + return; + } + if (s[i].label && (s[i].label_y < y || (s[i].label_y == y && s[i].label_x <= x))) { + dg_doc_add(dg, text, strlen(text)); + return; + } + if (s[i].label) { + dg_doc_add(dg, s[i].label, strlen(s[i].label)); + } + s[i].label = text; + s[i].label_x = x; + s[i].label_y = y; +} + +/* The end bound to free text: the shape that text names, else the text as an + * element of its own. */ +static const char *ex_text_end(dg_t *dg, const ex_shape_t *s, int n, CBMHashTable *texts, + const char *id) { + const char *text = (const char *)cbm_ht_get(texts, id); + if (!text) { + return NULL; + } + for (int i = 0; i < n; i++) { + if (s[i].label == text) { + return s[i].id; + } + } + return dg_element(dg, dg_s(id, strlen(id)), dg_s(text, strlen(text)), "text", dg->line0) ? id + : NULL; +} + +/* An arrow end: the shape (or free text) it is bound to, else (arrows only) + * the smallest labelled region the point meets. */ +static const char *ex_end(dg_t *dg, const ex_shape_t *s, int n, CBMHashTable *texts, yyjson_val *e, + const char *binding, bool first, bool loose_ok) { + const char *bound = dg_json_str(yyjson_obj_get(e, binding), "elementId"); + if (bound) { + return cbm_ht_get(dg->ids, bound) ? bound : ex_text_end(dg, s, n, texts, bound); + } + yyjson_val *pts = yyjson_obj_get(e, "points"); + yyjson_val *pt = first ? yyjson_arr_get_first(pts) : yyjson_arr_get_last(pts); + if (!loose_ok || !yyjson_is_arr(pt)) { + return NULL; + } + double x = ex_num(e, "x") + yyjson_get_num(yyjson_arr_get(pt, 0)); + double y = ex_num(e, "y") + yyjson_get_num(yyjson_arr_get(pt, SKIP_ONE)); + int i = ex_region_at(s, n, x, y, DG_EX_MARGIN, true); + return i >= 0 ? s[i].id : NULL; +} + +static void ex_arrows(dg_t *dg, const ex_shape_t *s, int n, yyjson_val *els, CBMHashTable *labels, + CBMHashTable *texts) { + size_t i; + size_t max; + yyjson_val *e; + yyjson_arr_foreach(els, i, max, e) { + const char *type = dg_json_str(e, "type"); + const char *id = dg_json_str(e, "id"); + if (!type || !id || !ex_live(e) || + (strcmp(type, "arrow") != 0 && strcmp(type, "line") != 0)) { + continue; + } + bool arrow = strcmp(type, "arrow") == 0; + const char *from = ex_end(dg, s, n, texts, e, "startBinding", true, arrow); + const char *to = ex_end(dg, s, n, texts, e, "endBinding", false, arrow); + bool loose = !dg_json_str(yyjson_obj_get(e, "startBinding"), "elementId") || + !dg_json_str(yyjson_obj_get(e, "endBinding"), "elementId"); + if (!from || !to || (loose && strcmp(from, to) == 0)) { + continue; /* unbound, bound to a shape without text, or a loose loop in one box */ + } + const char *label = (const char *)cbm_ht_get(labels, id); + dg_arrow(dg, dg_s(from, strlen(from)), dg_s(to, strlen(to)), + dg_s(label ? label : "", label ? strlen(label) : 0), arrow ? "arrow" : "line", + dg->line0); + } +} + +static void dg_excalidraw(dg_t *dg, dg_lines_t *it, dg_str_t *title) { + (void)title; + snprintf(dg->kind, sizeof(dg->kind), "%s", "excalidraw"); + dg->element_kind = "shape"; + yyjson_doc *doc = yyjson_read(it->p, (size_t)(it->end - it->p), 0); + yyjson_val *els = doc ? yyjson_obj_get(yyjson_doc_get_root(doc), "elements") : NULL; + size_t total = yyjson_arr_size(els); + CBMHashTable *labels = + yyjson_is_arr(els) ? cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_64) : NULL; + ex_shape_t *s = labels && total + ? (ex_shape_t *)cbm_arena_alloc(dg->ctx->arena, total * sizeof(ex_shape_t)) + : NULL; + CBMHashTable *texts = s ? cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_64) : NULL; + if (!texts) { + cbm_ht_free(labels); + yyjson_doc_free(doc); + return; + } + size_t i; + size_t max; + yyjson_val *e; + yyjson_arr_foreach(els, i, max, e) { /* bound text: the label of its container */ + const char *text = dg_json_str(e, "originalText"); + text = text ? text : dg_json_str(e, "text"); + const char *type = dg_json_str(e, "type"); + const char *container = dg_json_str(e, "containerId"); + const char *id = dg_json_str(e, "id"); + if (ex_live(e) && type && strcmp(type, "text") == 0 && text && container) { + cbm_ht_set(labels, container, (void *)text); + } else if (ex_live(e) && type && strcmp(type, "text") == 0 && text && id) { + cbm_ht_set(texts, id, (void *)text); /* free text an arrow may be bound to */ + } + } + int n = 0; + yyjson_arr_foreach(els, i, max, e) { /* the shapes */ + const char *type = dg_json_str(e, "type"); + const char *id = dg_json_str(e, "id"); + if (!type || !id || !ex_live(e) || ex_is(type, EX_NOT_SHAPES)) { + continue; + } + ex_shape_t *sh = &s[n++]; + memset(sh, 0, sizeof(*sh)); + sh->id = id; + sh->type = type; + sh->x0 = ex_num(e, "x"); + sh->y0 = ex_num(e, "y"); + sh->x1 = sh->x0 + ex_num(e, "width"); + sh->y1 = sh->y0 + ex_num(e, "height"); + sh->area = (sh->x1 - sh->x0) * (sh->y1 - sh->y0); + sh->region = ex_is(type, EX_REGIONS); + sh->label = (const char *)cbm_ht_get(labels, id); + sh->label = sh->label ? sh->label : dg_json_str(e, "name"); /* a frame's name */ + sh->fixed = sh->label != NULL; + sh->link = dg_json_str(e, "link"); + } + qsort(s, (size_t)n, sizeof(ex_shape_t), ex_by_area); + yyjson_arr_foreach(els, i, max, e) { /* loose text: in a shape, or the docstring */ + const char *type = dg_json_str(e, "type"); + const char *text = dg_json_str(e, "originalText"); + text = text ? text : dg_json_str(e, "text"); + if (ex_live(e) && type && strcmp(type, "text") == 0 && text && + !dg_json_str(e, "containerId")) { + ex_loose_text(dg, s, n, e, text); + } + } + if (n > SKIP_ONE && s[n - SKIP_ONE].region) { /* only the largest box can frame the rest */ + ex_shape_t *f = &s[n - SKIP_ONE]; + bool all = true; + for (int j = 0; j < n - SKIP_ONE && all; j++) { + all = s[j].x0 >= f->x0 && s[j].x1 <= f->x1 && s[j].y0 >= f->y0 && s[j].y1 <= f->y1; + } + f->frame = all; + } + for (int k = 0; k < n; k++) { + if (s[k].label) { + const char *qn = dg_element(dg, dg_s(s[k].id, strlen(s[k].id)), + dg_s(s[k].label, strlen(s[k].label)), s[k].type, dg->line0); + dg_link(dg, qn, dg_s(s[k].link, s[k].link ? strlen(s[k].link) : 0), + CBM_DOCLINK_DG_EXCALIDRAW_LINK, dg->line0); + } + } + ex_arrows(dg, s, n, els, labels, texts); + cbm_ht_free(texts); + cbm_ht_free(labels); + yyjson_doc_free(doc); +} + +/* ── One diagram ─────────────────────────────────────────────────── */ + +typedef struct { + const char *format; + const char *title; /* a draw.io page's name, NULL for none */ + const char *fallback; /* the name when the text gives none: a diagram file's */ + const char *parent_qn; /* the Section's, NULL: the file */ + int ordinal; + const char *text; + size_t len; + uint32_t first_line; /* the text's first line in the file */ + uint32_t start_line; /* the block's lines */ + uint32_t end_line; +} dg_source_t; + +static void dg_build(CBMExtractCtx *ctx, const dg_source_t *src) { + dg_t dg; + memset(&dg, 0, sizeof(dg)); + dg.ctx = ctx; + dg.format = src->format; + const char *parent = src->parent_qn ? src->parent_qn : ctx->module_qn; + if (!parent) { + return; + } + dg.qn = cbm_arena_sprintf(ctx->arena, "%s#diagram-%d", parent, src->ordinal); + dg.ids = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_32); + if (!dg.qn || !dg.ids) { + cbm_ht_free(dg.ids); + return; + } + CBMDefinition def; + memset(&def, 0, sizeof(def)); + def.label = "Diagram"; + def.qualified_name = dg.qn; + def.file_path = ctx->rel_path; + def.start_line = src->start_line; + def.end_line = src->end_line; + def.parent_class = src->parent_qn; + def.name = src->format; + cbm_defs_push(&ctx->result->defs, ctx->arena, def); + dg.def_index = ctx->result->defs.count - SKIP_ONE; + dg.line0 = src->first_line; + dg_lines_t it = {src->text, src->text + src->len, src->first_line}; + dg_str_t title = src->title ? dg_s(src->title, strlen(src->title)) : dg_s(NULL, 0); + if (strcmp(src->format, "mermaid") == 0) { + dg_mermaid(&dg, &it, &title); + } else if (strcmp(src->format, "plantuml") == 0) { + dg_plantuml(&dg, &it, &title); + } else if (strcmp(src->format, "dot") == 0) { + dg_dot(&dg, &it, &title); + } else if (strcmp(src->format, "d2") == 0) { + dg_d2(&dg, &it, &title); + } else if (strcmp(src->format, "drawio") == 0) { + dg_drawio(&dg, &it, &title); + } else if (strcmp(src->format, "excalidraw") == 0) { + dg_excalidraw(&dg, &it, &title); + } + cbm_ht_free(dg.ids); + CBMDefinition *d = &ctx->result->defs.items[dg.def_index]; + char tbuf[DG_LABEL_MAX]; + size_t tl = dg_label_text(title, tbuf, sizeof(tbuf)); + d->name = tl ? cbm_arena_strndup(ctx->arena, tbuf, tl) + : src->fallback ? src->fallback + : cbm_arena_sprintf(ctx->arena, "%s %s", src->format, + dg.kind[0] ? dg.kind : "diagram"); + d->docstring = dg.doc_len ? cbm_arena_strndup(ctx->arena, dg.doc, dg.doc_len) : NULL; + const char *kind = dg.kind[0] ? cbm_arena_strdup(ctx->arena, dg.kind) : "unknown"; + const char **kv = (const char **)cbm_arena_alloc(ctx->arena, CBM_SZ_5 * sizeof(char *)); + if (kv) { + kv[0] = "format"; + kv[SKIP_ONE] = src->format; + kv[PAIR_LEN] = "diagram_kind"; + kv[CBM_SZ_3] = kind; + kv[CBM_SZ_4] = NULL; + d->extra_props = kv; + } +} + +/* ── Sources ─────────────────────────────────────────────────────── */ + +/* The diagram format an info string names, NULL for none. */ +static const char *dg_format_of(const char *s, size_t n) { + static const struct { + const char *word; + const char *format; + } names[] = { + {"mermaid", "mermaid"}, {"plantuml", "plantuml"}, {"puml", "plantuml"}, {"uml", "plantuml"}, + {"dot", "dot"}, {"graphviz", "dot"}, {"d2", "d2"}, {NULL, NULL}}; + for (int k = 0; names[k].word; k++) { + size_t l = strlen(names[k].word); + bool same = n == l; + for (size_t i = 0; same && i < l; i++) { + same = tolower((unsigned char)s[i]) == names[k].word[i]; + } + if (same) { + return names[k].format; + } + } + return NULL; +} + +/* The Section a line of the file is written under: the last heading at or + * before it, NULL above the first. */ +static const char *dg_section_at(const CBMExtractCtx *ctx, uint32_t line) { + const char *qn = NULL; + uint32_t best = 0; + const CBMDefArray *defs = &ctx->result->defs; + for (int i = 0; i < defs->count; i++) { + const CBMDefinition *d = &defs->items[i]; + if (d->label && strcmp(d->label, "Section") == 0 && d->start_line <= line && + d->start_line >= best) { + best = d->start_line; + qn = d->qualified_name; + } + } + return qn; +} + +typedef struct { + const char *parent; + int count; +} dg_ordinal_t; + +enum { DG_PARENTS_MAX = 512 }; + +/* The next ordinal of a parent's diagrams. */ +static int dg_next_ordinal(dg_ordinal_t *ords, int *n, const char *parent) { + for (int i = 0; i < *n; i++) { + if (ords[i].parent == parent) { + return ++ords[i].count; + } + } + if (*n < DG_PARENTS_MAX) { + ords[(*n)++] = (dg_ordinal_t){parent, SKIP_ONE}; + } + return SKIP_ONE; +} + +static bool dg_kind(TSNode n, const char *k) { + return !ts_node_is_null(n) && strcmp(ts_node_type(n), k) == 0; +} + +/* A fenced block of a Markdown file: a diagram when its info string says so. */ +static void dg_markdown_fence(CBMExtractCtx *ctx, TSNode block, dg_ordinal_t *ords, int *nords) { + TSNode info = {0}; + TSNode content = {0}; + uint32_t nc = ts_node_named_child_count(block); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(block, i); + info = dg_kind(c, "info_string") ? c : info; + content = dg_kind(c, "code_fence_content") ? c : content; + } + if (ts_node_is_null(info) || ts_node_is_null(content)) { + return; + } + TSNode lang = ts_node_named_child(info, 0); + TSNode word = dg_kind(lang, "language") ? lang : info; + uint32_t wb = ts_node_start_byte(word); + uint32_t we = ts_node_end_byte(word); + const char *format = dg_format_of(ctx->source + wb, we - wb); + if (!format) { + return; + } + uint32_t start = ts_node_start_point(block).row + SKIP_ONE; + const char *parent = dg_section_at(ctx, start); + uint32_t cb = ts_node_start_byte(content); + dg_source_t src = { + .format = format, + .parent_qn = parent, + .ordinal = dg_next_ordinal(ords, nords, parent), + .text = ctx->source + cb, + .len = ts_node_end_byte(content) - cb, + .first_line = ts_node_start_point(content).row + SKIP_ONE, + .start_line = start, + .end_line = ts_node_end_point(block).row + SKIP_ONE, + }; + dg_build(ctx, &src); +} + +static void dg_markdown(CBMExtractCtx *ctx) { + dg_ordinal_t *ords = + (dg_ordinal_t *)cbm_arena_alloc(ctx->arena, DG_PARENTS_MAX * sizeof(dg_ordinal_t)); + if (!ords) { + return; + } + int nords = 0; + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (bool more = true; more;) { + TSNode n = ts_tree_cursor_current_node(&cur); + if (dg_kind(n, "fenced_code_block")) { + dg_markdown_fence(ctx, n, ords, &nords); + } else if (ts_tree_cursor_goto_first_child(&cur)) { + continue; + } + while (!ts_tree_cursor_goto_next_sibling(&cur)) { + if (!ts_tree_cursor_goto_parent(&cur)) { + more = false; + break; + } + } + } + ts_tree_cursor_delete(&cur); +} + +/* ── RST directives and AsciiDoc blocks ──────────────────────────── */ + +/* The diagram format a directive (RST) or block style (AsciiDoc) names; for + * Sphinx's `digraph` / `graph` also the graph keyword its body goes inside. */ +static const char *dg_block_format(dg_str_t word, const char **wrap) { + static const struct { + const char *word; + const char *format; + const char *wrap; + } names[] = { + {"mermaid", "mermaid", NULL}, {"uml", "plantuml", NULL}, {"plantuml", "plantuml", NULL}, + {"graphviz", "dot", NULL}, {"dot", "dot", NULL}, {"digraph", "dot", "digraph"}, + {"graph", "dot", "graph"}, {"d2", "d2", NULL}, {NULL, NULL, NULL}}; + *wrap = NULL; + for (int k = 0; names[k].word; k++) { + if (dg_eq(word, names[k].word)) { + *wrap = names[k].wrap; + return names[k].format; + } + } + return NULL; +} + +static dg_str_t dg_node_text(const CBMExtractCtx *ctx, TSNode n) { + uint32_t b = ts_node_start_byte(n); + return dg_s(ctx->source + b, ts_node_end_byte(n) - b); +} + +/* The first descendant of kind k, a null node for none. */ +static TSNode dg_find(TSNode n, const char *k) { + uint32_t nc = ts_node_named_child_count(n); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(n, i); + if (dg_kind(c, k)) { + return c; + } + TSNode d = dg_find(c, k); + if (!ts_node_is_null(d)) { + return d; + } + } + return (TSNode){0}; +} + +/* A directive's `:caption:` option, NULL for none. */ +static const char *dg_rst_caption(const CBMExtractCtx *ctx, TSNode options) { + uint32_t nc = ts_node_is_null(options) ? 0 : ts_node_named_child_count(options); + for (uint32_t i = 0; i < nc; i++) { + TSNode f = ts_node_named_child(options, i); + TSNode name = dg_find(f, "field_name"); + TSNode body = dg_find(f, "field_body"); + dg_str_t key = ts_node_is_null(name) ? dg_s("", 0) : dg_trim(dg_node_text(ctx, name)); + while (key.n && key.p[0] == ':') { /* `:caption:` or `caption` */ + key = dg_s(key.p + SKIP_ONE, key.n - SKIP_ONE); + } + while (key.n && key.p[key.n - SKIP_ONE] == ':') { + key.n--; + } + if (!ts_node_is_null(body) && dg_eq(key, "caption")) { + dg_str_t v = dg_trim(dg_node_text(ctx, body)); + return cbm_arena_strndup(ctx->arena, v.p, v.n); + } + } + return NULL; +} + +/* `.. mermaid::`, `.. uml::`, `.. graphviz::`, `.. digraph:: name`: a + * diagram when the directive holds its text (a file argument is the file, + * indexed as itself). */ +static void dg_rst_directive(CBMExtractCtx *ctx, TSNode dir, dg_ordinal_t *ords, int *nords) { + TSNode type = dg_find(dir, "type"); + const char *wrap = NULL; + const char *format = + ts_node_is_null(type) ? NULL : dg_block_format(dg_trim(dg_node_text(ctx, type)), &wrap); + TSNode content = format ? dg_find(dir, "content") : (TSNode){0}; + if (ts_node_is_null(content)) { + return; + } + dg_str_t text = dg_node_text(ctx, content); + uint32_t first = ts_node_start_point(content).row + SKIP_ONE; + if (wrap) { /* Sphinx writes the statements only */ + TSNode args = dg_find(dir, "arguments"); + dg_str_t name = ts_node_is_null(args) ? dg_s("", 0) : dg_trim(dg_node_text(ctx, args)); + char *w = cbm_arena_sprintf(ctx->arena, "%s \"%.*s\" {\n%.*s\n}\n", wrap, (int)name.n, + name.p, (int)text.n, text.p); + if (!w) { + return; + } + text = dg_s(w, strlen(w)); + first--; + } + uint32_t start = ts_node_start_point(dir).row + SKIP_ONE; + const char *parent = dg_section_at(ctx, start); + dg_source_t src = { + .format = format, + .title = dg_rst_caption(ctx, dg_find(dir, "options")), + .parent_qn = parent, + .ordinal = dg_next_ordinal(ords, nords, parent), + .text = text.p, + .len = text.n, + .first_line = first, + .start_line = start, + .end_line = ts_node_end_point(dir).row + SKIP_ONE, + }; + dg_build(ctx, &src); +} + +static void dg_rst(CBMExtractCtx *ctx) { + dg_ordinal_t *ords = + (dg_ordinal_t *)cbm_arena_alloc(ctx->arena, DG_PARENTS_MAX * sizeof(dg_ordinal_t)); + if (!ords) { + return; + } + int nords = 0; + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (bool more = true; more;) { + TSNode n = ts_tree_cursor_current_node(&cur); + if (dg_kind(n, "directive")) { + dg_rst_directive(ctx, n, ords, &nords); + } else if (ts_tree_cursor_goto_first_child(&cur)) { + continue; + } + while (!ts_tree_cursor_goto_next_sibling(&cur)) { + if (!ts_tree_cursor_goto_parent(&cur)) { + more = false; + break; + } + } + } + ts_tree_cursor_delete(&cur); +} + +/* An AsciiDoc block attribute line `[plantuml, target=x]` or + * `[source,mermaid]`: the diagram format it names, NULL for none. */ +static const char *dg_adoc_style(dg_str_t ln) { + if (ln.n < CBM_SZ_3 || ln.p[0] != '[' || ln.p[ln.n - SKIP_ONE] != ']' || + ln.p[SKIP_ONE] == '[') { + return NULL; + } + dg_str_t attrs = dg_s(ln.p + SKIP_ONE, ln.n - PAIR_LEN); + const char *comma = memchr(attrs.p, ',', attrs.n); + dg_str_t style = dg_trim(dg_s(attrs.p, comma ? (size_t)(comma - attrs.p) : attrs.n)); + if (dg_eq(style, "source") && comma) { /* a listing in a diagram language */ + dg_str_t rest = dg_s(comma + SKIP_ONE, (size_t)(attrs.p + attrs.n - comma) - SKIP_ONE); + const char *c2 = memchr(rest.p, ',', rest.n); + style = dg_trim(dg_s(rest.p, c2 ? (size_t)(c2 - rest.p) : rest.n)); + } + const char *wrap = NULL; + const char *format = dg_block_format(style, &wrap); + return wrap ? NULL : format; +} + +/* `----` or `....` (four or more): a listing or literal block delimiter. */ +static bool dg_adoc_delimiter(dg_str_t ln) { + if (ln.n < CBM_SZ_4 || (ln.p[0] != '-' && ln.p[0] != '.')) { + return false; + } + for (size_t i = SKIP_ONE; i < ln.n; i++) { + if (ln.p[i] != ln.p[0]) { + return false; + } + } + return true; +} + +/* AsciiDoc diagram blocks (Asciidoctor Diagram): the delimited block after a + * `[mermaid]`, `[plantuml]`, `[graphviz]` or `[d2]` line, titled by the + * `.Title` line before it. */ +static void dg_asciidoc(CBMExtractCtx *ctx) { + dg_ordinal_t *ords = + (dg_ordinal_t *)cbm_arena_alloc(ctx->arena, DG_PARENTS_MAX * sizeof(dg_ordinal_t)); + if (!ords) { + return; + } + int nords = 0; + dg_lines_t it = {ctx->source, ctx->source + ctx->source_len, SKIP_ONE}; + dg_str_t title = dg_s(NULL, 0); + dg_str_t ln; + uint32_t line = 0; + while (dg_next_line(&it, &ln, &line)) { + dg_str_t t = dg_trim(ln); + if (t.n > SKIP_ONE && t.p[0] == '.' && t.p[SKIP_ONE] != '.' && + !isspace((unsigned char)t.p[SKIP_ONE])) { + title = dg_s(t.p + SKIP_ONE, t.n - SKIP_ONE); + continue; + } + const char *format = dg_adoc_style(t); + dg_str_t delim; + uint32_t attr_line = line; + if (!format || !dg_next_line(&it, &delim, &line) || !dg_adoc_delimiter(dg_trim(delim))) { + title = t.n && t.p[0] == '[' ? title : dg_s(NULL, 0); /* attribute lines stack */ + continue; + } + delim = dg_trim(delim); + const char *body = it.p; + uint32_t first = it.line; + const char *stop = it.end; + uint32_t last = line; + while (dg_next_line(&it, &ln, &line)) { + dg_str_t c = dg_trim(ln); + if (c.n == delim.n && memcmp(c.p, delim.p, c.n) == 0) { + stop = ln.p; + last = line; + break; + } + } + const char *parent = dg_section_at(ctx, attr_line); + dg_source_t src = { + .format = format, + .title = title.n ? cbm_arena_strndup(ctx->arena, title.p, title.n) : NULL, + .parent_qn = parent, + .ordinal = dg_next_ordinal(ords, &nords, parent), + .text = body, + .len = (size_t)(stop - body), + .first_line = first, + .start_line = attr_line, + .end_line = last, + }; + dg_build(ctx, &src); + title = dg_s(NULL, 0); + } +} + +/* ── Diagram files ───────────────────────────────────────────────── */ + +enum { + DG_INFLATE_MAX = 64 * 1024 * 1024, /* a draw.io page; beyond it the page is skipped */ + DG_STARTUML_LEN = 9, /* @startuml */ + DG_DIAGRAM_CLOSE_LEN = 10, /* */ + DG_CONTENT_ATTR_LEN = 9, /* content=\" */ + DG_PAYLOAD_START_LEN = 22, /* */ + DG_PAYLOAD_VERSION_LEN = 21, /* "); + const char *stop = start ? strstr(start, "") : NULL; + if (!stop) { + return NULL; + } + const char *b = start + DG_PAYLOAD_START_LEN; + size_t bn = (size_t)(stop - b); + unsigned char *raw = (unsigned char *)cbm_arena_alloc(ctx->arena, bn + SKIP_ONE); + size_t rn = raw ? dg_base64(b, bn, raw, bn) : 0; + if (!rn) { + return NULL; + } + raw[rn] = '\0'; + const char *ver = strstr(ctx->source, "|calls| B(API)\n" /* 12 */ + " B --> C[(DB)] & D{Cache}\n" /* 13 */ + " B -. retries .-> E\n" /* 14 */ + " subgraph S [Backend]\n" /* 15 */ + " end\n" /* 16 */ + "```\n" /* 17 */ + "\n" /* 18 */ + "```mermaid\n" /* 19 */ + "sequenceDiagram\n" /* 20 */ + " participant U as User\n" /* 21 */ + " U->>API: login\n" /* 22 */ + " API-->>U: token\n" /* 23 */ + "```\n" /* 24 */ + "\n" /* 25 */ + "## Model\n" /* 26 */ + "\n" /* 27 */ + "```mermaid\n" /* 28 */ + "classDiagram\n" /* 29 */ + " Animal <|-- Dog : is a\n" /* 30 */ + " class Shop[\"Pet shop\"]\n" /* 31 */ + " Shop *-- Dog\n" /* 32 */ + "```\n"; /* 33 */ + +static const CBMDefinition *dg_def(const CBMFileResult *r, const char *qn_suffix) { + size_t sl = strlen(qn_suffix); + for (int i = 0; i < r->defs.count; i++) { + const char *qn = r->defs.items[i].qualified_name; + size_t ql = qn ? strlen(qn) : 0; + if (ql >= sl && strcmp(qn + ql - sl, qn_suffix) == 0) { + return &r->defs.items[i]; + } + } + printf(" no definition *%s\n", qn_suffix); + return NULL; +} + +static const char *dg_prop(const CBMDefinition *d, const char *key) { + for (const char **kv = d->extra_props; kv && kv[0]; kv += 2) { + if (strcmp(kv[0], key) == 0) { + return kv[1]; + } + } + return ""; +} + +static int dg_arrows(const CBMFileResult *r, const char *from_suffix, const char *to_suffix, + const char *style, const char *label) { + int n = 0; + size_t fl = strlen(from_suffix); + size_t tl = strlen(to_suffix); + for (int i = 0; i < r->diagram_arrows.count; i++) { + const CBMDiagramArrow *a = &r->diagram_arrows.items[i]; + size_t al = strlen(a->from_qn); + size_t bl = strlen(a->to_qn); + if (al >= fl && strcmp(a->from_qn + al - fl, from_suffix) == 0 && bl >= tl && + strcmp(a->to_qn + bl - tl, to_suffix) == 0 && strcmp(a->style, style) == 0 && + strcmp(a->label ? a->label : "", label) == 0) { + n++; + } + } + if (n != 1) { + printf(" arrow %s -> %s [%s \"%s\"]: %d found\n", from_suffix, to_suffix, style, label, n); + } + return n; +} + +TEST(diagrams_markdown_mermaid) { + CBMFileResult *r = dm_extract(DG_DOC, CBM_LANG_MARKDOWN, "docs/arch.md"); + ASSERT_NOT_NULL(r); + /* a flowchart under its section, titled by its front matter */ + const CBMDefinition *d = dg_def(r, ".Arch#diagram-1"); + ASSERT_NOT_NULL(d); + ASSERT_STR_EQ(d->label, "Diagram"); + ASSERT_STR_EQ(d->name, "System overview"); + ASSERT_STR_EQ(dg_prop(d, "format"), "mermaid"); + ASSERT_STR_EQ(dg_prop(d, "diagram_kind"), "flowchart"); + ASSERT_NOT_NULL(d->parent_class); + ASSERT(strstr(d->parent_class, ".Arch") != NULL); + ASSERT(d->docstring && strstr(d->docstring, "Web app") && strstr(d->docstring, "calls")); + ASSERT_EQ(d->start_line, 7); + const CBMDefinition *a = dg_def(r, ".Arch#diagram-1.A"); + ASSERT_NOT_NULL(a); + ASSERT_STR_EQ(a->label, "DiagramElement"); + ASSERT_STR_EQ(a->name, "Web app"); + ASSERT_STR_EQ(a->parent_class, d->qualified_name); + ASSERT_STR_EQ(dg_prop(a, "element_id"), "A"); + ASSERT_STR_EQ(dg_def(r, ".Arch#diagram-1.C")->name, "DB"); + ASSERT_STR_EQ(dg_def(r, ".Arch#diagram-1.D")->name, "Cache"); + ASSERT_STR_EQ(dg_def(r, ".Arch#diagram-1.E")->name, "E"); /* named only by an arrow */ + ASSERT_STR_EQ(dg_def(r, ".Arch#diagram-1.S")->name, "Backend"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.A", "#diagram-1.B", "arrow", "calls"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.B", "#diagram-1.C", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.B", "#diagram-1.D", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.B", "#diagram-1.E", "dotted", "retries"), 1); + /* a second diagram of the section: a sequence */ + ASSERT_STR_EQ(dg_prop(dg_def(r, ".Arch#diagram-2"), "diagram_kind"), "sequence"); + ASSERT_STR_EQ(dg_def(r, ".Arch#diagram-2.U")->name, "User"); + ASSERT_EQ(dg_arrows(r, "#diagram-2.U", "#diagram-2.API", "message", "login"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-2.API", "#diagram-2.U", "reply", "token"), 1); + /* a class diagram under the next section: inheritance points at the parent */ + ASSERT_STR_EQ(dg_def(r, ".Model#diagram-1.Shop")->name, "Pet shop"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Dog", "#diagram-1.Animal", "inheritance", "is a"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Shop", "#diagram-1.Dog", "composition", ""), 1); + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_mermaid_kinds) { + CBMFileResult *r = dm_extract("erDiagram\n" + " CUSTOMER ||--o{ ORDER : places\n" + " ORDER ||..|{ ITEM : contains\n" + " CUSTOMER {\n" + " string name\n" + " }\n", + CBM_LANG_MERMAID, "diagrams/er.mmd"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_prop(dg_def(r, "er#diagram-1"), "diagram_kind"), "er"); + ASSERT_EQ(dg_arrows(r, ".CUSTOMER", ".ORDER", "identifying", "places"), 1); + ASSERT_EQ(dg_arrows(r, ".ORDER", ".ITEM", "non_identifying", "contains"), 1); + cbm_free_result(r); + + r = dm_extract("stateDiagram-v2\n" + " [*] --> Idle\n" + " Idle --> Busy : start\n" + " state \"Working hard\" as Busy\n" + " Busy --> [*]\n" + " state Busy {\n" + " [*] --> Step\n" + " }\n", + CBM_LANG_MERMAID, "diagrams/state.mmd"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_arrows(r, ".start", ".Idle", "transition", ""), 1); + ASSERT_EQ(dg_arrows(r, ".Idle", ".Busy", "transition", "start"), 1); + ASSERT_EQ(dg_arrows(r, ".Busy", ".end", "transition", ""), 1); + /* a composite state's own start; states named by arrows are states */ + ASSERT_EQ(dg_arrows(r, ".Busy_start", ".Step", "transition", ""), 1); + ASSERT_STR_EQ(dg_prop(dg_def(r, "state#diagram-1.Idle"), "element_kind"), "state"); + ASSERT_STR_EQ(dg_def(r, "state#diagram-1.Busy")->name, "Working hard"); + cbm_free_result(r); + + r = dm_extract("C4Context\n" + " title Bank\n" + " Person(cust, \"Customer\", \"A customer\")\n" + " System_Ext(mail, \"Mail system\")\n" + " Rel(cust, mail, \"Reads\", \"SMTP\")\n", + CBM_LANG_MERMAID, "diagrams/c4.mmd"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_prop(dg_def(r, "c4#diagram-1"), "diagram_kind"), "c4_context"); + ASSERT_STR_EQ(dg_def(r, "c4#diagram-1")->name, "Bank"); /* its title line */ + const CBMDefinition *cust = dg_def(r, "c4#diagram-1.cust"); + ASSERT_NOT_NULL(cust); + ASSERT_STR_EQ(cust->name, "Customer"); + ASSERT_STR_EQ(dg_prop(cust, "element_kind"), "person"); + ASSERT_STR_EQ(dg_prop(dg_def(r, "c4#diagram-1.mail"), "element_kind"), "system_ext"); + ASSERT_EQ(dg_arrows(r, ".cust", ".mail", "relation", "Reads"), 1); + cbm_free_result(r); + + r = dm_extract("architecture-beta\n" + " group api(cloud)[API]\n" + " service db(database)[Database] in api\n" + " service server(server)[Server] in api\n" + " db:L -- R:server\n" + " server:T --> B:db\n", + CBM_LANG_MERMAID, "diagrams/arch.mmd"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "arch#diagram-1.db")->name, "Database"); + ASSERT_STR_EQ(dg_prop(dg_def(r, "arch#diagram-1.api"), "element_kind"), "group"); + ASSERT_EQ(dg_arrows(r, ".db", ".server", "line", ""), 1); + ASSERT_EQ(dg_arrows(r, ".server", ".db", "arrow", ""), 1); + cbm_free_result(r); + + /* a kind without elements is a Diagram all the same */ + r = dm_extract("pie title Pets\n \"Dogs\" : 386\n", CBM_LANG_MERMAID, "diagrams/pie.mmd"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_prop(dg_def(r, "pie#diagram-1"), "diagram_kind"), "pie"); + ASSERT_STR_EQ(dg_def(r, "pie#diagram-1")->name, "Pets"); + ASSERT_EQ(r->diagram_arrows.count, 0); + cbm_free_result(r); + PASS(); +} + +/* A fenced block of another language is no diagram. */ +TEST(diagrams_other_fences) { + CBMFileResult *r = dm_extract("# T\n\n```js\nA --> B\n```\n", CBM_LANG_MARKDOWN, "a.md"); + ASSERT_NOT_NULL(r); + for (int i = 0; i < r->defs.count; i++) { + ASSERT(strcmp(r->defs.items[i].label, "Diagram") != 0); + } + ASSERT_EQ(r->diagram_arrows.count, 0); + cbm_free_result(r); + PASS(); +} + +/* ── the other formats ───────────────────────────────────────────── */ + +static CBMFileResult *dg_file(const char *src, const char *rel) { + return dm_extract(src, CBM_LANG_DIAGRAM, rel); +} + +TEST(diagrams_plantuml) { + CBMFileResult *r = dg_file("@startuml Components\n" + "[Web] --> [API] : REST\n" + "component \"Billing\" as B\n" + "API ..> B\n" + "@enduml\n" + "@startuml\n" + "class Animal\n" + "class Dog {\n" + " +bark()\n" + "}\n" + "Animal <|-- Dog\n" + "Dog \"1\" *-- \"many\" Leg : has\n" + "Dog +-- Tail\n" + "@enduml\n", + "docs/model.puml"); + ASSERT_NOT_NULL(r); + const CBMDefinition *d = dg_def(r, "model#diagram-1"); + ASSERT_NOT_NULL(d); + ASSERT_STR_EQ(d->name, "Components"); + ASSERT_STR_EQ(dg_prop(d, "format"), "plantuml"); + ASSERT_STR_EQ(dg_prop(d, "diagram_kind"), "component"); + ASSERT_STR_EQ(dg_def(r, "model#diagram-1.B")->name, "Billing"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Web", "#diagram-1.API", "arrow", "REST"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.API", "#diagram-1.B", "dependency", ""), 1); + ASSERT_STR_EQ(dg_prop(dg_def(r, "model#diagram-2"), "diagram_kind"), "class"); + /* classes in packages: a class diagram, whatever line comes first */ + CBMFileResult *pk = dg_file("@startuml\npackage app {\n class A\n interface B\n}\n" + "A ..|> B\n@enduml\n", + "docs/pk.puml"); + ASSERT_STR_EQ(dg_prop(dg_def(pk, "pk#diagram-1"), "diagram_kind"), "class"); + ASSERT_STR_EQ(dg_def(pk, "pk#diagram-1")->name, "pk"); /* untitled: the file */ + cbm_free_result(pk); + ASSERT_EQ(dg_arrows(r, "#diagram-2.Dog", "#diagram-2.Animal", "inheritance", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-2.Dog", "#diagram-2.Leg", "composition", "has"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-2.Tail", "#diagram-2.Dog", "nesting", ""), 1); + cbm_free_result(r); + + /* a sequence in a Markdown block */ + r = dm_extract("# Flow\n\n```plantuml\n@startuml\nparticipant Alice\nactor Bob as B\n" + "Alice -> B : hello\nB --> Alice : hi\n@enduml\n```\n", + CBM_LANG_MARKDOWN, "docs/flow.md"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Alice", "#diagram-1.B", "message", "hello"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.B", "#diagram-1.Alice", "reply", "hi"), 1); + ASSERT_STR_EQ(dg_def(r, "Flow#diagram-1.B")->name, "Bob"); + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_dot) { + CBMFileResult *r = + dg_file("digraph G {\n" + " label=\"Pipeline\";\n" + " // a comment -> nowhere\n" + " a [label=\"Parse\"];\n" + " b [label=\"Resolve\"];\n" + " a -> b -> c [label=\"next\"];\n" + " subgraph cluster_x { d; e; }\n" + " c -> {d e};\n" + " \"Read every verification output in full before claiming that the step passed, " + "then record the result in the ledger with its timestamp\" -> b;\n" + " y [label=\"two\\nlines\"];\n" + "}\n", + "docs/pipe.dot"); + ASSERT_NOT_NULL(r); + const CBMDefinition *d = dg_def(r, "pipe#diagram-1"); + ASSERT_NOT_NULL(d); + ASSERT_STR_EQ(d->name, "Pipeline"); + ASSERT_STR_EQ(dg_prop(d, "diagram_kind"), "digraph"); + ASSERT_STR_EQ(dg_def(r, "pipe#diagram-1.a")->name, "Parse"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.a", "#diagram-1.b", "arrow", "next"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.b", "#diagram-1.c", "arrow", "next"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.c", "#diagram-1.d", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.c", "#diagram-1.e", "arrow", ""), 1); + ASSERT_EQ(r->diagram_arrows.count, 5); + /* a node named by a sentence too long for an id: kept, under a hashed key */ + int longs = 0; + for (int i = 0; i < r->diagram_arrows.count; i++) { + const CBMDiagramArrow *a = &r->diagram_arrows.items[i]; + longs += strstr(a->from_qn, "#diagram-1.Read_every_verification") && + strchr(a->from_qn, '~') && strstr(a->to_qn, "#diagram-1.b"); + } + ASSERT_EQ(longs, 1); + ASSERT_STR_EQ(dg_def(r, "pipe#diagram-1.y")->name, "two lines"); + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_d2) { + CBMFileResult *r = dg_file("server: Web Server\n" + "db: Database {\n" + " shape: cylinder\n" + "}\n" + "server -> db: queries\n" + "cloud: {\n" + " api -> worker: jobs\n" + "}\n", + "docs/sys.d2"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "sys#diagram-1.server")->name, "Web Server"); + ASSERT_STR_EQ(dg_def(r, "sys#diagram-1.db")->name, "Database"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.server", "#diagram-1.db", "arrow", "queries"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.cloud_api", "#diagram-1.cloud_worker", "arrow", "jobs"), 1); + ASSERT_EQ(r->diagram_arrows.count, 2); + cbm_free_result(r); + PASS(); +} + +static const char *const DG_DRAWIO_PAGE = + "" + "" + "" + "" + "" + "" + ""; + +static char *dg_b64(const unsigned char *in, size_t dn) { + static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + char *out = malloc(dn * 4 / 3 + 8); + size_t o = 0; + for (size_t i = 0; i < dn; i += 3) { + uint32_t v = (uint32_t)in[i] << 16; + v |= i + 1 < dn ? (uint32_t)in[i + 1] << 8 : 0; + v |= i + 2 < dn ? in[i + 2] : 0; + out[o++] = b64[(v >> 18) & 63]; + out[o++] = b64[(v >> 12) & 63]; + out[o++] = i + 1 < dn ? b64[(v >> 6) & 63] : '='; + out[o++] = i + 2 < dn ? b64[v & 63] : '='; + } + out[o] = '\0'; + return out; +} + +/* A draw.io page as the app stores it compressed: URI-encoded, raw deflate, + * base64. */ +static char *dg_drawio_compress(const char *xml) { + size_t n = strlen(xml); + char *enc = malloc(n * 3 + 1); + size_t w = 0; + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)xml[i]; + if (isalnum(c) || strchr("-_.!~*'()", c)) { + enc[w++] = (char)c; + } else { + w += (size_t)snprintf(enc + w, n * 3 + 1 - w, "%%%02X", c); + } + } + unsigned char *def = malloc(w + 64); + z_stream z; + memset(&z, 0, sizeof(z)); + deflateInit2(&z, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -MAX_WBITS, 8, Z_DEFAULT_STRATEGY); + z.next_in = (Bytef *)enc; + z.avail_in = (uInt)w; + z.next_out = def; + z.avail_out = (uInt)(w + 64); + deflate(&z, Z_FINISH); + size_t dn = z.total_out; + deflateEnd(&z); + char *out = dg_b64(def, dn); + free(enc); + free(def); + return out; +} + +TEST(diagrams_drawio) { + char file[4096]; + snprintf(file, sizeof(file), + "%s", DG_DRAWIO_PAGE); + CBMFileResult *r = dg_file(file, "docs/sys.drawio"); + ASSERT_NOT_NULL(r); + const CBMDefinition *d = dg_def(r, "sys#diagram-1"); + ASSERT_NOT_NULL(d); + ASSERT_STR_EQ(d->name, "Overview"); + ASSERT_STR_EQ(dg_def(r, "sys#diagram-1.2")->name, "Frontend"); + const CBMDefinition *b = dg_def(r, "sys#diagram-1.3"); + ASSERT_STR_EQ(b->name, "Backend"); /* its HTML dropped */ + ASSERT_STR_EQ(dg_prop(b, "element_kind"), "cylinder3"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.2", "#diagram-1.3", "arrow", "calls"), 1); + ASSERT_EQ(r->diagram_arrows.count, 1); /* the edge to a shape without text is none */ + cbm_free_result(r); + + /* the compressed form, two pages */ + char *packed = dg_drawio_compress(DG_DRAWIO_PAGE); + size_t big_cap = strlen(packed) * 2 + 256; + char *big = malloc(big_cap); + snprintf(big, big_cap, + "%s%s", + packed, packed); + r = dg_file(big, "docs/two.drawio"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "two#diagram-1")->name, "A"); + ASSERT_STR_EQ(dg_def(r, "two#diagram-2")->name, "B"); + ASSERT_EQ(dg_arrows(r, "#diagram-2.2", "#diagram-2.3", "arrow", "calls"), 1); + cbm_free_result(r); + free(big); + free(packed); + + /* draw.io's editable SVG keeps the file in its content attribute */ + r = dg_file("", + "docs/edit.drawio.svg"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "#diagram-1")->name, "S"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.2", "#diagram-1.3", "arrow", ""), 1); + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_excalidraw) { + CBMFileResult *r = + dg_file("{\"type\":\"excalidraw\",\"elements\":[" + "{\"id\":\"r1\",\"type\":\"rectangle\",\"isDeleted\":false}," + "{\"id\":\"t1\",\"type\":\"text\",\"text\":\"User\",\"containerId\":\"r1\"}," + "{\"id\":\"r2\",\"type\":\"ellipse\"}," + "{\"id\":\"t2\",\"type\":\"text\",\"text\":\"Service\",\"containerId\":\"r2\"}," + "{\"id\":\"a1\",\"type\":\"arrow\",\"startBinding\":{\"elementId\":\"r1\"}," + "\"endBinding\":{\"elementId\":\"r2\"}}," + "{\"id\":\"t3\",\"type\":\"text\",\"text\":\"calls\",\"containerId\":\"a1\"}," + "{\"id\":\"r3\",\"type\":\"rectangle\",\"isDeleted\":true}," + "{\"id\":\"t5\",\"type\":\"text\",\"text\":\"Gone\",\"containerId\":\"r3\"," + "\"isDeleted\":true}," + "{\"id\":\"t4\",\"type\":\"text\",\"text\":\"Note here\"}]}", + "docs/flow.excalidraw"); + ASSERT_NOT_NULL(r); + const CBMDefinition *d = dg_def(r, "flow#diagram-1"); + ASSERT_NOT_NULL(d); + ASSERT(d->docstring && strstr(d->docstring, "Note here")); + ASSERT_STR_EQ(dg_def(r, "flow#diagram-1.r1")->name, "User"); + ASSERT_STR_EQ(dg_prop(dg_def(r, "flow#diagram-1.r2"), "element_kind"), "ellipse"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.r1", "#diagram-1.r2", "arrow", "calls"), 1); + for (int i = 0; i < r->defs.count; i++) { + ASSERT(!strstr(r->defs.items[i].qualified_name, ".r3")); + } + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_d2_blocks) { + /* class and style blocks around containers (their braces once restored + * the path from an unset level), block-string labels, dotted attributes */ + CBMFileResult *r = dg_file("direction: down\n" + "classes: {\n" + " card: {\n" + " shape: text\n" + " }\n" + "}\n" + "label: Hierarchy\n" + "consumers: \"\" {\n" + " style: {\n" + " stroke-dash: 4\n" + " }\n" + " app: ||md\n" + " App\n" + " Cross-platform: everywhere\n" + " || {\n" + " class: card\n" + " }\n" + " cli: |md # CLI|\n" + "}\n" + "core.style.fill: red\n" + "core.label: Core\n" + "consumers.app -> core: |md uses|\n" + "consumers.cli -- core\n", + "docs/deps.d2"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "deps#diagram-1")->name, "Hierarchy"); + ASSERT_STR_EQ(dg_def(r, "deps#diagram-1.consumers_app")->name, "App"); + ASSERT_STR_EQ(dg_def(r, "deps#diagram-1.consumers_cli")->name, "CLI"); + ASSERT_STR_EQ(dg_def(r, "deps#diagram-1.core")->name, "Core"); + const char *doc = dg_def(r, "deps#diagram-1")->docstring; + ASSERT(doc && strstr(doc, "Cross-platform: everywhere")); + ASSERT_EQ(dg_arrows(r, "#diagram-1.consumers_app", "#diagram-1.core", "arrow", "uses"), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.consumers_cli", "#diagram-1.core", "line", ""), 1); + ASSERT_EQ(r->diagram_arrows.count, 2); + for (int i = 0; i < r->defs.count; i++) { /* no attribute or block text became a shape */ + const char *qn = r->defs.items[i].qualified_name; + ASSERT(!strstr(qn, "style") && !strstr(qn, "card") && !strstr(qn, "class") && + !strstr(qn, "Cross")); + } + cbm_free_result(r); + PASS(); +} + +TEST(diagrams_excalidraw_layout) { + /* text laid over shapes and arrows drawn up to them, nothing bound */ + CBMFileResult *r = dg_file( + "{\"type\":\"excalidraw\",\"elements\":[" + "{\"id\":\"f1\",\"type\":\"rectangle\",\"x\":0,\"y\":0,\"width\":900,\"height\":400}," + "{\"id\":\"t0\",\"type\":\"text\",\"text\":\"System\",\"x\":10,\"y\":5,\"width\":60," + "\"height\":20}," + "{\"id\":\"r1\",\"type\":\"rectangle\",\"x\":20,\"y\":50,\"width\":100,\"height\":60}," + "{\"id\":\"t2\",\"type\":\"text\",\"text\":\"browser\",\"x\":30,\"y\":95,\"width\":40," + "\"height\":10}," + "{\"id\":\"t1\",\"type\":\"text\",\"text\":\"Client\",\"x\":40,\"y\":70,\"width\":60," + "\"height\":20}," + "{\"id\":\"r2\",\"type\":\"ellipse\",\"x\":250,\"y\":50,\"width\":100,\"height\":60}," + "{\"id\":\"t3\",\"type\":\"text\",\"text\":\"Server\",\"x\":270,\"y\":70,\"width\":60," + "\"height\":20}," + "{\"id\":\"a1\",\"type\":\"arrow\",\"x\":122,\"y\":80,\"points\":[[0,0],[126,0]]}," + "{\"id\":\"l1\",\"type\":\"line\",\"x\":122,\"y\":90,\"points\":[[0,0],[126,0]]}," + "{\"id\":\"a2\",\"type\":\"arrow\",\"x\":30,\"y\":60,\"points\":[[0,0],[70,40]]}," + "{\"id\":\"a3\",\"type\":\"arrow\",\"x\":122,\"y\":200,\"points\":[[0,0],[500,400]]}," + /* from r2 deep into the frame around it all: at nothing named */ + "{\"id\":\"a4\",\"type\":\"arrow\",\"x\":300,\"y\":112,\"points\":[[0,0],[0,90]]}," + /* from r1 to the frame's border: at the frame */ + "{\"id\":\"a5\",\"type\":\"arrow\",\"x\":70,\"y\":112,\"points\":[[0,0],[0,284]]}," + /* two lanes, each with a named badge: an end deep in one is at the lane */ + "{\"id\":\"L1\",\"type\":\"rectangle\",\"x\":500,\"y\":0,\"width\":100,\"height\":300}," + "{\"id\":\"tl1\",\"type\":\"text\",\"text\":\"Lane A\",\"x\":520,\"y\":10,\"width\":60," + "\"height\":20}," + "{\"id\":\"b1\",\"type\":\"ellipse\",\"x\":540,\"y\":200,\"width\":20,\"height\":20}," + "{\"id\":\"tb1\",\"type\":\"text\",\"text\":\"1\",\"containerId\":\"b1\"}," + "{\"id\":\"L2\",\"type\":\"rectangle\",\"x\":700,\"y\":0,\"width\":100,\"height\":300}," + "{\"id\":\"tl2\",\"type\":\"text\",\"text\":\"Lane B\",\"x\":720,\"y\":10,\"width\":60," + "\"height\":20}," + "{\"id\":\"b2\",\"type\":\"rectangle\",\"x\":745,\"y\":150,\"width\":10,\"height\":40}," + "{\"id\":\"a6\",\"type\":\"arrow\",\"x\":555,\"y\":120,\"points\":[[0,0],[195,0]]}," + /* bound to the text that names r1: at r1 */ + "{\"id\":\"a7\",\"type\":\"arrow\",\"startBinding\":{\"elementId\":\"r2\"}," + "\"endBinding\":{\"elementId\":\"t1\"}}," + /* bound to free text that names no shape: at the text */ + "{\"id\":\"t9\",\"type\":\"text\",\"text\":\"Note\",\"x\":440,\"y\":350,\"width\":40," + "\"height\":20}," + "{\"id\":\"a8\",\"type\":\"arrow\",\"startBinding\":{\"elementId\":\"L2\"}," + "\"endBinding\":{\"elementId\":\"t9\"}}]}", + "docs/layout.excalidraw"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "layout#diagram-1.f1")->name, "System"); + ASSERT_STR_EQ(dg_def(r, "layout#diagram-1.r1")->name, + "Client"); /* the top text, not the frame */ + ASSERT_STR_EQ(dg_def(r, "layout#diagram-1.r2")->name, "Server"); + const char *doc = dg_def(r, "layout#diagram-1")->docstring; + ASSERT(doc && strstr(doc, "browser")); + ASSERT_EQ(dg_arrows(r, "#diagram-1.r1", "#diagram-1.r2", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.r1", "#diagram-1.f1", "arrow", ""), 1); + /* the loose line, the loop inside r1, the dangling end and the end deep in + * the frame are no arrows */ + ASSERT_EQ(dg_arrows(r, "#diagram-1.L1", "#diagram-1.L2", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.r2", "#diagram-1.r1", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.L2", "#diagram-1.t9", "arrow", ""), 1); + ASSERT_STR_EQ(dg_prop(dg_def(r, "layout#diagram-1.t9"), "element_kind"), "text"); + ASSERT_EQ(r->diagram_arrows.count, 5); + ASSERT_STR_EQ(dg_def(r, "layout#diagram-1")->name, "layout"); /* untitled: the file */ + cbm_free_result(r); + PASS(); +} + +/* An Excalidraw SVG export as the app writes it: the scene zlib-compressed, + * wrapped in JSON as a byte string, base64 in a payload comment. */ +static char *dg_excalidraw_svg(const char *scene) { + uLongf zn = compressBound((uLong)strlen(scene)); + unsigned char *z = malloc(zn); + compress2(z, &zn, (const Bytef *)scene, (uLong)strlen(scene), Z_DEFAULT_COMPRESSION); + size_t cap = zn * 6 + 128; + char *json = malloc(cap); + size_t w = (size_t)snprintf(json, cap, + "{\"version\":\"1\",\"encoding\":\"bstring\",\"compressed\":true," + "\"encoded\":\""); + for (uLongf i = 0; i < zn; i++) { + unsigned char b = z[i]; + if (b == '"' || b == '\\') { + json[w++] = '\\'; + json[w++] = (char)b; + } else if (b < 0x20) { + w += (size_t)snprintf(json + w, cap - w, "\\u%04x", b); + } else { + json[w++] = (char)b; /* a byte as one character: the wrapper is no UTF-8 */ + } + } + w += (size_t)snprintf(json + w, cap - w, "\"}"); + char *b64 = dg_b64((const unsigned char *)json, w); + size_t sc = strlen(b64) + 512; + char *svg = malloc(sc); + snprintf(svg, sc, + "\n" + " \n" + " " + "%s\n" + " \n\n", + b64); + free(b64); + free(json); + free(z); + return svg; +} + +TEST(diagrams_excalidraw_svg) { + char *svg = dg_excalidraw_svg( + "{\"type\":\"excalidraw\",\"elements\":[" + "{\"id\":\"r1\",\"type\":\"rectangle\"}," + "{\"id\":\"t1\",\"type\":\"text\",\"text\":\"Queue \xc3\xa9v\xc3\xa9nements\"," + "\"containerId\":\"r1\"}," + "{\"id\":\"r2\",\"type\":\"rectangle\"}," + "{\"id\":\"t2\",\"type\":\"text\",\"text\":\"Worker\",\"containerId\":\"r2\"}," + "{\"id\":\"a1\",\"type\":\"arrow\",\"startBinding\":{\"elementId\":\"r1\"}," + "\"endBinding\":{\"elementId\":\"r2\"}}]}"); + CBMFileResult *r = dg_file(svg, "docs/flow.excalidraw.svg"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_def(r, "#diagram-1.r1")->name, "Queue \xc3\xa9v\xc3\xa9nements"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.r1", "#diagram-1.r2", "arrow", ""), 1); + cbm_free_result(r); + free(svg); + PASS(); +} + +TEST(diagrams_rst) { + CBMFileResult *r = dm_extract("Guide\n" + "=====\n" + "\n" + ".. mermaid::\n" + " :caption: Request flow\n" + "\n" + " flowchart LR\n" + " A --> B\n" + "\n" + ".. digraph:: deps\n" + "\n" + " core -> util;\n" + " app -> core;\n" + "\n" + ".. graphviz:: other.dot\n" + "\n" + ".. uml::\n" + "\n" + " @startuml\n" + " [Web] --> [API]\n" + " @enduml\n", + CBM_LANG_RST, "docs/guide.rst"); + ASSERT_NOT_NULL(r); + const CBMDefinition *m = dg_def(r, "guide#diagram-1"); + ASSERT_NOT_NULL(m); + ASSERT_STR_EQ(m->name, "Request flow"); + ASSERT_STR_EQ(dg_prop(m, "format"), "mermaid"); + ASSERT_EQ(dg_arrows(r, "guide#diagram-1.A", "guide#diagram-1.B", "arrow", ""), 1); + /* Sphinx's digraph: the statements inside the graph it names */ + ASSERT_STR_EQ(dg_prop(dg_def(r, "guide#diagram-2"), "format"), "dot"); + ASSERT_EQ(dg_arrows(r, "guide#diagram-2.core", "guide#diagram-2.util", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "guide#diagram-2.app", "guide#diagram-2.core", "arrow", ""), 1); + /* a graphviz file argument is no diagram here (the file is one itself) */ + ASSERT_STR_EQ(dg_prop(dg_def(r, "guide#diagram-3"), "format"), "plantuml"); + ASSERT_EQ(dg_arrows(r, "guide#diagram-3.Web", "guide#diagram-3.API", "arrow", ""), 1); + ASSERT_NULL(dg_def(r, "guide#diagram-4")); + cbm_free_result(r); + PASS(); +} + +/* The Diagram named `name`, NULL for none. */ +static const CBMDefinition *dg_named(const CBMFileResult *r, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->label && strcmp(d->label, "Diagram") == 0 && d->name && strcmp(d->name, name) == 0) { + return d; + } + } + return NULL; +} + +TEST(diagrams_asciidoc) { + CBMFileResult *r = dm_extract("= Guide\n" + "\n" + "== Flow\n" + "\n" + ".Login sequence\n" + "[plantuml, target=login, format=svg]\n" + "----\n" + "participant Alice\n" + "Alice -> Bob : hello\n" + "----\n" + "\n" + ".Steps\n" + "[source,mermaid]\n" + "....\n" + "flowchart TD\n" + " X --> Y\n" + "....\n" + "\n" + "[source,python]\n" + "----\n" + "a -> b\n" + "----\n" + "\n" + "[graphviz]\n" + "----\n" + "digraph { a -> b }\n" + "----\n", + CBM_LANG_ASCIIDOC, "docs/guide.adoc"); + ASSERT_NOT_NULL(r); + const CBMDefinition *p = dg_named(r, "Login sequence"); + ASSERT_NOT_NULL(p); + ASSERT_NOT_NULL(p->parent_class); /* under its Section */ + ASSERT(strncmp(p->qualified_name, p->parent_class, strlen(p->parent_class)) == 0); + ASSERT_STR_EQ(dg_prop(p, "diagram_kind"), "sequence"); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Alice", "#diagram-1.Bob", "message", "hello"), 1); + ASSERT_STR_EQ(dg_prop(dg_named(r, "Steps"), "format"), "mermaid"); + ASSERT_EQ(dg_arrows(r, "#diagram-2.X", "#diagram-2.Y", "arrow", ""), 1); + ASSERT_EQ(dg_arrows(r, "#diagram-3.a", "#diagram-3.b", "arrow", ""), 1); /* not python */ + ASSERT_EQ(r->diagram_arrows.count, 3); + cbm_free_result(r); + PASS(); +} + +/* The doc-link records of one syntax whose source ends in `src` and whose + * reference is `raw`. */ +static int dg_links(const CBMFileResult *r, int syntax, const char *src, const char *raw) { + int n = 0; + size_t sl = strlen(src); + for (int i = 0; i < r->doc_links.count; i++) { + const CBMDocLink *l = &r->doc_links.items[i]; + size_t ql = strlen(l->source_qn); + n += l->syntax == syntax && ql >= sl && strcmp(l->source_qn + ql - sl, src) == 0 && + strcmp(l->raw, raw) == 0; + } + if (n != 1) { + printf(" link %d %s -> %s: %d found\n", syntax, src, raw, n); + } + return n; +} + +TEST(diagrams_links) { + CBMFileResult *r = + dm_extract("# Flow\n\n```mermaid\nflowchart LR\n A[Api] --> B\n" + " click A \"src/api.py\" \"the API\"\n click B call cb()\n```\n", + CBM_LANG_MARKDOWN, "docs/flow.md"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_MERMAID_CLICK, "#diagram-1.A", "src/api.py"), 1); + ASSERT_EQ(r->doc_links.count, 1); /* a callback links nothing */ + cbm_free_result(r); + + r = dg_file("@startuml\n!include common.iuml\n!include \n" + "class Foo [[src/foo.py]]\nclass Bar\nurl of Bar is [[docs/bar.md{the bar}]]\n" + "Foo --> Bar\n@enduml\n", + "docs/m.puml"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_PLANTUML_INCLUDE, "m#diagram-1", "common.iuml"), 1); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_PLANTUML_URL, "#diagram-1.Foo", "src/foo.py"), 1); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_PLANTUML_URL, "#diagram-1.Bar", "docs/bar.md"), 1); + ASSERT_EQ(r->doc_links.count, 3); /* the standard library is none of the repo's */ + cbm_free_result(r); + + r = dg_file("digraph { a [label=\"A\", URL=\"src/a.c\"]; b [href=\"#top\"]; a -> b; }\n", + "docs/g.dot"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_DOT_URL, "#diagram-1.a", "src/a.c"), 1); + ASSERT_EQ(r->doc_links.count, 1); /* an anchor links nothing */ + cbm_free_result(r); + + r = dg_file("x: X\nx.link: src/x.go\ny: Y {\n link: https://example.com/y\n}\n", "docs/d.d2"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_D2_LINK, "#diagram-1.x", "src/x.go"), 1); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_D2_LINK, "#diagram-1.y", "https://example.com/y"), 1); + cbm_free_result(r); + + r = dg_file("" + "" + "" + "", + "docs/s.drawio"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_DRAWIO_LINK, "#diagram-1.7", "src/svc.py"), 1); + ASSERT_EQ(r->doc_links.count, 1); /* a page link links nothing of the repo */ + cbm_free_result(r); + + r = dg_file("{\"type\":\"excalidraw\",\"elements\":[" + "{\"id\":\"r1\",\"type\":\"rectangle\",\"link\":\"src/e.ts\"}," + "{\"id\":\"t1\",\"type\":\"text\",\"text\":\"E\",\"containerId\":\"r1\"}," + "{\"id\":\"r2\",\"type\":\"rectangle\",\"link\":null}]}", + "docs/e.excalidraw"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(dg_links(r, CBM_DOCLINK_DG_EXCALIDRAW_LINK, "#diagram-1.r1", "src/e.ts"), 1); + ASSERT_EQ(r->doc_links.count, 1); + cbm_free_result(r); + PASS(); +} + +static int dg_db_edges(const char *db, const char *type, const char *src_suffix, + const char *tgt_suffix, const char *props_part); + +TEST(diagrams_links_graph) { + /* the families are held until their audit: shipped here */ + dm_ship_held_families(); + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dglinks_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(db, sizeof(db), "%s/g.db", tmp); + th_write_file(TH_PATH(repo, "src/api.py"), "def handle():\n return 1\n"); + th_write_file(TH_PATH(repo, "diagrams/a.puml"), + "@startuml\nclass Api [[../src/api.py]]\nclass Db\nApi --> Db\n@enduml\n"); + ASSERT_EQ(dm_index(repo, db, NULL), 0); + /* the element names the file it links to */ + ASSERT_EQ(dg_db_edges(db, "MENTIONS", "a#diagram-1.Api", "", NULL), 1); + th_cleanup(tmp); + cbm_doclink_test_reset_ships(); + PASS(); +} + +/* ── the graph ───────────────────────────────────────────────────── */ + +static int dg_db_edges(const char *db, const char *type, const char *src_suffix, + const char *tgt_suffix, const char *props_part) { + sqlite3 *h = NULL; + int n = 0; + if (sqlite3_open_v2(db, &h, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(h); + return -1; + } + sqlite3_stmt *st = NULL; + const char *sql = "SELECT e.properties FROM edges e JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id WHERE e.type = ?1 " + "AND s.qualified_name LIKE '%' || ?2 AND t.qualified_name LIKE '%' || ?3"; + if (sqlite3_prepare_v2(h, sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, type, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, src_suffix, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, tgt_suffix, -1, SQLITE_TRANSIENT); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *p = (const char *)sqlite3_column_text(st, 0); + n += !props_part || (p && strstr(p, props_part)); + } + } + sqlite3_finalize(st); + sqlite3_close(h); + if (n != 1) { + printf(" %s %s -> %s (%s): %d found\n", type, src_suffix, tgt_suffix, + props_part ? props_part : "", n); + } + return n; +} + +TEST(diagrams_graph) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dg_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(db, sizeof(db), "%s/g.db", tmp); + th_write_file(TH_PATH(repo, "docs/arch.md"), DG_DOC); + th_write_file(TH_PATH(repo, "diagrams/flow.mmd"), "flowchart TD\n X[Start] --> Y[Stop]\n"); + th_write_file(TH_PATH(repo, "diagrams/c.puml"), "@startuml\n[A] --> [B]\n@enduml\n"); + /* an image by its last extension, a diagram by its name */ + th_write_file(TH_PATH(repo, "diagrams/e.drawio.svg"), + "\n"); + ASSERT_EQ(dm_index(repo, db, NULL), 0); + /* containment: the section holds the diagram, the diagram its elements */ + ASSERT_EQ(dg_db_edges(db, "DEFINES", "docs.arch.Arch", "docs.arch.Arch#diagram-1", NULL), 1); + ASSERT_EQ(dg_db_edges(db, "DEFINES", "Arch#diagram-1", "Arch#diagram-1.A", NULL), 1); + /* the arrows, with their text */ + ASSERT_EQ(dg_db_edges(db, "DIAGRAM_ARROW", "Arch#diagram-1.A", "Arch#diagram-1.B", + "\"label\":\"calls\""), + 1); + ASSERT_EQ(dg_db_edges(db, "DIAGRAM_ARROW", "Model#diagram-1.Dog", "Model#diagram-1.Animal", + "\"style\":\"inheritance\""), + 1); + /* a diagram file: its diagram under the file */ + ASSERT_EQ(dg_db_edges(db, "DIAGRAM_ARROW", "flow#diagram-1.X", "flow#diagram-1.Y", NULL), 1); + /* a diagram file of another format: discovered, its arrows in the graph */ + ASSERT_EQ(dg_db_edges(db, "DIAGRAM_ARROW", "c#diagram-1.A", "c#diagram-1.B", NULL), 1); + ASSERT_EQ(dg_db_edges(db, "DIAGRAM_ARROW", "#diagram-1.p", "#diagram-1.q", NULL), 1); + th_cleanup(tmp); + PASS(); +} + +/* ── incremental == full ─────────────────────────────────────────── */ + +/* The diagram layer of a graph as sorted lines: its nodes, their containment, + * the arrows, and the edges the elements' links resolved to. */ +static char *dg_state(const char *db) { + sqlite3 *h = NULL; + if (sqlite3_open_v2(db, &h, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(h); + return NULL; + } + const char *sql = + "SELECT 'N|' || label || '|' || qualified_name || '|' || name || '|' || " + "COALESCE(properties, '') AS l FROM nodes WHERE label IN ('Diagram', 'DiagramElement') " + "UNION ALL SELECT 'E|' || e.type || '|' || s.qualified_name || '|' || t.qualified_name || " + "'|' || COALESCE(e.properties, '') FROM edges e JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id WHERE e.type = 'DIAGRAM_ARROW' OR (e.type = " + "'DEFINES' AND t.label IN ('Diagram', 'DiagramElement')) OR (e.type = 'MENTIONS' AND " + "s.label IN ('Diagram', 'DiagramElement')) ORDER BY 1"; + sqlite3_stmt *st = NULL; + size_t cap = 4096; + size_t len = 0; + char *buf = malloc(cap); + buf[0] = '\0'; + if (sqlite3_prepare_v2(h, sql, -1, &st, NULL) == SQLITE_OK) { + while (sqlite3_step(st) == SQLITE_ROW) { + const char *line = (const char *)sqlite3_column_text(st, 0); + size_t l = strlen(line); + if (len + l + 2 > cap) { + cap = (len + l + 2) * 2; + buf = realloc(buf, cap); + } + memcpy(buf + len, line, l); + len += l; + buf[len++] = '\n'; + buf[len] = '\0'; + } + } + sqlite3_finalize(st); + sqlite3_close(h); + return buf; +} + +/* An incremental index of `repo` into inc_db, a full one into a fresh + * full_db: the same diagram layer. 0, or -1 after printing the difference. */ +static int dg_step(const char *repo, const char *inc_db, const char *full_db, const char *what) { + if (dm_index(repo, inc_db, NULL) != 0) { + printf(" %s: incremental index failed\n", what); + return -1; + } + dm_unlink_db(full_db); + if (dm_index(repo, full_db, NULL) != 0) { + printf(" %s: full index failed\n", what); + return -1; + } + char *inc = dg_state(inc_db); + char *full = dg_state(full_db); + int rc = inc && full && strcmp(inc, full) == 0 && full[0] ? 0 : -1; + if (rc != 0) { + printf(" %s: incremental != full\n--- incremental\n%s--- full\n%s", what, + inc ? inc : "(null)", full ? full : "(null)"); + } + free(inc); + free(full); + return rc; +} + +static void dg_write_all(const char *repo, int round) { + char text[1024]; + snprintf(text, sizeof(text), + "# Arch%s\n\n```mermaid\nflowchart LR\n A[Api] --> B[Db%d]\n" + " click A \"../src/api.py\"\n```\n", + round == 2 ? "itecture" : "", round); + th_write_file(TH_PATH(repo, "docs/arch.md"), text); + snprintf(text, sizeof(text), + "@startuml\nclass Api [[../src/api.py]]\nclass Db\nApi --> Db : r%d\n" + "@enduml\n", + round); + th_write_file(TH_PATH(repo, "diagrams/m.puml"), text); + snprintf(text, sizeof(text), "digraph { a -> b [label=\"r%d\"]; }\n", round); + th_write_file(TH_PATH(repo, "diagrams/g.dot"), text); + snprintf(text, sizeof(text), "x: X\ny: Y%d\nx -> y\n", round); + th_write_file(TH_PATH(repo, "diagrams/s.d2"), text); + snprintf(text, sizeof(text), + "" + "" + "", + round); + th_write_file(TH_PATH(repo, "diagrams/p.drawio"), text); + snprintf(text, sizeof(text), + "Guide\n=====\n\n.. mermaid::\n\n flowchart LR\n G --> H%d\n", round); + th_write_file(TH_PATH(repo, "docs/guide.rst"), text); + snprintf(text, sizeof(text), "= Guide\n\n== Flow\n\n[plantuml]\n----\n[P] --> [Q%d]\n----\n", + round); + th_write_file(TH_PATH(repo, "docs/flow.adoc"), text); + if (round < 2) { + snprintf(text, sizeof(text), + "{\"type\":\"excalidraw\",\"elements\":[" + "{\"id\":\"r1\",\"type\":\"rectangle\"},{\"id\":\"t1\",\"type\":\"text\"," + "\"text\":\"U%d\",\"containerId\":\"r1\"},{\"id\":\"r2\",\"type\":\"rectangle\"}," + "{\"id\":\"t2\",\"type\":\"text\",\"text\":\"V\",\"containerId\":\"r2\"}," + "{\"id\":\"a\",\"type\":\"arrow\",\"startBinding\":{\"elementId\":\"r1\"}," + "\"endBinding\":{\"elementId\":\"r2\"}}]}", + round); + th_write_file(TH_PATH(repo, "diagrams/e.excalidraw"), text); + } +} + +TEST(diagrams_incremental_equals_full) { + dm_ship_held_families(); + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dg_inc_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char inc_db[512]; + char full_db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(inc_db, sizeof(inc_db), "%s/inc.db", tmp); + snprintf(full_db, sizeof(full_db), "%s/full.db", tmp); + th_write_file(TH_PATH(repo, "src/api.py"), "def handle():\n return 1\n"); + dg_write_all(repo, 0); + ASSERT_EQ(dm_index(repo, inc_db, NULL), 0); + /* every diagram's text edited */ + dg_write_all(repo, 1); + ASSERT_EQ(dg_step(repo, inc_db, full_db, "edit"), 0); + /* a heading renamed (the diagram's parent with it), a diagram file gone */ + dg_write_all(repo, 2); + char gone[600]; + snprintf(gone, sizeof(gone), "%s/diagrams/e.excalidraw", repo); + unlink(gone); + ASSERT_EQ(dg_step(repo, inc_db, full_db, "rename + delete"), 0); + /* and back */ + dg_write_all(repo, 1); + ASSERT_EQ(dg_step(repo, inc_db, full_db, "re-add"), 0); + th_cleanup(tmp); + cbm_doclink_test_reset_ships(); + PASS(); +} + +SUITE(diagrams) { + RUN_TEST(diagrams_markdown_mermaid); + RUN_TEST(diagrams_mermaid_kinds); + RUN_TEST(diagrams_other_fences); + RUN_TEST(diagrams_plantuml); + RUN_TEST(diagrams_dot); + RUN_TEST(diagrams_d2); + RUN_TEST(diagrams_drawio); + RUN_TEST(diagrams_excalidraw); + RUN_TEST(diagrams_d2_blocks); + RUN_TEST(diagrams_excalidraw_layout); + RUN_TEST(diagrams_excalidraw_svg); + RUN_TEST(diagrams_rst); + RUN_TEST(diagrams_asciidoc); + RUN_TEST(diagrams_links); + RUN_TEST(diagrams_links_graph); + RUN_TEST(diagrams_incremental_equals_full); + RUN_TEST(diagrams_graph); +} diff --git a/tests/test_doc_mentions_helpers.h b/tests/test_doc_mentions_helpers.h index 95e5f0503..778796393 100644 --- a/tests/test_doc_mentions_helpers.h +++ b/tests/test_doc_mentions_helpers.h @@ -430,19 +430,40 @@ static inline int dm_reason_lines(const char *db, const char *block) { * so it is the resolution an audit would ship; doc_links_md_ship_gate tests * the production gate itself. */ static const int DM_HELD_FAMILIES[] = { - CBM_DOCLINK_MD_BARE_PATH, CBM_DOCLINK_MD_SUPERSEDES_PROSE, - CBM_DOCLINK_RST_INCLUDE, CBM_DOCLINK_RST_KERNEL_DOC, - CBM_DOCLINK_ADOC_ATTRIBUTE, CBM_DOCLINK_ADOC_CODE_PATH, - CBM_DOCLINK_ADOC_CODE_NAME, CBM_DOCLINK_GO_DOC_LINK, - CBM_DOCLINK_JAVA_LINK, CBM_DOCLINK_JAVA_LINKPLAIN, - CBM_DOCLINK_JAVA_SEE, CBM_DOCLINK_JAVA_THROWS, - CBM_DOCLINK_JAVA_VALUE, CBM_DOCLINK_KT_KDOC_LINK, - CBM_DOCLINK_KT_SEE, CBM_DOCLINK_KT_THROWS, - CBM_DOCLINK_KT_SAMPLE, CBM_DOCLINK_RS_INTRA_DOC, - CBM_DOCLINK_RS_LINK_DEF, CBM_DOCLINK_PERL_POD_L, - CBM_DOCLINK_TS_LINK, CBM_DOCLINK_TS_LINKCODE, - CBM_DOCLINK_TS_SEE, CBM_DOCLINK_PHP_SEE, - CBM_DOCLINK_C_REF, CBM_DOCLINK_C_SEE, + CBM_DOCLINK_MD_BARE_PATH, + CBM_DOCLINK_MD_SUPERSEDES_PROSE, + CBM_DOCLINK_RST_INCLUDE, + CBM_DOCLINK_RST_KERNEL_DOC, + CBM_DOCLINK_ADOC_ATTRIBUTE, + CBM_DOCLINK_ADOC_CODE_PATH, + CBM_DOCLINK_ADOC_CODE_NAME, + CBM_DOCLINK_GO_DOC_LINK, + CBM_DOCLINK_JAVA_LINK, + CBM_DOCLINK_JAVA_LINKPLAIN, + CBM_DOCLINK_JAVA_SEE, + CBM_DOCLINK_JAVA_THROWS, + CBM_DOCLINK_JAVA_VALUE, + CBM_DOCLINK_KT_KDOC_LINK, + CBM_DOCLINK_KT_SEE, + CBM_DOCLINK_KT_THROWS, + CBM_DOCLINK_KT_SAMPLE, + CBM_DOCLINK_RS_INTRA_DOC, + CBM_DOCLINK_RS_LINK_DEF, + CBM_DOCLINK_PERL_POD_L, + CBM_DOCLINK_TS_LINK, + CBM_DOCLINK_TS_LINKCODE, + CBM_DOCLINK_TS_SEE, + CBM_DOCLINK_PHP_SEE, + CBM_DOCLINK_C_REF, + CBM_DOCLINK_C_SEE, + CBM_DOCLINK_DG_MERMAID_CLICK, + CBM_DOCLINK_DG_PLANTUML_URL, + CBM_DOCLINK_DG_PLANTUML_INCLUDE, + CBM_DOCLINK_DG_DRAWIO_LINK, + CBM_DOCLINK_DG_EXCALIDRAW_LINK, + CBM_DOCLINK_DG_DOT_URL, + CBM_DOCLINK_DG_D2_LINK, + CBM_DOCLINK_OPENAPI_OPERATION_ID, }; static inline void dm_ship_held_families(void) { diff --git a/tests/test_grammar_labels.c b/tests/test_grammar_labels.c index 7246e5b95..f2e58074d 100644 --- a/tests/test_grammar_labels.c +++ b/tests/test_grammar_labels.c @@ -242,7 +242,7 @@ static const LabelGolden LABEL_GOLDENS[] = { {"astro", "Module:1,Variable:1"}, {"templ", "Class:1,Module:1"}, {"typst", "Module:1"}, - {"mermaid", "Module:1"}, + {"mermaid", "Diagram:1,DiagramElement:2,Module:1"}, /* the fixture's flowchart */ {NULL, NULL}, }; diff --git a/tests/test_main.c b/tests/test_main.c index de3d80508..2bee46fef 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1832,6 +1832,8 @@ extern void suite_doc_mentions_perl(void); extern void suite_doc_mentions_ts(void); extern void suite_doc_mentions_php(void); extern void suite_doc_mentions_c(void); +extern void suite_diagrams(void); +extern void suite_openapi(void); extern void suite_infrascan(void); extern void suite_cli(void); extern void suite_agent_clients(void); @@ -2310,6 +2312,8 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(doc_mentions_ts); RUN_SELECTED_SUITE(doc_mentions_php); RUN_SELECTED_SUITE(doc_mentions_c); + RUN_SELECTED_SUITE(diagrams); + RUN_SELECTED_SUITE(openapi); /* Infrastructure scanning */ RUN_SELECTED_SUITE(infrascan); diff --git a/tests/test_openapi.c b/tests/test_openapi.c new file mode 100644 index 000000000..acc336fed --- /dev/null +++ b/tests/test_openapi.c @@ -0,0 +1,242 @@ +/* + * test_openapi.c -- OpenAPI / Swagger documents: each operation a Route named + * as code names it, each operationId a reference to its handler (openapi.c). + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include "test_doc_mentions_helpers.h" +#include "cbm.h" +#include "discover/discover.h" +#include +#include +#include + +static const CBMDefinition *oa_route(const CBMFileResult *r, const char *qn) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->qualified_name && strcmp(d->qualified_name, qn) == 0) { + return d; + } + } + printf(" no definition %s\n", qn); + return NULL; +} + +static const char *oa_prop(const CBMDefinition *d, const char *key) { + for (int i = 0; d && d->extra_props && d->extra_props[i]; i += 2) { + if (strcmp(d->extra_props[i], key) == 0) { + return d->extra_props[i + 1]; + } + } + return NULL; +} + +static int oa_count(const CBMFileResult *r, const char *label) { + int n = 0; + for (int i = 0; i < r->defs.count; i++) { + n += r->defs.items[i].label && strcmp(r->defs.items[i].label, label) == 0; + } + return n; +} + +static int oa_link(const CBMFileResult *r, const char *source_qn, const char *raw) { + int n = 0; + for (int i = 0; i < r->doc_links.count; i++) { + const CBMDocLink *l = &r->doc_links.items[i]; + n += l->syntax == CBM_DOCLINK_OPENAPI_OPERATION_ID && + strcmp(l->source_qn, source_qn) == 0 && strcmp(l->raw, raw) == 0; + } + if (n != 1) { + printf(" link %s -> %s: %d found\n", source_qn, raw, n); + } + return n; +} + +static const char OA_YAML[] = "openapi: 3.0.0\n" + "info:\n" + " title: Users\n" + "paths:\n" + " /users/{id}:\n" + " x-openapi-router-controller: app.api.users\n" + " get:\n" + " operationId: get_user\n" + " delete:\n" + " operationId: deleteUser\n" + " x-openapi-router-controller: app.admin\n" + " /users:\n" + " post:\n" + " operationId: \"app.api.users.create_user\"\n" + " parameters: []\n" + " /health:\n" + " get:\n" + " summary: no id\n"; + +TEST(openapi_yaml) { + CBMFileResult *r = dm_extract(OA_YAML, CBM_LANG_YAML, "api/openapi.yaml"); + ASSERT_NOT_NULL(r); + const CBMDefinition *g = oa_route(r, "__route__GET__/users/{}"); + ASSERT_NOT_NULL(g); + ASSERT_STR_EQ(g->label, "Route"); + ASSERT_STR_EQ(g->name, "/users/{id}"); + ASSERT_STR_EQ(oa_prop(g, "method"), "GET"); + ASSERT_STR_EQ(oa_prop(g, "source"), "openapi"); + ASSERT_STR_EQ(oa_prop(g, "operation_id"), "get_user"); + ASSERT_NOT_NULL(oa_route(r, "__route__DELETE__/users/{}")); + ASSERT_NOT_NULL(oa_route(r, "__route__POST__/users")); + ASSERT_NULL(oa_prop(oa_route(r, "__route__GET__/health"), "operation_id")); + ASSERT_EQ(oa_count(r, "Route"), 4); /* `parameters` is no operation */ + /* the controller names the module of a bare id; a dotted id is whole */ + ASSERT_EQ(oa_link(r, "__route__GET__/users/{}", "app.api.users.get_user"), 1); + ASSERT_EQ(oa_link(r, "__route__DELETE__/users/{}", "app.admin.deleteUser"), 1); + ASSERT_EQ(oa_link(r, "__route__POST__/users", "app.api.users.create_user"), 1); + ASSERT_EQ(r->doc_links.count, 3); + cbm_free_result(r); + + /* YAML that describes no API is none */ + r = dm_extract("paths:\n /x:\n get:\n operationId: x\n", CBM_LANG_YAML, "conf.yaml"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(oa_count(r, "Route"), 0); + cbm_free_result(r); + PASS(); +} + +TEST(openapi_json) { + static const char spec[] = + "{\"swagger\": \"2.0\", \"info\": {\"title\": \"Pets\"},\n" + " \"paths\": {\"/pets\": {\"get\": {\"operationId\": \"listPets\"},\n" + " \"post\": {\"operationId\": \"pets.create\"}}}}\n"; + /* openapi.json: its operations, not a node per key */ + CBMFileResult *r = dm_extract(spec, CBM_LANG_OPENAPI, "openapi.json"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(oa_route(r, "__route__GET__/pets")); + ASSERT_EQ(oa_link(r, "__route__GET__/pets", "listPets"), 1); + ASSERT_EQ(oa_link(r, "__route__POST__/pets", "pets.create"), 1); + ASSERT_EQ(oa_count(r, "Variable"), 0); + cbm_free_result(r); + /* a JSON file of another name that describes an API */ + r = dm_extract(spec, CBM_LANG_JSON, "spec/pets-api.json"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(oa_count(r, "Route"), 2); + cbm_free_result(r); + /* the document names it */ + ASSERT_EQ(cbm_language_for_filename("openapi.json"), CBM_LANG_OPENAPI); + ASSERT_EQ(cbm_language_for_filename("swagger.json"), CBM_LANG_OPENAPI); + PASS(); +} + +/* MENTIONS edges from the Route `route_qn` to a node whose QN ends with `tgt`. */ +static int oa_db_mentions(const char *db, const char *route_qn, const char *tgt) { + sqlite3 *h = NULL; + int n = 0; + if (sqlite3_open_v2(db, &h, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(h); + return -1; + } + sqlite3_stmt *st = NULL; + const char *sql = "SELECT count(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id WHERE e.type = 'MENTIONS' " + "AND s.qualified_name = ?1 AND t.qualified_name LIKE '%' || ?2"; + if (sqlite3_prepare_v2(h, sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, route_qn, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, tgt, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) { + n = sqlite3_column_int(st, 0); + } + } + sqlite3_finalize(st); + sqlite3_close(h); + return n; +} + +/* MENTIONS edges from the Route `route_qn` whose properties hold `part`. */ +static int oa_db_mentions_props(const char *db, const char *route_qn, const char *part) { + sqlite3 *h = NULL; + int n = 0; + if (sqlite3_open_v2(db, &h, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(h); + return -1; + } + sqlite3_stmt *st = NULL; + const char *sql = "SELECT count(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "WHERE e.type = 'MENTIONS' AND s.qualified_name = ?1 " + "AND e.properties LIKE '%' || ?2 || '%'"; + if (sqlite3_prepare_v2(h, sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, route_qn, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, part, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) { + n = sqlite3_column_int(st, 0); + } + } + sqlite3_finalize(st); + sqlite3_close(h); + return n; +} + +static int oa_db_nodes(const char *db, const char *label, const char *qn) { + sqlite3 *h = NULL; + int n = 0; + if (sqlite3_open_v2(db, &h, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(h); + return -1; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(h, "SELECT count(*) FROM nodes WHERE label = ?1 AND qualified_name = ?2", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, label, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, qn, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) { + n = sqlite3_column_int(st, 0); + } + } + sqlite3_finalize(st); + sqlite3_close(h); + return n; +} + +TEST(openapi_graph) { + dm_ship_held_families(); /* the operationId family is held until its audit */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_oa_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + char db[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + snprintf(db, sizeof(db), "%s/g.db", tmp); + th_write_file(TH_PATH(repo, "app/api/users.py"), + "def get_user(id):\n return id\n\n\ndef create_user():\n return 1\n"); + th_write_file(TH_PATH(repo, "api/openapi.yaml"), OA_YAML); + th_write_file(TH_PATH(repo, "openapi.json"), + "{\"openapi\": \"3.1.0\", \"paths\": {\"/pets\": {\"get\": " + "{\"operationId\": \"listPets\"}}, \"/dup\": {\"get\": " + "{\"operationId\": \"dup\"}}, \"/none\": {\"get\": " + "{\"operationId\": \"noSuchHandler\"}}}}\n"); + th_write_file(TH_PATH(repo, "app/pets.py"), "def listPets():\n return []\n\n\ndef dup():\n" + " return 1\n"); + th_write_file(TH_PATH(repo, "app/other.py"), "def dup():\n return 2\n"); + ASSERT_EQ(dm_index(repo, db, NULL), 0); + ASSERT_EQ(oa_db_nodes(db, "Route", "__route__GET__/users/{}"), 1); + ASSERT_EQ(oa_db_nodes(db, "Route", "__route__GET__/pets"), 1); + /* a dotted operationId names its handler */ + ASSERT_EQ(oa_db_mentions(db, "__route__GET__/users/{}", "users.get_user"), 1); + ASSERT_EQ(oa_db_mentions(db, "__route__POST__/users", "users.create_user"), 1); + /* through the API document, whichever resolver read its file (a YAML one) */ + ASSERT_EQ(oa_db_mentions_props(db, "__route__POST__/users", "\"via\":\"openapi\""), 1); + ASSERT_EQ(oa_db_mentions_props(db, "__route__POST__/users", "\"tier\":\"exact\""), 1); + ASSERT_EQ(oa_db_mentions_props(db, "__route__GET__/pets", "\"tier\":\"unique\""), 1); + /* a bare id names the one function of that name */ + ASSERT_EQ(oa_db_mentions(db, "__route__GET__/pets", "pets.listPets"), 1); + /* a module with no such function; a bare id two functions carry; one none does */ + ASSERT_EQ(oa_db_mentions(db, "__route__DELETE__/users/{}", ""), 0); + ASSERT_EQ(oa_db_mentions(db, "__route__GET__/dup", ""), 0); + ASSERT_EQ(oa_db_mentions(db, "__route__GET__/none", ""), 0); + th_cleanup(tmp); + cbm_doclink_test_reset_ships(); + PASS(); +} + +SUITE(openapi) { + RUN_TEST(openapi_yaml); + RUN_TEST(openapi_json); + RUN_TEST(openapi_graph); +}