llm
Download
490 lines 20.9 KiBCModified 2026-09-28 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
// `tomo test`: compile and run the `test`/`fails`/`fails_compile` blocks in a
// Tomo file, reporting a per-test pass/fail summary.
//
// Runtime tests (`test` and `fails`) are batched into a single runner binary
// that forks per test (see stdlib/test_harness.c). Compile-failure tests
// (`fails_compile`) are checked here in the driver by forking the frontend and
// typechecking the block in a child, with no C compiler involved. Both kinds of
// result flow into one merged summary.

#include <errno.h>
#include <gc.h>
#include <poll.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#include "../ast.h"
#include "../compile/blocks.h"
#include "../compile/files.h"
#include "../environment.h"
#include "../packages.h"
#include "../parse/files.h"
#include "../stdlib/bool.h"
#include "../stdlib/files.h"
#include "../stdlib/list.h"
#include "../stdlib/path.h"
#include "../stdlib/print.h"
#include "../stdlib/stdlib.h"
#include "../stdlib/table.h"
#include "../stdlib/test_harness.h"
#include "../stdlib/text.h"
#include "commands.h"
#include "common.h"
#include "compilation.h"

typedef struct {
    test_result_t *items;
    int64_t len, cap;
} results_t;

static List_t files = EMPTY_LIST;
static OptionalText_t filter = NONE_TEXT;
static OptionalPath_t package_sources = NULL;
static int32_t jobs = 0; // 0 = unset; see max_jobs below

static cli_arg_t test_spec[] = {
    {"files", &files, List$info(&Path$info), .positional = true, .required = true, .metavar = "file.tm",
     .description = "the files whose tests to run"}, //
    {"filter", &filter, &Text$info, .metavar = "substring",
     .description = "only run tests whose label contains this substring"}, //
    {"jobs", &jobs, &Int32$info, .short_flag = 'j', .metavar = "n",
     .description = "how many test files to build and run at once (default: one per CPU)"}, //
    {"package-sources", &package_sources, &Path$info, .metavar = "dir",
     .description = "a directory holding package sources by digest, used instead of downloading pinned packages"}, //
    OPTIMIZATION_FLAG, //
    DEBUG_FLAG, //
    VERBOSE_FLAG, //
};

// ---- streaming results ----------------------------------------------------
//
// Test jobs run several at a time, so results arrive interleaved. Each one is
// printed the moment it lands, tagged with the file it came from; the run's
// consolidated report (failures with source excerpts, per-file tallies, the
// summary) still comes at the end, from tomo_test_render.
//
// This is append-only on purpose: nothing is redrawn or erased, so the output
// is identical on a terminal and in a pipe apart from color, and scrollback
// stays intact.
static void stream_result(const test_result_t *r) {
    bool ok = r->outcome == TEST_RESULT_PASS;
    const char *green = USE_COLOR ? "\x1b[92m" : "";
    const char *red = USE_COLOR ? "\x1b[91m" : "";
    const char *dim = USE_COLOR ? "\x1b[2m" : "";
    const char *reset = USE_COLOR ? "\x1b[m" : "";
    const char *mark = ok ? (USE_COLOR ? "✔" : "ok") : (USE_COLOR ? "✘" : "FAIL");
    printf("  %s%s%s %s%s%s %s\n", ok ? green : red, mark, reset, dim, r->file ? r->file : "", reset, r->label);
    fflush(stdout);
}

static void push_result(results_t *r, test_result_t x) {
    if (r->len >= r->cap) {
        r->cap = r->cap ? r->cap * 2 : 16;
        r->items = GC_REALLOC(r->items, (size_t)r->cap * sizeof(test_result_t));
    }
    r->items[r->len++] = x;
}

// Read everything from `fd` until EOF into a NUL-terminated buffer.
static char *drain_fd(int fd) {
    size_t cap = 1024, len = 0;
    char *buf = GC_MALLOC_ATOMIC(cap);
    ssize_t n;
    while ((n = read(fd, buf + len, cap - len - 1)) > 0) {
        len += (size_t)n;
        if (len + 1 >= cap) {
            cap *= 2;
            char *bigger = GC_MALLOC_ATOMIC(cap);
            memcpy(bigger, buf, len);
            buf = bigger;
        }
    }
    buf[len] = '\0';
    return buf;
}

