Skip to content

Language support: Dart #28

Description

@CodeEnPlace

Add a new language to bough


Phase 1: Scaffold — wire the language into the system

Goal: LanguageId::Dart 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:

arborium-dart = "2.16"

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 = "dart")]
Dart,

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() → "dart"
  • display_name() → "Dart"
  • corpus_dir_name() → "dart"
  • file_extension() → delegates to slug()

Also add the variant to LanguageId::ALL.

1.3 Create stub driver

Create crates/bough-core/src/language/dart.rs:

use super::LanguageDriver;
use crate::mutant::{MutantKind, Span};

pub(crate) struct DartDriver;

impl LanguageDriver for DartDriver {
    fn ts_language(&self) -> arborium_tree_sitter::Language {
        arborium_dart::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 dart;
  • Add pub(crate) use dart::DartDriver;
  • Add match arm in driver_for_lang: crate::LanguageId::Dart => Box::new(DartDriver),

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:

"dart" => "bough_core::LanguageId::Dart",
// and
"dart" => "dart",

1.7 Enable syntax highlighting for docs site

In crates/bough-docs-cli/Cargo.toml, add "lang-dart" 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 Dart language support"
jj new

Phase 2: Corpus test files

Goal: create corpus/dart/<case>/base.dart 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:

  1. Existing kinds that apply — which current MutantKind variants have equivalents in this language
  2. 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 Dart
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/dart/<case-name>/base.dart file containing a minimal, valid Dart 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 Dart.

2.4 Verify & commit

cargo test -p bough-corpus-tests dart 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 Dart 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:

# Dart Driver Implementation

- [x] Wire Dart 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

  1. 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.

  2. 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
  3. 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
  4. 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.

  5. Generate and verify corpus mutations:

    BOUGH_UPDATE_CORPUS=1 cargo test -p bough-corpus-tests dart::<test_name>

    This generates <hash>.dart and <hash>.mutation.json files. Verify:

    • Each generated file contains valid Dart syntax
    • The substitution makes semantic sense
    • Run without BOUGH_UPDATE_CORPUS to confirm stability:
    cargo test -p bough-corpus-tests dart::<test_name>
  6. Save jj revision:

    jj describe -m "dart: <kind-name>"
    jj new

3.2 Order of implementation

Start with the simplest kinds and build up:

  1. Binary ops (arithmetic: add, sub, mul, div, rem)
  2. Binary ops (comparison: eq, ne, gt, gte, lt, lte)
  3. Binary ops (logical: and, or)
  4. Binary ops (bitwise: bit-and, bit-or, bit-xor, shl, shr)
  5. Language-specific binary ops (if any)
  6. Assignment ops (normal, augmented)
  7. Condition (if, while, for)
  8. Statement block
  9. Literals (bool, number, string)
  10. Collections (array/list, dict/object, tuple)
  11. 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 Dart project with a real test runner.

4.1 Create project

Create examples/dart-<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.dart] section with file includes/excludes and optional skip queries

4.2 Verify

cd examples/dart-<test-runner>
bough show src
bough show mutations

Both should produce meaningful output.

4.3 Commit

jj describe -m "Add dart-<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_dart::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 dart::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/dart/<case>/base.dart. Each test:

  1. Parses the base file and finds all mutations
  2. Applies each mutation, hashes the result
  3. Writes <hash>.dart (mutated source) and <hash>.mutation.json (mutation metadata)
  4. Compares against existing files in the case dir
  5. 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-dart dep
crates/bough-core/src/lib.rs Add LanguageId::Dart variant + match arms
crates/bough-core/src/language/dart.rs New driver file
crates/bough-core/src/language/mod.rs Wire driver module
crates/bough-corpus-tests/build.rs Add language mappings
corpus/dart/*/base.dart 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-dart to arborium features
docs/mutations/dart.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 dart::<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 Dart when spliced into the base file
  • Use compiler-driven development: make the change, compile, fix errors, repeat
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions