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
+45
View File
@@ -130,6 +130,51 @@ else
fail "two positionals exited rc=$rc (want 2)"
fi
# --- 10. unit tests (tests/unit/*.c via munit) ----------------------------
# Harness contract (see .omo/notepads/stupidtools/learnings.md):
# - each tests/unit/<name>.c declares its extra link sources on its FIRST
# line as: /* LINK: ../../src/foo.c ../../src/bar.c */
# (paths relative to tests/unit/, space-separated)
# - the harness adds -I <repo>/thirdparty/munit -I <repo>/src and
# thirdparty/munit/munit.c automatically, compiles with
# -std=c23 -Wall -Wextra -Wpedantic, and runs each binary.
# - a missing LINK line, a compile failure, or a non-zero exit all FAIL.
UNIT_DIR="$TOP_DIR/tests/unit"
UNIT_BUILD="$TOP_DIR/tests/.unit-build"
if [ -d "$UNIT_DIR" ]; then
if ! mkdir -p "$UNIT_BUILD"; then
fail "cannot create unit build dir: $UNIT_BUILD"
fi
for t in "$UNIT_DIR"/*.c; do
[ -e "$t" ] || continue
name=$(basename "$t" .c)
# first line only: /* LINK: src1.c src2.c */
link=$(sed -n '1s|^/\* LINK: \(.*\) \*/$|\1|p' "$t")
if [ -z "$link" ]; then
fail "unit test $name: missing LINK comment on line 1"
continue
fi
# compile with cwd=tests/unit so LINK paths resolve relative to it;
# absolute paths for everything else.
if (cd "$UNIT_DIR" && cc -std=c23 -Wall -Wextra -Wpedantic \
-I "$TOP_DIR/thirdparty/munit" -I "$TOP_DIR/src" \
"$TOP_DIR/thirdparty/munit/munit.c" $link "$t" \
-o "$UNIT_BUILD/unit_$name") 2>"$UNIT_BUILD/unit_$name.build.log"
then
:
else
fail "unit test $name: compile failed (see $UNIT_BUILD/unit_$name.build.log)"
continue
fi
if "$UNIT_BUILD/unit_$name" >"$UNIT_BUILD/unit_$name.log" 2>&1; then
pass "unit test $name passed"
else
rc=$?
fail "unit test $name exited rc=$rc (see $UNIT_BUILD/unit_$name.log)"
fi
done
fi
# --- summary -------------------------------------------------------------
if [ "$TESTS_FAILED" -ne 0 ]; then
printf 'FAILED: %d/%d checks failed\n' "$TESTS_FAILED" "$TESTS_RUN" >&2
+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);
}