// Fork the frontend and typecheck `test`'s body in a child, expecting a
// compiler error. A clean compile means the test failed (it should not have
// compiled); a compiler_err (exit 1 + message on stderr) means it passed.
static test_result_t run_compile_fail(env_t *module_env, ast_t *test_node) {
    DeclareMatch(t, test_node, Test);
    int pipes[2];
    if (pipe(pipes) != 0) print_err("Could not create pipe");
    fflush(NULL);
    pid_t pid = fork();
    if (pid == 0) {
        dup2(pipes[1], STDOUT_FILENO);
        dup2(pipes[1], STDERR_FILENO);
        close(pipes[0]);
        close(pipes[1]);
        USE_COLOR = false;
        setenv("TOMO_PLAIN_ERRORS", "1", 1); // match the message, not the echoed source
        (void)compile_block(fresh_scope(module_env), t->body);
        fflush(NULL);
        _exit(0); // compiled cleanly -> did NOT fail as expected
    }
    close(pipes[1]);
    char *output = drain_fd(pipes[0]);
    close(pipes[0]);
    int status = 0;
    waitpid(pid, &status, 0);

    // Peel off the span trailer before matching, so `fails_compile "..."` never sees it:
    const char *fail_file = NULL;
    int fail_start = 0, fail_end = 0;
    tomo_test_take_span(output, &fail_file, &fail_start, &fail_end);

    bool failed = !WIFEXITED(status) || WEXITSTATUS(status) != 0;
    test_outcome_t outcome;
    if (WIFSIGNALED(status)) {
        // The compiler crashed rather than emitting a diagnostic, which is a compiler bug, not a legitimate
        // "expected failure", so surface it as a failing test instead of a silent pass.
        output = (char *)String("(the compiler crashed with signal ", (int64_t)WTERMSIG(status),
                                " instead of reporting an error)\n", output);
        outcome = TEST_RESULT_WRONG_MESSAGE;
    } else if (!failed) outcome = TEST_RESULT_UNEXPECTED_SUCCESS;
    else if (t->expected_compile_error[0] && !strstr(output, t->expected_compile_error))
        outcome = TEST_RESULT_WRONG_MESSAGE;
    else outcome = TEST_RESULT_PASS;

    return (test_result_t){
        .outcome = outcome,
        .label = t->label,
        .expected_msg = t->expected_compile_error[0] ? t->expected_compile_error : NULL,
        .expect_failure = true,
        .output = output,
        .first_line = (int)get_line_number(test_node->file, test_node->start),
        .last_line = (int)get_line_number(test_node->file, test_node->end),
        .fail_file = fail_file,
        .fail_start = fail_start,
        .fail_end = fail_end,
    };
}

// ---- worker ---------------------------------------------------------------

#define WORKER_RUNNER_CRASHED 3 // exit code: the test runner died on a signal

