feat(kdl): add KDL parser producing AST

This commit is contained in:
2026-08-28 20:53:45 -04:00
parent 15f8e1a61b
commit 88d78990d5
4 changed files with 1431 additions and 0 deletions
+99
View File
@@ -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 <stddef.h>
#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 */
+621
View File
@@ -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 <stdbool.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
/* ---- 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);
}