commit a0d3f7beb9a75d03bd13d3b65ffb49b1f5deb17e
parent 0a7d4b2e190eaa8b05699f2d7a9474b853a4ef7f
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 9 Jun 2026 10:21:33 -0700
trace: add KIT_TRACE / KIT_LOG* developer tracing facility
A single tracing facility (include/kit/trace.h) replacing the ad-hoc
per-module KIT_TRACE printf macros: KIT_TRACE(level, ...) plus
KIT_LOGE/W/I/D/T sugar over five severity levels.
- libkit stays stateless: trace points forward (module, level, file,
line, msg) across a weak-hook seam (kit_trace_enabled / kit_trace_emit,
no-op fallbacks in src/core/core.c). A libkit-only link traces nothing.
- driver/env/common.c owns the hosted side: parse KIT_TRACE once into a
cached static, substring module match + level threshold, stderr sink.
- Compiled out under NDEBUG (args unevaluated); KIT_TRACE_FORCE opts back
in for release/bootstrap debugging.
- Module defaults to __FILE__ (path filters work); override per file with
#define KIT_TRACE_MODULE for a short tag.
KIT_TRACE grammar: comma-separated [module=]level, e.g.
KIT_TRACE=cg=trace,coff=debug or KIT_TRACE=1 for everything.
Docs: doc/BUILD.md (Build modes) covers the NDEBUG/KIT_TRACE_FORCE tie
and the weak-override seam.
Diffstat:
5 files changed, 286 insertions(+), 4 deletions(-)
diff --git a/doc/BUILD.md b/doc/BUILD.md
@@ -61,6 +61,19 @@ recorded into `build/.../.build-config` so that flipping a mode flag forces a
rebuild of objects that were produced under the old flags. Build-mode concerns
are kept strictly separate from host-environment concerns (next section).
+`RELEASE=1`'s `-DNDEBUG` also compiles out the `KIT_TRACE` / `KIT_LOG*`
+developer tracing (`include/kit/trace.h`): each trace point expands to nothing,
+its arguments unevaluated. Define `KIT_TRACE_FORCE` to keep tracing in an NDEBUG
+build — useful for debugging a release or bootstrap image. Otherwise tracing is
+a pure runtime affordance, dormant until the `KIT_TRACE` env var selects modules
+and levels (`KIT_TRACE=cg=trace,coff=debug`, or `KIT_TRACE=1` for everything,
+matched as a substring against each trace point's module name, default
+`__FILE__`). It rides the same weak-override seam as the rest of the hosted
+boundary: `libkit.a` ships no-op `kit_trace_enabled` / `kit_trace_emit` and
+holds no trace state, while the hosted `driver/env/common.c` supplies the strong
+overrides that parse `KIT_TRACE` once and write to stderr — so a libkit-only
+link traces nothing and pays nothing.
+
## Host detection: mk/env.mk
`mk/env.mk` is the **only** place in the build that branches on the host OS or
diff --git a/driver/env/common.c b/driver/env/common.c
@@ -261,6 +261,156 @@ void kit_debug_printf(const char* fmt, ...) {
const char* kit_debug_getenv(const char* name) { return getenv(name); }
+/* ---------------- tracing (KIT_TRACE) ---------------- */
+
+/* Hosted side of the kit/trace.h seam: parse KIT_TRACE once, cache it, and
+ * answer kit_trace_enabled / format kit_trace_emit. The config is immutable
+ * after the first read, so the lazy init is intentionally lock-free --
+ * concurrent first-callers re-parse identical data into the same cache, which
+ * is idempotent. Tracing is diagnostic-only and never affects compiler
+ * output, so this process-scoped state is the one place trace state lives. */
+
+#define TRACE_MAX_SPECS 32
+#define TRACE_BUF_CAP 512
+
+typedef struct TraceSpec {
+ const char* module; /* substring matched against the trace point's module */
+ int level; /* threshold 1..5 (KitTraceLevel) */
+} TraceSpec;
+
+static struct {
+ int parsed;
+ int global_level; /* threshold for modules with no matching spec; 0 = off */
+ int nspecs;
+ TraceSpec specs[TRACE_MAX_SPECS];
+ char buf[TRACE_BUF_CAP]; /* owns the tokenized copy the specs point into */
+} g_trace;
+
+static int trace_level_from_name(const char* s, size_t n) {
+ if (n == 5 && memcmp(s, "error", 5) == 0) return KIT_TRACE_ERROR;
+ if (n == 4 && memcmp(s, "warn", 4) == 0) return KIT_TRACE_WARN;
+ if (n == 7 && memcmp(s, "warning", 7) == 0) return KIT_TRACE_WARN;
+ if (n == 4 && memcmp(s, "info", 4) == 0) return KIT_TRACE_INFO;
+ if (n == 5 && memcmp(s, "debug", 5) == 0) return KIT_TRACE_DEBUG;
+ if (n == 5 && memcmp(s, "trace", 5) == 0) return KIT_TRACE_TRACE;
+ return 0;
+}
+
+/* "1" / "*" / "all" are spellings of "everything, max verbosity". */
+static int trace_is_all(const char* s, size_t n) {
+ return (n == 1 && (s[0] == '1' || s[0] == '*')) ||
+ (n == 3 && memcmp(s, "all", 3) == 0);
+}
+
+static void trace_raise_global(int lvl) {
+ if (lvl > g_trace.global_level) g_trace.global_level = lvl;
+}
+
+static void trace_add_module(const char* module, int lvl) {
+ if (g_trace.nspecs >= TRACE_MAX_SPECS) return;
+ g_trace.specs[g_trace.nspecs].module = module;
+ g_trace.specs[g_trace.nspecs].level = lvl;
+ g_trace.nspecs++;
+}
+
+static void trace_parse(void) {
+ const char* env = getenv("KIT_TRACE");
+ size_t len, i, start;
+
+ g_trace.parsed = 1;
+ g_trace.global_level = 0;
+ g_trace.nspecs = 0;
+ if (!env || !env[0]) return;
+
+ len = strlen(env);
+ if (len >= TRACE_BUF_CAP) len = TRACE_BUF_CAP - 1;
+ memcpy(g_trace.buf, env, len);
+ g_trace.buf[len] = '\0';
+
+ /* Split on ',' in place; each token is "[module=]level" or a bare token. */
+ start = 0;
+ for (i = 0; i <= len; ++i) {
+ char* tok;
+ size_t tlen;
+ char* eq;
+ if (i != len && g_trace.buf[i] != ',') continue;
+ g_trace.buf[i] = '\0';
+ tok = &g_trace.buf[start];
+ tlen = i - start;
+ start = i + 1;
+ if (tlen == 0) continue;
+
+ eq = memchr(tok, '=', tlen);
+ if (eq) {
+ size_t mlen = (size_t)(eq - tok);
+ const char* lvls = eq + 1;
+ size_t llen = tlen - mlen - 1;
+ int lvl = trace_level_from_name(lvls, llen);
+ if (lvl == 0 && trace_is_all(lvls, llen)) lvl = KIT_TRACE_TRACE;
+ if (lvl == 0) continue; /* unknown level name: ignore the spec */
+ *eq = '\0'; /* terminate the module substring in place */
+ if (mlen == 0)
+ trace_raise_global(lvl);
+ else
+ trace_add_module(tok, lvl);
+ } else {
+ int lvl = trace_level_from_name(tok, tlen);
+ if (trace_is_all(tok, tlen)) lvl = KIT_TRACE_TRACE;
+ if (lvl != 0)
+ trace_raise_global(lvl); /* bare level => default for all modules */
+ else
+ trace_add_module(tok, KIT_TRACE_TRACE); /* bare module => at TRACE */
+ }
+ }
+}
+
+int kit_trace_enabled(const char* module, int level) {
+ int threshold, i;
+ if (!g_trace.parsed) trace_parse();
+ threshold = g_trace.global_level;
+ if (module) {
+ /* Most-verbose matching module spec wins, falling back to the global. */
+ for (i = 0; i < g_trace.nspecs; ++i)
+ if (g_trace.specs[i].level > threshold &&
+ strstr(module, g_trace.specs[i].module) != NULL)
+ threshold = g_trace.specs[i].level;
+ }
+ return threshold > 0 && level <= threshold;
+}
+
+static char trace_level_char(int level) {
+ switch (level) {
+ case KIT_TRACE_ERROR:
+ return 'E';
+ case KIT_TRACE_WARN:
+ return 'W';
+ case KIT_TRACE_INFO:
+ return 'I';
+ case KIT_TRACE_DEBUG:
+ return 'D';
+ case KIT_TRACE_TRACE:
+ return 'T';
+ default:
+ return '?';
+ }
+}
+
+void kit_trace_emit(const char* module, int level, const char* file, int line,
+ const char* fmt, ...) {
+ va_list ap;
+ char lc = trace_level_char(level);
+ if (!file) file = "?";
+ /* Show the module tag unless it is just the source path (the default). */
+ if (module && strcmp(module, file) != 0)
+ fprintf(stderr, "[%c][%s] %s:%d: ", lc, module, file, line);
+ else
+ fprintf(stderr, "[%c] %s:%d: ", lc, file, line);
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ va_end(ap);
+ fputc('\n', stderr);
+}
+
void driver_printf(const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
diff --git a/include/kit/core.h b/include/kit/core.h
@@ -22,6 +22,9 @@
#define KIT_API
#endif
+/* Developer tracing (KIT_TRACE / KIT_LOG*) is part of the core substrate. */
+#include <kit/trace.h>
+
/* Opaque handles shared across component headers. */
typedef struct KitCompiler KitCompiler;
typedef struct KitCompileSession KitCompileSession;
@@ -196,11 +199,12 @@ typedef struct KitTargetSpec {
* resolver) so consumers read a field instead of re-deriving by identity.
* Zero-initialized spec constructors that bypass kit_target_new leave these
* at 0; only specs that pass through kit_target_new carry resolved values. */
- uint8_t long_size; /* sizeof(long): 8 for LP64, else 4 (LLP64/ILP32) */
- uint8_t wchar_size; /* sizeof(wchar_t): 2 on Windows, else 4 */
+ uint8_t long_size; /* sizeof(long): 8 for LP64, else 4 (LLP64/ILP32) */
+ uint8_t wchar_size; /* sizeof(wchar_t): 2 on Windows, else 4 */
uint8_t long_double_format; /* KitLongDoubleFormat */
- uint8_t emits_eh_frame; /* 1 when os != KIT_OS_FREESTANDING, else 0 */
- uint8_t os_version_major; /* OS major version (FreeBSD: 14/15/…); 0 = unspecified */
+ uint8_t emits_eh_frame; /* 1 when os != KIT_OS_FREESTANDING, else 0 */
+ uint8_t os_version_major; /* OS major version (FreeBSD: 14/15/…); 0 =
+ unspecified */
} KitTargetSpec;
typedef struct KitTargetFeature {
diff --git a/include/kit/trace.h b/include/kit/trace.h
@@ -0,0 +1,90 @@
+#ifndef KIT_TRACE_H
+#define KIT_TRACE_H
+
+/*
+ * Structured developer tracing.
+ *
+ * libkit emits trace points through two weak hooks (kit_trace_enabled +
+ * kit_trace_emit). The library ships no-op fallbacks, so a libkit-only link
+ * (the test harnesses, an embedder) traces nothing and pays nothing. Hosted
+ * binaries override the hooks in driver/env: that side owns the KIT_TRACE
+ * config and the stderr sink. The compiler core itself holds no trace state --
+ * every KIT_TRACE site just forwards (module, level, file, line, message)
+ * across the seam -- so libkit stays stateless and reentrant; the only trace
+ * state lives on the application side, where it is parsed once from the
+ * immutable environment.
+ *
+ * Compile-time removal: under NDEBUG the macros expand to nothing and their
+ * arguments are not evaluated. Define KIT_TRACE_FORCE to keep them in an
+ * NDEBUG build (e.g. to debug a release / bootstrap image).
+ *
+ * Runtime filtering: the KIT_TRACE environment variable selects modules and
+ * levels. Each comma-separated spec is "[module=]level"; a bare token is a
+ * level (applies to every module) or, if it is not a level name, a module
+ * (enabled at TRACE). A module spec matches as a substring of the module
+ * name, which defaults to __FILE__, so path fragments work:
+ *
+ * KIT_TRACE=1 every module, max verbosity
+ * KIT_TRACE=info every module, INFO and more severe
+ * KIT_TRACE=cg=trace,coff=debug per-module thresholds
+ * KIT_TRACE=warn,cg/ir=trace default WARN; path "cg/ir" at TRACE
+ *
+ * A message at level L is emitted when its module's threshold T satisfies
+ * L <= T (lower value = more severe), so a DEBUG threshold shows ERROR..DEBUG
+ * but not TRACE.
+ */
+
+#ifndef KIT_API
+#if defined(__GNUC__) || defined(__clang__) || defined(__kit__)
+#define KIT_API __attribute__((visibility("default")))
+#else
+#define KIT_API
+#endif
+#endif
+
+typedef enum KitTraceLevel {
+ KIT_TRACE_ERROR = 1,
+ KIT_TRACE_WARN = 2,
+ KIT_TRACE_INFO = 3,
+ KIT_TRACE_DEBUG = 4,
+ KIT_TRACE_TRACE = 5,
+} KitTraceLevel;
+
+/* The seam. libkit supplies weak fallbacks (enabled -> 0, emit -> no-op);
+ * hosted binaries override both. Call through the KIT_TRACE macros below
+ * rather than directly, so NDEBUG removal and the module/file/line capture
+ * happen for you. */
+KIT_API int kit_trace_enabled(const char* module, int level);
+KIT_API void kit_trace_emit(const char* module, int level, const char* file,
+ int line, const char* fmt, ...);
+
+/* Per-file module identity. Defaults to the source path (so KIT_TRACE path
+ * filters work with zero boilerplate). A file may pick a short, stable tag by
+ * redefining it after its includes:
+ * #undef KIT_TRACE_MODULE
+ * #define KIT_TRACE_MODULE "cg"
+ * The value is a token expanded at each use site, so __FILE__ stays correct
+ * per file even though this default is set only once. */
+#ifndef KIT_TRACE_MODULE
+#define KIT_TRACE_MODULE __FILE__
+#endif
+
+#if defined(NDEBUG) && !defined(KIT_TRACE_FORCE)
+#define KIT_TRACE(lvl, ...) ((void)0)
+#else
+#define KIT_TRACE(lvl, ...) \
+ do { \
+ if (kit_trace_enabled(KIT_TRACE_MODULE, (lvl))) \
+ kit_trace_emit(KIT_TRACE_MODULE, (lvl), __FILE__, __LINE__, \
+ __VA_ARGS__); \
+ } while (0)
+#endif
+
+/* Per-level sugar. */
+#define KIT_LOGE(...) KIT_TRACE(KIT_TRACE_ERROR, __VA_ARGS__)
+#define KIT_LOGW(...) KIT_TRACE(KIT_TRACE_WARN, __VA_ARGS__)
+#define KIT_LOGI(...) KIT_TRACE(KIT_TRACE_INFO, __VA_ARGS__)
+#define KIT_LOGD(...) KIT_TRACE(KIT_TRACE_DEBUG, __VA_ARGS__)
+#define KIT_LOGT(...) KIT_TRACE(KIT_TRACE_TRACE, __VA_ARGS__)
+
+#endif
diff --git a/src/core/core.c b/src/core/core.c
@@ -28,6 +28,31 @@ const char* kit_debug_getenv(const char* name) {
return NULL;
}
+/* Weak fallbacks for the tracing seam (kit/trace.h). libkit holds no trace
+ * state, so the library-side gate is always closed and the sink is a no-op;
+ * hosted binaries (driver/env) override both with the KIT_TRACE-driven
+ * implementation. */
+#if defined(__GNUC__) || defined(__clang__) || defined(__kit__)
+__attribute__((weak))
+#endif
+int kit_trace_enabled(const char* module, int level) {
+ (void)module;
+ (void)level;
+ return 0;
+}
+
+#if defined(__GNUC__) || defined(__clang__) || defined(__kit__)
+__attribute__((weak))
+#endif
+void kit_trace_emit(const char* module, int level, const char* file, int line,
+ const char* fmt, ...) {
+ (void)module;
+ (void)level;
+ (void)file;
+ (void)line;
+ (void)fmt;
+}
+
/* Weak fallback for the <assert.h> failure hook (rt/include/assert.h). kit's
* own code uses compiler_panic, not C assert(), but vendored code compiled into
* libkit (the lz4 codecs) references __kit_assert_fail in non-NDEBUG builds.