// Run one file's tests, streaming each result back over `msg_fd`. This runs in
// a forked worker, so anything fatal here, whether an unparseable file or a
// compiler crash, takes down only this job, and the driver turns it into one failed
// file rather than an aborted run.
static void run_tests_for_file(Path_t path, int msg_fd) {
    env_t *env = global_env(source_mapping, instrument, debugging);
    ast_t *ast = parse_file(Path$as_c_string(path), NULL);
    if (!ast) print_err("Could not parse file: ", path);

    // Shared dependencies were built by the driver before any worker started, so this compiles only what belongs
    // to this file, so no two workers write the same artifact. It also collects the object list for the link below.
    List_t object_files = EMPTY_LIST, extra_ldlibs = EMPTY_LIST;
    compile_files(env, List(path), &object_files, &extra_ldlibs, COMPILE_OBJ);

    env_t *module_env = load_module_env(env, ast);

    // With --filter, only run tests whose label contains the substring. Compile-failure tests are skipped here;
    // runtime tests are skipped inside the runner (see TOMO_TEST_FILTER below), so this only gates whether a
    // runner is worth building.
    const char *filt = filter.length > 0 ? Text$as_c_string(filter) : NULL;

    // (1) Compile-failure tests: forked frontend, no C compiler.
    bool has_runtime_tests = false;
    for (ast_list_t *stmt = Match(ast, Block)->statements; stmt; stmt = stmt->next) {
        if (stmt->ast->tag != Test) continue;
        DeclareMatch(test, stmt->ast, Test);
        if (filt && !strstr(test->label, filt)) continue;
        if (test->expected_compile_error) {
            test_result_t r = run_compile_fail(module_env, stmt->ast);
            tomo_test_write_record(msg_fd, &r);
        } else has_runtime_tests = true;
    }

    // (2) Runtime tests: one runner binary that forks per test.
    if (!has_runtime_tests) return;
    int64_t count = 0;
    Text_t runner_source = compile_test_runner(module_env, ast, &count);
    if (count == 0) return;

    Path_t runner = build_test_runner(path, object_files, extra_ldlibs, runner_source);
    const char *runner_path = Path$as_c_string(Path$relative_to(runner, Path$current_dir()));

    // The runner writes its records straight to the driver's pipe, with no need to relay them through this process.
    fflush(NULL);
    pid_t pid = fork();
    if (pid == 0) {
        setenv("TOMO_TEST_RESULT_FD", String((int64_t)msg_fd), 1);
        // The runner's own stdout is a pipe, so left to itself it would turn color off and flatten every captured
        // `>>` value to monochrome. Whether the report is colorized is the driver's call, so pass that down.
        setenv("COLOR", USE_COLOR ? "1" : "0", 1);
        if (filt) setenv("TOMO_TEST_FILTER", filt, 1);
        if (!zig_cache_dir_from_env) unsetenv("ZIG_GLOBAL_CACHE_DIR");
        execl(runner_path, runner_path, (char *)NULL);
        _exit(127);
    }
    int run_status = 0;
    waitpid(pid, &run_status, 0);
    // A clean exit (0 = all passed, 1 = some failed) is expected; death by signal means the runner itself
    // crashed, so the records we forwarded may be truncated. Make sure that can't read as a green suite.
    if (WIFSIGNALED(run_status)) {
        // Flush first: this worker's stdout is a pipe (fully buffered), so _exit() alone would throw away the
        // build/diagnostic output that is the only explanation the driver has to show.
        fflush(NULL);
        _exit(WORKER_RUNNER_CRASHED);
    }
}

// ---- the driver -----------------------------------------------------------

typedef struct {
    Path_t path;
    const char *label;
    pid_t pid;
    int msg_fd, out_fd; // results, and the worker's captured stdout/stderr
    char *out;
    size_t out_len, out_cap;
    results_t results; // buffered so the final report stays grouped by file
    bool started, finished;
} job_t;

static void job_absorb_output(job_t *j) {
    if (j->out_len + 4096 >= j->out_cap) {
        j->out_cap = j->out_cap ? j->out_cap * 2 : 8192;
        char *bigger = GC_MALLOC_ATOMIC(j->out_cap);
        if (j->out_len) memcpy(bigger, j->out, j->out_len);
        j->out = bigger;
    }
    ssize_t n = read(j->out_fd, j->out + j->out_len, j->out_cap - j->out_len - 1);
    if (n > 0) {
        j->out_len += (size_t)n;
        j->out[j->out_len] = '\0';
        return;
    }
    // Only a real end-of-stream closes this: an interrupted read is not EOF, and treating it as one would throw
    // away the rest of a failing job's diagnostic.
    if (n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) return;
    close(j->out_fd);
    j->out_fd = -1;
}

static void start_job(job_t *j) {
    int msg_pipe[2], out_pipe[2];
    if (pipe(msg_pipe) != 0 || pipe(out_pipe) != 0) print_err("Could not create pipe");
    fflush(NULL);
    pid_t pid = fork();
    if (pid == 0) {
        close(msg_pipe[0]);
        close(out_pipe[0]);
        // Everything the worker says goes to the driver, not the terminal, so a diagnostic can't land in the
        // middle of a streamed result line:
        dup2(out_pipe[1], STDOUT_FILENO);
        dup2(out_pipe[1], STDERR_FILENO);
        close(out_pipe[1]);
        run_tests_for_file(j->path, msg_pipe[1]);
        fflush(NULL);
        _exit(EXIT_SUCCESS);
    }
    close(msg_pipe[1]);
    close(out_pipe[1]);
    j->pid = pid;
    j->msg_fd = msg_pipe[0];
    j->out_fd = out_pipe[0];
    j->started = true;
}

