diff --git a/src/kdl/schema.c b/src/kdl/schema.c new file mode 100644 index 0000000..9cef174 --- /dev/null +++ b/src/kdl/schema.c @@ -0,0 +1,724 @@ +/* + * schema.c - stupidtools DSL schema validation (todo 9). + * + * Validates a parsed KDL document (src/kdl/ast.h) against the stupidtools + * DSL. The grammar is pinned in schema.h - the contract for todos 10/16/23. + * This module only READS the AST; it never modifies or frees it. + * + * Type checks go through the value model (src/kdl/value.h): names, files, + * version strings and check arguments must be non-empty unannotated + * strings; an option's `default` must be an unannotated boolean. Value- + * model failures (bad escapes, overflow) are re-owned as schema errors. + * + * ERRORS AND SPANS + * ---------------- + * Every failure returns an owned st_error of category ST_ERR_KDL_SCHEMA + * whose span is heap-allocated IN THE SAME BLOCK as the error struct + * (the parser.c pattern), so err->span stays valid until st_error_free() + * and is never dangling. The span is borrowed-by-value from the AST's + * token refs, whose `file` pointer in turn borrows the caller's source + * buffer - the same lifetime contract as the parser. The single span-less + * error is "missing project" on an empty document: there is no source + * position (or file name) to point at. + * + * Copyright (c) 2026 huntedbytheirs + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include "kdl/schema.h" + +#include "error.h" +#include "kdl/ast.h" +#include "kdl/value.h" + +#include +#include +#include +#include +#include + +/* ---- owned errors ----------------------------------------------------- */ + +/* Build an owned ST_ERR_KDL_SCHEMA 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_SCHEMA; + spc = (struct st_span *)((unsigned char *)e + esize); + *spc = sp; + e->span = spc; + return e; +} + +/* Re-own a value-model (ST_ERR_KDL_PARSE) error as a schema error: copy + * the message and the span VALUE into a fresh block. The caller frees the + * original error. */ +static struct st_error * +schema_err_from(const struct st_error *e) +{ + struct st_span sp; + + if (e->span != NULL) { + sp = *e->span; + } else { + sp = (struct st_span){ NULL, 0, 0 }; + } + return err_at_owned(sp, e->message != NULL ? e->message : ""); +} + +/* Interpret a token ref through the value model, converting any failure + * into an owned schema error (the original is freed here). On success + * `*out` holds the interpreted value (caller frees via + * st_kdl_value_free). */ +static struct st_error * +value_interpret(const struct st_kdl_token_ref *tok, + const struct st_kdl_token_ref *ann, + struct st_kdl_value *out) +{ + struct st_error *e = st_kdl_value_from_token_annotated(tok, ann, out); + + if (e != NULL) { + struct st_error *r = schema_err_from(e); + + st_error_free(e); + return r; + } + return NULL; +} + +/* ---- naming helpers --------------------------------------------------- */ + +/* Copy a node name / property key into a fixed buffer for MESSAGE text, + * truncating to fit. Identifiers are copied raw (the common case, no + * allocation); other string forms are interpreted via the value model, + * falling back to "?" when that fails (message-only, never fatal). */ +static void +name_into(const struct st_kdl_token_ref *name, char *buf, size_t cap) +{ + if (cap == 0) { + return; + } + if (name->kind == ST_TOK_IDENT) { + size_t n = name->len < cap - 1 ? name->len : cap - 1; + + memcpy(buf, name->text, n); + buf[n] = '\0'; + return; + } + { + struct st_kdl_value v; + struct st_error *e = st_kdl_value_from_token(name, &v); + + if (e == NULL && v.kind == ST_KDL_VAL_STRING) { + snprintf(buf, cap, "%s", v.as.str); + } else { + snprintf(buf, cap, "?"); + } + st_error_free(e); + st_kdl_value_free(&v); + } +} + +/* Compare a node name against a literal, accepting every string form + * (identifiers compared directly; other forms via the value model). + * Sets *out and returns NULL on success, or an owned schema error when + * the name is a string the value model cannot interpret. */ +static struct st_error * +name_equals(const struct st_kdl_token_ref *name, const char *s, bool *out) +{ + if (name->kind == ST_TOK_IDENT) { + *out = name->len == strlen(s) && + memcmp(name->text, s, name->len) == 0; + return NULL; + } + { + struct st_kdl_value v; + struct st_error *e = st_kdl_value_from_token(name, &v); + + if (e != NULL) { + struct st_error *r = schema_err_from(e); + + st_error_free(e); + *out = false; + return r; + } + *out = v.kind == ST_KDL_VAL_STRING && strcmp(v.as.str, s) == 0; + st_kdl_value_free(&v); + return NULL; + } +} + +/* ---- argument helpers ------------------------------------------------- */ + +/* Interpret `tok` (with optional annotation) as the non-empty unannotated + * string a DSL slot requires; on success *out owns the string (caller + * frees). `ctx` names the slot in messages, e.g. "target name". */ +static struct st_error * +require_string_arg(const struct st_kdl_token_ref *tok, + const struct st_kdl_token_ref *ann, + const char *ctx, char **out) +{ + struct st_kdl_value v; + struct st_error *e; + char msg[192]; + + *out = NULL; + e = value_interpret(tok, ann, &v); + if (e != NULL) { + return e; + } + if (v.annotation != NULL) { + snprintf(msg, sizeof msg, "%s must not carry a type annotation", + ctx); + e = err_at_owned(tok->span, msg); + st_kdl_value_free(&v); + return e; + } + if (v.kind != ST_KDL_VAL_STRING) { + snprintf(msg, sizeof msg, "%s must be a non-empty string (got %s)", + ctx, st_kdl_value_kind_name(v.kind)); + e = err_at_owned(tok->span, msg); + st_kdl_value_free(&v); + return e; + } + if (v.as.str[0] == '\0') { + snprintf(msg, sizeof msg, "%s must be a non-empty string", ctx); + e = err_at_owned(tok->span, msg); + st_kdl_value_free(&v); + return e; + } + *out = v.as.str; + v.as.str = NULL; + st_kdl_value_free(&v); /* frees the annotation only */ + return NULL; +} + +/* The shared name shape of target/feature/option: exactly one non-empty + * string argument. `ctx` is the node kind ("target"). On success *out + * owns the name (caller frees). */ +static struct st_error * +one_name_arg(const struct st_kdl_node *n, const char *ctx, char **out) +{ + char msg[192]; + char cctx[96]; + + *out = NULL; + if (n->args == NULL) { + snprintf(msg, sizeof msg, "%s requires a name argument", ctx); + return err_at_owned(n->name.span, msg); + } + if (n->args->next != NULL) { + char nm[65]; + + name_into(&n->args->value, nm, sizeof nm); + snprintf(msg, sizeof msg, "%s '%s' takes exactly one argument", + ctx, nm); + return err_at_owned(n->args->next->value.span, msg); + } + snprintf(cctx, sizeof cctx, "%s name", ctx); + return require_string_arg(&n->args->value, n->args->annotation, cctx, + out); +} + +/* ---- per-node validators ---------------------------------------------- */ + +/* project "name" version "semver": the name is the first positional + * argument; the version string follows the LITERAL `version` keyword. + * No properties, no children, exactly three arguments. */ +static struct st_error * +validate_project(const struct st_kdl_node *n) +{ + struct st_kdl_arg *a = n->args; + struct st_kdl_arg *ver_kw; + char msg[192]; + char name[65]; + char ctx[96]; + struct st_error *e; + char *name_str = NULL; + char *ver_str = NULL; + + if (a == NULL) { + return err_at_owned(n->name.span, + "project requires a name argument"); + } + e = require_string_arg(&a->value, a->annotation, "project name", + &name_str); + if (e != NULL) { + return e; + } + name_into(&a->value, name, sizeof name); + + a = a->next; + if (a == NULL) { + snprintf(msg, sizeof msg, + "project '%s' requires a version (expected: project \"name\" " + "version \"semver\")", name); + e = err_at_owned(n->name.span, msg); + goto done; + } + if (a->annotation != NULL || + !(a->value.kind == ST_TOK_IDENT && a->value.len == 7 && + memcmp(a->value.text, "version", 7) == 0)) { + snprintf(msg, sizeof msg, + "project '%s': expected the keyword 'version' as the second " + "argument", name); + e = err_at_owned(a->value.span, msg); + goto done; + } + ver_kw = a; + a = a->next; + if (a == NULL) { + snprintf(msg, sizeof msg, + "project '%s': 'version' requires the version string argument", + name); + e = err_at_owned(ver_kw->value.span, msg); + goto done; + } + snprintf(ctx, sizeof ctx, "project '%s' version", name); + e = require_string_arg(&a->value, a->annotation, ctx, &ver_str); + if (e != NULL) { + goto done; + } + if (a->next != NULL) { + snprintf(msg, sizeof msg, + "project '%s' takes exactly three arguments: the name, the " + "'version' keyword, and the version string", name); + e = err_at_owned(a->next->value.span, msg); + goto done; + } + if (n->props != NULL) { + char key[65]; + + name_into(&n->props->key, key, sizeof key); + snprintf(msg, sizeof msg, "project '%s' has unexpected property " + "'%s'", name, key); + e = err_at_owned(n->props->key.span, msg); + goto done; + } + if (n->children != NULL) { + snprintf(msg, sizeof msg, "project '%s' takes no children", name); + e = err_at_owned(n->children->name.span, msg); + goto done; + } + e = NULL; +done: + free(name_str); + free(ver_str); + return e; +} + +/* One `src ""` or `feature ""` child of a target: exactly one + * non-empty string argument, no properties, no children. */ +static struct st_error * +validate_target_child(const struct st_kdl_node *c, const char *ctx, + bool is_src) +{ + const char *kind = is_src ? "src" : "feature"; + const char *slot = is_src ? "file" : "name"; + char cctx[192]; + char msg[192]; + + if (c->args == NULL) { + snprintf(msg, sizeof msg, "%s '%s' requires a %s argument", ctx, + kind, slot); + return err_at_owned(c->name.span, msg); + } + if (c->args->next != NULL) { + snprintf(msg, sizeof msg, "%s '%s' takes exactly one argument", + ctx, kind); + return err_at_owned(c->args->next->value.span, msg); + } + if (c->props != NULL) { + snprintf(msg, sizeof msg, "%s '%s' takes no properties", ctx, kind); + return err_at_owned(c->props->key.span, msg); + } + if (c->children != NULL) { + snprintf(msg, sizeof msg, "%s '%s' takes no children", ctx, kind); + return err_at_owned(c->children->name.span, msg); + } + { + char *tmp = NULL; + struct st_error *e; + + snprintf(cctx, sizeof cctx, "%s '%s' %s", ctx, kind, slot); + e = require_string_arg(&c->args->value, c->args->annotation, cctx, + &tmp); + free(tmp); + return e; + } +} + +/* target "name" { src ...; feature ...; }: the name shape plus a children + * block whose nodes are only `src` (a file) or `feature` (a reference to + * a top-level feature by name). Presence-checking the reference against + * the document is OPTIONAL for this todo - only the shape is enforced. */ +static struct st_error * +validate_target(const struct st_kdl_node *n) +{ + char msg[256]; + char name[65]; + char ctx[96]; + struct st_error *e; + char *name_str = NULL; + struct st_kdl_node *c; + + e = one_name_arg(n, "target", &name_str); + if (e != NULL) { + return e; + } + name_into(&n->args->value, name, sizeof name); + + if (n->props != NULL) { + char key[65]; + + name_into(&n->props->key, key, sizeof key); + snprintf(msg, sizeof msg, "target '%s' has unexpected property " + "'%s'", name, key); + e = err_at_owned(n->props->key.span, msg); + goto done; + } + + snprintf(ctx, sizeof ctx, "target '%s'", name); + for (c = n->children; c != NULL; c = c->next) { + bool is_src = false; + bool is_feat = false; + + e = name_equals(&c->name, "src", &is_src); + if (e != NULL) { + goto done; + } + e = name_equals(&c->name, "feature", &is_feat); + if (e != NULL) { + goto done; + } + if (is_src || is_feat) { + e = validate_target_child(c, ctx, is_src); + if (e != NULL) { + goto done; + } + continue; + } + { + char cname[65]; + + name_into(&c->name, cname, sizeof cname); + snprintf(msg, sizeof msg, + "%s has unknown child node '%s' (expected 'src' or " + "'feature')", ctx, cname); + e = err_at_owned(c->name.span, msg); + goto done; + } + } + e = NULL; +done: + free(name_str); + return e; +} + +/* feature "name" { }: the name shape (no properties) plus a + * children block of CHECK nodes, validated STRUCTURALLY ONLY. The 8 + * check kinds and their exact argument/property shapes are todo 10's job; + * until its registry lands, every child is accepted provided it has at + * least one non-empty string argument and no children (its properties + * are deliberately left for todo 10 to validate). */ +static struct st_error * +validate_feature(const struct st_kdl_node *n) +{ + char msg[192]; + char name[65]; + struct st_error *e; + char *name_str = NULL; + struct st_kdl_node *c; + + e = one_name_arg(n, "feature", &name_str); + if (e != NULL) { + return e; + } + name_into(&n->args->value, name, sizeof name); + + if (n->props != NULL) { + char key[65]; + + name_into(&n->props->key, key, sizeof key); + snprintf(msg, sizeof msg, "feature '%s' has unexpected property " + "'%s'", name, key); + e = err_at_owned(n->props->key.span, msg); + goto done; + } + + for (c = n->children; c != NULL; c = c->next) { + /* + * HOOK (todo 10): dispatch on the check node NAME through the + * feature-check registry (src/detect/check_registry.h) to + * validate the 8 kinds - header, function, library, type, + * sizeof, program, compiler_flag, pkg_config - and their + * argument/property shapes. Until then, structural checks only: + */ + if (c->args == NULL) { + char cname[65]; + + name_into(&c->name, cname, sizeof cname); + snprintf(msg, sizeof msg, + "feature '%s': check '%s' requires an argument", name, + cname); + e = err_at_owned(c->name.span, msg); + goto done; + } + { + char cname[65]; + char cctx[192]; + char *tmp = NULL; + + name_into(&c->name, cname, sizeof cname); + snprintf(cctx, sizeof cctx, "feature '%s': check '%s' argument", + name, cname); + e = require_string_arg(&c->args->value, c->args->annotation, + cctx, &tmp); + free(tmp); + if (e != NULL) { + goto done; + } + } + if (c->children != NULL) { + char cname[65]; + + name_into(&c->name, cname, sizeof cname); + snprintf(msg, sizeof msg, + "feature '%s': check '%s' takes no children", name, cname); + e = err_at_owned(c->children->name.span, msg); + goto done; + } + } + e = NULL; +done: + free(name_str); + return e; +} + +/* option "name" (default=)?: the name shape, no children, and at + * most one `default` property whose value must be an unannotated boolean + * (#true/#false) once interpreted through the value model. */ +static struct st_error * +validate_option(const struct st_kdl_node *n) +{ + char msg[256]; + char name[65]; + struct st_error *e; + char *name_str = NULL; + struct st_kdl_prop *p; + bool seen_default = false; + + e = one_name_arg(n, "option", &name_str); + if (e != NULL) { + return e; + } + name_into(&n->args->value, name, sizeof name); + + if (n->children != NULL) { + snprintf(msg, sizeof msg, "option '%s' takes no children", name); + e = err_at_owned(n->children->name.span, msg); + goto done; + } + + for (p = n->props; p != NULL; p = p->next) { + bool is_def = false; + + e = name_equals(&p->key, "default", &is_def); + if (e != NULL) { + goto done; + } + if (!is_def) { + char key[65]; + + name_into(&p->key, key, sizeof key); + snprintf(msg, sizeof msg, + "option '%s' has unexpected property '%s' (only 'default' " + "is allowed)", name, key); + e = err_at_owned(p->key.span, msg); + goto done; + } + if (seen_default) { + snprintf(msg, sizeof msg, + "option '%s' has a duplicate 'default' property", name); + e = err_at_owned(p->key.span, msg); + goto done; + } + seen_default = true; + { + struct st_kdl_value v; + + e = value_interpret(&p->value, p->annotation, &v); + if (e != NULL) { + goto done; + } + if (v.annotation != NULL) { + snprintf(msg, sizeof msg, + "option '%s': 'default' must not carry a type " + "annotation", name); + e = err_at_owned(p->value.span, msg); + st_kdl_value_free(&v); + goto done; + } + if (v.kind != ST_KDL_VAL_BOOL) { + snprintf(msg, sizeof msg, + "option '%s': 'default' must be a boolean (got %s)", + name, st_kdl_value_kind_name(v.kind)); + e = err_at_owned(p->value.span, msg); + st_kdl_value_free(&v); + goto done; + } + st_kdl_value_free(&v); + } + } + e = NULL; +done: + free(name_str); + return e; +} + +/* ---- top level -------------------------------------------------------- */ + +/* Classify a top-level node name. */ +enum top_kind { + TOP_PROJECT = 0, + TOP_TARGET, + TOP_FEATURE, + TOP_OPTION, + TOP_UNKNOWN, +}; + +static struct st_error * +classify_top(const struct st_kdl_node *n, enum top_kind *out) +{ + static const char *const names[] = { "project", "target", "feature", + "option" }; + static const enum top_kind kinds[] = { TOP_PROJECT, TOP_TARGET, + TOP_FEATURE, TOP_OPTION }; + size_t i; + + for (i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + bool eq = false; + struct st_error *e = name_equals(&n->name, names[i], &eq); + + if (e != NULL) { + return e; + } + if (eq) { + *out = kinds[i]; + return NULL; + } + } + *out = TOP_UNKNOWN; + return NULL; +} + +/* ---- public API ------------------------------------------------------- */ + +struct st_error * +st_kdl_validate(const struct st_kdl_document *doc) +{ + struct st_kdl_node *n; + bool saw_goal = false; /* saw at least one target or feature */ + struct st_error *e; + + if (doc == NULL || doc->nodes == NULL) { + /* No source position (or file name) exists to point at. */ + return st_error_kdl_schema("missing required 'project' node"); + } + + n = doc->nodes; + { + enum top_kind kind; + + e = classify_top(n, &kind); + if (e != NULL) { + return e; + } + if (kind != TOP_PROJECT) { + char nm[65]; + char msg[192]; + + name_into(&n->name, nm, sizeof nm); + snprintf(msg, sizeof msg, + "expected 'project' as the first top-level node, got '%s'", + nm); + return err_at_owned(n->name.span, msg); + } + e = validate_project(n); + if (e != NULL) { + return e; + } + } + + for (n = n->next; n != NULL; n = n->next) { + enum top_kind kind; + + e = classify_top(n, &kind); + if (e != NULL) { + return e; + } + switch (kind) { + case TOP_PROJECT: + return err_at_owned(n->name.span, "duplicate 'project' node"); + case TOP_TARGET: + e = validate_target(n); + if (e != NULL) { + return e; + } + saw_goal = true; + break; + case TOP_FEATURE: + e = validate_feature(n); + if (e != NULL) { + return e; + } + saw_goal = true; + break; + case TOP_OPTION: + e = validate_option(n); + if (e != NULL) { + return e; + } + break; + case TOP_UNKNOWN: { + char nm[65]; + char msg[192]; + + name_into(&n->name, nm, sizeof nm); + snprintf(msg, sizeof msg, + "unknown top-level node '%s' (expected 'target', " + "'feature', or 'option')", nm); + return err_at_owned(n->name.span, msg); + } + } + } + + if (!saw_goal) { + return err_at_owned(doc->nodes->name.span, + "missing 'target' or 'feature' node after 'project'"); + } + return NULL; +} diff --git a/src/kdl/schema.h b/src/kdl/schema.h new file mode 100644 index 0000000..02a4c55 --- /dev/null +++ b/src/kdl/schema.h @@ -0,0 +1,63 @@ +/* + * schema.h - stupidtools DSL schema validation (todo 9). + * + * st_kdl_validate() checks a parsed document (src/kdl/ast.h) against the + * stupidtools build-file DSL - a KDL subset. THE GRAMMAR PINNED HERE IS THE + * CONTRACT consumed by todo 10 (feature-check registry), todo 16 (configure + * generation) and todo 23 (self-host). Change it only deliberately: + * + * document := project (target | feature | option)* project FIRST + * project := 'project' version + * target := 'target' '{' (src | feature)* '}' + * src := 'src' + * featref := 'feature' (name of a top-level feature) + * feature := 'feature' '{' check* '}' + * check := props* STRUCTURAL ONLY until todo 10 + * option := 'option' (default '=' )? + * + * /// are KDL strings (identifier or any quoted/ + * raw/multi-line form), all required to be NON-EMPTY and unannotated; + * is an unannotated #true/#false. At least one target or feature + * must follow the project node. + * + * PINNED DECISIONS (deliberate; the plan's shorthand left these open): + * - project version syntax: `project "p" version "1.0.0"` - the version + * is the argument AFTER the literal `version` keyword (a bare + * identifier). The property form `version="..."` is NOT accepted. + * - option default: an optional `default` PROPERTY (`option "x" + * default=#true`), not a child node. + * - feature children are validated structurally only: at least one + * non-empty string argument, no children, arbitrary properties. The + * 8 check kinds (header/function/library/type/sizeof/program/ + * compiler_flag/pkg_config) and their exact argument/property shapes + * are todo 10's job - see the marked HOOK in schema.c. + * - the semver FORMAT is not checked (any non-empty string is accepted). + * - duplicate NAMES among target/feature/option are not checked + * (deferred to the generation todos; duplicate project IS rejected). + * + * ERRORS + * ------ + * Returns NULL on success, or an owned st_error (ST_ERR_KDL_SCHEMA) whose + * span is heap-allocated IN THE SAME BLOCK as the error (the parser.c + * pattern), so err->span stays valid until st_error_free(err). The one + * span-less case: an empty (or NULL) document, where no source position or + * file name exists to point at. + * + * Copyright (c) 2026 huntedbytheirs + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef ST_KDL_SCHEMA_H +#define ST_KDL_SCHEMA_H + +#include "ast.h" /* struct st_kdl_document */ + +struct st_error; + +/* Validate a parsed document against the stupidtools DSL (grammar above). + * Returns NULL on success; on failure returns an owned ST_ERR_KDL_SCHEMA + * error naming the offending node. The document is only read - never + * modified and never freed. */ +struct st_error *st_kdl_validate(const struct st_kdl_document *doc); + +#endif /* ST_KDL_SCHEMA_H */ diff --git a/tests/fixtures/stupid.kdl b/tests/fixtures/stupid.kdl new file mode 100644 index 0000000..b467be4 --- /dev/null +++ b/tests/fixtures/stupid.kdl @@ -0,0 +1,43 @@ +/* stupid.kdl - the canonical stupidtools build file (todo 9 fixture). + + Pins the stupidtools DSL grammar (enforced by src/kdl/schema.h): + + project "name" version "semver" required, MUST be the first node + target "name" { src "file" ... ; feature "name" ... } + feature "name" { } checks: structural until todo 10 + option "name" default=#bool default is an optional bool property + + This is the VALID fixture for tests/unit/test_schema.c and is reused by + todo 16 (configure generation) and todo 23 (self-host), so it is + representative of a real build file, not a minimal toy. */ + +// The project node: the name is the first positional argument; the +// version string follows the literal `version` keyword. +project "stupidtools" version "1.0.0" + +// Features bundle the checks a target depends on. `header` and `library` +// are two of the 8 check kinds (todo 10 defines them all); here they are +// structural placeholders. +feature "pthread" { + header "pthread.h" + library "pthread" +} + +feature "math" { + library "m" +} + +// A target: the sources that make up one artifact, plus the features it +// needs at build/link time. +target "default" { + src "src/main.c" + src "src/cli.c" + src "src/kdl/lexer.c" + src "src/kdl/parser.c" + feature "pthread" + feature "math" +} + +// An option maps to --enable-debug / --disable-debug; `default` is an +// optional boolean property (unannotated #true/#false only). +option "debug" default=#false diff --git a/tests/unit/test_schema.c b/tests/unit/test_schema.c new file mode 100644 index 0000000..c0bfb68 --- /dev/null +++ b/tests/unit/test_schema.c @@ -0,0 +1,529 @@ +/* LINK: ../../src/kdl/schema.c ../../src/kdl/value.c ../../src/kdl/parser.c ../../src/kdl/lexer.c ../../src/error.c ../../src/span.c */ +/* tests/unit/test_schema.c + * + * Unit tests for the stupidtools DSL schema validator (todo 9). + * + * 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). schema.c depends on value.c (typed + * value interpretation) and error.c (typed errors); parser.c + lexer.c + * are linked so documents can be built end-to-end from source text, and + * span.c via error.c's st_span_print. + * + * The grammar under test is pinned in src/kdl/schema.h. Assertions check + * REAL error properties: category (always ST_ERR_KDL_SCHEMA), message + * text (must name the offending node), and exact line/col spans — never + * just "returns an error". + */ +#include "munit.h" + +#include "error.h" +#include "kdl/ast.h" +#include "kdl/schema.h" + +#include +#include +#include + +/* ---- fixture loading -------------------------------------------------- */ + +/* Locate tests/fixtures/stupid.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/stupid.kdl", + "../fixtures/stupid.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; +} + +/* ---- helpers ---------------------------------------------------------- */ + +/* Parse `src` (asserting parse success) and validate; assert the result + * is a ST_ERR_KDL_SCHEMA error whose message contains `needle` and whose + * span is exactly (line, col). */ +static void +assert_schema_error(const char *src, const char *needle, size_t line, + size_t col) +{ + struct st_error *err = NULL; + struct st_kdl_document *doc = st_kdl_parse(src, "t.kdl", &err); + + munit_assert_null(err); + munit_assert_not_null(doc); + err = st_kdl_validate(doc); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA); + munit_assert_not_null(err->span); + munit_assert_size(err->span->line, ==, line); + munit_assert_size(err->span->col, ==, col); + munit_assert_true(strstr(st_error_message(err), needle) != NULL); + st_error_free(err); + st_kdl_document_free(doc); +} + +/* Parse `src` (asserting parse success) and validate; assert success. */ +static void +assert_valid(const char *src) +{ + struct st_error *err = NULL; + struct st_kdl_document *doc = st_kdl_parse(src, "t.kdl", &err); + + munit_assert_null(err); + munit_assert_not_null(doc); + err = st_kdl_validate(doc); + munit_assert_null(err); + st_kdl_document_free(doc); +} + +/* ---- tests ------------------------------------------------------------ */ + +/* (a) tests/fixtures/stupid.kdl validates clean and carries the expected + * canonical shapes: project first (name + version keyword + semver), a + * target with src/feature children, features with header/library check + * placeholders, and an option with a bool default. */ +static MunitResult +test_valid_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; + size_t top_count = 0; + size_t target_children = 0; + + 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); + + /* validates clean (the core acceptance of this todo) */ + munit_assert_null(st_kdl_validate(doc)); + + /* five top-level nodes: project, feature, feature, target, option */ + for (n = doc->nodes; n != NULL; n = n->next) { + top_count++; + } + munit_assert_size(top_count, ==, 5); + + /* project spans (hand-verified against cat -n of the fixture) */ + n = doc->nodes; + munit_assert_size(n->name.span.line, ==, 16); + munit_assert_size(n->name.span.col, ==, 1); + munit_assert_not_null(n->args); + munit_assert_size(n->args->value.span.col, ==, 9); /* "stupidtools" */ + munit_assert_size(n->args->next->value.span.col, ==, 23); /* version kw */ + munit_assert_size(n->args->next->next->value.span.col, ==, 31); /* "1.0.0" */ + + /* target "default" has 6 children: 4 src + 2 feature refs */ + for (n = doc->nodes; n != NULL && top_count-- > 0; n = n->next) { + if (n->name.kind == ST_TOK_IDENT && n->name.len == 6 && + memcmp(n->name.text, "target", 6) == 0) { + struct st_kdl_node *c; + for (c = n->children; c != NULL; c = c->next) { + target_children++; + } + } + } + munit_assert_size(target_children, ==, 6); + + /* option "debug" default=#false: key and value spans */ + n = doc->nodes->next->next->next->next; + munit_assert_size(n->name.span.line, ==, 43); + munit_assert_size(n->args->value.span.col, ==, 8); /* "debug" */ + munit_assert_not_null(n->props); + munit_assert_size(n->props->key.span.col, ==, 16); /* default */ + munit_assert_size(n->props->value.span.col, ==, 24); /* #false */ + + st_kdl_document_free(doc); + free(src); + return MUNIT_OK; +} + +/* (c) missing project: an empty document (and a NULL document) yields a + * schema error with NO span (no file name is available for an empty + * document). */ +static MunitResult +test_missing_project(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); + err = st_kdl_validate(doc); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA); + munit_assert_null(err->span); + munit_assert_true( + strstr(st_error_message(err), "missing required 'project'") != NULL); + st_error_free(err); + st_kdl_document_free(doc); + + /* comment-only documents are equally empty */ + doc = st_kdl_parse("// nothing\n", "t.kdl", &err); + munit_assert_not_null(doc); + err = st_kdl_validate(doc); + munit_assert_not_null(err); + munit_assert_null(err->span); + st_error_free(err); + st_kdl_document_free(doc); + + /* a NULL document is the same failure */ + err = st_kdl_validate(NULL); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA); + st_error_free(err); + return MUNIT_OK; +} + +/* (c/d) project must be FIRST: a document starting with a non-project + * node errors naming that node with its span. */ +static MunitResult +test_project_not_first(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + assert_schema_error("target \"x\" { src \"a.c\" }\n" + "project \"p\" version \"1.0\"", + "'target'", 1, 1); + assert_schema_error("feature \"f\" { header \"h.h\" }\n" + "project \"p\" version \"1.0\"", + "'feature'", 1, 1); + return MUNIT_OK; +} + +/* (b) unknown top-level node names the node + span, in any position after + * the project. */ +static MunitResult +test_unknown_top_level(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + assert_schema_error("project \"p\" version \"1.0\"\n" + "bogus \"x\"\n", + "'bogus'", 2, 1); + assert_schema_error("project \"p\" version \"1.0\"\n" + "target \"t\" { src \"a.c\" }\n" + "wat 1\n", + "'wat'", 3, 1); + return MUNIT_OK; +} + +/* duplicate project nodes are rejected with the second project's span. */ +static MunitResult +test_duplicate_project(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + assert_schema_error("project \"p\" version \"1.0\"\n" + "project \"q\" version \"2.0\"\n" + "target \"t\" { src \"a.c\" }\n", + "duplicate 'project'", 2, 1); + return MUNIT_OK; +} + +/* (e) missing/extra required arguments on every node shape. */ +static MunitResult +test_missing_args(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + /* project argument shapes */ + assert_schema_error("project", "requires a name argument", 1, 1); + assert_schema_error("project \"p\"", "requires a version", 1, 1); + assert_schema_error("project \"p\" version", + "requires the version string", 1, 13); + assert_schema_error("project \"p\" version \"1.0\" extra", + "exactly three arguments", 1, 27); + + /* target/feature/option names */ + assert_schema_error("project \"p\" version \"1.0\"\ntarget", + "target requires a name argument", 2, 1); + assert_schema_error("project \"p\" version \"1.0\"\ntarget \"t\" \"u\"", + "takes exactly one argument", 2, 12); + assert_schema_error("project \"p\" version \"1.0\"\nfeature", + "feature requires a name argument", 2, 1); + assert_schema_error("project \"p\" version \"1.0\"\noption", + "option requires a name argument", 2, 1); + + /* project-only: a build file needs at least one target or feature */ + assert_schema_error("project \"p\" version \"1.0\"", + "missing 'target' or 'feature'", 1, 1); + assert_schema_error("project \"p\" version \"1.0\"\noption \"x\"", + "missing 'target' or 'feature'", 1, 1); + return MUNIT_OK; +} + +/* wrong argument types are rejected via the value model, naming what was + * expected and what was found. */ +static MunitResult +test_wrong_arg_types(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + assert_schema_error("project 42 version \"1.0\"", + "project name must be a non-empty string (got int)", + 1, 9); + assert_schema_error("project \"p\" 99 \"1.0\"", + "expected the keyword 'version'", 1, 13); + assert_schema_error("project \"p\" version 42", + "version must be a non-empty string (got int)", 1, 21); + assert_schema_error("project \"p\" version \"\"", + "version must be a non-empty string", 1, 21); + assert_schema_error("project \"p\" version \"1.0\"\ntarget 42", + "target name must be a non-empty string (got int)", + 2, 8); + return MUNIT_OK; +} + +/* project extras: properties and children are not part of the pinned + * grammar. */ +static MunitResult +test_project_extras(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + assert_schema_error("project \"p\" version \"1.0\" foo=1", + "unexpected property 'foo'", 1, 27); + assert_schema_error( + "project \"p\" version \"1.0\" { target \"t\" { src \"a.c\" } }", + "takes no children", 1, 29); + return MUNIT_OK; +} + +/* (g) unknown target children name the child + span; known children get + * their argument/property/children shapes enforced. */ +static MunitResult +test_target_children(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + assert_schema_error( + "project \"p\" version \"1.0\"\ntarget \"t\" { cc-flags \"-O2\" }", + "'cc-flags'", 2, 14); + assert_schema_error("project \"p\" version \"1.0\"\ntarget \"t\" { jobs 8 }", + "'jobs'", 2, 14); + assert_schema_error( + "project \"p\" version \"1.0\"\ntarget \"t\" { src }", + "'src' requires a file argument", 2, 14); + assert_schema_error( + "project \"p\" version \"1.0\"\ntarget \"t\" { src 42 }", + "'src' file must be a non-empty string (got int)", 2, 18); + assert_schema_error( + "project \"p\" version \"1.0\"\ntarget \"t\" { src \"a.c\" extra }", + "'src' takes exactly one argument", 2, 24); + assert_schema_error( + "project \"p\" version \"1.0\"\ntarget \"t\" { src \"a.c\" foo=1 }", + "'src' takes no properties", 2, 24); + assert_schema_error( + "project \"p\" version \"1.0\"\ntarget \"t\" { src \"a.c\" { x } }", + "'src' takes no children", 2, 26); + + /* feature references inside a target */ + assert_schema_error( + "project \"p\" version \"1.0\"\ntarget \"t\" { feature }", + "'feature' requires a name argument", 2, 14); + assert_schema_error( + "project \"p\" version \"1.0\"\ntarget \"t\" { feature 42 }", + "'feature' name must be a non-empty string (got int)", 2, 22); + assert_schema_error( + "project \"p\" version \"1.0\"\ntarget \"t\" { feature \"p\" foo=1 }", + "'feature' takes no properties", 2, 26); + return MUNIT_OK; +} + +/* feature children: structural validation only (the 8 check kinds are + * todo 10). Any check name passes provided it has >= 1 non-empty string + * argument and no children; check-kind semantics are out of scope here. */ +static MunitResult +test_feature_checks_structural(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + /* the two fixture kinds plus arbitrary names all pass structurally */ + assert_valid("project \"p\" version \"1.0\"\n" + "feature \"f\" { header \"h.h\"\nlibrary \"l\" }"); + assert_valid("project \"p\" version \"1.0\"\n" + "feature \"f\" { type \"size_t\"\nsizeof \"int\"\n" + "function \"strdup\"\nprogram \"gcc\"\n" + "compiler_flag \"-Wall\"\npkg_config \"zlib\" }"); + assert_valid("project \"p\" version \"1.0\"\n" + "feature \"f\" { frobnicate \"x\" }"); + + /* but a check still needs its argument (a non-empty string) */ + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" { header }", + "check 'header' requires an argument", 2, 15); + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" { header 42 }", + "check 'header' argument must be a non-empty string (got int)", + 2, 22); + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" { header \"h.h\" { x } }", + "check 'header' takes no children", 2, 30); + + /* feature shapes */ + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" \"g\" { header \"h.h\" }", + "feature 'f' takes exactly one argument", 2, 13); + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" foo=1 { header \"h.h\" }", + "feature 'f' has unexpected property 'foo'", 2, 13); + return MUNIT_OK; +} + +/* (f) option: `default` is an optional bool property; non-bool defaults + * are rejected with the value's span, as are other properties, duplicate + * defaults, children, and annotated values. */ +static MunitResult +test_option_default(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + assert_valid("project \"p\" version \"1.0\"\n" + "target \"t\" { src \"a.c\" }\n" + "option \"debug\" default=#true"); + assert_valid("project \"p\" version \"1.0\"\n" + "target \"t\" { src \"a.c\" }\n" + "option \"x\""); + + assert_schema_error( + "project \"p\" version \"1.0\"\n" + "target \"t\" { src \"a.c\" }\n" + "option \"x\" default=\"yes\"", + "'default' must be a boolean (got string)", 3, 20); + assert_schema_error( + "project \"p\" version \"1.0\"\n" + "target \"t\" { src \"a.c\" }\n" + "option \"x\" default=1", + "'default' must be a boolean (got int)", 3, 20); + assert_schema_error( + "project \"p\" version \"1.0\"\n" + "target \"t\" { src \"a.c\" }\n" + "option \"x\" default=#null", + "'default' must be a boolean (got null)", 3, 20); + assert_schema_error( + "project \"p\" version \"1.0\"\n" + "target \"t\" { src \"a.c\" }\n" + "option \"x\" default=(bool)#true", + "'default' must not carry a type annotation", 3, 26); + + assert_schema_error( + "project \"p\" version \"1.0\"\n" + "target \"t\" { src \"a.c\" }\n" + "option \"x\" foo=1", + "unexpected property 'foo' (only 'default' is allowed)", 3, 12); + assert_schema_error( + "project \"p\" version \"1.0\"\n" + "target \"t\" { src \"a.c\" }\n" + "option \"x\" default=#true default=#false", + "duplicate 'default' property", 3, 26); + assert_schema_error( + "project \"p\" version \"1.0\"\n" + "target \"t\" { src \"a.c\" }\n" + "option \"x\" { default #true }", + "option 'x' takes no children", 3, 14); + return MUNIT_OK; +} + +static MunitTest tests[] = { + { "/schema/valid-fixture", test_valid_fixture, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/schema/missing-project", test_missing_project, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/schema/project-not-first", test_project_not_first, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/schema/unknown-top-level", test_unknown_top_level, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/schema/duplicate-project", test_duplicate_project, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/schema/missing-args", test_missing_args, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/schema/wrong-arg-types", test_wrong_arg_types, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/schema/project-extras", test_project_extras, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/schema/target-children", test_target_children, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/schema/feature-checks-structural", test_feature_checks_structural, + NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/schema/option-default", test_option_default, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, +}; + +static const MunitSuite suite = { + "/schema", 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); +}