Goose

Debugging Goose programs

Research note, November 2026. Nothing here is on master. The question was how a Goose program should be debugged in VS Code and in other editors, given that the compiler emits C: by default a debugger shows that C, with C names and C types. Three options are compared. Option A gives the C compilers' debug information Goose's source lines and names, and leaves the debugging to the native debuggers that already speak the Debug Adapter Protocol, with visualizers inside them for Goose values. Option B builds a debugger into the compiler, goose dap, which runs the program itself and shows it in Goose terms. Option C, which a prototype on a fork implements, is also a goose dap, but one that stands in front of LLDB's adapter: LLDB runs the program, and the compiler's adapter turns what LLDB reports into Goose. Section 3 is A, section 5 is B, section 6 is C, section 7 compares them, section 8 gives the recommendation and the order to build it in.

0. Summary

Option C is recommended. #line directives map the generated C back to Goose lines in every C compiler's debug information (PDB or DWARF), which every option needs. goose build --debug writes a map of each function's locals and each type's representation, and goose dap speaks DAP to the editor while driving lldb-dap underneath: LLDB does breakpoints, stepping, threads and unwinding, and the adapter reads values out of memory by their Goose types, names frames and locals as the source does, steps past the compiler's code and stops on failed checks. The Goose knowledge is in one place, in the compiler, changing with the code generator and tested by the suite, and every editor with a DAP client runs the same command. It depends on how lldb-dap behaves, not only on the protocol, and leaves out Microsoft's debugger.

Option A puts the Goose knowledge into the debuggers instead: a natvis file generated per program for Microsoft's debugger and an LLDB module for the rest, interpreting the same kind of description, with the generated C reshaped under --debug so that a C debugger reads it well. It keeps Visual Studio and cppvsdbg, but it has two interpreters of the description, shows C names and C expressions, and needs configuration in each editor. Its natvis half stays worth having if Visual Studio users ask.

Option B, a DAP server in the compiler driving the program through hooks the code generator inserts, is the only way to debug under the JIT and the only way to get Goose-syntax watch expressions with calls. It costs a debugger's worth of code (stopping, stepping, frames, a value printer, an expression compiler, a protocol server), it cannot see into C, and it cannot debug programs with threads under the JIT. It is the option to revisit if debugging without a C toolchain turns out to matter to many users, and C's map, value printer and tests carry over to it.

The JIT gets no interactive debugger under A or C. What it can get cheaply is a stack trace in Goose lines when a program crashes, from TinyCC's own backtracer (section 4).

1. What a debugger sees today

goose build --debug compiles the C with /Od /Zi or -O0 -g (src/build.h) and turns on the overflow, range and tag checks. On master that is all the support there is. The VS Code extension contributes a goose debug type, but it only runs the program with the JIT in a task (noDebug) and refuses attach.

The C that goose -o shapes.c samples/05_shapes.goose writes shows what a C debugger has to work with:

2. What is wanted

From the user's side:

  1. Breakpoints and stepping in .goose files, in the standard library's files too.
  2. A call stack of Goose functions.
  3. Locals by their Goose names, only those in scope.
  4. Values as Goose values: an array's elements, the active variant of an ADT, text as text, a reference followed to what it refers to.
  5. Watch and console expressions.
  6. VS Code first, then the editors docs/editors.md covers (Neovim, Helix, Emacs), on Windows, Linux and macOS.
  7. A stop at a runtime error (a failed check, abort) or a crash, with the frames intact.

From the project's side: few moving parts, and little to keep in step as the language and its representations change. Each part should be tested by the suite, so that a codegen change that breaks debugging fails a test.

3. Option A: native debug information, existing debuggers

The Debug Adapter Protocol is how an editor talks to a debugger; the debugger still has to exist. Native debuggers for C already speak it: Microsoft's (cppvsdbg, through the C/C++ extension), LLDB's lldb-dap (shipped with LLVM) and CodeLLDB, and GDB, which has had a DAP interpreter since version 14. They already do breakpoints, stepping, threads, attach, core dumps and data breakpoints, for every C compiler Goose builds with. Option A makes the C they see carry Goose's lines, names and types, and writes nothing that speaks DAP.

