/* LINK: ../../src/kdl/schema.c ../../src/detect/check_registry.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: each check's NAME must be one of the 8 kinds from * the feature-check registry (todo 10), and the kind's argument shape is * enforced (exactly one non-empty string target, no children; the * optional `version` property is todo 10's too - see * test_check_registry.c for the per-kind table). */ static MunitResult test_feature_checks_structural(const MunitParameter params[], void *data) { (void)params; (void)data; /* the two fixture kinds plus the rest of the 8 kinds pass */ 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\" }"); /* unknown check kinds are rejected (registry dispatch, todo 10) */ assert_schema_error( "project \"p\" version \"1.0\"\nfeature \"f\" { frobnicate \"x\" }", "'frobnicate'", 2, 15); /* 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); }