Skip to content

Contributing to Badness ​

Thanks for your interest in Badness, a formatter, linter, and language server for LaTeX. This guide covers everything you need to build the project, run the tests, and get a change merged. Contributions of all sizes are welcome, from typo fixes to new lint rules and parser features.

Getting set up ​

Badness is a Rust workspace (edition 2024). The root package, badness, contains the CLI, language server, and linter. The publishable badness-parser and badness-formatter library crates live under crates/. The toolchain is pinned by rust-toolchain.toml, so a stable rustup install picks up the right version automatically. The published crates support Rust 1.89 and newer; CI checks that compatibility floor separately from the pinned development toolchain.

sh
git clone https://github.com/jolars/badness
cd badness
cargo build

If you use Nix with devenv, the development shell provides the full toolchain plus the profiling and benchmarking tools (perf, cargo-flamegraph, hyperfine, cargo-show-asm, cargo-llvm-cov) and the go-task runner. It loads automatically with direnv.

The task runner is go-task. Run task --list to see every available task. The most common ones are below, but every task maps to a plain cargo invocation if you’d rather not install it.

Building and testing ​

TaskEquivalentWhat it does
task buildcargo buildDev build.
task testcargo testRun the whole test suite.
task parser-propertiesRun parser losslessness properties at nightly depth.
task fmtcargo fmtFormat the code.
task lintcargo clippy --all-targets --all-features -- -D warningsClippy, warnings as errors.
task checkEverything CI runs: fmt-check, lint, test, wasm.

Run task check before opening a pull request; it mirrors CI exactly.

Badness uses insta for snapshot tests. When a change deliberately alters formatter or parser output, refresh snapshots with task snapshots and review the diff before committing.

The ordinary test suite runs 256 cases for each parser losslessness property. task parser-properties raises that to 4,096 cases, matching the scheduled nightly job. When proptest finds a counterexample, reduce it and preserve it as a readable regression test; the suite deliberately does not persist opaque seed files.

Benchmark before optimizing, and preserve losslessness when improving performance.

Checks that don’t run in CI ​

Two oracles need more than a Rust toolchain, so run them by hand when your change touches what they cover.

