Debugging¶
Kaappi provides several tools for understanding what your code is doing: REPL inspection commands for quick exploration, a stepping debugger for tracing execution, profiling for performance analysis, and bytecode disassembly for seeing what the compiler produces.
Reading error messages¶
Errors include the source location, a stable diagnostic code, and the offending line:
program.scm:5:12: error[KP3002]: type error in 'arithmetic': expected number, got #<string>
(+ 1 "hello")
The format is source:line:column: category[KPnnnn]: message. Error
categories:
- read error (
KP1xxx) — malformed syntax (unclosed parentheses, bad escapes) - compile error (
KP2xxx) — invalid forms or undefined variables at compile time - error (
KP3xxx) — runtime type errors, arity mismatches, out-of-bounds access
When errors occur in nested calls, Kaappi prints a backtrace:
program.scm:1:20: error[KP3002]: type error in 'arithmetic': expected number, got #<string>
(define (helper x) (+ x "bad"))
called from program.scm:3
Diagnostic codes¶
Every diagnostic carries a stable KP code (KP1xxx read, KP2xxx
compile, KP3xxx runtime, KP4xxx lint, KP9xxx internal). Codes never
change meaning between releases, so they are safe to match on in scripts and
tooling.
Look up any code — its meaning, a minimal example, and how to fix it — straight from the binary:
$ kaappi explain KP3002
KP3002 type-error
runtime · error
type error
A procedure was applied to an argument of the wrong type — for example
'car' on a non-pair or '+' on a non-number. The message names the
procedure, the type it expected, and the value it got.
Example:
(car 5)
The Diagnostic Reference documents every code on one page.
For machine-readable output, kaappi explain <code> --json emits a single
JSON object, and --diagnostics=json makes the interpreter itself report
errors as LSP Diagnostic
objects.
Catching errors before running¶
kaappi check reads, expands, and compiles a file without executing it,
reporting read and compile errors plus
KP4xxx lint findings for
mistakes that are guaranteed to fail at run time:
$ kaappi check program.scm
1:1: warning[KP4001]: unknown variable 'undefined-name' at top level
2:1: error[KP4002]: 'car' expects 1 argument, but 2 were given
3:1: error[KP4003]: 'vector-ref' expects a vector as argument 1, but a string literal was given
check: 2 errors, 1 warning
The exit code is non-zero when any error is found. Findings that are
only warnings — like KP4001, since R7RS permits top-level forward
references — leave the exit code at 0 unless you pass --deny-warnings;
kaappi check never rejects a program R7RS permits:
Like the interpreter, check honors --diagnostics=json for tooling.
REPL introspection¶
The REPL has several comma commands for exploring values and bindings without stopping execution.
,type <expr> — show the type of a result:
,describe <name> — show binding details including arity and source:
kaappi> ,describe map
map
type: procedure
arity: 2+
kaappi> (define (greet name) (string-append "hello " name))
kaappi> ,describe greet
greet
type: procedure
arity: 1, locals: 0
source: <repl>:1
,apropos <string> — search bindings by substring:
kaappi> ,apropos vector
vector-append, vector-copy, vector-fill!, vector-for-each,
vector-length, vector-map, vector-ref, vector-set!, ...
; 18 matches
,env [prefix] — list global bindings, optionally filtered:
,expand <expr> — show macro expansion:
_ variable — the last evaluation result, useful for incremental exploration:
Stepping debugger¶
The REPL includes a built-in stepping debugger for tracing execution frame by frame.
Setting breakpoints¶
kaappi> (define (factorial n) (if (<= n 1) 1 (* n (factorial (- n 1)))))
kaappi> ,break factorial
Breakpoint set on factorial
Manage breakpoints with:
Hitting a breakpoint¶
When execution reaches a breakpointed function, Kaappi pauses and shows
a debug> prompt:
Debugger commands¶
| Command | Short | Action |
|---|---|---|
step |
s |
Step into the next expression |
next |
n |
Step over (stay in current frame) |
finish |
out |
Step out — run until current frame returns |
continue |
c |
Continue to next breakpoint or end |
locals |
l |
Show local variable bindings |
backtrace |
bt |
Print the call stack (marks inspected frame with >) |
up |
Move inspection up to the caller frame | |
down |
Move inspection down to the callee frame | |
watch VAR |
Break when a local variable's value changes | |
unwatch VAR |
Stop watching a variable | |
quit |
q |
Exit the debugger |
Conditional breakpoints¶
Break only when a condition is true:
The expression is evaluated each time the breakpoint is hit. The debugger only pauses when the result is truthy.
Watch expressions¶
Watch a variable and break when its value changes:
Use unwatch n to remove the watch.
Frame navigation¶
Use up and down to inspect locals at different stack levels:
debug> backtrace
> [2] factorial
[1] factorial
[0] <top-level>
debug> up
[1] factorial
debug> locals
n = 3
debug> down
[2] factorial
Walkthrough¶
kaappi> (factorial 3)
Break at factorial (<repl>:1)
debug> locals
n = 3
debug> continue
Break at factorial (<repl>:1)
debug> locals
n = 2
debug> backtrace
> [1] factorial
[0] factorial
debug> continue
Break at factorial (<repl>:1)
debug> locals
n = 1
debug> continue
6
Step through an expression¶
Use ,step to single-step from the start without setting breakpoints:
Profiling¶
In the REPL¶
kaappi> ,profile (factorial 20)
2432902008176640000
; Profile Report
; Self ms Total ms Calls Alloc KB Function
; 0.1 0.1 20 - factorial (<repl>:1)
; 0.0 0.0 19 - * (built-in)
Columns:
- Self ms — time in this function, excluding callees
- Total ms — inclusive time (self + callees)
- Calls — number of invocations
- Alloc KB — heap memory allocated in this function
From the command line¶
Profile an entire program run. The top 20 functions by self time are shown.
Timing a single expression¶
For a quick wall-clock measurement without the full profile breakdown:
Per-stage pipeline timings¶
To see where startup time goes — reading, expansion, compilation, or execution — and whether the bytecode cache was used:
$ kaappi --timings program.scm
3
timings: read 0.0ms | expand 0.0ms | lower 0.0ms | optimize 0.0ms | emit 0.0ms | execute 0.0ms
cache: MISS (wrote /home/user/.kaappi/cache/7f6d8fd16240bf92.sbc)
Both lines go to stderr. On a second run of an unchanged program the
compile stages disappear — the cache line reports HIT and only
execute remains. --timings=json emits the same data as JSON.
Performance tips¶
- Prefer vectors over lists for random access.
vector-refis O(1);list-refis O(n) because it walks the chain of pairs. Use lists for sequential processing, vectors when you need indexing:
;; Fast: O(1) lookup
(define v (vector 10 20 30 40 50))
(vector-ref v 3) ;=> 40
;; Slow: O(n) traversal
(define ls (list 10 20 30 40 50))
(list-ref ls 3) ;=> 40
-
For maximum performance, compile to native. Use
zig build native -Dnative-src=program.scmto compile your program via the LLVM backend. Simple functions compile to native LLVM functions with direct calls, bypassing the bytecode interpreter. -
Use
for-eachinstead ofmapwhen you don't need results.mapallocates a new list for its return value;for-eachjust applies the procedure for side effects:
;; Allocates a list you throw away -- wasteful
(map display '(1 2 3))
;; No allocation
(for-each display '(1 2 3))
- Hash tables are O(1) for lookup. Kaappi uses open-addressing with
linear probing. For key-value associations that grow beyond a handful of
entries,
hash-table-refis much faster thanassocon an alist:
(import (srfi 69))
(define ht (alist->hash-table '((a . 1) (b . 2) (c . 3))))
(hash-table-ref ht 'b) ;=> 2
Pipeline stage dumps¶
Three read-only subcommands print a program at successive stages of the compile pipeline — useful for macro debugging and for seeing what the optimizer does:
kaappi ast file.scm— the datums exactly as the reader produced them, before any expansionkaappi expand file.scm— the program after full macro expansion; the output is valid source and round-trips through the readerkaappi ir file.scm— the compiler's IR tree after the optimization passes (--no-optshows it before them)
Macro expansion makes hygiene visible — template-introduced bindings
show up under their renamed __hyg_... identities:
$ cat swap.scm
(define-syntax swap!
(syntax-rules ()
((_ a b) (let ((tmp a)) (set! a b) (set! b tmp)))))
(define x 1)
(define y 2)
(swap! x y)
$ kaappi expand swap.scm
(define-syntax swap! (syntax-rules () ((_ a b) (let ((tmp a)) (set! a b) (set! b tmp)))))
(define x 1)
(define y 2)
(__hyg_1_let ((__hyg_2_tmp x)) (set! x y) (set! y __hyg_2_tmp))
The REPL's ,expand command does the same for a single expression.
Bytecode inspection¶
Disassembling a procedure¶
Use the ,dis REPL command or the (disassemble) procedure:
kaappi> (define (factorial n) (if (<= n 1) 1 (* n (factorial (- n 1)))))
kaappi> ,dis factorial
; Function: factorial
; Source: <repl>
; Arity: 1, Locals: 7, Upvalues: 0
; Constants: <=, 1, *, factorial, -
;
0000 move r2, r0
0003 load_const r3, 1
0007 call_global r1, <=, 2
0012 jump_false r1, -> 0023
0016 load_const r1, 1
0020 jump -> 0050
0023 get_global r1, *
...
0047 tail_call r1, 2
0050 return r1
,dis <expr> evaluates the expression, then disassembles the resulting
procedure. (disassemble proc) does the same from Scheme code. This shows
the register-based bytecode the compiler produces — useful for
understanding tail call optimization, closure captures, and performance.
Disassembling a file¶
Shows bytecode for all top-level definitions without executing the program.
GC diagnostics¶
In the REPL¶
kaappi> ,gc
GC Statistics:
Collections: 0
Live objects: 589 (peak: 589)
Heap size: 42552 bytes (peak: 42552)
Freed: 0 objects, 0 bytes
Mark time: 0.00 ms total
Sweep time: 0.00 ms total
Allocations by type:
native_fn 585 port 3 rng 1
From the command line¶
Prints GC statistics after the program finishes. Useful for spotting excessive allocation or frequent collections.
Error handling in code¶
Use R7RS guard for structured exception handling:
(guard (e
((string? (condition/message e))
(display "caught: ")
(display (condition/message e))
(newline)))
(error "something went wrong"))
To branch on which error occurred, dispatch on its stable diagnostic
code with error-object-code from (kaappi diagnostics) instead of
matching message text — wording may change between releases; codes never
do:
(import (kaappi diagnostics))
(guard (e ((eq? (error-object-code e) 'KP3004) ; division by zero
'undefined))
(/ 1 0))
;=> undefined
error-object-code never raises — it returns #f for any value that
carries no code — so it is safe as the first test in a guard clause.
See the reference entry.
Use with-exception-handler for lower-level control:
Use call/ec (escape continuations) for non-local exits:
(define (find-negative lst)
(call/ec
(lambda (return)
(for-each (lambda (x)
(when (negative? x) (return x)))
lst)
#f)))
(find-negative '(3 7 -2 5)) ;=> -2
Tips¶
Compare interpreter vs native — if a native-compiled binary behaves differently from the interpreter, run both and diff:
kaappi program.scm > expected.txt
zig build native -Dnative-src=program.scm && ./zig-out/bin/program > actual.txt
diff expected.txt actual.txt
Check library coverage — see which exported procedures your tests exercise:
Use write-shared for circular structures — write will hang on
circular lists or vectors. write-shared prints them with datum labels:
Catch runaway programs — use --timeout to kill programs that
take too long, and --max-memory to cap heap usage:
kaappi --timeout 5000 program.scm # 5-second limit
kaappi --max-memory 10000000 program.scm # ~10 MB limit
Tune initial stack capacity — the frame stack and register array grow automatically during execution. To reduce the number of reallocations for deeply recursive programs, set a larger initial capacity:
zig build -Dmax-frames=4096 # default: 480, grows to 32768
zig build -Dmax-registers=8192 # default: 2048, grows to 65536
Next: Editor Support