3.1 Source lines: #line

The code generator writes #line N "path.goose" before the C of each Goose statement, with the statement's Line (which already carries the file and line, src/ast.h). MSVC puts it in the PDB's line table and gcc and clang in DWARF's, so every native debugger then sets breakpoints in, steps through, and shows the call stack in, .goose files. TinyCC's line information follows #line too, which section 4 relies on.

Runtime functions (gs_*) have no Goose source. Debuggers can be told to step over them: LLDB's target.process.thread.step-avoid-regexp, and Visual Studio's Just My Code rules for native code (.natjmc), whose effect under cppvsdbg is still to be checked.

--debug should also imply the Goose compiler's -O0, so that the stack the debugger shows is the one the source describes. -O1 and -O2 with --debug stay available for debugging what the inliner did.

3.2 Keeping the C close to the source

Under --debug only, the code generator changes the shape of what it writes, never what it does:

These are code generator changes, and the suite tests them: a debug build of every fixture must still produce the expected output.

3.3 Showing Goose values

The code generator knows how every Goose type is represented, because it chose the representation. Under --debug it writes that down, beside the executable, as a description: per C type, the Goose type it represents and which kind of representation it is, with the kind's facts (a slice's or resizable's element type, an ADT's variant names by tag and the union member each one's payload is in, a relative reference's width, target type and pool, a variable-mode value's layout, a root struct's fields). The visualizers interpret that description. They contain one piece of code per kind of representation, and none per type.

Microsoft's debugger reads natvis, which matches C type names exactly (its wildcards are for C++ template arguments), so the compiler generates the natvis for each program from the description. For an ADT:

<Type Name="Shape_g">
  <DisplayString Condition="tag == 0">Circle {u.v_Circle_g}</DisplayString>
  <DisplayString Condition="tag == 1">Rect {u.v_Rect_g}</DisplayString>
  <DisplayString Condition="tag == 2">Tri {u.v_Tri_g}</DisplayString>
  <DisplayString Condition="tag == 3">Dot</DisplayString>
  <Expand>
    <ExpandedItem Condition="tag == 0">u.v_Circle_g</ExpandedItem>
    <ExpandedItem Condition="tag == 1">u.v_Rect_g</ExpandedItem>
    <ExpandedItem Condition="tag == 2">u.v_Tri_g</ExpandedItem>
  </Expand>
</Type>

