Add a new language to bough
Phase 1: Scaffold — wire the language into the system
Goal: LanguageId::Lua exists, compiles, is selectable in config, but produces zero mutations. One jj revision.
1.1 Add tree-sitter grammar dependency
In crates/bough-core/Cargo.toml, add:
Run cargo check -p bough-core to ensure the crate resolves.
1.2 Add LanguageId variant
In crates/bough-core/src/lib.rs, add to the LanguageId enum:
#[facet(rename = "lua")]
Lua,
Then add arms to every match self block in the same file — the compiler will tell you exactly which ones. There are currently four methods with exhaustive matches:
slug() → "lua"
display_name() → "Lua"
corpus_dir_name() → "lua"
file_extension() → delegates to slug()
Also add the variant to LanguageId::ALL.
1.3 Create stub driver
Create crates/bough-core/src/language/lua.rs:
use super::LanguageDriver;
use crate::mutant::{MutantKind, Span};
pub(crate) struct LuaDriver;
impl LanguageDriver for LuaDriver {
fn ts_language(&self) -> arborium_tree_sitter::Language {
arborium_lua::language().into()
}
fn check_node(
&self,
_node: &arborium_tree_sitter::Node<'_>,
_file_content: &[u8],
) -> Option<(MutantKind, Span, Span)> {
None
}
fn substitutions(&self, _kind: &MutantKind) -> Vec<String> {
vec![]
}
fn is_context_boundary(&self, _node: &arborium_tree_sitter::Node<'_>) -> bool {
false
}
}
Include a debug_tree test at the bottom (see §4.1).
1.4 Wire driver into language/mod.rs
- Add
mod lua;
- Add
pub(crate) use lua::LuaDriver;
- Add match arm in
driver_for_lang: crate::LanguageId::Lua => Box::new(LuaDriver),
1.5 Fix remaining compiler errors
Run cargo check on the whole workspace. The compiler will surface every exhaustive match that needs a new arm. These are scattered across:
crates/bough-cli/src/render.rs — Render impl for LanguageId (terse/verbose/markdown/json methods + tests)
crates/bough-cli/src/show_single_mutation.rs — lang name in single mutation display
- Any other match on
LanguageId
Follow compiler errors. Do not grep.
1.6 Wire into corpus test build script
In crates/bough-corpus-tests/build.rs, add two mappings:
"lua" => "bough_core::LanguageId::Lua",
// and
"lua" => "lua",
1.7 Enable syntax highlighting for docs site
In crates/bough-docs-cli/Cargo.toml, add "lang-lua" to the arborium features list.
Run cargo check -p bough-docs-cli to verify.
1.8 Verify & commit
cargo test 2>&1 | tail -5
All existing tests must still pass. Save a jj revision:
jj describe -m "Add Lua language support"
jj new
Phase 2: Corpus test files
Goal: create corpus/lua/<case>/base.lua files for every mutation kind you intend to support. One jj revision.
2.1 Identify needed MutantKind variants
Before writing any corpus files, survey the language's syntax and compare against the existing MutantKind enum in crates/bough-core/src/mutant.rs. Produce two lists:
- Existing kinds that apply — which current
MutantKind variants have equivalents in this language
- New kinds needed — language constructs that are mutable but have no existing
MutantKind
For new kinds: add the variants to MutantKind, MutantKind::all_variants(), and follow compiler errors. Other drivers' substitutions should return vec![] for the new variants. Do this now, before Phase 3, so corpus cases can be written for them.
2.2 Decide which MutantKind variants to support
Review the MutantKind enum in crates/bough-core/src/mutant.rs. For each variant, decide:
- Does this language have an equivalent construct?
- What's the tree-sitter node kind for it?
Use the debug_tree test (§4.1) to explore the grammar.
Common kinds and their typical tree-sitter nodes (language-specific — names vary):
| MutantKind |
JS node kind |
What to look for in Lua |
BinaryOp(*) |
binary_expression |
Arithmetic, comparison, logical, bitwise |
Assign(*) |
augmented_assignment_expression, assignment_expression |
=, +=, -=, etc. |
StatementBlock |
statement_block |
Block/body of function/if/loop |
Condition |
if_statement, while_statement |
Condition expression in control flow |
ArrayDecl(Inline) |
array |
Array/list literal with elements |
DictDecl |
object |
Dict/map/object literal with entries |
TupleDecl |
(JS doesn't have) |
Tuple literal if language has one |
Literal(BoolTrue/False) |
true/false |
Boolean literals |
Literal(String/EmptyString) |
string |
String literals |
Literal(Number) |
number |
Numeric literals |
Assert |
(not in JS) |
Assert statements |
UnaryNot |
(not in JS) |
Logical not operator |
OptionalChain(*) |
member_expression with ?. |
Optional chaining if language has it |
SwitchCase |
switch_case |
Switch/match case if language has it |
2.3 Create base files
For each mutation kind, create a corpus/lua/<case-name>/base.lua file containing a minimal, valid Lua source snippet that exercises that kind.
Naming convention: bin-op-add, condition, statement-block, literal-bool-true, etc. (kebab-case, match existing JS/Python corpus dirs where applicable).
Each base file should be as minimal as possible — typically 1–3 lines. It must be syntactically valid Lua.
2.4 Verify & commit
cargo test -p bough-corpus-tests lua 2>&1
All tests should pass (each test finds zero mutations because the driver returns None). Though note: if a test dir has no mutation output files yet and the driver returns nothing, the test just passes (no stale files to complain about).
jj describe -m "Add Lua syntax corpus test files"
jj new
Phase 3: Implement mutation kinds
Goal: implement check_node, substitutions, and is_context_boundary for each corpus case. One jj revision per mutation kind (or small groups of closely related kinds).
3.1 Maintain a TODO checklist
Create a .md tracking each corpus case. Example:
# Lua Driver Implementation
- [x] Wire Lua into corpus test build script (`build.rs`)
- [ ] Implement each corpus case
- [x] `bin-op-add`
- [x] check_node
- [x] substitutions
- [x] is_context_boundary
- [x] save jj rev
- [ ] `condition`
- [ ] check_node
- [ ] substitutions
- [ ] is_context_boundary
- [ ] save jj rev
Related cases can share a rev (e.g. condition-else covered by the condition rev). Mark skipped cases with a reason.
3.2 For each corpus case
-
Explore the tree-sitter AST — edit the debug_tree test with the base file's content, run it (see §4.1) to discover the exact node kind, field names, and structure.
-
Implement check_node — add a match arm for the tree-sitter node kind. Return (MutantKind, subst_span, effect_span):
subst_span: the span of text that will be replaced (e.g. just the operator +)
effect_span: the span of the whole affected expression (e.g. the entire 1 + 2)
- Use
span_from_node(&node) helper from crate::mutant
- For operators with a field name, use
node.child_by_field_name("operator")
- For operators without a field name, iterate children to find the right one
-
Implement substitutions — add a match arm returning language-appropriate replacement strings. Key language-specific concerns:
- Boolean keywords (
true/false vs True/False vs TRUE/FALSE)
- Logical operators (
&&/|| vs and/or)
- Empty block syntax (
{} vs pass vs end vs ...)
- Number boundary values (
Infinity vs float('inf') vs math.inf vs ...)
- Empty collection syntax
-
Implement is_context_boundary — add node kinds that represent "scope boundaries" (functions, classes, methods). These are used to group mutations by context. Typically done once, not per-kind.
-
Generate and verify corpus mutations:
BOUGH_UPDATE_CORPUS=1 cargo test -p bough-corpus-tests lua::<test_name>
This generates <hash>.lua and <hash>.mutation.json files. Verify:
- Each generated file contains valid Lua syntax
- The substitution makes semantic sense
- Run without
BOUGH_UPDATE_CORPUS to confirm stability:
cargo test -p bough-corpus-tests lua::<test_name>
-
Save jj revision:
jj describe -m "lua: <kind-name>"
jj new
3.2 Order of implementation
Start with the simplest kinds and build up:
- Binary ops (arithmetic: add, sub, mul, div, rem)
- Binary ops (comparison: eq, ne, gt, gte, lt, lte)
- Binary ops (logical: and, or)
- Binary ops (bitwise: bit-and, bit-or, bit-xor, shl, shr)
- Language-specific binary ops (if any)
- Assignment ops (normal, augmented)
- Condition (if, while, for)
- Statement block
- Literals (bool, number, string)
- Collections (array/list, dict/object, tuple)
- Language-specific kinds (assert, unary-not, optional-chain, switch-case, etc.)
3.3 Final verification
After all kinds are implemented:
cargo test 2>&1 | grep "^test result:"
All tests across the entire workspace must pass.
Phase 4: Example project (optional but recommended)
Goal: create a working example in examples/ that demonstrates bough running against a real Lua project with a real test runner.
4.1 Create project
Create examples/lua-<test-runner>/ with:
- Source file(s) with partial test coverage (some code paths deliberately untested)
- Test file(s) using the language's test runner
bough.config.toml with:
include/exclude globs
test.cmd pointing to the test runner
[lang.lua] section with file includes/excludes and optional skip queries
4.2 Verify
cd examples/lua-<test-runner>
bough show src
bough show mutations
Both should produce meaningful output.
4.3 Commit
jj describe -m "Add lua-<test-runner> example project"
jj new
Reference: tools and techniques
4.1 debug_tree test
Every driver file should include this test for AST exploration:
#[cfg(test)]
mod tests {
fn dump_tree(src: &str) {
let lang: arborium_tree_sitter::Language = arborium_lua::language().into();
let mut parser = arborium_tree_sitter::Parser::new();
parser.set_language(&lang).unwrap();
let tree = parser.parse(src.as_bytes(), None).unwrap();
fn print_node(node: &arborium_tree_sitter::Node, src: &[u8], indent: usize) {
let text = node.utf8_text(src).unwrap_or("");
let field = node.parent().and_then(|p| {
(0..p.child_count())
.find(|&i| p.child(i as u32).map(|c| c.id() == node.id()).unwrap_or(false))
.and_then(|i| p.field_name_for_child(i as u32))
});
let field_str = field.map(|f| format!("{f}: ")).unwrap_or_default();
eprintln!("{:indent$}{field_str}{} [{}-{}] {text:?}", "", node.kind(), node.start_byte(), node.end_byte(), indent=indent);
for i in 0..node.child_count() {
if let Some(child) = node.child(i as u32) {
print_node(&child, src, indent + 2);
}
}
}
print_node(&tree.root_node(), src.as_bytes(), 0);
}
#[test]
#[ignore]
fn debug_tree() {
dump_tree("your code here");
}
}
Run with:
cargo test -p bough-core lua::tests::debug_tree -- --ignored --nocapture 2>&1
4.2 Corpus test runner
The corpus test system (crates/bough-corpus-tests/) auto-generates one #[test] per corpus/lua/<case>/base.lua. Each test:
- Parses the base file and finds all mutations
- Applies each mutation, hashes the result
- Writes
<hash>.lua (mutated source) and <hash>.mutation.json (mutation metadata)
- Compares against existing files in the case dir
- Fails if there are stale files (set
BOUGH_UPDATE_CORPUS=1 to auto-remove)
4.3 LanguageDriver trait
pub(crate) trait LanguageDriver {
fn ts_language(&self) -> arborium_tree_sitter::Language;
fn check_node(&self, node: &Node<'_>, file_content: &[u8]) -> Option<(MutantKind, Span, Span)>;
fn substitutions(&self, kind: &MutantKind) -> Vec<String>;
fn is_context_boundary(&self, node: &Node<'_>) -> bool;
}
check_node is called on every tree-sitter node during a depth-first walk. Return Some((kind, subst_span, effect_span)) to create a mutant.
substitutions returns the list of replacement strings for a given mutation kind. Return vec![] for kinds this language doesn't support.
is_context_boundary returns true for nodes that represent scope boundaries (functions, classes). Used for grouping mutations in output.
4.4 Key files to touch
| File |
What to change |
crates/bough-core/Cargo.toml |
Add arborium-lua dep |
crates/bough-core/src/lib.rs |
Add LanguageId::Lua variant + match arms |
crates/bough-core/src/language/lua.rs |
New driver file |
crates/bough-core/src/language/mod.rs |
Wire driver module |
crates/bough-corpus-tests/build.rs |
Add language mappings |
corpus/lua/*/base.lua |
Corpus test files |
crates/bough-cli/src/render.rs |
LanguageId render arms + tests (compiler-driven) |
crates/bough-cli/src/show_single_mutation.rs |
Lang name match arm (compiler-driven) |
crates/bough-docs-cli/Cargo.toml |
Add lang-lua to arborium features |
docs/mutations/lua.examples.toml |
Mutation examples for docs site |
4.5 Verification strategy
- After Phase 1:
cargo test — all existing tests pass, no regressions
- After each Phase 3 kind:
cargo test -p bough-corpus-tests lua::<test_name> — mutations generated correctly
- After all Phase 3 kinds:
cargo test — full workspace green
- After Phase 4:
bough show mutations in example dir — produces output
Constraints
- Match existing architecture — don't introduce new abstractions or patterns
- One jj revision per phase-1 scaffold, one per corpus files, one per mutation kind
- Every match on
MutantKind in other drivers that doesn't apply to that language returns vec![]
- All substitutions must produce syntactically valid Lua when spliced into the base file
- Use compiler-driven development: make the change, compile, fix errors, repeat
Add a new language to bough
Phase 1: Scaffold — wire the language into the system
Goal:
LanguageId::Luaexists, compiles, is selectable in config, but produces zero mutations. One jj revision.1.1 Add tree-sitter grammar dependency
In
crates/bough-core/Cargo.toml, add:Run
cargo check -p bough-coreto ensure the crate resolves.1.2 Add
LanguageIdvariantIn
crates/bough-core/src/lib.rs, add to theLanguageIdenum:Then add arms to every
match selfblock in the same file — the compiler will tell you exactly which ones. There are currently four methods with exhaustive matches:slug()→"lua"display_name()→"Lua"corpus_dir_name()→"lua"file_extension()→ delegates toslug()Also add the variant to
LanguageId::ALL.1.3 Create stub driver
Create
crates/bough-core/src/language/lua.rs:Include a
debug_treetest at the bottom (see §4.1).1.4 Wire driver into
language/mod.rsmod lua;pub(crate) use lua::LuaDriver;driver_for_lang:crate::LanguageId::Lua => Box::new(LuaDriver),1.5 Fix remaining compiler errors
Run
cargo checkon the whole workspace. The compiler will surface every exhaustive match that needs a new arm. These are scattered across:crates/bough-cli/src/render.rs—Renderimpl forLanguageId(terse/verbose/markdown/json methods + tests)crates/bough-cli/src/show_single_mutation.rs— lang name in single mutation displayLanguageIdFollow compiler errors. Do not grep.
1.6 Wire into corpus test build script
In
crates/bough-corpus-tests/build.rs, add two mappings:1.7 Enable syntax highlighting for docs site
In
crates/bough-docs-cli/Cargo.toml, add"lang-lua"to thearboriumfeatures list.Run
cargo check -p bough-docs-clito verify.1.8 Verify & commit
All existing tests must still pass. Save a jj revision:
jj describe -m "Add Lua language support" jj newPhase 2: Corpus test files
Goal: create
corpus/lua/<case>/base.luafiles for every mutation kind you intend to support. One jj revision.2.1 Identify needed
MutantKindvariantsBefore writing any corpus files, survey the language's syntax and compare against the existing
MutantKindenum incrates/bough-core/src/mutant.rs. Produce two lists:MutantKindvariants have equivalents in this languageMutantKindFor new kinds: add the variants to
MutantKind,MutantKind::all_variants(), and follow compiler errors. Other drivers'substitutionsshould returnvec![]for the new variants. Do this now, before Phase 3, so corpus cases can be written for them.2.2 Decide which
MutantKindvariants to supportReview the
MutantKindenum incrates/bough-core/src/mutant.rs. For each variant, decide:Use the
debug_treetest (§4.1) to explore the grammar.Common kinds and their typical tree-sitter nodes (language-specific — names vary):
BinaryOp(*)binary_expressionAssign(*)augmented_assignment_expression,assignment_expression=,+=,-=, etc.StatementBlockstatement_blockConditionif_statement,while_statementArrayDecl(Inline)arrayDictDeclobjectTupleDeclLiteral(BoolTrue/False)true/falseLiteral(String/EmptyString)stringLiteral(Number)numberAssertUnaryNotOptionalChain(*)member_expressionwith?.SwitchCaseswitch_case2.3 Create base files
For each mutation kind, create a
corpus/lua/<case-name>/base.luafile containing a minimal, valid Lua source snippet that exercises that kind.Naming convention:
bin-op-add,condition,statement-block,literal-bool-true, etc. (kebab-case, match existing JS/Python corpus dirs where applicable).Each base file should be as minimal as possible — typically 1–3 lines. It must be syntactically valid Lua.
2.4 Verify & commit
All tests should pass (each test finds zero mutations because the driver returns
None). Though note: if a test dir has no mutation output files yet and the driver returns nothing, the test just passes (no stale files to complain about).jj describe -m "Add Lua syntax corpus test files" jj newPhase 3: Implement mutation kinds
Goal: implement
check_node,substitutions, andis_context_boundaryfor each corpus case. One jj revision per mutation kind (or small groups of closely related kinds).3.1 Maintain a TODO checklist
Create a
.mdtracking each corpus case. Example:Related cases can share a rev (e.g.
condition-elsecovered by theconditionrev). Mark skipped cases with a reason.3.2 For each corpus case
Explore the tree-sitter AST — edit the
debug_treetest with the base file's content, run it (see §4.1) to discover the exact node kind, field names, and structure.Implement
check_node— add a match arm for the tree-sitter node kind. Return(MutantKind, subst_span, effect_span):subst_span: the span of text that will be replaced (e.g. just the operator+)effect_span: the span of the whole affected expression (e.g. the entire1 + 2)span_from_node(&node)helper fromcrate::mutantnode.child_by_field_name("operator")Implement
substitutions— add a match arm returning language-appropriate replacement strings. Key language-specific concerns:true/falsevsTrue/FalsevsTRUE/FALSE)&&/||vsand/or){}vspassvsendvs ...)Infinityvsfloat('inf')vsmath.infvs ...)Implement
is_context_boundary— add node kinds that represent "scope boundaries" (functions, classes, methods). These are used to group mutations by context. Typically done once, not per-kind.Generate and verify corpus mutations:
This generates
<hash>.luaand<hash>.mutation.jsonfiles. Verify:BOUGH_UPDATE_CORPUSto confirm stability:Save jj revision:
jj describe -m "lua: <kind-name>" jj new3.2 Order of implementation
Start with the simplest kinds and build up:
3.3 Final verification
After all kinds are implemented:
All tests across the entire workspace must pass.
Phase 4: Example project (optional but recommended)
Goal: create a working example in
examples/that demonstrates bough running against a real Lua project with a real test runner.4.1 Create project
Create
examples/lua-<test-runner>/with:bough.config.tomlwith:include/excludeglobstest.cmdpointing to the test runner[lang.lua]section with file includes/excludes and optional skip queries4.2 Verify
Both should produce meaningful output.
4.3 Commit
jj describe -m "Add lua-<test-runner> example project" jj newReference: tools and techniques
4.1
debug_treetestEvery driver file should include this test for AST exploration:
Run with:
4.2 Corpus test runner
The corpus test system (
crates/bough-corpus-tests/) auto-generates one#[test]percorpus/lua/<case>/base.lua. Each test:<hash>.lua(mutated source) and<hash>.mutation.json(mutation metadata)BOUGH_UPDATE_CORPUS=1to auto-remove)4.3
LanguageDrivertraitcheck_nodeis called on every tree-sitter node during a depth-first walk. ReturnSome((kind, subst_span, effect_span))to create a mutant.substitutionsreturns the list of replacement strings for a given mutation kind. Returnvec![]for kinds this language doesn't support.is_context_boundaryreturns true for nodes that represent scope boundaries (functions, classes). Used for grouping mutations in output.4.4 Key files to touch
crates/bough-core/Cargo.tomlarborium-luadepcrates/bough-core/src/lib.rsLanguageId::Luavariant + match armscrates/bough-core/src/language/lua.rscrates/bough-core/src/language/mod.rscrates/bough-corpus-tests/build.rscorpus/lua/*/base.luacrates/bough-cli/src/render.rscrates/bough-cli/src/show_single_mutation.rscrates/bough-docs-cli/Cargo.tomllang-luato arborium featuresdocs/mutations/lua.examples.toml4.5 Verification strategy
cargo test— all existing tests pass, no regressionscargo test -p bough-corpus-tests lua::<test_name>— mutations generated correctlycargo test— full workspace greenbough show mutationsin example dir — produces outputConstraints
MutantKindin other drivers that doesn't apply to that language returnsvec![]