Files
stupidtools/tests/unit/test_schema.c
T

530 lines
19 KiB
C

/* 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 <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ---- 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);
}