From f4a49feba1a8325c4751de130b5166fb3f3fe986 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 11 Oct 2026 02:08:58 +0200 Subject: [PATCH] feat(docs): diagrams and OpenAPI operations in the graph Diagrams name the parts of a system and how they connect; an OpenAPI document names its endpoints and their handlers. Both now enter the graph. Diagrams: - a Diagram node per diagram: a fenced block of a Markdown file (under its Section), a directive of a reST file (`.. mermaid::`, `.. uml::`, `.. graphviz::`, Sphinx `.. digraph::`), an Asciidoctor Diagram block of an AsciiDoc file (`[plantuml]`, `[source,mermaid]`, ...), or a diagram file of its own, named by its title, else by its file; - a DiagramElement node per element, DEFINES from its Diagram, and a DIAGRAM_ARROW edge per arrow (label, style, line); - formats: Mermaid (flowchart, sequence, class, ER, state, C4, architecture), PlantUML, Graphviz DOT, D2, draw.io (plain, compressed, .drawio.svg) and Excalidraw (.excalidraw, .excalidraw.json, .excalidraw.svg); .drawio.svg and .excalidraw.svg are no longer dropped as images; - an element's explicit link (Mermaid click/link, PlantUML [[url]], url of and !include, draw.io and Excalidraw link, DOT URL/href, D2 link) is a doc link read as a Markdown link, MENTIONS when it resolves. OpenAPI: - each operation of an OpenAPI or Swagger document (openapi.json and swagger.json, now read with the JSON grammar but no node per key, and any YAML or JSON file whose root says openapi/swagger) is a Route, named by the same QN a route its handler registers in code gets (cbm_route_canon_path, moved into the extraction library); - its operationId is a doc link to the handler: a dotted one (Connexion, with x-openapi-router-controller) as a qualified code name, a bare one when exactly one function or method of the repository has that name. The eight new link families are held until their held-out audit passes. Read on real repositories, which shaped the rules: Excalidraw drawings put text over shapes and draw arrows up to them without binding, so loose text names the smallest box it sits in and a loose arrow end meets the smallest named box it touches (the drawing's frame only at its border); arrows bound to free text meet the text; D2 block strings and nested blocks; DOT line breaks and sentence-long ids; PlantUML kinds of classes inside packages and nested classes (`..+`, nested -> container, so they do not collide with the container's association). Connexion's 24 specs: 160 operationIds, all linked to their handlers. Signed-off-by: Martin Vogel --- Makefile.cbm | 6 +- internal/cbm/cbm.c | 31 + internal/cbm/cbm.h | 43 + internal/cbm/diagram.c | 3544 ++++++++++++++++++++++++ internal/cbm/doclink.h | 166 +- internal/cbm/lang_specs.c | 11 + internal/cbm/openapi.c | 239 ++ internal/cbm/result_compact.c | 9 + internal/cbm/route_path.c | 98 + src/discover/discover.c | 8 + src/discover/language.c | 35 +- src/discover/userconfig.c | 2 + src/pipeline/doc_links.c | 20 +- src/pipeline/doc_links.h | 2 +- src/pipeline/doc_links_adoc.c | 5 + src/pipeline/doc_links_md.c | 48 +- src/pipeline/doc_links_rst.c | 5 + src/pipeline/pass_definitions.c | 3 + src/pipeline/pass_diagrams.c | 68 + src/pipeline/pass_parallel.c | 4 + src/pipeline/pass_route_nodes.c | 89 - src/pipeline/pipeline.c | 2 + src/pipeline/pipeline_internal.h | 17 +- tests/repro/repro_call_node_manifest.c | 3 +- tests/repro/repro_language_registry.c | 8 +- tests/test_diagrams.c | 1065 +++++++ tests/test_doc_mentions_helpers.h | 47 +- tests/test_grammar_labels.c | 2 +- tests/test_main.c | 4 + tests/test_openapi.c | 242 ++ 30 files changed, 5621 insertions(+), 205 deletions(-) create mode 100644 internal/cbm/diagram.c create mode 100644 internal/cbm/openapi.c create mode 100644 internal/cbm/route_path.c create mode 100644 src/pipeline/pass_diagrams.c create mode 100644 tests/test_diagrams.c create mode 100644 tests/test_openapi.c 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); +}