// Build the modules that more than one input depends on, before any worker
// starts. Two workers that shared a dependency would otherwise race writing its
// .h/.c/.o. Everything else, each file's own module, is left to its worker,
// so compilation still happens in parallel and results start arriving as soon
// as the first file is ready.
static void build_shared_dependencies(env_t *env, List_t paths) {
    struct {
        Path_t path;
        int64_t count;
    } *seen = NULL;
    int64_t n_seen = 0, cap = 0;

    for (int64_t i = 0; i < (int64_t)paths.length; i++) {
        Path_t path = *(Path_t *)(paths.data + i * paths.stride);
        // Parse it here first, catching a parse error rather than dying on it: this scan runs in the driver, so an
        // unparseable file would otherwise abort the whole run. Left out of the scan, it simply gets no shared
        // deps, and its own worker hits the same error and reports it as that one file failing, which is also
        // why the captured error is dropped rather than printed here.
        parse_error_t parse_err = {};
        if (!parse_file(Path$as_c_string(path), &parse_err)) continue;

        Table_t deps = EMPTY_TABLE, links = EMPTY_TABLE;
        build_file_dependency_graph(env->build_info, path, &deps, &links);
        for (int64_t d = 0; d < (int64_t)deps.entries.length; d++) {
            // Entries are (Path_t key, staleness value); we only want the key, which sits at the front:
            Path_t dep = *(Path_t *)(deps.entries.data + d * deps.entries.stride);
            int64_t at = -1;
            for (int64_t k = 0; k < n_seen; k++)
                if (streq(seen[k].path, dep)) at = k;
            if (at >= 0) {
                seen[at].count += 1;
            } else {
                if (n_seen >= cap) {
                    cap = cap ? cap * 2 : 32;
                    seen = GC_REALLOC(seen, (size_t)cap * sizeof(seen[0]));
                }
                seen[n_seen].path = dep;
                seen[n_seen].count = 1;
                n_seen += 1;
            }
        }
    }

    // A file's own graph includes itself, so an input nothing else depends on has a count of 1 and is left alone.
    // Transitivity needs no special handling: a dependency of a shared module is reached by at least as many
    // inputs, so it's counted shared too.
    List_t shared = EMPTY_LIST;
    for (int64_t k = 0; k < n_seen; k++)
        if (seen[k].count > 1) List$insert(&shared, &seen[k].path, I(0), sizeof(Path_t));

    if (shared.length > 0) compile_files(env, shared, NULL, NULL, COMPILE_OBJ);
}

