If you feel like implementing a feature for nnd, here are some ideas that seem tractable.
If you go for it, you probably want to send a WIP draft PR early so I can help you not waste a lot of time going down a wrong path. If anything seems like it requires writing a lot of code or complicated code then it's probably not what I had in mind, and you should ask about it.
Write a byte to memory to let the program know that a debugger is attached to it
Difficulty: 2/10
See #53 . Just look for the section when loading debug info in Symbols (similar to how it looks e.g. for .symtab), remember its address, and write to it in Debugger in the is_initial_exec situation.
Memory hex view window
Difficulty: 5/10
See #47 (comment) (paragraph starting with "Hex memory panel would be nice").
More convenient --tty
(Mostly obsoleted by the built-in terminal emulator now.)
Details
Difficulty: 3/10
nnd --help-tty currently tells you to:
- go to target terminal window
- run
ls -l /proc/$$/fd | grep /dev/pts/
- copy the path from the output
- run
sleep inf (I always forget to do this)
- got back to nnd window
- paste the path
I'd like a small convenience nnd subcommand that does (an equivalent of) steps 2, 3, 4, and 6 for you. So that the usage becomes:
- go to the target terminal window
- run
nnd --here
- go back to nnd window
- run
nnd --there my_program
nnd --here would get the current tty path and write it to some file in ~/.nnd/, then go to sleep forever. nnd --there will then read that file. Some details to figure out:
- Need some kind of file locking protocol to make sure that:
- two
nnd --there can't attach to the same nnd --here simultaneously,
- after
nnd --here exits, it's not attachable anymore,
- all of that is true even if
nnd --here or nnd --there were killed,
- after
nnd --there quits, another nnd --there can attach to the same nnd --here.
- If there are multiple
nnd --heres, nnd --there needs to somehow pick one; maybe allow naming them, like nnd --here=foo nnd --there=foo?
- How to quit
nnd --here? It shouldn't read keyboard inputs from the terminal, that would steal them from the debuggee (if it's running). Maybe it can detect whether there's an nnd --there currently attached (probably through the same file locking protocol as above), and take over the terminal when detached: clear screen and show a message with explanation and instructions, listen to input and exit if 'q' is pressed. Then disengage when nnd --there is attached again.
- But there's a race condition:
nnd --there's program may start using the terminal before nnd --here notices that it should disengage from the terminal. Solving it would require additional communication so that nnd --there waits for nnd --here to ack that the terminal is free from interference. But maybe it's ok to leave the race condition, because the user usually won't type into the terminal in the first milliseconds after launching nnd --there.
Reverse --tty
(Mostly obsoleted by the built-in terminal emulator now, but can still be done if anyone wants this feature.)
Details
Difficulty: 2/10
See nnd --help-tty for the current way to debug interactive CLI/TUI programs. It has at least one problem: if the debuggee uses /dev/tty, it'll point to the same tty the debugger uses, not to the other terminal's tty. So the debuggee and debugger mess up each other's UI and steal each other's keyboard inputs, and nothing works. AFAIK, there's no good way to redirect another process's /dev/tty.
We can add a mode where the debuggee runs in the current terminal, while the debugger is redirected to a different one. The opposite of what --tty does. Debugger doesn't use /dev/tty, it's easy for it to redirect its own tty usage: just open the requested file in /dev/pts/ and use that fd instead of stdin and stdout.
Input buffering
Difficulty: 3/10
I often want to kill the process and immediately start another one: C-k immediately followed by r or n. This is currently annoying to do because sometimes I press the second key too soon after the first, while the process is still quitting. Then the debugger shows an error, and I have to press r again some unspecified time later.
(Another example is continue immediately followed by step, expecting the c to quickly hit a particular breakpoint.)
Videogames solved this 30 years ago with input buffering. If the user inputs a command that can't be executed from the current state, store the command for e.g. 500ms; if the state changes within that time, execute the command automatically, as if the key was pressed at just the right moment.
I guess we can do exactly that. Keep a list of keys that we recently failed to apply because of ProcessState. Remove ones older than 500ms. Retry all of them in each DebuggerUI::build() call. To determine whether an input should be buffered, maybe check for ErrorCode::ProcessState, or maybe hardcode a list of KeyActions for which buffering is enabled.
Not sure what exactly to do with error messages. Delay showing the error message for 500ms? That would feel broken and confusing. Show the error immediately? That would be confusing if the operation later succeeds. I guess we have to show it immediately, then hide it on successful retry. Maybe even replace it with a different message confirming success, otherwise it's still confusing. (I wish we had animations, then maybe we could, like, fade out the message in a way that intuitively looks like the message no longer applies. With a poof of smoke or something.)
Whole-file breakpoints
Difficulty: 3/10
Add a way to put breakpoint on all lines of a given source code file at once.
In UI, maybe we should add a header line at the start of the source code listing; a breakpoint on that line would be a whole-file breakpoint. Or maybe just say that breakpoint on the first line of the file is a whole-file breakpoint (there's usually no runnable code on the first line of a file anyway) (but then it's not clear how to communicate this in the UI).
In debugger, it'd be a new enumerand in BreakpointOn, resolved to addresses by listing all LineInfos in the file (same way as for normal line breakpoints, but start at line 0 and iterate through all lines).
Breakpoints on function's inlined call sites
Difficulty: 5/10
Currently you can set breakpoint on a function by name: open it in disassembly window, put a breakpoint on the first instruction. But what if this function is inlined? E.g. it's std::vector::push_back. We could do this:
- In disassembly window, at the top of the function code, show a line saying how many inlined call sites it has. Adding breakpoint on that line sets breakpoints on all those inlined calls.
- Allow opening functions that are inline-only and have no machine code.
This would require adding some data structure in Symbols that maps function to its call sites. I guess a sorted Vec<(/*function_idx*/ usize, /*addr*/ usize)>, I guess in each shard separately. Lookup would binary search the range of entries or a given function_idx. We'd add to this Vec when traversing DWARF, then at the end we sort it. Maybe it won't work exactly like this because, IIRC, function_idx is assigned by dedup_functions(), way after traversing DWARF. So we'd have to initially use some other identifiers for the functions, then either replace them with function_idx after dedup_functions(), or use those other identifiers in lookup too.
For breakpoints, it'd be a new enumerand in BreakpointOn, resolved to addresses using the data structure above.
Better UI for builtin breakpoints and breakpoint settings
Difficulty: 4/10
Currently there are two builtin special breakpoints: rust panics and C++ exceptions. Currently they're awkwardly shown at the top of the list of breakpoints, barely distinguishable from normal breakpoints, probably looking confusing. Would be nice to change this to a collapsible section: by default show one line saying "▸ options", which can be expanded by clicking or pressing right arrow key, like in watches. Inside it would be the current two builtin breakpoints, more builtin breakpoints (signals, start of main(), syscalls), and some settings (checkbox for whether breakpoints should be disabled when stepping, checkbox for whether stepping should keep other threads stopped).
The nnd's (very custom) UI system currently doesn't have a reusable widget for tree lists (the tree in watches pane is very bespoke and can't be reused). So this would be either a one-off implementation of this one collapsible section in one place (like, just is_options_section_expanded: bool in BreakpointsWindow) or some significant research to make a reusable tree table widget (risky, I may not like it and reject the PR, and the UI system is probably hard to work with; but then the widget may come in handy in the threads window if we add multi-process support in future).
Disassembly without debug info or symbol table
Difficulty: 7/10
Currently the disassembly window can only show functions. Information about functions comes from .symtab or .debug_info. If both are missing, no disassembly is shown. This rarely happens in practice, but there are a few cases:
- very minimal/stripped executables,
- JIT-generated code,
- windows executable running in proton; surprisingly, you can still attach debugger to it and see threads and stack traces; I would be curious to see disassembly and step through it.
I imagine this can be implemented in this order:
- Say that each tab in the disassembly window shows either a function or a memory range.
- Add UI to enter a custom memory range and open a disassembly tab for it. Similar to the go-to-address bar ('g' key in disassembly window). (Also allow entering a single address and just disassemble e.g. 64 KiB starting from that address.)
- If we tried to auto-open a disassembly tab for the instruction pointer, but no function was found, open a memory-range tab instead, for range e.g. [ip, ip + 16 KiB].
- Maybe ip is near the end of executable, and ip + 16 KiB is out of bounds. Read memory in aligned 4 KiB blocks; on error, stop and truncate the range.
- If there are breakpoints, some bytes of memory may be overwritten by us. After reading the memory, cross-reference it with Debugger::breakpoint_locations to fix up those bytes.
- Make sure drop_caches() (e.g. C-l hotkey) makes us re-read the memory and re-disassemble. I guess that would happen automatically as long as the disassembly lives in DisassemblyWindow::cache.
- If we failed to read or disassemble any memory range covering ip, we should somehow cache information about this failure. Otherwise we'll keep retrying the whole procedure every time ip changes, and maybe it would be slow. It can be just a sorted
Vec<Range<usize>> in DisassemblyWindow with a limit of 1000 ranges, cleared in drop_caches().
- But [ip, ip + 16 KiB] is kind of inconvenient, we want [ip - 16 KiB, ip + 16 KiB]. There's an obstacle: x86 instructions are variable-length (and are not prefix-free code or anything like that). If you start disassembling from a random address, it'll probably be in the middle of an instructions, and the results will be garbage, at least for the first few instructions. But we can try different starting addresses and see which one produces non-garbage. Instruction length is at most 15 bytes. So we can do this (when opening an address-range-based disassembly tab, either manually or automatically):
- Maintain a set of candidate addresses, initially {start, start + 1, ..., start + 14}.
- At each step, remove the smallest address from the set and decode one instruction starting from it. If decoding succeeded, add the end of the instruction to the set. (If it's already in the set, ignore.) So, the set size never increases.
- Keep going for e.g. 1 KiB of machine code. At the end of 1 KiB, if the candidate set has exactly one element, it's a success. Otherwise this is probably not machine code.
- For auto-opened tabs, apply this procedure starting from ip - 16 KiB. If it succeeds (at ip - 15 KiB), use that as the start address. If it fails, chop off e.g. 4 KiB from the start of the range and try again from ip - 12 KiB. Keep going in 4 KiB steps until success or until we reach ip (then just use ip as the start address).
- For manually opened tabs, start this procedure from address 1 KiB before the requested address. If the procedure succeeds, adjust the tab's start address downwards by at most 14 bytes to align with instruction boundary. If the procedure fails, trust the user and open the tab at the requested address anyway.
- (Also make sure to read the [ip - 16 KiB, ip] memory range in reverse in aligned 4 KiB blocks, just like [ip, ip + 16 KiB] above.)
Step without resuming other threads, step without disabling breakpoints
Difficulty: 4/10
See #50
Need to figure out where to put it in the UI. I guess it'd be a checkbox in breakpoints window, maybe just occupying a row at the top of the list of breakpoints, unless you have better ideas. (Also need to make a new widget for checkboxes, there are no checkboxed in nnd yet).
Then it would just affect StepState::keep_other_threads_suspended in Debugger::step(), that's it (probably).
To step without disabling breakpoints, add another flag in StepState and look at it in handle_breakpoint_trap(), where it says "Ignore regular breakpoints when stepping".
Search in disassembly
Difficulty: 5/10
Just like search_dialog et al in CodeWindow, but in DisassemblyWindow.
Stdin/stdout/terminal window
(Done.)
Details
Difficulty: 7/10
The most requested feature.
EDIT: More discussion in #6 (comment)
I think it should be a terminal emulator, even if initially it will do almost none of the terminal stuff (e.g. parsing ANSI escape codes). So, we'd do the same things a terminal (e.g. iTerm2 or Kitty) does. My vague understanding is that it involves things like:
- create a tty, taking the "master" side of it,
- use some syscall in the forked debuggee process to change its
/dev/tty,
- read from our side of the tty,
- send keyboard and mouse input into the tty when we want, e.g. when the corresponding window in the debugger is active,
- maintain various terminal state like cursor position and current color,
- maintain a screen buffer (2d array of cells, we already have data structures for this in
terminal.rs) and draw stuff on it as needed; e.g. if we read bytes "hello world\n" from the tty, we should update 11 cells of the buffer to spell "hello world" starting from the cursor position, then move the cursor one cell down,
- interpret ANSI codes (e.g. colors and cursor movement), handle resizing, handle things like alternate screen; but at first we can just not do that,
- do scrollback (I guess we'll just make the
ScreenBuffer much taller than the viewport),
- show the screen buffer in a pane in the debugger.
Seems perfectly doable.
Modifying variables, memory, and registers
Difficulty: 7/10
Have some kind of UI in the watches window to modify a variable value.
- Similar to editing watch expressions, there would be a hotkey / mouse click on value to turn the value into a text input.
- Once text editing is complete, parse the value (probably by evaluating an expression with the same interpreter as watch expressions) and write it either to memory or to register (this part is simple).
- Allow modifying value if it has primitive type and is in memory (
AddrOrValueBlob::Addr).
- Also allow modifying a register, if the whole expression is just a register name (possibly with modifiers like
.#x).
- If a variable lives in a register, we could allow editing it, but probably shouldn't, as it may be a secondary copy of a value that normally lives on the stack. (It also would be much more complex because currently we don't propagate information about whether the value came from a register.)
- Store error message if write fails or editing is refused (e.g. if the value is not in memory). Show this error under the value that the user tried to edit (making the table cell taller). Clear it if any input is made in the watches window (e.g. arrow keys). Store it in
ValueTreeNode, or in WatchesWindow (together with identity of the node to which it's attached) to make it easier to clear.
- Editing values is a pretty dangerous operation. Would be nice to be able to undo it. Maybe store the last performed edit (address/register and previous value) in
WatchesWindow and show a message like "edited; press C-u to undo" in place of error message.
Modifying instruction pointer
Difficulty: 7/10
Have some way to forcefully jump to another place in the code. It amounts to just changing the rip register for the thread; the difficult part is UI.
I think the standard UI for this is drag-n-dropping the green arrow pointing to the current line. But this seems too easy to do accidentally, and also requires mouse. But I don't have better ideas right now. Random thoughts:
- We should have undo for it, similar to the "Modifying variables" feature. Not sure where to put the message about in the UI. Maybe just in the status window (
log!()). The message should emphasize that forceful jump is a very unsafe operation. (... but if the message is in the status window, there's not enough space there for such long message; idk what to do about that.)
- I guess requiring mouse is not critical because jump can also be done by editing
rip using the "Modifying variables" feature from above.
- If there are multiple statements (highlighted characters in source code) on one line, it would be nice to be able to choose one. Drag-n-drop onto the highlighted character would be good. There'd need to be some visual feedback when drag-hovering over a highlighted character - highlight it with a different color.
- If there are multiple machine code addresses for the same line+column, I guess we just pick one the same way as when adding a breakpoint.
- Support this drag-n-drop in both source code and disassembly.
- What if you need to jump by more than one screen of text, or to a different file? I guess allow dragging the green arrow from another pane... no, there are no green arrows in other panes most of the time. Maybe drag from the status line in the status window? Maybe just click on a highlighted character in the code, and it'd pop up a dialog asking whether you want to jump there? no, that would be too annoying when just using clicking to navigate and accidentally hitting highlighted characters sometimes. Welp, I'm out of ideas for now. Consider it part of the task to come up with good UI. Research what other debuggers do, surely this UI problem was solved in the 1980s by turbo basic or something.
Syntax highlighting
Difficulty: 7/10?
Hopefully easy to do with tree-sitter, embedding grammars for the top few languages (c, c++, rust, zig, odin, ...). Need to check that its dependency tree is small enough that it won't blow up the compilation time and executable size.
If you feel like implementing a feature for nnd, here are some ideas that seem tractable.
If you go for it, you probably want to send a WIP draft PR early so I can help you not waste a lot of time going down a wrong path. If anything seems like it requires writing a lot of code or complicated code then it's probably not what I had in mind, and you should ask about it.
Write a byte to memory to let the program know that a debugger is attached to it
Difficulty: 2/10
See #53 . Just look for the section when loading debug info in Symbols (similar to how it looks e.g. for
.symtab), remember its address, and write to it inDebuggerin theis_initial_execsituation.Memory hex view window
Difficulty: 5/10
See #47 (comment) (paragraph starting with "Hex memory panel would be nice").
More convenient --tty(Mostly obsoleted by the built-in terminal emulator now.)
Details
Difficulty: 3/10nnd --help-ttycurrently tells you to:ls -l /proc/$$/fd | grep /dev/pts/sleep inf(I always forget to do this)I'd like a small convenience nnd subcommand that does (an equivalent of) steps 2, 3, 4, and 6 for you. So that the usage becomes:
nnd --herennd --there my_programnnd --herewould get the current tty path and write it to some file in~/.nnd/, then go to sleep forever.nnd --therewill then read that file. Some details to figure out:nnd --therecan't attach to the samennd --heresimultaneously,nnd --hereexits, it's not attachable anymore,nnd --hereornnd --therewere killed,nnd --therequits, anothernnd --therecan attach to the samennd --here.nnd --heres,nnd --thereneeds to somehow pick one; maybe allow naming them, likennd --here=foonnd --there=foo?nnd --here? It shouldn't read keyboard inputs from the terminal, that would steal them from the debuggee (if it's running). Maybe it can detect whether there's annnd --therecurrently attached (probably through the same file locking protocol as above), and take over the terminal when detached: clear screen and show a message with explanation and instructions, listen to input and exit if 'q' is pressed. Then disengage whennnd --thereis attached again.nnd --there's program may start using the terminal beforennd --herenotices that it should disengage from the terminal. Solving it would require additional communication so thatnnd --therewaits fornnd --hereto ack that the terminal is free from interference. But maybe it's ok to leave the race condition, because the user usually won't type into the terminal in the first milliseconds after launchingnnd --there.Reverse --tty(Mostly obsoleted by the built-in terminal emulator now, but can still be done if anyone wants this feature.)
Details
Difficulty: 2/10See
nnd --help-ttyfor the current way to debug interactive CLI/TUI programs. It has at least one problem: if the debuggee uses/dev/tty, it'll point to the same tty the debugger uses, not to the other terminal's tty. So the debuggee and debugger mess up each other's UI and steal each other's keyboard inputs, and nothing works. AFAIK, there's no good way to redirect another process's/dev/tty.We can add a mode where the debuggee runs in the current terminal, while the debugger is redirected to a different one. The opposite of what --tty does. Debugger doesn't use
/dev/tty, it's easy for it to redirect its own tty usage: just open the requested file in /dev/pts/ and use that fd instead of stdin and stdout.Input buffering
Difficulty: 3/10
I often want to kill the process and immediately start another one:
C-kimmediately followed byrorn. This is currently annoying to do because sometimes I press the second key too soon after the first, while the process is still quitting. Then the debugger shows an error, and I have to pressragain some unspecified time later.(Another example is
continue immediately followed bystep, expecting thecto quickly hit a particular breakpoint.)Videogames solved this 30 years ago with input buffering. If the user inputs a command that can't be executed from the current state, store the command for e.g. 500ms; if the state changes within that time, execute the command automatically, as if the key was pressed at just the right moment.
I guess we can do exactly that. Keep a list of keys that we recently failed to apply because of ProcessState. Remove ones older than 500ms. Retry all of them in each DebuggerUI::build() call. To determine whether an input should be buffered, maybe check for
ErrorCode::ProcessState, or maybe hardcode a list ofKeyActions for which buffering is enabled.Not sure what exactly to do with error messages. Delay showing the error message for 500ms? That would feel broken and confusing. Show the error immediately? That would be confusing if the operation later succeeds. I guess we have to show it immediately, then hide it on successful retry. Maybe even replace it with a different message confirming success, otherwise it's still confusing. (I wish we had animations, then maybe we could, like, fade out the message in a way that intuitively looks like the message no longer applies. With a poof of smoke or something.)
Whole-file breakpoints
Difficulty: 3/10
Add a way to put breakpoint on all lines of a given source code file at once.
In UI, maybe we should add a header line at the start of the source code listing; a breakpoint on that line would be a whole-file breakpoint. Or maybe just say that breakpoint on the first line of the file is a whole-file breakpoint (there's usually no runnable code on the first line of a file anyway) (but then it's not clear how to communicate this in the UI).
In debugger, it'd be a new enumerand in
BreakpointOn, resolved to addresses by listing allLineInfos in the file (same way as for normal line breakpoints, but start at line 0 and iterate through all lines).Breakpoints on function's inlined call sites
Difficulty: 5/10
Currently you can set breakpoint on a function by name: open it in disassembly window, put a breakpoint on the first instruction. But what if this function is inlined? E.g. it's std::vector::push_back. We could do this:
This would require adding some data structure in
Symbolsthat maps function to its call sites. I guess a sortedVec<(/*function_idx*/ usize, /*addr*/ usize)>, I guess in each shard separately. Lookup would binary search the range of entries or a givenfunction_idx. We'd add to this Vec when traversing DWARF, then at the end we sort it. Maybe it won't work exactly like this because, IIRC,function_idxis assigned bydedup_functions(), way after traversing DWARF. So we'd have to initially use some other identifiers for the functions, then either replace them withfunction_idxafterdedup_functions(), or use those other identifiers in lookup too.For breakpoints, it'd be a new enumerand in
BreakpointOn, resolved to addresses using the data structure above.Better UI for builtin breakpoints and breakpoint settings
Difficulty: 4/10
Currently there are two builtin special breakpoints: rust panics and C++ exceptions. Currently they're awkwardly shown at the top of the list of breakpoints, barely distinguishable from normal breakpoints, probably looking confusing. Would be nice to change this to a collapsible section: by default show one line saying "▸ options", which can be expanded by clicking or pressing right arrow key, like in watches. Inside it would be the current two builtin breakpoints, more builtin breakpoints (signals, start of main(), syscalls), and some settings (checkbox for whether breakpoints should be disabled when stepping, checkbox for whether stepping should keep other threads stopped).
The nnd's (very custom) UI system currently doesn't have a reusable widget for tree lists (the tree in watches pane is very bespoke and can't be reused). So this would be either a one-off implementation of this one collapsible section in one place (like, just
is_options_section_expanded: boolinBreakpointsWindow) or some significant research to make a reusable tree table widget (risky, I may not like it and reject the PR, and the UI system is probably hard to work with; but then the widget may come in handy in the threads window if we add multi-process support in future).Disassembly without debug info or symbol table
Difficulty: 7/10
Currently the disassembly window can only show functions. Information about functions comes from
.symtabor.debug_info. If both are missing, no disassembly is shown. This rarely happens in practice, but there are a few cases:I imagine this can be implemented in this order:
Vec<Range<usize>>in DisassemblyWindow with a limit of 1000 ranges, cleared in drop_caches().Step without resuming other threads, step without disabling breakpoints
Difficulty: 4/10
See #50
Need to figure out where to put it in the UI. I guess it'd be a checkbox in breakpoints window, maybe just occupying a row at the top of the list of breakpoints, unless you have better ideas. (Also need to make a new widget for checkboxes, there are no checkboxed in nnd yet).
Then it would just affect StepState::keep_other_threads_suspended in Debugger::step(), that's it (probably).
To step without disabling breakpoints, add another flag in StepState and look at it in handle_breakpoint_trap(), where it says "Ignore regular breakpoints when stepping".
Search in disassembly
Difficulty: 5/10
Just like
search_dialoget al inCodeWindow, but inDisassemblyWindow.Stdin/stdout/terminal window(Done.)
Details
Difficulty: 7/10The most requested feature.
EDIT: More discussion in #6 (comment)
I think it should be a terminal emulator, even if initially it will do almost none of the terminal stuff (e.g. parsing ANSI escape codes). So, we'd do the same things a terminal (e.g. iTerm2 or Kitty) does. My vague understanding is that it involves things like:
/dev/tty,terminal.rs) and draw stuff on it as needed; e.g. if we read bytes "hello world\n" from the tty, we should update 11 cells of the buffer to spell "hello world" starting from the cursor position, then move the cursor one cell down,ScreenBuffermuch taller than the viewport),Seems perfectly doable.
Modifying variables, memory, and registers
Difficulty: 7/10
Have some kind of UI in the watches window to modify a variable value.
AddrOrValueBlob::Addr)..#x).ValueTreeNode, or inWatchesWindow(together withidentityof the node to which it's attached) to make it easier to clear.WatchesWindowand show a message like "edited; press C-u to undo" in place of error message.Modifying instruction pointer
Difficulty: 7/10
Have some way to forcefully jump to another place in the code. It amounts to just changing the
ripregister for the thread; the difficult part is UI.I think the standard UI for this is drag-n-dropping the green arrow pointing to the current line. But this seems too easy to do accidentally, and also requires mouse. But I don't have better ideas right now. Random thoughts:
log!()). The message should emphasize that forceful jump is a very unsafe operation. (... but if the message is in the status window, there's not enough space there for such long message; idk what to do about that.)ripusing the "Modifying variables" feature from above.Syntax highlighting
Difficulty: 7/10?
Hopefully easy to do with tree-sitter, embedding grammars for the top few languages (c, c++, rust, zig, odin, ...). Need to check that its dependency tree is small enough that it won't blow up the compilation time and executable size.