task typeset:check compiles tests/typeset/*.tex before and after formatting and diffs the typeset output. CST comparisons cannot detect every risk introduced by the key-value argument flag: a space token can be trivia to the CST but content to TeX. Run this check when changing keyval signature data or optional-argument lowering. It needs a TeX installation.

task parse-compat runs texlab’s parser as a differential oracle over a corpus. It simplifies both parsers’ trees and compares their structures. Texlab provides a reference for comparison, so a divergence needs an explanation rather than an automatic fix.

Project layout ​

Badness parses LaTeX into a lossless concrete syntax tree (CST) and builds three tools on top of it: a formatter (badness format), a linter (badness lint), and a language server (badness lsp). The architecture follows rust-analyzer. A hand-written lexer turns LaTeX into a flat token stream. An error-tolerant parser produces an event stream that a tree builder feeds into rowan. A separate semantic layer assigns meaning to the generic tree, and salsa provides incremental recomputation.

Read the book’s Architecture page for a full tour before making a substantial change.

Where things live:

  • crates/badness-parser—syntax layer, parser, semantic layer, the BibTeX pipeline, and the data/ signature artifacts.
  • crates/badness-formatter—the layout engine and the .bib formatter.
  • crates/badness-wasm—the wasm shim powering the docs playground.
  • src/—the CLI, LSP, linter, and project layers, plus shim modules re-exporting the member crates at their old paths.

Both library crates must keep building for wasm32-unknown-unknown, so nothing in them may touch the filesystem, threads, or processes. A CI job guards this. Anything that needs the outside world belongs in the root crate.

Invariants ​

The design preserves these properties, and tests enforce them. A change that breaks one is a bug, not a trade-off.

  • Losslessness: reconstruct(text) == text, byte for byte.
  • Idempotence: format(format(x)) == format(x).
  • The formatter is whitespace-only: it changes trivia (whitespace, newlines, comments, .dtx margins and guards) and nothing else. Content rewrites such as x^{2} → x^2 are linter autofixes, not layout.
  • Protected regions: verbatim-like content (verbatim, lstlisting, \verb, comments) is never altered by the formatter, apart from a document-wide line-terminator normalization.

A couple of ground rules keep the design coherent:

  • Semantic facts reach the parser only through a narrow, curated admission test; when in doubt, a fact belongs in the semantic layer. Parsing is the parser’s job; layout is the formatter’s job. Never paper over a parser mistake in the formatter.
  • New parser features need corpus and snapshot tests and a losslessness assertion.

Making a change ​

  • Prefer trunk-based development and atomic commits. Branch first for substantial changes; small fixes can go straight to main.
  • Follow Conventional Commits, for example feat(linter): add missing-required-argument rule or fix(parser): recover at unbalanced brace. The CHANGELOG.md is generated from the commit history by versionary, so a clear, well-scoped commit message is what shows up in the release notes. Don’t hand-edit CHANGELOG.md.
  • Keep commit subjects short (imperative mood, ideally under 60 characters) and use the body for rationale. Close issues with Fixes #123 in the body.
  • A rustfmt git hook rewrites unformatted files and aborts the commit, so run cargo fmt first. Clippy warnings are treated as errors.

Each workspace crate is its own versionary package with its own changelog and version. The root CLI uses bare v* tags; the library crates use badness-parser-v* and badness-formatter-v*. Only releases with bare v* tags carry release assets.

Adding a lint rule ​

The add-lint-rule workflow automates these steps:

  1. Implement Rule in a new src/linter/rules/<name>.rs, choosing node-shape, whole-file, or streaming dispatch, with an id, a default_severity, a description, and at least one triggering example. Emit a losslessness-safe fix where one is warranted, and set emits_fix accordingly. Rules are enabled by default; override default_enabled only for useful checks whose unavoidable false positives make them better suited to explicit selection.
  2. Register it in the three lockstep lists in src/linter/rules.rs: the module declaration, the re-export, and the entry in all_rules().
  3. Ship unit tests next to the rule and an integration test, plus a losslessness assertion on any fixture.
  4. Regenerate the rules reference with task docs:rules. Do not edit the rendered page by hand.

Generated data files ​

Several files in crates/badness-parser/data/ are generated from pinned upstream sources by scripts/gen_*.py and guarded by paired task …:check and :sync targets: cwl_signatures.json, the package and class name lists with package_metadata.json, and bib_fields.json. Regenerate them through their tasks rather than editing the generated data by hand. signatures.json, colors.json, and tikz_libraries.json are curated by hand and may be edited directly.

Command entries in signatures.json may set completionKind to symbol for argument-free math and text symbols or logos, or keyword for argument-free control, spacing, and declaration commands. This metadata affects completion only. Omit it when the classification is unknown; an empty CWL signature does not establish zero arguments. Document definitions and project declarations override these classifications.

The published badness.toml schema is generated from the Rust configuration types. Regenerate badness.schema.json with UPDATE_EXPECTED=1 cargo test --test config_schema, and review the diff rather than editing the JSON by hand.

Windows CI bites twice ​

Line endings: the formatter emits LF and tests compare bytes against checked-in fixtures. When you add a fixture in a new extension under crates/*/tests/fixtures/** or crates/badness-parser/tests/corpus/**, add a matching … eol=lf line to .gitattributes. Never normalize line endings in code to pass a test; fix the attribute instead.

URIs: decode LSP URIs to filesystem paths only through uri_to_fs_path and path_to_uri in lsp.rs. Tests and snapshots must not assume / versus \.

Documentation ​

User-facing docs use VitePress under docs/. Preview them locally with task docs:serve (live reload), build them with task docs, or run the documentation checks with task docs:check. The linter-rules reference and benchmark results are generated; regenerate them with task docs:rules and task bench rather than editing them by hand. Building the site reads the committed benchmark data without running benchmarks.

The documentation build publishes canonical and social metadata, the sitemap, processed Markdown, installers, the configuration schema, and the standalone WASM playground. The benchmark renderer loads the vendored Vega scripts only on pages with charts. docs/doc-utils renders generated content shared by VitePress and Markdown exports.

GitHub Actions validates pull requests and main pushes. Release tags and manual runs deploy the built site to Cloudflare Workers at https://badness.dev. pnpm --dir docs preview:worker previews redirects and Markdown negotiation locally after a build. Deployment uses the CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN GitHub secrets.

A note on AGENTS.md ​

The repo’s AGENTS.md is the operational contract for AI coding agents. It includes both repository-wide and subsystem-specific directives and is kept under 32 KiB so agents can load it as a single checklist of things not to break. For architectural rationale and tradeoffs, read the book’s Architecture page.

License ​

By contributing, you agree that your contributions are licensed under the project’s MIT License.