static int cmd_test(cli_command_t *self, List_t extra_args) {
    (void)self, (void)extra_args;
    set_default_logs(0);
    if (package_sources != NONE_PATH) set_package_sources_dir(package_sources);
    // For `tomo test`, --verbose means "show each test's output", not "show build/toolchain logs", so keep the
    // compiler-progress logs off even when verbose (set_default_logs turns them all on for --verbose):
    enabled_logs = 0;
    // Tests are compiled once and run once; favor fast compilation. -O0, not
    // -O1: clang's -O1 costs nearly as much as -O3 (see cmd/run.c).
    configure_codegen(opt_flag.tag == TEXT_NONE ? Text("0") : opt_flag, /*optimize=*/false);

    // A directory argument is not expanded here; use shell globbing to test many files at once.
    List_t paths = normalize_tm_paths(files);
    if (paths.length == 0) print_err("No files provided to test!\n", self->usage);

    long cpus = sysconf(_SC_NPROCESSORS_ONLN);
    int64_t max_jobs = jobs > 0 ? (int64_t)jobs : (cpus > 0 ? (int64_t)cpus : 1);
    if (max_jobs < 1) max_jobs = 1;

    build_shared_dependencies(global_env(source_mapping, instrument, debugging), paths);
    // Every worker below compiles at least its own object and test runner, and
    // they all preload the same precompiled header. Build it here, before the
    // first fork, so a cold cache costs one compile of <tomo.h> rather than one
    // per worker (build_shared_dependencies only warms it when the inputs
    // actually share a dependency).
    warm_precompiled_header();

    int64_t n = (int64_t)paths.length;
    job_t *job_list = GC_MALLOC((size_t)n * sizeof(job_t));
    memset(job_list, 0, (size_t)n * sizeof(job_t));
    for (int64_t i = 0; i < n; i++) {
        job_list[i].path = *(Path_t *)(paths.data + i * paths.stride);
        job_list[i].label = Path$as_c_string(Path$relative_to(job_list[i].path, Path$current_dir()));
        job_list[i].msg_fd = job_list[i].out_fd = -1;
    }

    // Two fds per in-flight job, and never more in flight than max_jobs:
    size_t poll_cap = 2 * (size_t)max_jobs;
    struct pollfd *fds = GC_MALLOC_ATOMIC(poll_cap * sizeof(*fds));
    job_t **owner = GC_MALLOC(poll_cap * sizeof(*owner));
    bool *is_msg = GC_MALLOC_ATOMIC(poll_cap * sizeof(*is_msg));

    bool crashed = false;
    int64_t next_to_start = 0, running = 0, finished = 0;
    while (finished < n) {
        while (running < max_jobs && next_to_start < n) {
            start_job(&job_list[next_to_start++]);
            running += 1;
        }

        nfds_t nfds = 0;
        for (int64_t i = 0; i < n; i++) {
            job_t *j = &job_list[i];
            if (!j->started || j->finished) continue;
            if (j->msg_fd >= 0) {
                owner[nfds] = j, is_msg[nfds] = true;
                fds[nfds++] = (struct pollfd){.fd = j->msg_fd, .events = POLLIN};
            }
            if (j->out_fd >= 0) {
                owner[nfds] = j, is_msg[nfds] = false;
                fds[nfds++] = (struct pollfd){.fd = j->out_fd, .events = POLLIN};
            }
        }

        if (nfds > 0 && poll(fds, nfds, -1) < 0) {
            if (errno == EINTR) continue;
            print_err("poll() failed while waiting for test jobs");
        }

        for (nfds_t k = 0; k < nfds; k++) {
            if (!(fds[k].revents & (POLLIN | POLLHUP | POLLERR))) continue;
            job_t *j = owner[k];
            if (is_msg[k]) {
                test_result_t rec;
                if (tomo_test_read_record(j->msg_fd, &rec)) {
                    rec.file = j->label;
                    push_result(&j->results, rec);
                    if (verbose != yes) stream_result(&rec);
                } else {
                    close(j->msg_fd);
                    j->msg_fd = -1;
                }
            } else job_absorb_output(j);
        }

        // A job is done once both its streams are at EOF; only then is it safe to reap it.
        for (int64_t i = 0; i < n; i++) {
            job_t *j = &job_list[i];
            if (!j->started || j->finished || j->msg_fd >= 0 || j->out_fd >= 0) continue;
            int status = 0;
            waitpid(j->pid, &status, 0);
            bool died = !WIFEXITED(status) || WEXITSTATUS(status) != 0;
            if (died) {
                // The worker never finished: report it as one failed file rather than losing the whole run. Its
                // captured output is the diagnostic that explains why.
                bool runner_crashed = WIFEXITED(status) && WEXITSTATUS(status) == WORKER_RUNNER_CRASHED;
                if (runner_crashed) crashed = true;
                test_result_t r = {
                    .outcome = TEST_RESULT_UNEXPECTED_FAILURE,
                    .label = (WIFSIGNALED(status) || runner_crashed) ? "<the test job crashed>"
                                                                     : "<the test job could not be built>",
                    .output = j->out_len ? j->out : (char *)"",
                    .file = j->label,
                };
                push_result(&j->results, r);
                if (verbose != yes) stream_result(&r);
            }
            j->finished = true;
            finished += 1;
            running -= 1;
        }
    }

    // Concatenate in input order, not completion order, so the report below is grouped by file and identical
    // from run to run however the jobs happened to interleave:
    results_t results = {0};
    for (int64_t i = 0; i < n; i++)
        for (int64_t k = 0; k < job_list[i].results.len; k++)
            push_result(&results, job_list[i].results.items[k]);

    if (results.len > 0 && verbose != yes) printf("\n");
    tomo_test_render(results.items, results.len, verbose == yes);
    if (crashed) fprint(stderr, "\nA test runner terminated abnormally; results may be incomplete.");

    if (crashed) return 1;
    for (int64_t i = 0; i < results.len; i++)
        if (results.items[i].outcome != TEST_RESULT_PASS) return 1;
    return 0;
}

cli_command_t test_command = {
    .name = "test",
    .summary = "Run the tests in a Tomo file",
    .description = "Compiles and runs the `test`, `fails`, and `fails_compile` blocks in a file,\n"
                   "reporting a per-test pass/fail summary. Each runtime test runs in its own\n"
                   "forked process, so a panic or infinite loop in one test can't take down the\n"
                   "rest of the suite, and test files are built and run in parallel (see --jobs).\n"
                   "Pass --verbose to show every test and its output.",
    .spec_len = sizeof(test_spec) / sizeof(test_spec[0]),
    .spec = test_spec,
    .handler = cmd_test,
};