diff --git a/.gitignore b/.gitignore index b3bfb8a..9a27dde 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,9 @@ test-suite.log *.trs *.log +# ---> unit test binaries (built by tests/run.sh, never committed) +/tests/.unit-build/ + # ---> stupidtools binaries (built, never committed) /stupidtools /src/stupidtools diff --git a/src/error.c b/src/error.c new file mode 100644 index 0000000..98d6174 --- /dev/null +++ b/src/error.c @@ -0,0 +1,138 @@ +#include "error.h" + +#include +#include + +/* C23 only: ISO/IEC 9899:2024. */ + +static const char *const category_names[] = { + [ST_ERR_KDL_PARSE] = "kdl-parse", + [ST_ERR_KDL_SCHEMA] = "kdl-schema", + [ST_ERR_IO] = "io", + [ST_ERR_USAGE] = "usage", + [ST_ERR_INTERNAL] = "internal", +}; + +struct st_error * +st_error_new(enum st_error_category category, const char *message) +{ + struct st_error *e; + size_t len; + + if (message == NULL) { + message = ""; + } + e = calloc(1, sizeof(*e)); + if (e == NULL) { + return NULL; + } + len = strlen(message); + e->message = malloc(len + 1); + if (e->message == NULL) { + free(e); + return NULL; + } + memcpy(e->message, message, len + 1); + e->category = category; + return e; +} + +struct st_error * +st_error_kdl_parse(const char *message) +{ + return st_error_new(ST_ERR_KDL_PARSE, message); +} + +struct st_error * +st_error_kdl_schema(const char *message) +{ + return st_error_new(ST_ERR_KDL_SCHEMA, message); +} + +struct st_error * +st_error_io(const char *message) +{ + return st_error_new(ST_ERR_IO, message); +} + +struct st_error * +st_error_usage(const char *message) +{ + return st_error_new(ST_ERR_USAGE, message); +} + +struct st_error * +st_error_internal(const char *message) +{ + return st_error_new(ST_ERR_INTERNAL, message); +} + +struct st_error * +st_error_at(struct st_error *e, const struct st_span *span) +{ + if (e != NULL) { + e->span = span; + } + return e; +} + +void +st_error_free(struct st_error *e) +{ + if (e == NULL) { + return; + } + free(e->message); + free(e); +} + +enum st_error_category +st_error_category_of(const struct st_error *e) +{ + return e != NULL ? e->category : ST_ERR_INTERNAL; +} + +const char * +st_error_message(const struct st_error *e) +{ + return e != NULL ? e->message : ""; +} + +const char * +st_error_category_name(enum st_error_category category) +{ + if ((size_t)category >= sizeof(category_names) / sizeof(category_names[0])) { + return "unknown"; + } + return category_names[category]; +} + +void +st_error_print(FILE *out, const struct st_error *e) +{ + const char *cat; + char *prefixed; + size_t len; + int rc; + + if (out == NULL || e == NULL) { + return; + } + cat = st_error_category_name(e->category); + len = strlen(cat) + strlen(e->message) + 4; /* "[", "]", " ", NUL */ + prefixed = malloc(len); + if (prefixed == NULL) { + fprintf(out, "%s\n", e->message); + return; + } + rc = snprintf(prefixed, len, "[%s] %s", cat, e->message); + if (rc < 0) { + prefixed[0] = '\0'; + } + if (e->span != NULL) { + st_span_print(out, e->span, prefixed); + } else { + fprintf(out, "%s\n", prefixed); + } + free(prefixed); +} diff --git a/src/error.h b/src/error.h new file mode 100644 index 0000000..3b9a18f --- /dev/null +++ b/src/error.h @@ -0,0 +1,55 @@ +#ifndef ST_ERROR_H +#define ST_ERROR_H + +#include + +#include "span.h" + +/* + * Typed error for all library code paths. Library code RETURNS these; + * it never calls exit()/abort() on user input (only main.c exits). + */ +enum st_error_category { + ST_ERR_KDL_PARSE = 0, + ST_ERR_KDL_SCHEMA, + ST_ERR_IO, + ST_ERR_USAGE, + ST_ERR_INTERNAL, +}; + +struct st_error { + enum st_error_category category; + char *message; /* owned copy; never NULL after construction */ + const struct st_span *span; /* borrowed, may be NULL */ +}; + +/* Constructors. All duplicate `message`; NULL is treated as "". They + * return NULL only on allocation failure. */ +struct st_error *st_error_new(enum st_error_category category, + const char *message); +struct st_error *st_error_kdl_parse(const char *message); +struct st_error *st_error_kdl_schema(const char *message); +struct st_error *st_error_io(const char *message); +struct st_error *st_error_usage(const char *message); +struct st_error *st_error_internal(const char *message); + +/* Attaches a borrowed span and returns `e` for chaining: + * err = st_error_at(st_error_kdl_parse("bad token"), &span); */ +struct st_error *st_error_at(struct st_error *e, const struct st_span *span); + +/* Frees the error (message + struct). NULL is a safe no-op. */ +void st_error_free(struct st_error *e); + +enum st_error_category st_error_category_of(const struct st_error *e); +const char *st_error_message(const struct st_error *e); + +/* Stable display name, e.g. "kdl-parse". Unknown categories yield + * "unknown" rather than indexing out of range. */ +const char *st_error_category_name(enum st_error_category category); + +/* Prints the diagnostic. With a span: the canonical two-line form with + * the category-prefixed message ("file:line:col: [cat] \n" + + * caret line). Without a span: "[cat] \n". NULL is tolerated. */ +void st_error_print(FILE *out, const struct st_error *e); + +#endif diff --git a/src/span.c b/src/span.c new file mode 100644 index 0000000..2c24751 --- /dev/null +++ b/src/span.c @@ -0,0 +1,33 @@ +#include "span.h" + +/* C23 only: ISO/IEC 9899:2024. */ +#define ST_SPAN_CARET_PAD_CAP 60 + +void +st_span_print(FILE *out, const struct st_span *span, const char *message) +{ + size_t pad; + + if (out == NULL || span == NULL || span->file == NULL || + message == NULL) { + return; + } + fprintf(out, "%s:%zu:%zu: %s\n", span->file, span->line, span->col, + message); + /* + * Caret line: 4-space indent, "..." elision, caret. The caret sits + * three cells past the reported column (the elision marker occupies + * those cells), so col 5 renders exactly " ...^". Columns below + * 5 collapse onto the elision; columns above 5 push the caret right, + * capped to keep the line bounded. + */ + fputs(" ...", out); + pad = span->col > 5 ? span->col - 5 : 0; + if (pad > ST_SPAN_CARET_PAD_CAP) { + pad = ST_SPAN_CARET_PAD_CAP; + } + while (pad-- > 0) { + fputc(' ', out); + } + fputs("^\n", out); +} diff --git a/src/span.h b/src/span.h new file mode 100644 index 0000000..8787bd7 --- /dev/null +++ b/src/span.h @@ -0,0 +1,33 @@ +#ifndef ST_SPAN_H +#define ST_SPAN_H + +#include +#include + +/* + * A source span: 1-based line/column into a source file. + * `file` is borrowed (never freed); typically points into stable + * storage such as a file-name arena or a string literal. + */ +struct st_span { + const char *file; + size_t line; + size_t col; +}; + +/* + * Renders the canonical two-line diagnostic: + * + * ::: + * ...^ + * + * The caret line is a fixed 4-space indent followed by a "..." + * elision marker and the caret. For the canonical span (col 5) + * this is exactly " ...^"; the caret shifts right for larger + * columns and is capped so hostile spans cannot force unbounded + * output. NULL arguments are tolerated and render nothing. + */ +void st_span_print(FILE *out, const struct st_span *span, + const char *message); + +#endif diff --git a/tests/run.sh b/tests/run.sh index 549e8cf..f31a95a 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -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/.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 /thirdparty/munit -I /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 diff --git a/tests/unit/test_error.c b/tests/unit/test_error.c new file mode 100644 index 0000000..0563184 --- /dev/null +++ b/tests/unit/test_error.c @@ -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 +#include +#include + +/* 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, ""); + got = capture(f); + fclose(f); + munit_assert_not_null(got); + munit_assert_string_equal(got, "file.kdl:3:5: \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, ""); + got = capture(f); + fclose(f); + munit_assert_not_null(got); + munit_assert_string_equal(got, "zero.kdl:0:0: \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(""); + + 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), ""); + 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(""), &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] \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); +}