Slices and resizables are ArrayItems over the data pointer and length, text is a length-limited UTF-8 string, and a relative reference is pointer arithmetic and a cast (a pool's base is a global, which natvis expressions can name). Walking an array of variable-mode values needs natvis's CustomListItems loop, which can do it, awkwardly. The linker's /NATVIS: option embeds the file in the PDB, so Visual Studio and VS Code's cppvsdbg find it with no configuration.

LLDB gets one Python module, goose_lldb.py, kept in the repository and the same for every program. LLDB matches formatters by regular expression on type names (type summary add -x, type synthetic add -x), so the module registers one formatter per kind and looks the type up in the program's description. Python makes the awkward cases easy: a varint is a loop, an array of variable-mode values is a walk with a limit. LLDB reads DWARF on all three platforms and PDBs on Windows, so this one module serves every editor on every platform; the natvis adds Microsoft's debugger where that is used.

GDB has its own Python API. The decoding can be shared with the LLDB module behind a thin layer per debugger; GDB's front end can wait until someone asks for it, since lldb-dap debugs gcc's output as well. GDB loads a script named in an object's .debug_gdb_scripts section, as Rust's standard library does, so it would need no configuration. LLDB has no equivalent outside macOS dSYM bundles; the launch configuration imports the module.

When the language changes, a type built from existing kinds needs nothing. A new kind of representation needs a description kind, a natvis template and a Python class: three small pieces in three places, generated from or driven by one description that the code generator writes.

3.4 Editors

In VS Code the extension changes in two places:

Microsoft's debugger is licensed for Microsoft's own editors only (VS Code as Microsoft builds it, and Visual Studio), so VSCodium, Cursor and every other editor use LLDB on Windows too. Other editors need no Goose code: nvim-dap, Emacs's dape, Helix's DAP support and Zed all launch lldb-dap with a program and initial commands, and docs/editors.md gets a snippet for each, the way it has one for the language server. Visual Studio itself opens the executable with its PDB, natvis included, with no extension at all.

3.5 Testing

A fixture per kind of representation, built with --debug, and a script that runs the debugger in batch mode, sets a breakpoint by Goose line, prints the locals and the stack, and compares them with expected text:

lldb -b -o "command script import goose_lldb.py" -o "b shapes.goose:120" \
     -o run -o "frame variable" -o bt ./shapes

On Windows, cdb does the same for Microsoft's debugger, with .nvload and dx. The tests run where a debugger is installed and are skipped elsewhere, as the native-package fixtures are. A representation change that breaks a visualizer then fails a test instead of a user's session.

3.6 What it cannot do

4. The JIT

Under A and C the JIT gets no interactive debugging. A native debugger would attach to the goose process, and the program's code in it has no debug information any debugger loads:

So a debug launch builds an executable, and "run" stays the JIT. What the JIT can get is a Goose stack trace when a program crashes. TinyCC has a backtracer of its own (tccrun.c, and tcc_set_backtrace_func in libtcc.h) that maps the faulting address and its callers to file and line through the in-memory line information, which with #line names .goose lines. cmake/libtcc.cmake turns it off with bounds checking, because the support objects behind them need the full Windows SDK headers (jit_backend.md). Reading TinyCC's sources, that applies to bt-exe.c (an executable's backtracer, which includes tccrun.c and so windows.h) and bcheck.c. An in-memory program adds only bt-log.c, which includes stdarg.h, stdio.h and string.h, and the backtracer itself is in tccrun.c, which the host compiler builds with the SDK at hand. Backtraces alone may therefore be possible on Windows too. That is unverified, and the runtime's own fault handling (it commits stack pages on a fault on Windows) has to keep running in front of TinyCC's.

5. Option B: goose dap, a debugger in the compiler

5.1 Shape

goose dap would be a command beside goose lsp, speaking DAP on stdin and stdout. DAP's base protocol is the same Content-Length framing as LSP's, so the language server's transport (src/lsp_rpc.h: the reader thread and the JSON writer) carries over. The requests are fewer than the language server's: launch, setBreakpoints, configurationDone, threads, stackTrace, scopes, variables, continue, next, stepIn, stepOut, pause, evaluate, setVariable, disconnect, and the stopped, output and exited events.

The program runs in the compiler's process through the JIT, as goose run does. The compile already has a thread of its own and the program the main thread; the protocol server would be a third. Every editor with a DAP client gets the same debugger, and nothing outside goose is needed.

5.2 Stopping: statement sites

The code generator numbers every place a stop can happen (each statement, and each loop's back edge) and emits a check there:

GS_DBG_SITE(17);    // if (gs_dbg_armed) gs_dbg_stop(17, &gs_f);
total_g = 0.0;

A site table records each site's file, line, column, function and the locals in scope there. gs_dbg_armed is set whenever any breakpoint, step or pause is pending, and gs_dbg_stop checks the site against them and, to stop, blocks the program's thread until the protocol server says to go on. A breakpoint marks the sites on its line. Stepping compares the depth of the frame stack (5.3): over stops at the next site at the same depth or shallower, in at the next site, out at the next shallower one. A pause sets gs_dbg_armed from the server's thread. Since a site knows its column, stepping can go statement by statement within a line, which A and C cannot.

When nothing is set, a site costs a load and a branch not taken. Under TinyCC, which inlines nothing and keeps locals in memory, that adds less, relatively, than it would to optimized code. It has not been measured.

5.3 Frames and values

Each function links a frame record on entry and unlinks it on every exit: its function's descriptor, the caller's record, the current site, and the addresses of its locals. return … from propagates through ordinary returns (spec appendix C.3), so ordinary exits are all there are; an abort ends the program. Under TinyCC, whose locals live in memory anyway, taking their addresses costs nothing.

Because the program shares the process, the debugger reads its memory directly, and the value printer is C++ in the compiler working from the compiler's own types: a resizable, a variable-mode array, a relative reference, a root struct or a pool is decoded by code that sits next to the code that chose its representation. To keep the C compiler the authority on layout (packed structs, frame objects, root descriptors), the code generator emits offsetof and sizeof tables for the types the debugger shows rather than the printer working the layout out again. Locals come from the site table, so the debugger shows exactly the Goose variables in scope, by their Goose names, and never a temporary.

A failed check or an abort calls into the debugger before the runtime reports it, so the program stops on the failing line with its frames and locals intact. A crash in generated code reaches the runtime's fault handler, which can stop the same way.

5.4 Expressions in Goose

An evaluate request's text is parsed and type-checked in the scope of the site the program stopped at (the language server already resolves names at a position), compiled to C as a function that takes the frame record, compiled by TinyCC into the running process, and called on the stopped thread. Watch expressions are then Goose, function calls included, and they mean what the same expression means in the program. The evaluation has to contain what the expression does: a check that fails in a watch must stop the evaluation, not the program, and the data stacks' tops must be restored afterwards.

5.5 Built programs and threads

A built executable runs in its own process, where the compiler is not. Option B either stays JIT-only, or the debug runtime linked into a --debug executable listens on a pipe and serves a small protocol of its own (read memory, mark sites, resume, list frames) that goose dap drives: a gdbserver written for Goose. The printer stays in the compiler. The expression compiler cannot load code into another process, so expressions in a built program would be limited to what can be interpreted: names, fields, indexing.

The JIT refuses programs that start threads (TinyCC cannot place thread-local storage; jit_backend.md), so JIT-only B cannot debug any program with a worker until that is solved. With threads, each worker needs its own frame stack, and "pause" has to stop every thread at its next site; a worker blocked in C (a queue, a read) reaches no site, and the debugger has to report it as running.

5.6 What it cannot do

5.7 What it costs to keep

B tests well: like the language server, the suite can drive goose dap from Python (test/lsp_protocol.py's client, with DAP messages) and compare stops, stacks and values without any debugger installed.

6. Option C: goose dap in front of lldb-dap

A prototype on a fork implements this option (pdoane/goose, branch dap-prototype, commits 190a9e6a and 4f2c161e, November 2026). Its #line half comes from the source-debugging part of goose#169. This section describes that prototype as its code reads; it has not been built or run for this note.

6.1 Shape

goose dap speaks DAP on stdin and stdout, like goose lsp, and starts lldb-dap as a child process that it speaks DAP to in turn (src/dap.h, about 1,400 lines). A launch whose program is a .goose file runs goose build --debug -O0 on it and launches the executable under lldb-dap. LLDB places the breakpoints (through the #line information), runs, steps, stops and unwinds the program, and reads its memory. The adapter answers stackTrace, scopes, variables, threads, evaluate and exceptionInfo itself, adds to setFunctionBreakpoints, and looks at every stop before the editor sees it; every other request and event passes through unchanged. It finds lldb-dap through --lldb-dap, GOOSE_LLDB_DAP, xcrun on macOS, or PATH.

Compared with A, the debugger underneath is the same, but the Goose knowledge sits in front of it rather than inside it, and the editor talks to the compiler. Compared with B, the protocol server and the value printer are the same idea, but nothing in the generated C stops the program: LLDB does.

6.2 The debug map

goose build --debug writes <program>.debug.json into the build directory (src/codegen_debugmap.h):

The map calls itself a private contract between the compiler and its own adapter, so that both change together. It is the description of section 3.3, with one interpreter of it instead of two, and that interpreter in the compiler. Under --debug the optimizer also stops propagating constants into their uses, so that a let stays a variable a debugger can read.

6.3 What the adapter shows

test/dap_protocol.py drives the adapter from the suite, with fixtures under test/dap/, wherever lldb-dap is installed. It checks breakpoints, names and values, stepping past generated code, threads, return … from and failed checks.

6.4 What it depends on

Much of what the adapter relies on is how lldb-dap behaves rather than what the protocol says:

Each of these depends on a version of lldb-dap, where A depends on the debuggers' documented formatter interfaces. Some layout facts are also in the adapter rather than in the map: a resizable's length 8 bytes after its base, a runtime-sized limited array's two u32 counts, a frame object's tail 16 bytes before the end of its C struct, a queue node's 16-byte header. A code generator change to any of them breaks the adapter, and only a fixture that covers that shape notices.

LLDB is the only debugger underneath. Microsoft's debugger is licensed only for use with Microsoft's editors (section 3.4), which rules it out behind goose dap in other editors and makes it doubtful in VS Code too, so Visual Studio and cppvsdbg get none of the Goose view. On Windows the adapter is lldb-dap reading an MSVC build's PDB or a clang build's debug information; lldb-dap.exe ships with Visual Studio's LLVM component. The prototype's paths assume /, and its suite test runs only on macOS or where lldb-dap is on PATH, so it does not look to have been run on Windows yet.

6.5 What it cannot do

6.6 What to tighten

7. The three compared

A: native debuggers, visualizers C: goose dap in front of lldb-dap B: goose dap alone
Runs the program: breakpoints, steps, threads the native debugger lldb-dap the compiler, through hooks in the generated C
Needs a C compiler and a debugger yes yes, and lldb-dap no, under the JIT
Programs with threads yes yes not under the JIT until it has thread-local storage; built programs only with B's own stop-the-world
Editors every DAP client, configured per editor, plus Visual Studio every DAP client, running one command every DAP client, running one command
Microsoft's debugger yes, in VS Code and Visual Studio no not involved
Breakpoints and steps by line by line, past the compiler's code and through return … from by line and column
Call stack C function names Goose names, C frames left out Goose names
Locals C names, all of a function's Goose names, by line range Goose names, exactly those in scope
Values natvis and an LLDB module interpreting a description the compiler's adapter interpreting its map the compiler's own types
Console C a Goose subset over memory, no calls Goose, calls included
Runtime errors a function breakpoint on the runtime's failure path an exception stop on the failing line a stop on the failing line
C and extern frames yes in LLDB, hidden from the stack invisible
Crash dumps and attach yes LLDB can; the adapter does not ask it to yet no
Optimized builds as well as C debugging of them goes no (the map is for debug builds) no
Cost in the program none beyond a debug build none beyond a debug build a check per statement, a frame link per call
Depends on the debuggers' formatter interfaces lldb-dap's behaviour nothing outside goose
When a representation changes a description kind, a natvis template, a Python class the map and the adapter's reader printer code
When control flow changes nothing nothing, unless stepping needs a new rule site and scope placement
What there is to own #line, debug shapes, a natvis generator, an LLDB module, extension glue #line, the map, the adapter a debugger: protocol server, stop and step machinery, printer, expression compiler, frame records

Sizes: C's prototype is about 1,400 lines of adapter, 240 of map and some 50 of code generator and optimizer changes, plus its tests. A would be of the same order, most of it the natvis generator and the LLDB module. B would be several thousand lines for the JIT alone. The sizes of A and B are estimates.

8. Recommendation and order of work

Option C, in steps that are each useful on their own:

  1. Source lines. #line in debug builds where the Goose line changes, and --debug implying -O0 and kept variables (6.6). This alone gives every native debugger Goose lines, and every later step needs it.
  2. goose dap in front of lldb-dap, from the prototype, with the layout in the map and run on all three platforms.
  3. VS Code: the breakpoints contribution (3.4), and the goose debug type's debug launch served by goose dap as its adapter executable, while noDebug keeps running the JIT task. Other editors get a snippet each in docs/editors.md, as they have for the language server.
  4. The console on the compiler's front end (6.6).
  5. Generated natvis, from the same map, for Visual Studio and cppvsdbg, if their users ask: the one part of A still worth having, and cheap once the map exists.
  6. JIT backtraces (section 4), independent of the rest.

Option A's debug shapes in the code generator (3.2) and its LLDB module are not needed under C: the adapter hides the temporaries, and the map gives a resizable's element type.

Option B is worth revisiting if a large share of users debug without a C toolchain, or if Goose watch expressions with calls turn out to matter. C carries over to it: the protocol server, the map, the value reader (which reads through one memory-reading function, which under B would read the process's own memory), the console, the tests and the editor configuration. C is a step toward B rather than away from it.

9. Open questions

References