From 88d78990d58d0a01a58fb5f735ae9e01aa9050a6 Mon Sep 17 00:00:00 2001 From: huntedbytheirs Date: Fri, 28 Aug 2026 20:53:45 -0400 Subject: [PATCH] feat(kdl): add KDL parser producing AST --- src/kdl/ast.h | 99 ++++++ src/kdl/parser.c | 621 +++++++++++++++++++++++++++++++++++ tests/fixtures/basic.kdl | 21 ++ tests/unit/test_parser.c | 690 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 1431 insertions(+) create mode 100644 src/kdl/ast.h create mode 100644 src/kdl/parser.c create mode 100644 tests/fixtures/basic.kdl create mode 100644 tests/unit/test_parser.c diff --git a/src/kdl/ast.h b/src/kdl/ast.h new file mode 100644 index 0000000..2ee67a0 --- /dev/null +++ b/src/kdl/ast.h @@ -0,0 +1,99 @@ +/* + * ast.h - KDL 2.0.0 syntax tree for stupidtools (todo 7). + * + * The tree mirrors the KDL node grammar: + * + * document := node* + * node := [type-annotation] name (arg | prop)* children? + * + * Every element is a RAW TOKEN REFERENCE into the original source buffer: + * kind + span + text slice. Strings keep their delimiters and escapes, + * numbers keep their radix spelling, keywords keep their `#` spelling. + * INTERPRETATION (unescaping, radix conversion, keyword-to-value mapping, + * value-level type annotations) is the typed value model (todo 8) and + * deliberately does NOT happen here. + * + * OWNERSHIP + * --------- + * st_kdl_parse() allocates every node/arg/prop on the heap; + * st_kdl_document_free() releases the whole tree (NULL is a safe no-op). + * Token text slices and span `file` pointers are BORROWED from the + * caller's `src`/`filename` buffers, which must therefore outlive the + * document. + * + * ERROR-SPAN OWNERSHIP (unlike the lexer's borrowed span) + * --------- + * The lexer borrows its error span from the lexer object (src/kdl/lexer.h). + * The parser cannot do that: its context dies before st_kdl_parse() + * returns. Parse errors therefore carry a span that is heap-allocated IN + * THE SAME BLOCK as the error itself (error struct first, span struct + * immediately after it, aligned). st_error_free() frees the block, so the + * span needs no separate cleanup and is never dangling. Callers may read + * err->span until st_error_free(err), exactly like err->message. + * + * Copyright (c) 2026 huntedbytheirs + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef ST_KDL_AST_H +#define ST_KDL_AST_H + +#include + +#include "lexer.h" /* enum st_token_kind */ + +struct st_error; + +/* One raw token reference. `text` is a borrowed slice into the source + * buffer; it is NOT NUL-terminated (use `len`). */ +struct st_kdl_token_ref { + enum st_token_kind kind; + struct st_span span; + const char *text; + size_t len; +}; + +/* A positional argument, in source order (spec mandates order). */ +struct st_kdl_arg { + struct st_kdl_token_ref value; + struct st_kdl_arg *next; +}; + +/* A `key = value` property, in source order. The key is a string-ish + * token (identifier or any string form); the value is any value token. */ +struct st_kdl_prop { + struct st_kdl_token_ref key; + struct st_kdl_token_ref value; + struct st_kdl_prop *next; +}; + +/* A node: optional type annotation, name, ordered arguments, ordered + * properties (interleaved in source, collapsed into the two lists while + * preserving relative order within each), and an optional children + * block. `next` links siblings in document order. */ +struct st_kdl_node { + struct st_kdl_token_ref *type; /* NULL when not annotated */ + struct st_kdl_token_ref name; + struct st_kdl_arg *args; + struct st_kdl_prop *props; + struct st_kdl_node *children; + struct st_kdl_node *next; +}; + +/* A document: zero or more top-level nodes in source order. */ +struct st_kdl_document { + struct st_kdl_node *nodes; +}; + +/* Parse `src` (a NUL-terminated buffer, borrowed) into an AST. On success + * returns the document — possibly with zero nodes — and leaves `*err` + * untouched. On failure returns NULL and, if `err != NULL`, sets `*err` + * to an owned st_error of category ST_ERR_KDL_PARSE with a stable span + * (see the header comment). `filename` is borrowed into every span. */ +struct st_kdl_document *st_kdl_parse(const char *src, const char *filename, + struct st_error **err); + +/* Release a document produced by st_kdl_parse. NULL is a safe no-op. */ +void st_kdl_document_free(struct st_kdl_document *doc); + +#endif /* ST_KDL_AST_H */ diff --git a/src/kdl/parser.c b/src/kdl/parser.c new file mode 100644 index 0000000..7c69fc1 --- /dev/null +++ b/src/kdl/parser.c @@ -0,0 +1,621 @@ +/* + * parser.c - KDL 2.0.0 recursive-descent parser for stupidtools (todo 7). + * + * Consumes the lexer's flat token stream (src/kdl/lexer.h) and builds the + * AST from src/kdl/ast.h. Roles are assigned BY POSITION, per the lexer's + * contract: a string-ish token followed by ST_TOK_EQUALS (on the same + * line) is a property key, otherwise a bare value is an argument. + * + * Grammar implemented (see the plan, todo 7): + * + * document := node* (separated by whitespace/`;`; comments and + * `/-` are trivia, skipped) + * node := [type] name (arg | prop)* children? + * children := '{' node* '}' (`;`-separated, recursively) + * arg := value token + * prop := string-ish-token '=' value token + * + * Node termination: a node ends at `;`, `/-`, the end of its parent's + * child block (`}`), EOF, or a token on a LATER source line than the last + * consumed token (the lexer discards newlines, so the parser infers + * newline termination from token spans). Entries and the children block + * may span lines only via the lexer's line continuations (esclines), which + * the lexer consumes invisibly. + * + * ERRORS AND SPANS + * ---------------- + * Every parse failure returns a st_error (ST_ERR_KDL_PARSE) carrying a + * span. Because st_error only holds a BORROWED span pointer (src/error.h + * is frozen), and the lexer's err_span dies with the lexer, the parser + * copies the span into storage that is heap-allocated IN THE SAME BLOCK + * as the error struct itself — st_error_free() releases both, so the span + * is never dangling and never leaked. Lexer errors are likewise copied + * into an owned block before the lexer is freed. + * + * Copyright (c) 2026 huntedbytheirs + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include "kdl/ast.h" + +#include "error.h" +#include "kdl/lexer.h" + +#include +#include +#include +#include + +/* ---- parser state ----------------------------------------------------- */ + +struct st_parser { + struct st_lexer *lx; + struct st_token tok; /* one-token lookahead */ + bool has_tok; /* is `tok` a valid peeked token? */ +}; + +/* ---- owned errors ----------------------------------------------------- */ + +/* Build an owned ST_ERR_KDL_PARSE error whose span lives in the same + * allocation as the error struct (aligned right after it). Returns NULL + * only on allocation failure. */ +static struct st_error * +err_at_owned(struct st_span sp, const char *msg) +{ + const size_t align = _Alignof(struct st_span); + const size_t esize = + (sizeof(struct st_error) + align - 1) & ~(align - 1); + struct st_error *e; + struct st_span *spc; + size_t mlen; + + if (msg == NULL) { + msg = ""; + } + e = malloc(esize + sizeof(struct st_span)); + if (e == NULL) { + return NULL; + } + mlen = strlen(msg); + e->message = malloc(mlen + 1); + if (e->message == NULL) { + free(e); + return NULL; + } + memcpy(e->message, msg, mlen + 1); + e->category = ST_ERR_KDL_PARSE; + spc = (struct st_span *)((unsigned char *)e + esize); + *spc = sp; + e->span = spc; + return e; +} + +/* ---- token plumbing --------------------------------------------------- */ + +/* Pull the next token from the lexer into p->tok. On a lex error, copies + * the error (message + span value) into an owned block, frees the + * lexer's error, and returns ST_TOK_ERROR with *err set. */ +static enum st_token_kind +p_fetch(struct st_parser *p, struct st_error **err) +{ + struct st_error *lexerr = NULL; + enum st_token_kind k; + struct st_span sp; + + k = st_lexer_next(p->lx, &p->tok, &lexerr); + if (k != ST_TOK_ERROR) { + return k; + } + /* Duplicate the message while `lexerr` is still alive (its span value + * is copied by value; the message must be copied BEFORE the free). */ + if (lexerr != NULL) { + sp = lexerr->span != NULL ? *lexerr->span : p->tok.span; + *err = err_at_owned(sp, st_error_message(lexerr)); + st_error_free(lexerr); + } else { + *err = err_at_owned(p->tok.span, "lexer error"); + } + return ST_TOK_ERROR; +} + +/* Peek the next token (leaving it in the lookahead). ST_TOK_ERROR on a + * lex failure (*err set). */ +static enum st_token_kind +p_peek(struct st_parser *p, struct st_error **err) +{ + if (!p->has_tok) { + enum st_token_kind k = p_fetch(p, err); + + if (k == ST_TOK_ERROR) { + return ST_TOK_ERROR; + } + p->has_tok = true; + } + return p->tok.kind; +} + +/* Discard the peeked token. */ +static void +p_consume(struct st_parser *p) +{ + p->has_tok = false; +} + +/* Skip separators/trivia at node-boundary level (document and children + * block): line/block comments, slashdash, semicolons. Returns 0 on + * success, -1 on lex error (*err set). */ +static int +skip_trivia(struct st_parser *p, struct st_error **err) +{ + for (;;) { + enum st_token_kind k = p_peek(p, err); + + if (k == ST_TOK_ERROR) { + return -1; + } + switch (k) { + case ST_TOK_LINE_COMMENT: + case ST_TOK_BLOCK_COMMENT: + case ST_TOK_SLASHDASH: + case ST_TOK_SEMICOLON: + p_consume(p); + continue; + default: + return 0; + } + } +} + +/* Skip comments only (positions INSIDE a node, where `;` and `/-` + * terminate the node instead of being trivia). Returns 0 on success, + * -1 on lex error (*err set). */ +static int +skip_comments(struct st_parser *p, struct st_error **err) +{ + for (;;) { + enum st_token_kind k = p_peek(p, err); + + if (k == ST_TOK_ERROR) { + return -1; + } + if (k == ST_TOK_LINE_COMMENT || k == ST_TOK_BLOCK_COMMENT) { + p_consume(p); + continue; + } + return 0; + } +} + +/* ---- token classification --------------------------------------------- */ + +/* String-ish tokens: legal node names and property keys (the KDL grammar + * "string": identifiers plus every quoted/raw/multi-line string form). */ +static bool +tok_is_stringish(enum st_token_kind k) +{ + return k == ST_TOK_IDENT || k == ST_TOK_STRING || + k == ST_TOK_MULTILINE_STRING || k == ST_TOK_RAW_STRING; +} + +/* Value tokens: anything usable as an argument or a property value. */ +static bool +tok_is_value(enum st_token_kind k) +{ + return tok_is_stringish(k) || k == ST_TOK_NUMBER || k == ST_TOK_INF || + k == ST_TOK_NEG_INF || k == ST_TOK_NAN || k == ST_TOK_TRUE || + k == ST_TOK_FALSE || k == ST_TOK_NULL; +} + +/* ---- AST construction ------------------------------------------------- */ + +static void +ref_from_token(struct st_kdl_token_ref *ref, const struct st_token *t) +{ + ref->kind = t->kind; + ref->span = t->span; + ref->text = t->text; + ref->len = t->len; +} + +static struct st_kdl_arg * +arg_new(const struct st_token *t) +{ + struct st_kdl_arg *a = calloc(1, sizeof(*a)); + + if (a == NULL) { + return NULL; + } + ref_from_token(&a->value, t); + return a; +} + +static struct st_kdl_prop * +prop_new(const struct st_token *key, const struct st_token *value) +{ + struct st_kdl_prop *pr = calloc(1, sizeof(*pr)); + + if (pr == NULL) { + return NULL; + } + ref_from_token(&pr->key, key); + ref_from_token(&pr->value, value); + return pr; +} + +static struct st_kdl_node * +node_new(const struct st_token *name, const struct st_token *type, + bool has_type) +{ + struct st_kdl_node *n = calloc(1, sizeof(*n)); + + if (n == NULL) { + return NULL; + } + ref_from_token(&n->name, name); + if (has_type) { + n->type = malloc(sizeof(*n->type)); + if (n->type == NULL) { + free(n); + return NULL; + } + ref_from_token(n->type, type); + } + return n; +} + +static void +nodes_free(struct st_kdl_node *head); + +static void +node_free(struct st_kdl_node *n) +{ + struct st_kdl_arg *a; + struct st_kdl_prop *pr; + + if (n == NULL) { + return; + } + free(n->type); + for (a = n->args; a != NULL;) { + struct st_kdl_arg *nx = a->next; + free(a); + a = nx; + } + for (pr = n->props; pr != NULL;) { + struct st_kdl_prop *px = pr->next; + free(pr); + pr = px; + } + nodes_free(n->children); + free(n); +} + +static void +nodes_free(struct st_kdl_node *head) +{ + while (head != NULL) { + struct st_kdl_node *nx = head->next; + node_free(head); + head = nx; + } +} + +/* ---- the grammar ------------------------------------------------------ */ + +static struct st_kdl_node *parse_node(struct st_parser *p, + struct st_error **err); + +/* Parse a children block. The opening `{` is already consumed; `lbrace` + * is its span (used for the unterminated-block error). Returns the child + * list head (NULL for an empty block), or NULL with *err set. */ +static struct st_kdl_node * +parse_children(struct st_parser *p, struct st_span lbrace, + struct st_error **err) +{ + struct st_kdl_node *head = NULL; + struct st_kdl_node *tail = NULL; + + for (;;) { + enum st_token_kind k; + struct st_kdl_node *n; + + if (skip_trivia(p, err) < 0) { + nodes_free(head); + return NULL; + } + k = p_peek(p, err); + if (k == ST_TOK_ERROR) { + nodes_free(head); + return NULL; + } + if (k == ST_TOK_EOF) { + nodes_free(head); + *err = err_at_owned(lbrace, + "unterminated children block (missing closing '}')"); + return NULL; + } + if (k == ST_TOK_RBRACE) { + p_consume(p); + return head; + } + n = parse_node(p, err); + if (n == NULL) { + nodes_free(head); + return NULL; + } + if (tail == NULL) { + head = tail = n; + } else { + tail->next = n; + tail = n; + } + } +} + +/* Parse one node. Returns NULL with *err set on failure. The caller is + * responsible for freeing the node list when NULL comes back. */ +static struct st_kdl_node * +parse_node(struct st_parser *p, struct st_error **err) +{ + struct st_kdl_node *n = NULL; + struct st_token name_tok; + struct st_token type_tok; + struct st_kdl_arg *args_tail = NULL; + struct st_kdl_prop *props_tail = NULL; + bool has_type = false; + size_t last_line; + enum st_token_kind k; + + /* Optional type annotation. */ + if (skip_comments(p, err) < 0) { + return NULL; + } + k = p_peek(p, err); + if (k == ST_TOK_ERROR) { + return NULL; + } + if (k == ST_TOK_TYPE) { + type_tok = p->tok; + has_type = true; + p_consume(p); + if (skip_comments(p, err) < 0) { + return NULL; + } + k = p_peek(p, err); + if (k == ST_TOK_ERROR) { + return NULL; + } + } + + /* Name: any string-ish token. */ + if (!tok_is_stringish(k)) { + *err = err_at_owned(p->tok.span, "expected a node name"); + return NULL; + } + name_tok = p->tok; + p_consume(p); + + n = node_new(&name_tok, &type_tok, has_type); + if (n == NULL) { + *err = err_at_owned(name_tok.span, "out of memory"); + return NULL; + } + last_line = name_tok.span.line; + + /* Entries: interleaved arguments and properties. */ + for (;;) { + if (skip_comments(p, err) < 0) { + node_free(n); + return NULL; + } + k = p_peek(p, err); + if (k == ST_TOK_ERROR) { + node_free(n); + return NULL; + } + switch (k) { + case ST_TOK_EOF: + case ST_TOK_RBRACE: + case ST_TOK_SEMICOLON: + case ST_TOK_SLASHDASH: + /* Terminated: the peeked token belongs to the caller's level. */ + return n; + default: + break; + } + if (p->tok.span.line > last_line) { + /* Newline termination (the lexer discards newlines). */ + return n; + } + switch (k) { + case ST_TOK_LBRACE: { + struct st_span lbrace = p->tok.span; + + p_consume(p); + n->children = parse_children(p, lbrace, err); + if (*err != NULL) { + node_free(n); + return NULL; + } + return n; + } + case ST_TOK_EQUALS: + /* The previous token was the node name or a non-key value. */ + *err = err_at_owned(p->tok.span, "unexpected '='"); + node_free(n); + return NULL; + case ST_TOK_TYPE: + *err = err_at_owned(p->tok.span, + "unexpected type annotation (value annotations are " + "not supported yet)"); + node_free(n); + return NULL; + default: + if (!tok_is_value(k)) { + *err = err_at_owned(p->tok.span, + "expected an argument or property"); + node_free(n); + return NULL; + } + break; + } + + /* A value-ish entry: classify as property (key `=` value) or + * argument. */ + { + struct st_token val_tok = p->tok; + + p_consume(p); + if (tok_is_stringish(val_tok.kind)) { + /* `=` lookahead, same source line only. */ + if (skip_comments(p, err) < 0) { + node_free(n); + return NULL; + } + k = p_peek(p, err); + if (k == ST_TOK_ERROR) { + node_free(n); + return NULL; + } + if (k == ST_TOK_EQUALS && + p->tok.span.line == val_tok.span.line) { + struct st_kdl_prop *pr; + + p_consume(p); /* '=' */ + if (skip_comments(p, err) < 0) { + node_free(n); + return NULL; + } + k = p_peek(p, err); + if (k == ST_TOK_ERROR) { + node_free(n); + return NULL; + } + if (!tok_is_value(k)) { + *err = err_at_owned(p->tok.span, + "expected a value after '='"); + node_free(n); + return NULL; + } + pr = prop_new(&val_tok, &p->tok); + if (pr == NULL) { + *err = err_at_owned(val_tok.span, "out of memory"); + node_free(n); + return NULL; + } + last_line = p->tok.span.line; + p_consume(p); + if (props_tail == NULL) { + n->props = props_tail = pr; + } else { + props_tail->next = pr; + props_tail = pr; + } + continue; + } + } + /* Argument. */ + { + struct st_kdl_arg *a = arg_new(&val_tok); + + if (a == NULL) { + *err = err_at_owned(val_tok.span, "out of memory"); + node_free(n); + return NULL; + } + last_line = val_tok.span.line; + if (args_tail == NULL) { + n->args = args_tail = a; + } else { + args_tail->next = a; + args_tail = a; + } + } + } + } +} + +/* ---- public API ------------------------------------------------------- */ + +struct st_kdl_document * +st_kdl_parse(const char *src, const char *filename, struct st_error **err) +{ + struct st_parser parser; + struct st_kdl_document *doc; + struct st_kdl_node *head = NULL; + struct st_kdl_node *tail = NULL; + + if (err != NULL) { + *err = NULL; + } + + parser.lx = st_lexer_new(src, filename); + if (parser.lx == NULL) { + if (err != NULL) { + *err = err_at_owned((struct st_span){ filename, 0, 0 }, + "out of memory"); + } + return NULL; + } + parser.has_tok = false; + + doc = calloc(1, sizeof(*doc)); + if (doc == NULL) { + st_lexer_free(parser.lx); + if (err != NULL) { + *err = err_at_owned((struct st_span){ filename, 0, 0 }, + "out of memory"); + } + return NULL; + } + + for (;;) { + enum st_token_kind k; + struct st_kdl_node *n; + + if (skip_trivia(&parser, err) < 0) { + goto fail; + } + k = p_peek(&parser, err); + if (k == ST_TOK_ERROR) { + goto fail; + } + if (k == ST_TOK_EOF) { + break; + } + if (k == ST_TOK_RBRACE) { + *err = err_at_owned(parser.tok.span, "unexpected '}'"); + goto fail; + } + n = parse_node(&parser, err); + if (n == NULL) { + goto fail; + } + if (tail == NULL) { + head = tail = n; + } else { + tail->next = n; + tail = n; + } + } + + st_lexer_free(parser.lx); + doc->nodes = head; + return doc; + +fail: + st_lexer_free(parser.lx); + nodes_free(head); + free(doc); + return NULL; +} + +void +st_kdl_document_free(struct st_kdl_document *doc) +{ + if (doc == NULL) { + return; + } + nodes_free(doc->nodes); + free(doc); +} diff --git a/tests/fixtures/basic.kdl b/tests/fixtures/basic.kdl new file mode 100644 index 0000000..6357d5d --- /dev/null +++ b/tests/fixtures/basic.kdl @@ -0,0 +1,21 @@ +/* Parser fixture: a realistic multi-node build file exercising node names, + arguments, properties, children blocks, comments, a slashdash, and + semicolon separators. Parsed by tests/unit/test_parser.c against an + exact expected AST (counts, order, nesting, spans). */ + +// top-level line comment +project name="stupidtools" version="1.0.0" + +target "default" { + src "src/main.c" ; src "src/kdl/lexer.c" + cc-flags "-std=c23" "-Wall" // pinned warning flags + jobs 8 + optimize #true debug=#false + feature "unit-tests" { + option "munit" { default #true } + } +} + +/- + +(meta)credits "huntedbytheirs" diff --git a/tests/unit/test_parser.c b/tests/unit/test_parser.c new file mode 100644 index 0000000..fb4b723 --- /dev/null +++ b/tests/unit/test_parser.c @@ -0,0 +1,690 @@ +/* LINK: ../../src/kdl/parser.c ../../src/kdl/lexer.c ../../src/error.c ../../src/span.c */ +/* tests/unit/test_parser.c + * + * Unit tests for the KDL 2.0.0 recursive-descent parser (todo 7). + * + * The magic LINK comment on line 1 is REQUIRED by tests/run.sh: it lists + * the extra .c sources to compile into this test binary (paths relative + * to tests/unit/, space-separated). parser.c depends on lexer.c (token + * stream), error.c (typed errors) and span.c (caret rendering). + * + * These tests assert on the REAL AST structure — node counts, argument + * and property order, children nesting, spans — never just "no crash". + * AST token slices are borrowed from the source buffer, so the source + * must outlive the document (both kept alive until st_kdl_document_free). + */ +#include "munit.h" + +#include "error.h" +#include "kdl/ast.h" +#include "kdl/lexer.h" +#include "span.h" + +#include +#include +#include +#include + +/* ---- fixture loading -------------------------------------------------- */ + +/* Locate tests/fixtures/basic.kdl. The harness runs the test binary with + * cwd = repo top (make check) or tests/unit (manual run); probe both. */ +static const char * +fixture_path(void) +{ + static const char *const candidates[] = { + "tests/fixtures/basic.kdl", + "../fixtures/basic.kdl", + }; + size_t i; + + for (i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) { + FILE *f = fopen(candidates[i], "rb"); + if (f != NULL) { + fclose(f); + return candidates[i]; + } + } + return NULL; +} + +/* Slurp the fixture into a NUL-terminated buffer (the lexer requires NUL + * termination). Returns NULL on any I/O failure. Caller frees. */ +static char * +slurp_fixture(const char *path) +{ + FILE *f; + long n; + char *buf; + + if (path == NULL) { + return NULL; + } + f = fopen(path, "rb"); + if (f == NULL) { + return NULL; + } + if (fseek(f, 0, SEEK_END) != 0) { + fclose(f); + return NULL; + } + n = ftell(f); + if (n < 0 || fseek(f, 0, SEEK_SET) != 0) { + fclose(f); + return NULL; + } + buf = munit_malloc((size_t)n + 1); + if (fread(buf, 1, (size_t)n, f) != (size_t)n) { + free(buf); + fclose(f); + return NULL; + } + fclose(f); + buf[n] = '\0'; + return buf; +} + +/* ---- token-ref assertions --------------------------------------------- */ + +static void +assert_ref(const struct st_kdl_token_ref *ref, enum st_token_kind kind, + const char *text, size_t line, size_t col) +{ + munit_assert_not_null(ref); + munit_assert_int(ref->kind, ==, kind); + if (text != NULL) { + munit_assert_not_null(ref->text); + munit_assert_size(ref->len, ==, strlen(text)); + munit_assert_memory_equal(ref->len, ref->text, text); + } + munit_assert_size(ref->span.line, ==, line); + munit_assert_size(ref->span.col, ==, col); +} + +/* ---- tests ------------------------------------------------------------ */ + +/* (a) tests/fixtures/basic.kdl parses to the expected AST: node count, + * names, arg order, prop key/value kinds, children nesting, spans. */ +static MunitResult +test_parse_fixture(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + const char *path = fixture_path(); + char *src; + struct st_error *err = NULL; + struct st_kdl_document *doc; + struct st_kdl_node *n; + struct st_kdl_prop *p; + + munit_assert_not_null(path); + src = slurp_fixture(path); + munit_assert_not_null(src); + + doc = st_kdl_parse(src, path, &err); + munit_assert_not_null(doc); + munit_assert_null(err); + + /* --- three top-level nodes: project, target, credits ------------- */ + n = doc->nodes; + munit_assert_not_null(n); + assert_ref(&n->name, ST_TOK_IDENT, "project", 7, 1); + + munit_assert_null(n->args); + p = n->props; + munit_assert_not_null(p); + assert_ref(&p->key, ST_TOK_IDENT, "name", 7, 9); + assert_ref(&p->value, ST_TOK_STRING, "\"stupidtools\"", 7, 14); + p = p->next; + munit_assert_not_null(p); + assert_ref(&p->key, ST_TOK_IDENT, "version", 7, 28); + assert_ref(&p->value, ST_TOK_STRING, "\"1.0.0\"", 7, 36); + munit_assert_null(p->next); + + /* --- target "default" with 6 children ---------------------------- */ + n = n->next; + munit_assert_not_null(n); + assert_ref(&n->name, ST_TOK_IDENT, "target", 9, 1); + munit_assert_not_null(n->args); + assert_ref(&n->args->value, ST_TOK_STRING, "\"default\"", 9, 8); + munit_assert_null(n->args->next); + munit_assert_null(n->props); + + { + struct st_kdl_node *c = n->children; + + /* child 1: src "src/main.c" */ + munit_assert_not_null(c); + assert_ref(&c->name, ST_TOK_IDENT, "src", 10, 5); + munit_assert_not_null(c->args); + assert_ref(&c->args->value, ST_TOK_STRING, "\"src/main.c\"", 10, 9); + munit_assert_null(c->args->next); + + /* child 2: src "src/kdl/lexer.c" (after ';') */ + c = c->next; + munit_assert_not_null(c); + assert_ref(&c->name, ST_TOK_IDENT, "src", 10, 24); + assert_ref(&c->args->value, ST_TOK_STRING, "\"src/kdl/lexer.c\"", 10, + 28); + munit_assert_null(c->args->next); + + /* child 3: cc-flags "-std=c23" "-Wall" */ + c = c->next; + munit_assert_not_null(c); + assert_ref(&c->name, ST_TOK_IDENT, "cc-flags", 11, 5); + munit_assert_not_null(c->args); + assert_ref(&c->args->value, ST_TOK_STRING, "\"-std=c23\"", 11, 14); + assert_ref(&c->args->next->value, ST_TOK_STRING, "\"-Wall\"", 11, 25); + munit_assert_null(c->args->next->next); + + /* child 4: jobs 8 */ + c = c->next; + munit_assert_not_null(c); + assert_ref(&c->name, ST_TOK_IDENT, "jobs", 12, 5); + assert_ref(&c->args->value, ST_TOK_NUMBER, "8", 12, 10); + munit_assert_null(c->args->next); + + /* child 5: optimize #true debug=#false */ + c = c->next; + munit_assert_not_null(c); + assert_ref(&c->name, ST_TOK_IDENT, "optimize", 13, 5); + assert_ref(&c->args->value, ST_TOK_TRUE, "#true", 13, 14); + munit_assert_null(c->args->next); + munit_assert_not_null(c->props); + assert_ref(&c->props->key, ST_TOK_IDENT, "debug", 13, 20); + assert_ref(&c->props->value, ST_TOK_FALSE, "#false", 13, 26); + munit_assert_null(c->props->next); + + /* child 6: feature "unit-tests" { option "munit" { default #true } } */ + c = c->next; + munit_assert_not_null(c); + assert_ref(&c->name, ST_TOK_IDENT, "feature", 14, 5); + assert_ref(&c->args->value, ST_TOK_STRING, "\"unit-tests\"", 14, 13); + munit_assert_null(c->args->next); + munit_assert_not_null(c->children); + assert_ref(&c->children->name, ST_TOK_IDENT, "option", 15, 9); + assert_ref(&c->children->args->value, ST_TOK_STRING, "\"munit\"", 15, + 16); + munit_assert_not_null(c->children->children); + assert_ref(&c->children->children->name, ST_TOK_IDENT, "default", 15, + 26); + assert_ref(&c->children->children->args->value, ST_TOK_TRUE, "#true", + 15, 34); + munit_assert_null(c->children->children->args->next); + munit_assert_null(c->next); + } + + /* --- credits with type annotation (meta) -------------------------- */ + n = n->next; + munit_assert_not_null(n); + munit_assert_not_null(n->type); + /* the TYPE token's span starts at the opening '('; its text slice is + * the inner identifier without the parens (see lexer.h) */ + assert_ref(n->type, ST_TOK_TYPE, "meta", 21, 1); + assert_ref(&n->name, ST_TOK_IDENT, "credits", 21, 7); + assert_ref(&n->args->value, ST_TOK_STRING, "\"huntedbytheirs\"", 21, 15); + munit_assert_null(n->args->next); + munit_assert_null(n->props); + munit_assert_null(n->next); + + st_kdl_document_free(doc); + free(src); + return MUNIT_OK; +} + +/* (b) `node {` — unterminated children block errors with the span of the + * `{` token. */ +static MunitResult +test_unterminated_children(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + const char *src = "node {"; + struct st_error *err = NULL; + struct st_kdl_document *doc; + + doc = st_kdl_parse(src, "t.kdl", &err); + munit_assert_null(doc); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_PARSE); + /* the span must point at the `{` (line 1, col 6), not at EOF */ + munit_assert_not_null(err->span); + munit_assert_size(err->span->line, ==, 1); + munit_assert_size(err->span->col, ==, 6); + munit_assert_true(strstr(st_error_message(err), "children") != NULL); + st_error_free(err); + return MUNIT_OK; +} + +/* (c) a stray `}` at top level errors with the span of the `}`. */ +static MunitResult +test_stray_rbrace(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + + doc = st_kdl_parse("}", "t.kdl", &err); + munit_assert_null(doc); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_PARSE); + munit_assert_not_null(err->span); + munit_assert_size(err->span->line, ==, 1); + munit_assert_size(err->span->col, ==, 1); + st_error_free(err); + + /* also stray inside a completed children block: `node { child } }` */ + err = NULL; + doc = st_kdl_parse("node { child } }", "t.kdl", &err); + munit_assert_null(doc); + munit_assert_not_null(err); + munit_assert_size(err->span->col, ==, 16); + st_error_free(err); + return MUNIT_OK; +} + +/* (d) prop classification: a token followed by `=` is a key, the next + * value token is its value; bare values are args, in source order. */ +static MunitResult +test_prop_classification(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + struct st_kdl_node *n; + struct st_kdl_arg *a; + struct st_kdl_prop *p; + + doc = st_kdl_parse("node key=value key2=\"v2\" 1 2 #true", "t.kdl", &err); + munit_assert_not_null(doc); + munit_assert_null(err); + n = doc->nodes; + munit_assert_not_null(n); + munit_assert_null(n->next); + + p = n->props; + munit_assert_not_null(p); + assert_ref(&p->key, ST_TOK_IDENT, "key", 1, 6); + assert_ref(&p->value, ST_TOK_IDENT, "value", 1, 10); + p = p->next; + munit_assert_not_null(p); + assert_ref(&p->key, ST_TOK_IDENT, "key2", 1, 16); + assert_ref(&p->value, ST_TOK_STRING, "\"v2\"", 1, 21); + munit_assert_null(p->next); + + a = n->args; + munit_assert_not_null(a); + assert_ref(&a->value, ST_TOK_NUMBER, "1", 1, 26); + a = a->next; + munit_assert_not_null(a); + assert_ref(&a->value, ST_TOK_NUMBER, "2", 1, 28); + a = a->next; + munit_assert_not_null(a); + assert_ref(&a->value, ST_TOK_TRUE, "#true", 1, 30); + munit_assert_null(a->next); + + st_kdl_document_free(doc); + return MUNIT_OK; +} + +/* (e) interleaved args/props keep their relative order within each list: + * `node a=1 b c=2 d` -> args [b, d], props [a=1, c=2]. */ +static MunitResult +test_interleaved_order(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + struct st_kdl_node *n; + + doc = st_kdl_parse("node a=1 b c=2 d", "t.kdl", &err); + munit_assert_not_null(doc); + munit_assert_null(err); + n = doc->nodes; + munit_assert_not_null(n); + + /* args, in order: b then d */ + munit_assert_not_null(n->args); + assert_ref(&n->args->value, ST_TOK_IDENT, "b", 1, 10); + assert_ref(&n->args->next->value, ST_TOK_IDENT, "d", 1, 16); + munit_assert_null(n->args->next->next); + + /* props, in order: a=1 then c=2 */ + munit_assert_not_null(n->props); + assert_ref(&n->props->key, ST_TOK_IDENT, "a", 1, 6); + assert_ref(&n->props->value, ST_TOK_NUMBER, "1", 1, 8); + assert_ref(&n->props->next->key, ST_TOK_IDENT, "c", 1, 12); + assert_ref(&n->props->next->value, ST_TOK_NUMBER, "2", 1, 14); + munit_assert_null(n->props->next->next); + + st_kdl_document_free(doc); + return MUNIT_OK; +} + +/* (f) newline terminates a node: `a 1\nb 2` is two nodes, not one node + * with four entries. */ +static MunitResult +test_newline_separates_nodes(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + struct st_kdl_node *n; + + doc = st_kdl_parse("a 1\nb 2", "t.kdl", &err); + munit_assert_not_null(doc); + munit_assert_null(err); + + n = doc->nodes; + munit_assert_not_null(n); + assert_ref(&n->name, ST_TOK_IDENT, "a", 1, 1); + assert_ref(&n->args->value, ST_TOK_NUMBER, "1", 1, 3); + munit_assert_null(n->args->next); + + n = n->next; + munit_assert_not_null(n); + assert_ref(&n->name, ST_TOK_IDENT, "b", 2, 1); + assert_ref(&n->args->value, ST_TOK_NUMBER, "2", 2, 3); + munit_assert_null(n->args->next); + munit_assert_null(n->next); + + st_kdl_document_free(doc); + return MUNIT_OK; +} + +/* (g) semicolons separate nodes; repeated separators are trivia. */ +static MunitResult +test_semicolon_separators(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + struct st_kdl_node *n; + size_t count = 0; + + doc = st_kdl_parse("node ; ; node", "t.kdl", &err); + munit_assert_not_null(doc); + munit_assert_null(err); + + for (n = doc->nodes; n != NULL; n = n->next) { + assert_ref(&n->name, ST_TOK_IDENT, "node", 1, + (size_t)(count == 0 ? 1 : 10)); + count++; + } + munit_assert_size(count, ==, 2); + + st_kdl_document_free(doc); + return MUNIT_OK; +} + +/* (h) an empty document parses to a document with zero nodes. */ +static MunitResult +test_empty_document(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + + doc = st_kdl_parse("", "t.kdl", &err); + munit_assert_not_null(doc); + munit_assert_null(err); + munit_assert_null(doc->nodes); + st_kdl_document_free(doc); + + /* whitespace/comment-only is also an empty document */ + err = NULL; + doc = st_kdl_parse(" // nothing\n/* nor here */\n/- ;", "t.kdl", &err); + munit_assert_not_null(doc); + munit_assert_null(err); + munit_assert_null(doc->nodes); + st_kdl_document_free(doc); + return MUNIT_OK; +} + +/* (i) `node = x` is invalid: the node name cannot be a property key. */ +static MunitResult +test_equals_after_name(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + + doc = st_kdl_parse("node = x", "t.kdl", &err); + munit_assert_null(doc); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_PARSE); + munit_assert_not_null(err->span); + munit_assert_size(err->span->line, ==, 1); + munit_assert_size(err->span->col, ==, 6); + st_error_free(err); + + /* a non-string value before `=` is equally invalid: `node 1 = 2` */ + err = NULL; + doc = st_kdl_parse("node 1 = 2", "t.kdl", &err); + munit_assert_null(doc); + munit_assert_not_null(err); + munit_assert_size(err->span->col, ==, 8); + st_error_free(err); + return MUNIT_OK; +} + +/* (j) a property needs a value: `node key =` errors at EOF; `node key = {` + * errors at the `{`. */ +static MunitResult +test_prop_missing_value(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + + doc = st_kdl_parse("node key =", "t.kdl", &err); + munit_assert_null(doc); + munit_assert_not_null(err); + munit_assert_true(strstr(st_error_message(err), "value") != NULL); + munit_assert_not_null(err->span); + munit_assert_size(err->span->col, ==, 11); + st_error_free(err); + + err = NULL; + doc = st_kdl_parse("node key = {", "t.kdl", &err); + munit_assert_null(doc); + munit_assert_not_null(err); + munit_assert_true(strstr(st_error_message(err), "value") != NULL); + munit_assert_size(err->span->col, ==, 12); + st_error_free(err); + return MUNIT_OK; +} + +/* (k) a lexer error is propagated with its span, copied into owned + * storage: the span must still be readable after st_kdl_parse returns. */ +static MunitResult +test_lexer_error_propagates(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + + doc = st_kdl_parse("node \"unterminated", "t.kdl", &err); + munit_assert_null(doc); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_PARSE); + munit_assert_not_null(err->span); + munit_assert_size(err->span->line, ==, 1); + munit_assert_size(err->span->col, ==, 6); + munit_assert_true(strstr(st_error_message(err), "unterminated") != NULL); + st_error_free(err); + return MUNIT_OK; +} + +/* (l) type annotation attaches to the node name; quoted node names and + * string keys work. */ +static MunitResult +test_annotation_and_string_names(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + struct st_kdl_node *n; + + doc = st_kdl_parse("(u8)size 4\n\"quoted node\" x\np \"k s\"=1", "t.kdl", + &err); + munit_assert_not_null(doc); + munit_assert_null(err); + + n = doc->nodes; + munit_assert_not_null(n); + munit_assert_not_null(n->type); + assert_ref(n->type, ST_TOK_TYPE, "u8", 1, 1); + assert_ref(&n->name, ST_TOK_IDENT, "size", 1, 5); + assert_ref(&n->args->value, ST_TOK_NUMBER, "4", 1, 10); + munit_assert_null(n->args->next); + + n = n->next; + munit_assert_not_null(n); + assert_ref(&n->name, ST_TOK_STRING, "\"quoted node\"", 2, 1); + assert_ref(&n->args->value, ST_TOK_IDENT, "x", 2, 15); + munit_assert_null(n->args->next); + + n = n->next; + munit_assert_not_null(n); + assert_ref(&n->name, ST_TOK_IDENT, "p", 3, 1); + munit_assert_not_null(n->props); + assert_ref(&n->props->key, ST_TOK_STRING, "\"k s\"", 3, 3); + assert_ref(&n->props->value, ST_TOK_NUMBER, "1", 3, 9); + munit_assert_null(n->props->next); + munit_assert_null(n->next); + + st_kdl_document_free(doc); + return MUNIT_OK; +} + +/* (m) single-line children blocks and nested blocks parse recursively. */ +static MunitResult +test_children_nesting(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + struct st_kdl_node *n; + + doc = st_kdl_parse("p { a; b; c { d } }", "t.kdl", &err); + munit_assert_not_null(doc); + munit_assert_null(err); + + n = doc->nodes; + munit_assert_not_null(n); + assert_ref(&n->name, ST_TOK_IDENT, "p", 1, 1); + munit_assert_not_null(n->children); + assert_ref(&n->children->name, ST_TOK_IDENT, "a", 1, 5); + assert_ref(&n->children->next->name, ST_TOK_IDENT, "b", 1, 8); + assert_ref(&n->children->next->next->name, ST_TOK_IDENT, "c", 1, 11); + munit_assert_not_null(n->children->next->next->children); + assert_ref(&n->children->next->next->children->name, ST_TOK_IDENT, "d", + 1, 15); + munit_assert_null(n->children->next->next->next); + munit_assert_null(n->next); + + st_kdl_document_free(doc); + return MUNIT_OK; +} + +/* (n) `/-` and comments are trivia: a slashdash between entries vanishes, + * and a node ends at the slashdash/semicolon regardless of line. */ +static MunitResult +test_trivia_skipped(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc; + struct st_kdl_node *n; + size_t count = 0; + + doc = st_kdl_parse("node a=1 /-\n; // still trivia\nb", "t.kdl", &err); + munit_assert_not_null(doc); + munit_assert_null(err); + + n = doc->nodes; + munit_assert_not_null(n); + assert_ref(&n->name, ST_TOK_IDENT, "node", 1, 1); + munit_assert_not_null(n->props); + assert_ref(&n->props->key, ST_TOK_IDENT, "a", 1, 6); + assert_ref(&n->props->value, ST_TOK_NUMBER, "1", 1, 8); + munit_assert_null(n->props->next); + munit_assert_null(n->args); + + for (n = n->next; n != NULL; n = n->next) { + count++; + } + munit_assert_size(count, ==, 1); + assert_ref(&doc->nodes->next->name, ST_TOK_IDENT, "b", 3, 1); + + st_kdl_document_free(doc); + return MUNIT_OK; +} + +/* (o) `st_kdl_document_free(NULL)` is a safe no-op. */ +static MunitResult +test_free_null(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + st_kdl_document_free(NULL); + return MUNIT_OK; +} + +static MunitTest tests[] = { + { "/parse/fixture-basic-kdl", test_parse_fixture, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/unterminated-children", test_unterminated_children, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/stray-rbrace", test_stray_rbrace, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/prop-classification", test_prop_classification, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/interleaved-order", test_interleaved_order, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/newline-separates-nodes", test_newline_separates_nodes, NULL, + NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/semicolon-separators", test_semicolon_separators, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/empty-document", test_empty_document, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/equals-after-name", test_equals_after_name, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/prop-missing-value", test_prop_missing_value, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/lexer-error-propagates", test_lexer_error_propagates, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/annotation-and-string-names", test_annotation_and_string_names, + NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/children-nesting", test_children_nesting, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/trivia-skipped", test_trivia_skipped, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/parse/free-null", test_free_null, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, +}; + +static const MunitSuite suite = { + "/parser", tests, NULL, 1, MUNIT_SUITE_OPTION_NONE, +}; + +int +main(int argc, char *argv[MUNIT_ARRAY_PARAM(argc + 1)]) +{ + return munit_suite_main(&suite, NULL, argc, argv); +}