feat(error): add typed errors + source-span diagnostics

This commit is contained in:
2026-08-28 19:58:29 -04:00
parent 911061eab7
commit d02b5aabcd
7 changed files with 529 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
/* LINK: ../../src/error.c ../../src/span.c */
/* tests/unit/test_error.c
*
* Unit tests for the error + source-span diagnostic infrastructure.
*
* 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). munit.c and the include dirs are
* added automatically by the harness.
*/
#include "munit.h"
#include "error.h"
#include "span.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Render into a tmpfile and slurp it back into a NUL-terminated string.
* Returns NULL on any I/O failure. Caller frees with free(). */
static char *
capture(FILE *f)
{
long n;
char *buf;
if (fseek(f, 0, SEEK_END) != 0) {
return NULL;
}
n = ftell(f);
if (n < 0 || fseek(f, 0, SEEK_SET) != 0) {
return NULL;
}
buf = munit_malloc((size_t)n + 1);
if (fread(buf, 1, (size_t)n, f) != (size_t)n) {
free(buf);
return NULL;
}
buf[n] = '\0';
return buf;
}
/* (a) the canonical span renders byte-for-byte per the plan's contract. */
static MunitResult
test_span_exact_rendering(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
const struct st_span span = { "file.kdl", 3, 5 };
FILE *f = tmpfile();
char *got;
munit_assert_not_null(f);
st_span_print(f, &span, "<msg>");
got = capture(f);
fclose(f);
munit_assert_not_null(got);
munit_assert_string_equal(got, "file.kdl:3:5: <msg>\n ...^\n");
free(got);
return MUNIT_OK;
}
/* (b) a span at line 0 / col 0 renders without underflow: no crash,
* sane byte-exact output. */
static MunitResult
test_span_zero_coords_no_underflow(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
const struct st_span span = { "zero.kdl", 0, 0 };
FILE *f = tmpfile();
char *got;
munit_assert_not_null(f);
st_span_print(f, &span, "<msg>");
got = capture(f);
fclose(f);
munit_assert_not_null(got);
munit_assert_string_equal(got, "zero.kdl:0:0: <msg>\n ...^\n");
free(got);
return MUNIT_OK;
}
/* (c) error categories round-trip through their constructors. */
static MunitResult
test_error_category_roundtrip(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
struct st_error *e = st_error_kdl_parse("<msg>");
munit_assert_not_null(e);
munit_assert_int(st_error_category_of(e), ==, ST_ERR_KDL_PARSE);
munit_assert_string_equal(st_error_message(e), "<msg>");
st_error_free(e);
return MUNIT_OK;
}
/* every category constructor tags its own category. */
static MunitResult
test_error_constructors(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
struct st_error *e;
e = st_error_kdl_schema("m");
munit_assert_int(st_error_category_of(e), ==, ST_ERR_KDL_SCHEMA);
st_error_free(e);
e = st_error_io("m");
munit_assert_int(st_error_category_of(e), ==, ST_ERR_IO);
st_error_free(e);
e = st_error_usage("m");
munit_assert_int(st_error_category_of(e), ==, ST_ERR_USAGE);
st_error_free(e);
e = st_error_internal("m");
munit_assert_int(st_error_category_of(e), ==, ST_ERR_INTERNAL);
st_error_free(e);
st_error_free(NULL); /* must be a safe no-op */
return MUNIT_OK;
}
/* category names exist, are non-empty, and are pairwise distinct. */
static MunitResult
test_error_category_names(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
int c;
const char *prev = NULL;
for (c = ST_ERR_KDL_PARSE; c <= ST_ERR_INTERNAL; c++) {
const char *n =
st_error_category_name((enum st_error_category)c);
munit_assert_not_null(n);
munit_assert_int(strlen(n), >, 0);
if (prev != NULL) {
munit_assert_string_not_equal(n, prev);
}
prev = n;
}
return MUNIT_OK;
}
/* st_error_print with a span renders span shape + category-prefixed msg. */
static MunitResult
test_error_print_with_span(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
const struct st_span span = { "file.kdl", 3, 5 };
struct st_error *e = st_error_at(st_error_kdl_parse("<msg>"), &span);
FILE *f = tmpfile();
char *got;
munit_assert_not_null(e);
munit_assert_not_null(f);
st_error_print(f, e);
got = capture(f);
fclose(f);
munit_assert_not_null(got);
munit_assert_string_equal(
got, "file.kdl:3:5: [kdl-parse] <msg>\n ...^\n");
free(got);
st_error_free(e);
return MUNIT_OK;
}
/* st_error_print without a span renders category-prefixed message only. */
static MunitResult
test_error_print_without_span(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
struct st_error *e = st_error_io("disk on fire");
FILE *f = tmpfile();
char *got;
munit_assert_not_null(e);
munit_assert_not_null(f);
st_error_print(f, e);
got = capture(f);
fclose(f);
munit_assert_not_null(got);
munit_assert_string_equal(got, "[io] disk on fire\n");
free(got);
st_error_free(e);
return MUNIT_OK;
}
static MunitTest tests[] = {
{ "/span/exact-rendering", test_span_exact_rendering, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/span/zero-coords-no-underflow", test_span_zero_coords_no_underflow,
NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL },
{ "/error/category-roundtrip", test_error_category_roundtrip, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/error/constructors", test_error_constructors, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/error/category-names", test_error_category_names, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/error/print-with-span", test_error_print_with_span, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/error/print-without-span", test_error_print_without_span, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL },
};
static const MunitSuite suite = {
"/error", 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);
}