Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Badness

Badness is a language server, formatter, and linter for LaTeX.

It parses LaTeX into a lossless concrete syntax tree and builds three tools on top of it:

  • a formatter (badness format) that lays out source deterministically,
  • a linter (badness lint) that reports diagnostics, and
  • a language server (badness lsp) that brings both to your editor, as well as many other features.

The architecture follows rust-analyzer: a generic, error-tolerant, hand-written parser produces a lossless tree, semantics are layered on top as a separate concern, and recomputation is incremental.

The Architecture

Badness treats input as generic TeX surface syntax. It never requires resolving macros or catcodes to succeed—doing that in full generality is equivalent to running a TeX engine, so anything it cannot statically recognize degrades to generic nodes rather than a crash. Two properties are guaranteed by construction and enforced as test oracles:

  • Losslessness: the parsed tree reconstructs the input byte-for-byte.
  • Idempotence: formatting an already-formatted file changes nothing.

Where to Go Next

Installation

Badness is distributed as a single binary, badness. The current version is 0.17.0. It is available from several sources:

  • crates.io: cargo install badness
  • Homebrew: brew install jolars/tap/badness
  • npm: npm install -g badness (bundles a prebuilt binary)
  • PyPI: uv tool install badness/pipx install badness
  • AUR (Arch Linux): yay -S badness-bin (prebuilt binary)
  • Prebuilt binaries: from the releases page
  • VS Code/Open VSX: the Badness extension (also on Open VSX; works in Positron and Cursor)

The editor extension bundles a platform-specific badness binary and starts the language server automatically, so no separate CLI install is required. See Editor Setup for configuration.

From Source

Badness is written in Rust. With a Rust toolchain installed, build from a checkout:

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

The binary lands at target/release/badness. Copy it onto your PATH, or run it in place.

To install it into Cargo’s bin directory instead:

cargo install --path .

Verifying the Install

badness --version

This should print badness 0.17.0.

Getting Started

Badness’s main subcommands are format, lint, and lsp (with parse and init as helpers). This page walks through formatting and linting from the command line. For editor integration, see Editor Setup.

Formatting a File

Format a file in place:

badness format paper.tex

Pass several paths to format them all:

badness format intro.tex methods.tex results.tex

Pass - to read from standard input and write the formatted result to standard output—handy for piping or editor integrations:

cat paper.tex | badness format -

A piped standard input is also read when you pass no paths at all, so the shorter cat paper.tex | badness format works too. At an interactive prompt, though, where there is nothing to pipe, badness format with no paths reports a usage error rather than silently waiting on the terminal.

Checking Without Writing

In CI you usually want to verify that files are already formatted rather than rewrite them. The --check flag prints a diff of what would change and exits non-zero if any file is not already formatted:

badness format --check paper.tex
Diff in paper.tex:12:
 \section{Introduction}
-Some    text with   odd spacing.
+Some text with odd spacing.
1 of 1 file(s) would be reformatted

Since --check writes nothing, that report is the only account of what would change, which is why it is shown by default. Pass --quiet for just the file list and the summary—useful when a first run over an unformatted project would otherwise flood a CI log:

badness format --check --quiet .

The report goes to stdout (only errors use stderr) and is colorized when writing to a terminal; --color always|never overrides that, and NO_COLOR is honored.

Linting

lint parses each file and reports any diagnostics found, rendered with source snippets. It exits non-zero when there is at least one diagnostic:

badness lint paper.tex

Like format, it reads standard input when given - (or when piped with no paths):

cat paper.tex | badness lint -

The snippets go to stderr and are colorized when it is a terminal, under the same --color always|never and NO_COLOR rules as the --check diff. The --output concise and --output json forms are meant for other programs to read, so they stay plain whatever --color says.

Adjusting Layout

The formatter takes a few style options on the command line:

badness format --line-width 100 --indent-width 4 --wrap preserve paper.tex

See the CLI Reference for every flag and the Configuration reference for what --wrap controls.

Formatting

badness format lays out LaTeX source deterministically. Output is decided solely by the formatter’s rules and its layout engine—there are no per-construct special cases to memorize.

In Place, stdin, or check

badness format paper.tex          # rewrite the file in place
cat paper.tex | badness format    # stdin → stdout
badness format --check paper.tex  # diff, don't write; non-zero if unformatted

--check prints a diff of the pending change for each file, then a summary; add --quiet to reduce that to the file list and the summary. See Checking without writing.

Style Options

The style flags—--line-width, --indent-width, and --wrap—mirror the [format] section of badness.toml and override it for a single run. Each option’s default and meaning is listed in the Configuration reference.

For persistent settings, badness reads a badness.toml discovered from the working directory upward; pass --config <PATH> to point at a specific file or --no-config to ignore any discovered one. Run badness init to write a starter badness.toml.

Turning the formatter off

Sometimes a block is laid out by hand and should stay that way—a tikzpicture aligned by eye, a table whose columns line up in the source. Comment directives turn the formatter off over exactly as much as you point at, and content inside is reproduced byte for byte.

Skip the next construct:

% badness-format skip: hand-aligned by eye
\begin{tikzpicture}
  \foreach \p/\pos in {A/left, B/left, C/right, D/right}%
  \node[\pos] at (\p) {$\p$};%
\end{tikzpicture}

Skip a region:

% badness-format off
\begin{tabular}{ll}
  a   &   b \\
  ccc &   d \\
\end{tabular}
% badness-format on

Skip a whole file, wherever in it the directive sits:

% badness-format skip-file: generated, do not edit

An off with no matching on runs to the end of the file. The : <reason> is optional everywhere and is never interpreted—it is there for the next person to read.

Each directive has a bare counterpart that turns off both the formatter and every lint rule over the same span: % badness skip, % badness off / % badness on, and % badness skip-file. Use the -format spelling when you want the linter to keep reporting.

To exclude whole files by path instead, use exclude/extend-exclude in badness.toml; see the Configuration reference. That is the better tool when you control the config, since it keeps the directive out of the document.

The mirror of these is % badness-lint, which suppresses diagnostics without touching layout and takes an optional rule name; see Linting.

One note on where directives are read: a directive must be its own % comment. In a .dtx documentation line the leading % is a documentation margin rather than a comment, so a directive written there is inert (inside a macrocode chunk it works normally).

Guarantees

The formatter is built around a small set of invariants that double as test oracles:

  • Idempotence: format(format(x)) == format(x).
  • Losslessness: the parsed tree reconstructs the input byte-for-byte, so the formatter never loses or corrupts content.
  • Protected regions: verbatim-like content (verbatim, lstlisting, \verb, comments) is never altered. An environment badness cannot tell is verbatim—one built by machinery no scan follows—can be named in [environments], which is also how you teach it a \bea/\eea pair defined in a sibling .sty.
  • Whitespace-only: formatting changes whitespace, line breaks, and comment placement, and nothing else. It never inserts, deletes, or rewrites a token of real content.

Content normalizations—rewriting x^{2} to x^2, or $$…$$ to \[…\]—are therefore lint fixes, not formatting. Run badness lint --fix for those.

Linting

badness lint parses each file and reports diagnostics, rendered with source snippets pointing at the offending range. It exits non-zero when there is at least one diagnostic, which makes it usable as a CI gate.

badness lint paper.tex
cat paper.tex | badness lint   # stdin

Parse diagnostics

Alongside the rules, the linter surfaces parse diagnostics: places where the parser recovered from malformed input. Because the parser is error-tolerant, a single problem never aborts the parse—badness anchors recovery on clean LaTeX boundaries (\end{…}, \begin, a blank line, }, $, &, \\) and keeps going, so one file can report several independent diagnostics in one run. Parse diagnostics carry the rule id parse and are never silenced by select/ignore.

Rules

Beyond parse recovery, badness ships a growing set of built-in rules (deprecated-command, dollar-display-math, undefined-ref, and more). Each has a stable id used in diagnostics, config, and suppression comments. See the Linter Rules reference for the full catalogue, or print a single rule’s description and examples from the terminal:

badness lint --explain deprecated-command

Every rule is on by default. Narrow the active set through the [lint] table in badness.toml or the matching --select/--ignore CLI flags; see the Configuration reference.

Suppress a rule at one site with a comment directive:

% badness-lint skip deprecated-command: legacy code
{\bf here}

The verb carries the scope, and there are three:

ScopeDirective
The next construct% badness-lint skip <rule>: <reason>
A region% badness-lint off <rule>% badness-lint on <rule>
The whole file% badness-lint skip-file <rule>: <reason>

Naming the <rule> is optional—leave it out and the directive covers every rule over that same span. An off with no matching on runs to the end of the file. The : <reason> tail is optional everywhere and is never interpreted.

Each has a bare counterpart that turns off the formatter at the same time: % badness skip, % badness off / % badness on, and % badness skip-file. For layout only, use the % badness-format spellings described in Formatting.

In .bib files the same grammar rides an @comment entry, since BibTeX has no line-comment token:

@comment{badness-lint skip missing-required-field: publisher long gone}
@book{oldbook, title = {An Orphaned Book}}

Some rules ship an auto-fix. badness lint --fix applies the meaning-preserving (Safe) ones; --unsafe-fixes also applies fixes that may change output, such as missing-nonbreaking-space (inserting a tie changes line breaking), abbreviation-spacing (inserting \ or \@ changes sentence spacing), or space-before-command (deleting a space before \footnote changes spacing).

Machine-readable output

badness lint --output json emits the findings as a JSON array on stdout (the human-readable pretty and concise modes write to stderr). A clean run emits [], so consumers always receive valid JSON; the exit code still signals whether findings exist. This is the contract external tools consume, e.g. panache when linting latex code blocks in Markdown documents.

[
  {
    "rule": "ellipsis",
    "severity": "warning",
    "path": "paper.tex",
    "start": 5,
    "end": 8,
    "message": "literal `...` ellipsis; use `\\dots`",
    "fix": {
      "edits": [{ "content": "\\dots", "start": 5, "end": 8 }],
      "applicability": "safe",
      "description": "Replace `...` with `\\dots`"
    },
    "related": []
  }
]

Ranges are 0-indexed byte offsets into the named file (no line/column resolution). severity is one of error, warning, info, or hint; applicability is safe or unsafe (the --fix/--unsafe-fixes split). The fix key is omitted when a finding has no auto-fix. An edit carries a path key only when it targets a different file than the diagnostic (a cross-file fix); related lists secondary “see also” locations.

Compared to the sibling tools arity and fatou, the schema differs in two ways: offsets are flat start/end keys rather than a range object, and message is a plain string rather than a structured object.

Editor Setup

Badness ships a language server. Start it with:

badness lsp

The server speaks the Language Server Protocol over stdio. Point your editor’s LSP client at the badness binary with the lsp argument and associate it with LaTeX (.tex) and BibTeX (.bib) files.

Settings can be supplied as initializationOptions at startup or through workspace/didChangeConfiguration, either as a bare object or namespaced under a badness key.

Formatter widths: lineWidth and indentWidth. They act as a fallback: a discovered badness.toml always wins outright, and absent one, your editor’s tab size (sent with each formatting request) overrides the indent width.

The language server is also the sole consumer of the [build] section of badness.toml, which locates the compile’s .aux artifacts; see the Configuration reference.

TEXMF discovery

How the language server discovers the installed TeX tree for package resolution: document links, package hover, go-to-definition, and installed-set completion. Where a TeX installation lives is a fact about the machine, not the project, so these settings come from the editor rather than badness.toml, and they never affect badness format or badness lint, whose output stays a pure function of the input regardless of what is installed.

A texmf object with three keys, all optional:

  • enabled (boolean, default true): whether to scan the TEXMF tree at all. When false, package resolution stays local to the document’s directory.
  • roots (array of paths, default []): extra TEXMF root directories to index in addition to (and ahead of) the discovered ones. Useful for a non-standard install that kpsewhich can’t see.
  • useKpsewhich (boolean, default true): whether to shell out to kpsewhich to discover the TEXMF tree roots. When false, discovery falls back to default-path heuristics only.
{ "texmf": { "enabled": true, "roots": ["/opt/texmf"], "useKpsewhich": true } }

Jump between a source line and the matching place in the compiled PDF.

Badness never typesets, and it never reads a .synctex.gz. Forward search works out three things — the file your cursor is in, the root document’s PDF, and the line number — and hands them to a viewer you configure. Every SyncTeX-aware viewer (zathura, Okular, SumatraPDF, Skim) links libsynctex and does the mapping itself, which is why they all want a file and a line rather than a coordinate. Inverse search runs in the other direction and is started by the viewer.

You need a PDF compiled with SyncTeX enabled — latexmk -pdf -synctex=1, or -synctex=1 passed to pdflatex/lualatex directly. Badness will not run that for you; use your existing build setup, or an extension like LaTeX Workshop.

Configuring the viewer

Which viewer is installed on your machine, and under what name, is a fact about the machine rather than the project — so these settings come from the editor, like TEXMF discovery, and not from badness.toml. Where the PDF lives is project data and belongs to the [build] section instead.

A forwardSearch object:

  • executable (string): the viewer program. Spawned directly, not through a shell, so it is a program name and never a command line — putting flags here ("zathura --synctex-forward") silently fails to launch. This is the most common misconfiguration.
  • args (array of strings): the viewer’s arguments. Required — there is no useful default, since every viewer spells forward search differently. Without it, forward search reports itself unconfigured.
  • ipcDir (path, optional): where inverse-search servers advertise themselves. An escape hatch for containers and sandboxes; see below.

Each argument may carry:

PlaceholderExpands to
%fthe .tex file the cursor is in
%pthe root document’s PDF
%lthe line number, counting from 1
%%fa literal %f

An argument wrapped entirely in " is passed through with the quotes stripped and nothing substituted — the escape hatch when a viewer needs a literal %.

Recipes, matching texlab’s, so an existing configuration ports unchanged:

Viewerexecutableargs
zathurazathura["--synctex-forward", "%l:1:%f", "%p"]
Okularokular["--unique", "file:%p#src:%l%f"]
SumatraPDFSumatraPDF["-reuse-instance", "%p", "-forward-search", "%f", "%l"]
Skimdisplayline["%l", "%p", "%f"]
Evinceevince-synctex["-f", "%l", "%p", "\"code -g %f:%l\""]
qpdfviewqpdfview["--unique", "%p#src:%f:%l:1"]
{
  "forwardSearch": {
    "executable": "zathura",
    "args": ["--synctex-forward", "%l:1:%f", "%p"]
  }
}

The server handles textDocument/forwardSearch, a custom request taking the standard { textDocument, position } params — the same method name and shape texlab uses, so a client written for texlab works unchanged. It never fails the request; it answers with a status:

StatusMeaning
0the viewer was launched
1the viewer would not start
2no PDF on disk, or the buffer has no path — build the document first
3no viewer configured

The capability is advertised as experimental.textDocumentForwardSearch.

If forward search opens the wrong PDF, or reports status 2 on a project that has been built, the root document is probably not being found — see root in the [build] reference.

Configure your viewer to run:

badness inverse-search --input "%f" --line "%l"

substituting the viewer’s own placeholders. For zathura that is:

zathura --synctex-editor-command "badness inverse-search --input %{input} --line %{line}"

Use --line0 instead if your viewer counts lines from zero. (--line1 is accepted as a synonym for --line, so a texlab configuration ports directly.)

The command finds the language server whose workspace contains the file and asks it to reveal the position, so an editor must already have that project open, and its LSP client must support window/showDocument. Servers whose client does not support it never register, which is why inverse search silently does nothing in an editor lacking it — the command says so when nothing is listening.

With several editor windows open, the server whose workspace root contains the file wins; the longest matching root is preferred, so nested projects resolve deterministically.

Servers advertise themselves in $BADNESS_IPC_DIR, else a per-user directory under your runtime directory ($XDG_RUNTIME_DIR), else the temporary directory. The forwardSearch.ipcDir setting overrides all of these — useful when the viewer and the server see different filesystems, as in a container or a remote development setup. Keep it short: a Unix socket path cannot exceed about 100 bytes, and badness says so explicitly in its log if yours does. On a system with no $XDG_RUNTIME_DIR and a /tmp shared between users, that last fallback is worth knowing about: the directory is created 0700, the advertisements 0600, and badness ignores any advertisement it does not own, so another user can neither read nor impersonate one.

One caveat inherent to SyncTeX: it maps the source as it was compiled. With unsaved edits, buffer line numbers and PDF line numbers drift apart until you rebuild.

Neovim

With the built-in vim.lsp client (Neovim 0.11+):

vim.lsp.config.badness = {
  cmd = { "badness", "lsp" },
  filetypes = { "tex", "latex", "plaintex", "bib" },
  root_markers = { "badness.toml", ".git" },
  init_options = { lineWidth = 80, indentWidth = 2 },
}
vim.lsp.enable("badness")

The init_options block is optional; omit it to use the defaults or a badness.toml.

VS Code

Install the Badness extension from the VS Code Marketplace or the Open VSX extension. It bundles a platform-specific badness binary and starts the language server automatically when you open a .tex file, so no separate CLI install is required.

The extension is configured through badness.* settings. By default it uses the bundled binary (badness.executableStrategy: "bundled"); set the strategy to environment to use a badness on your PATH, or path with badness.executablePath to point at a specific binary. See the extension’s README for the full list of settings.

Using only some features

The formatter, linter, and language features share one server but can be turned off independently, so you can adopt just the parts you want:

  • badness.formatting.enable — use Badness as a formatter.
  • badness.diagnostics.enable — show Badness diagnostics (the linter).
  • badness.languageFeatures.enable — hover, completion, navigation, symbols, rename, code actions, and the rest.

All three default to true. They are client-side gates, so the server keeps running and the toggles take effect without a reinstall. For a formatter-only setup, turn off the other two:

{
  "badness.diagnostics.enable": false,
  "badness.languageFeatures.enable": false
}

Turning off badness.diagnostics.enable this way suppresses every diagnostic, including the syntax/parse errors that a badness.toml [lint] selection cannot silence. The badness.toml route stays the right tool when you want to keep parse errors but mute specific lint rules across every editor and the CLI.

Using with LaTeX Workshop

Badness works alongside LaTeX Workshop rather than replacing it. The two divide cleanly: LaTeX Workshop handles building, PDF preview, and SyncTeX, while badness handles formatting, linting, and navigation. Run both, and let each own its half.

Formatting. The badness extension registers itself as the default formatter for LaTeX files. LaTeX Workshop’s own formatter integration is disabled by default (latex-workshop.formatting.latex is "none"); leave it that way so there is a single formatting authority. For BibTeX files, LaTeX Workshop ships a built-in formatter, so pick badness explicitly:

{
  "[bibtex]": {
    "editor.defaultFormatter": "jolars.badness"
  }
}

Linting. LaTeX Workshop’s ChkTeX and lacheck integrations are disabled by default (latex-workshop.linting.chktex.enabled and latex-workshop.linting.lacheck.enabled). Leave them off; enabling them alongside badness produces overlapping diagnostics for many common issues.

Completion. Both extensions contribute completion items, so you may see duplicate suggestions for commands, environments, or citations. This is harmless, but if it bothers you, the latex-workshop.intellisense.* settings let you turn off the overlapping parts on the LaTeX Workshop side.

Other Editors

Any LSP-capable editor can run badness: configure a server whose command is badness lsp, communicating over stdio, for LaTeX documents. Consult your editor’s LSP client documentation for the exact configuration shape.

Pre-commit

Badness ships a pre-commit hook through the badness-pre-commit mirror repository. The hook installs the prebuilt badness wheel from PyPI, so no Rust toolchain is needed. Add this to your .pre-commit-config.yaml:

repos:
  - repo: https://github.com/jolars/badness-pre-commit
    # badness version
    rev: v0.9.0
    hooks:
      # Lint .tex, .sty, .cls, .dtx, .ins, and .bib files
      - id: badness-lint
      # Format the same files in place
      - id: badness-format

Tags mirror badness releases: rev: v0.9.0 runs badness 0.9.0.

To apply safe lint autofixes before formatting (the fix-then-format pipeline), pass --fix:

- id: badness-lint
  args: [--fix]
- id: badness-format

To check formatting without rewriting files:

- id: badness-format
  args: [--check]

The hook then writes nothing, so pre-commit has no modified file to show you; the diff --check prints is the whole report. Add --quiet alongside it if you would rather see only the list of files that would be reformatted.

Configuration

Badness is configured through a badness.toml file. All keys are optional and spelled in kebab-case; an unknown key or section is a hard error, not a silent no-op. Run badness init to write a commented starter file showing every key at its default.

# Gitignore-style patterns to skip during directory discovery.
# exclude = [".git/"]
# extend-exclude = []

[format]
# line-width = 80
# indent-width = 2
# wrap = "reflow"  # reflow | stable | sentence | semantic | preserve
# line-ending = "auto"  # auto | lf | crlf | native

[lint]
# select = ["..."]  # if set, only these rules run
# ignore = []       # rules to disable

Discovery

For each input, Badness walks from the file’s directory upward and uses the first badness.toml it finds. The walk stops at a directory containing a .git entry (the repository root), so a config file outside your repository is never picked up.

If no project file is found, Badness next checks the BADNESS_CONFIG environment variable. When set (and non-empty), it names a config file to use instead of the global user config below—handy for keeping one config on a synced drive and pointing every machine at it. A set BADNESS_CONFIG shadows the global config entirely.

If BADNESS_CONFIG is unset, badness falls back to a global user config: the first existing file among

  1. $XDG_CONFIG_HOME/badness/config.toml
  2. ~/.config/badness/config.toml
  3. the platform config directory (%APPDATA%\badness\config.toml on Windows, ~/Library/Application Support/badness/config.toml on macOS)

The BADNESS_CONFIG and global files use the same schema as a project badness.toml and are whole-file fallbacks, never merged with a project config. Relative exclude patterns in them resolve against the working directory (CLI) or the document’s directory (language server) rather than the config’s own directory. The language server uses the same resolution, so both are easy ways to set editor-wide defaults such as wrap = "preserve" (an edit is picked up when the server restarts). If none of these files is found, built-in defaults apply.

Two global CLI flags override discovery:

  • --config <PATH> uses that file instead of discovering one.
  • --no-config ignores any project, BADNESS_CONFIG, or global file and uses built-in defaults.

CLI flags for individual options (--line-width, --wrap, --select, etc.) override the corresponding config values for a single run.

Top level

exclude

Gitignore-style patterns to exclude from directory discovery, resolved relative to the directory containing the badness.toml. Excludes apply to both format and lint, which share one file walk, so this is a top-level key rather than a [format] option.

When set, this replaces the built-in default set ([".git/"]); use extend-exclude to add patterns without restating the defaults. Patterns given with the --exclude CLI flag are always added on top.

Default value: [".git/"]

Type: array of strings

Example:

exclude = ["vendor/", "old-drafts/"]

extend-exclude

Gitignore-style patterns added in addition to the base set selected by exclude (the built-in defaults when exclude is unset). Use this to skip a few extra paths without replacing the defaults.

Default value: []

Type: array of strings

Example:

extend-exclude = ["build/"]

[format]

Options for badness format. Each mirrors a CLI flag of the same name, which takes precedence for a single run.

line-width

Maximum line width before the formatter breaks a line. Must be between 1 and 1000.

Default value: 80

Type: integer

Example:

[format]
line-width = 100

indent-width

Spaces per indent step. Must be between 1 and 1000.

Default value: 2

Type: integer

Example:

[format]
indent-width = 4

wrap

How the formatter lays out line breaks inside a paragraph. It does not affect structure, only where soft line breaks fall.

ModeBehavior
reflowGreedy fill: pack words up to line-width, breaking only where the next word would overflow.
stablePreserve acceptable authored breaks and rebalance only text that no longer fits (keeps revision diffs small).
preserveLeave the authored line breaks untouched.
sentenceOne sentence per line. Line width is ignored—a long sentence stays on one line.
semanticSemantic line breaks: keep the author’s soft breaks and add a break after each sentence.

Both sentence and semantic split a paragraph at sentence boundaries, one sentence per line. Boundary detection is a small per-language rule engine over the words: a ., !, or ? ends a sentence unless the word is a known abbreviation (e.g., Fig., Dr., etc.) an ellipsis (..., ), or a contextual abbreviation whose following word signals that the sentence continues (U.S. Government stays together, U.S. However splits). The abbreviation profile is chosen by lang and extended by no-break-abbreviations.

semantic additionally preserves the author’s own line breaks on top of the sentence breaks (the sembr convention). It does not detect clause boundaries itself—a break after a comma or and survives only where the author placed a newline. A run-on sentence on a single source line is still sentence-split.

stable also preserves authored line breaks, but treats them as preferred anchors rather than hard boundaries. It is aimed at keeping revision diffs small: a small prose edit perturbs the smallest possible region. Each prose run is solved as one global layout problem. Candidate layouts are compared lexicographically by total overflow, underflow below a soft target (line-width - 15), changed authored breaks, displacement from the nearest authored break, raggedness around that target, and line count. This makes the hard width non-negotiable before minimizing source churn, while a short final line remains unpenalized. Blank lines and command-only lines bound each independently optimized run, and code-like statement bodies retain ordinary greedy fill. (The soft target is not currently configurable.)

When omitted, every file kind reflows—.tex, .bib, .sty, .cls, .dtx, and .ins alike. A file’s extension is not a layout input.

That is safe because reflow is never the thing that decides whether content may move. The formatter declines to reflow anything it cannot lay out without changing meaning, in every wrap mode and regardless of what you configure: verbatim bodies and \verb, comments, .dtx documentation margins and docstrip guards (which must stay at column 0), and any documentation block whose rewrapping would push a % off column 0. Asking for wrap = "reflow" on a .dtx cannot corrupt it; asking for wrap = "preserve" on a .tex is a stylistic choice, not a safety one.

Code, in practice, has little to reflow: expl3 regions (\ExplSyntaxOn\ExplSyntaxOff) are laid out by their own rules whatever wrap says, and a source line consisting only of commands keeps its own line. So a package or class body formats much as it did before, and preserve remains available if you want authored breaks kept verbatim.

Default value: unset (reflow, for every file kind)

Type: "reflow" | "stable" | "sentence" | "semantic" | "preserve"

Example:

[format]
wrap = "stable"

math-wrap

How the formatter lays out line breaks inside display math: \[…\], $$…$$, and single-formula math environments such as equation. Alignment-grid environments (align, gather, matrices) and inline $…$ math are not affected.

ModeBehavior
autoDerive from the effective wrap: preserve keeps authored math breaks, every other mode breaks (amsmath).
preserveKeep the authored line breaks inside the body. Spacing within each line is still normalized.
single-lineNever insert breaks: the body stays on one line, overflowing line-width if too long (like inline math).
breakBreak a too-long body before its top-level relations and binary operators, aligning a relation chain (amsmath style).

Default value: "auto"

Type: "auto" | "preserve" | "single-line" | "break"

Example:

[format]
math-wrap = "preserve"

line-ending

How the line breaks in formatted output are spelled. The layout engine always decides where breaks go; this decides only the bytes they render as, and it applies to the whole document — including inside verbatim-style protected regions, which would otherwise keep their authored endings and leave the file mixed.

ModeBehavior
autoKeep the endings the file was written with: CRLF if its first line break is one, LF otherwise.
lfAlways \n.
crlfAlways \r\n.
nativeThe platform’s convention: \r\n on Windows, \n elsewhere.

The default is auto, so formatting never rewrites a repository’s line endings on its own — set lf (or add a .gitattributes rule) if you want them normalized.

Default value: "auto"

Type: "auto" | "lf" | "crlf" | "native"

Example:

[format]
line-ending = "lf"

lang

Document language as a BCP-47-style code (en, de, pt-BR, …), used by the sentence and semantic wrap modes to pick the sentence-boundary abbreviation profile. Built-in profiles cover English (default), Czech, German, Spanish, and French; the region subtag is folded away, and an unknown or unset language falls back to English. (Automatic detection from babel/polyglossia is not yet implemented.)

Default value: unset (English)

Type: string

Example:

[format]
lang = "de"

no-break-abbreviations

User-supplied no-break abbreviations for the sentence and semantic wrap modes, keyed by language code or the literal default bucket (applied to every document). An abbreviation listed here never ends a sentence, so no line break is inserted after it. Merged on top of the built-in per-language lists.

Default value: {}

Type: table of string arrays, keyed by language code or default

Example:

[format.no-break-abbreviations]
default = ["ibid."]         # applied to every document
de = ["bzw.", "Abb."]       # applied only when lang resolves to German

[lint]

Rule selection for badness lint, shared by the LaTeX and BibTeX rule sets. Every rule is on by default. An unknown rule id is reported at lint time, not rejected at config-parse time.

select

Explicit allowlist of rule ids. When set, only these rules run.

Default value: unset (all rules run)

Type: array of strings

Example:

[lint]
select = ["deprecated-command", "dollar-display-math"]

ignore

Rule ids to disable, applied on top of either select or the default rule set.

Default value: []

Type: array of strings

Example:

[lint]
ignore = ["missing-nonbreaking-space"]

[build]

Where the TeX compiler leaves its artifacts, and which file it was run on. Read by the language server only — it pulls resolved label and section numbers from the .aux files for hover and document symbols, and locates the compiled PDF for forward search. Never read by the formatter or linter.

aux-dir

Directory holding the build’s .aux files (latexmk’s -auxdir/-outdir), resolved relative to the root document’s directory when not absolute. When unset, each document’s .aux is expected next to it, as in plain latex/pdflatex runs.

Default value: unset (sibling .aux files)

Type: path

Example:

[build]
aux-dir = "out"

pdf-dir

Directory holding the build’s PDF output (latexmk’s -outdir), resolved relative to the root document’s directory when not absolute. When unset, the PDF is expected next to the root document.

Default value: unset (the root document’s own directory)

Type: path

Example:

[build]
pdf-dir = "out"

pdf-filename

The compiled PDF’s file name, when the build does not name it after the root document (latexmk’s -jobname). A bare file name, never a path — use pdf-dir for the directory — and .pdf is appended when it carries no extension, so "thesis" and "thesis.pdf" mean the same thing.

Default value: unset (<root document stem>.pdf)

Type: string

Example:

[build]
pdf-filename = "thesis.pdf"

root

The project’s root document — the file the compiler was run on — resolved relative to this badness.toml’s directory when not absolute.

Normally the root is found by scanning the project for a file carrying \documentclass or \begin{document}, and you do not need this key. But that scan only sees files the server has already loaded, and it loads them one directory at a time: editing chapters/ch1.tex in a project rooted at ../main.tex never loads main.tex, so the scan finds no root at all and forward search resolves the wrong PDF. Set root for that layout.

Default value: unset (scan the project for a document root)

Type: path

Example:

[build]
root = "main.tex"

[environments]

Declares environments Badness cannot recognize from the file alone: one that behaves like a built-in but has no built-in counterpart, one whose body is verbatim, and one reached through command spellings rather than \begin/\end. This is the only section that changes how your files are parsed, so it is read by format, lint, and the language server alike; editing it makes the server reparse the project.

Entries are keyed by the environment’s own name, whether or not Badness already knows it:

# \begin{myenv} … \end{myenv}, with no built-in counterpart
[environments.myenv]
like = "align"

# extra delimiter spellings for an environment Badness already knows
[environments.eqnarray]
begin = ['\bea']
end = ['\eea']

# one side alone: `\bsplit` expands to `\begin{split}`, so a written-out
# `\end{split}` closes it — there is no closing command to declare
[environments.split]
begin = ['\bsplit']

# both at once: an environment reached only through commands
[environments.mytheorem]
like = "theorem"
begin = ['\startmyenv']
end = ['\endmyenv']

Write control words as TOML literal strings (single quotes) so the backslash needs no escaping: '\bea', not "\\bea". Both spellings are accepted, and so is a name with no backslash at all — a control word can never contain one, so there is nothing to disambiguate.

A declaration names a spelling, never a pairing. Every structural rule still applies, so a declared \bea whose \eea is unreachable — stranded inside a brace group, or simply missing — stays an ordinary command, exactly as it would without the declaration. A wrong declaration therefore does nothing to your document; it cannot corrupt it.

What a declaration cannot do is invent behavior. It only ever points at an environment Badness already curates, so there is no way to spell out “this one is math, takes two arguments, and has a verbatim body” key by key. If nothing built in resembles yours, that is worth an issue rather than a workaround.

Anything a declaration cannot satisfy is an error at config load, reported against the key you wrote, rather than a block that parses and quietly does nothing:

  • an entry with no keys under it, which would declare nothing at all
  • like naming an environment Badness does not know
  • delimiter spellings for a verbatim environment (the closing command is never seen — the verbatim body has already swallowed it)
  • delimiter spellings for an environment that takes arguments (a bare command carries none)
  • delimiter spellings for an environment with no like and no built-in of that name, so its behavior is unknown
  • one spelling claimed by two entries, or listed twice by one
  • a spelling that is the delimiter itself ('\end{split}') rather than a command standing in for one — the written-out delimiter already pairs with a declared spelling, so the key can just be removed
  • a spelling that could never be a single control word ('\b ea', '\bea2')
  • a spelling that is already a LaTeX command Badness knows ('\emph'), which would change what that command means throughout the project

like

The built-in environment whose behavior this one copies: whether its body is math, whether it aligns on &, whether it is verbatim, and every such property at once. This is also how you name a verbatim environment defined by machinery no scan can follow — like = "lstlisting" protects its body from reflowing and from lint findings.

The target is looked up among the environments Badness curates by hand; a misspelled one is an error rather than a silent no-op.

Default value: unset

Type: string

Example:

[environments.mycode]
like = "lstlisting"

begin

Command spellings that stand in for this environment’s \begin{…}. Any of them opens it, and any spelling in end closes it — pairing is by side, not by position, so the two lists need not be the same length.

The written-out \end{…} closes it too, which is why end is optional. A command defined as \def\bsplit{\begin{split}} expands to \begin{split}, so \bsplit … \end{split} is a perfectly ordinary environment and there may be no closing command to name at all.

Use this when the definition is somewhere Badness cannot see: a sibling .sty, or one built by machinery no scan follows. A definition written with a plain \newcommand or \def in the same file — \newcommand{\bea}{\begin{eqnarray}}, or \def\bsplit{\begin{split}} on its own — is already recognized without any configuration.

A spelling must be a command of your own. Naming one Badness already knows ('\emph', '\section') is an error rather than a redefinition: the declaration would apply everywhere that command appears, which is never what a delimiter declaration means.

Default value: []

Type: array of strings (control words)

Example:

[environments.eqnarray]
begin = ['\bea', '\beqa']
end = ['\eea']

end

Command spellings that stand in for this environment’s \end{…}, the mirror of begin in every respect — including that it stands alone. A command defined as \def\eeq{\end{equation}} closes a written-out \begin{equation}, so an entry may name a closing spelling without naming an opening one.

Default value: []

Type: array of strings (control words)

Example:

[environments.eqnarray]
begin = ['\bea']
end = ['\eea']

Note: TEXMF-tree discovery (the former [texmf] section) is configured through your editor’s LSP settings, not badness.toml. Where a TeX installation lives is a fact about the machine, not the project, so it does not belong in a file shared across contributors. See Editor Setup.

Command-line reference

A formatter, linter, and language server for LaTeX

Usage: badness [OPTIONS] <COMMAND>

Options

--config <PATH>

Path to a badness.toml to use instead of discovering one. Applies to format and lint; ignored by parse, lsp, and init

--no-config

Ignore any badness.toml (project, $BADNESS_CONFIG, or global) and use built-in defaults

--color <WHEN>

When to use color in output

Default value: auto

Possible values:

  • auto: Colorize when writing to a terminal and NO_COLOR is unset (default)
  • always: Always colorize
  • never: Never colorize
-q, --quiet

Suppress non-essential output (errors are still shown). Under format --check this drops the per-file diff, leaving the list of files that would be reformatted and the summary

badness format

Format LaTeX source.

With paths, formats each file in place. Reads stdin (to stdout) when given -, or when paths are omitted and stdin is not a terminal.

Usage: badness format [OPTIONS] [PATHS]...

Arguments

<PATHS>...
Files or directories to format. Pass - for stdin, which is also read when paths are omitted and stdin is not a terminal

Options

--check

Report which files would change without writing them. Exits non-zero if any file is not already formatted. Requires path arguments: there is no file on disk to report on when reading stdin

--stdin-filepath <PATH>

Name the stdin buffer so its language is dispatched by extension (.bib → BibTeX, anything else → LaTeX). No file is read or written; only the extension is used. Ignored when paths are given

--line-width <LINE_WIDTH>

Maximum line width before the formatter breaks a line

--indent-width <INDENT_WIDTH>

Number of spaces per indent step

--wrap <WRAP>

How to lay out line breaks inside a paragraph

Possible values:

  • reflow: Greedy fill: wrap words to the line width (default)
  • stable: Preserve acceptable authored breaks and rebalance only nearby text (revision-stable wrapping)
  • sentence: One sentence per line (line width ignored)
  • semantic: Semantic line breaks (sembr.org): keep authored breaks and add breaks at sentence boundaries
  • preserve: Leave authored line breaks untouched
--math-wrap <MATH_WRAP>

How to lay out line breaks inside display math

Possible values:

  • auto: Derive from the effective wrap mode: preserve → preserve, else break (default)
  • preserve: Keep authored line breaks inside display-math bodies
  • single-line: Never insert breaks; a long body overflows the line width
  • break: Break a too-long body before its top-level operators (amsmath style)
--line-ending <LINE_ENDING>

How to spell the line breaks in the formatted output

Possible values:

  • auto: Keep the endings the file was written with (default)
  • lf: Always LF (\n)
  • crlf: Always CRLF (\r\n)
  • native: The platform’s convention: CRLF on Windows, LF elsewhere
--exclude <PATTERN>

Gitignore-style pattern to skip during directory discovery (repeatable). Added on top of any exclude/extend-exclude from badness.toml

--force-exclude

Apply exclude patterns to files named explicitly on the command line too (they are normally always processed). For runners like pre-commit that pass staged files as arguments

badness lint

Lint LaTeX source, reporting parse diagnostics.

With paths, lints each file. Reads stdin when given -, or when paths are omitted and stdin is not a terminal. Exits non-zero if any diagnostics are reported.

Usage: badness lint [OPTIONS] [PATHS]...

Arguments

<PATHS>...
Files or directories to lint. Pass - for stdin, which is also read when paths are omitted and stdin is not a terminal

Options

--fix

Apply safe autofixes in place, then report what remains. Requires path arguments; has no effect on stdin (there is nothing to write)

--unsafe-fixes

Also apply fixes that may change typeset output (requires --fix)

--stdin-filepath <PATH>

Name the stdin buffer so its language is dispatched by extension (.bib → BibTeX, anything else → LaTeX). No file is read or written; only the extension is used. Ignored when paths are given

--exclude <PATTERN>

Gitignore-style pattern to skip during directory discovery (repeatable). Added on top of any exclude/extend-exclude from badness.toml

--force-exclude

Apply exclude patterns to files named explicitly on the command line too (they are normally always processed). For runners like pre-commit that pass staged files as arguments

--select <RULE>

Run only these rules (repeatable). Overrides [lint] select from badness.toml when given

--ignore <RULE>

Disable these rules (repeatable). Overrides [lint] ignore from badness.toml when given

--explain <RULE>

Print the description and examples for a rule id, then exit. Ignores paths, config, and fixes

--output <OUTPUT>

Output format for findings. The human modes write to stderr; json writes to stdout

Default value: pretty

Possible values:

  • pretty: Source-snippet output with caret spans, on stderr (default)
  • concise: One path:line:col: severity [rule] message line per finding, on stderr
  • json: A machine-readable JSON array of findings on stdout ([] when clean), with byte-offset ranges and fix data

badness parse

Parse LaTeX source and print its concrete syntax tree (CST).

A debugging aid: prints the lossless parse tree as an indented KIND@range listing, with token text, followed by any parse errors. With a path, parses that file. Reads stdin when given -, or when the path is omitted and stdin is not a terminal.

Usage: badness parse [PATH]

Arguments

<PATH>
File to parse. Pass - for stdin, which is also read when the path is omitted and stdin is not a terminal

badness lsp

Run the language server over stdio

Usage: badness lsp

Answer a PDF viewer’s inverse (backward) search.

Point your viewer’s inverse-search command here — for zathura, --synctex-editor-command "badness inverse-search --input %{input} --line %{line}". The position is handed to a running badness language server, which reveals it in your editor via window/showDocument, so the file must belong to a workspace some editor currently has open.

Usage: badness inverse-search [OPTIONS] --input <PATH>

Options

-i, --input <PATH>

The .tex file the viewer resolved

-l, --line <LINE>

Line number, counting from 1 — what SyncTeX-aware viewers emit.

Required unless --line0 is given. Deliberately not enforced by clap, whose message for that would name only --line and so send a --line0 user the wrong way.

--line0 <LINE>

Line number counting from 0, for a viewer that reports it that way

--character <COLUMN>

Column, counting from 0, when the viewer supplies one

Default value: 0

--ipc-dir <DIR>

Directory holding the servers’ IPC advertisements. Defaults to $BADNESS_IPC_DIR, then a per-user directory under the runtime (or temporary) directory

badness init

Write a commented starter badness.toml to the current directory

Usage: badness init [OPTIONS]

Options

--force
Overwrite an existing badness.toml

Linter Rules

badness lint runs a set of built-in rules over each file’s parse tree and reports a diagnostic for every finding. This page is the catalogue: one section per rule, keyed by its stable rule id. That id is what appears in a diagnostic, what [lint] select/ignore (and --select/--ignore) target, and what a % badness-lint skip <id> comment suppresses.

Every rule is on by default; narrowing happens only through select/ignore in the [lint] table (see the Configuration reference). Where a rewrite is unambiguous a rule carries an auto-fix: a safe fix (shown below as “After applying the fix”) is applied by badness lint --fix; an unsafe fix, one that may change output such as inserting a line-breaking tie, is applied only with --unsafe-fixes or as an editor code action, so it has no “after” block here.

Each example below is linted live to produce its diagnostic and fixed output, so this page never drifts from the rules’ actual behavior.

This page covers the LaTeX linter. BibTeX files have a parallel set of rules (a separate BibRule registry under src/bib/linter/), selectable through the same [lint] config and catalogued in BibTeX Linter Rules.

abbreviation-spacing

Flag TeX’s sentence-vs-interword spacing going wrong around abbreviations and acronyms (ChkTeX 12/13). Outside \frenchspacing, TeX widens the space after ./?/! unless the punctuation follows an uppercase letter. Two shapes defeat that: a lowercase abbreviation (e.g., i.e., etc., et al.) gets a too-wide space, fixed with \ (e.g.\ foo); and an uppercase acronym ending a sentence (USA.) gets a too-narrow space, fixed with \@ (USA\@.). To stay conservative the first fires only before a lowercase word (the sentence clearly continues) and the second only for a run of two or more capitals before the period and before an uppercase word (a new sentence), so initials (J.), dotted forms (U.S.A.), and mid-sentence acronyms are left alone. Both fixes are unsafe – they change the typeset spacing – so --fix leaves them alone while --unsafe-fixes and the editor code action apply them. The rule is silent under \frenchspacing, and never touches comments, verbatim, or math.

A lowercase abbreviation followed by more text takes an interword space:

We tried several methods, e.g. gradient descent.
warning: abbreviation-spacing
 --> example.tex:1:31
  |
1 | We tried several methods, e.g. gradient descent.
  |                               ^ `e.g.` is an abbreviation, not a sentence end; use an interword space `\ ` (`e.g.\ `) so TeX does not widen the gap

An acronym ending a sentence takes intersentence spacing:

The rover reached the USA. Then it stopped.
warning: abbreviation-spacing
 --> example.tex:1:26
  |
1 | The rover reached the USA. Then it stopped.
  |                          ^ capital before sentence-ending punctuation suppresses intersentence spacing; use `\@` (`Word\@.`) to restore it

blank-line-in-keyval

Flag a blank line at the top level of a key=value argument. A blank line is a \par token and a keyval processor walks its entries with macros that are not \long, so the call aborts – and the error TeX reports names the processor rather than the command the author wrote (\hypersetup yields “Paragraph ended before \kv@processor@default was complete”), which is what makes the finding worth more than the compiler’s own message. Scoped by measurement: a blank line nested inside a value’s brace group (\tikzset{aa/.style={draw,\n\nthick}}) compiles clean and is not flagged, an unclosed { is left to the parse error it already draws, and only the hand-curated signature tier is consulted. The autofix drops the blank line and keeps the following indentation; it is safe by construction, since it edits only whitespace and ContentKind::Keyval is exactly the claim that the processor strips spaces around entries.

A blank line separating two keys, which aborts the call:

\hypersetup{colorlinks=true,

linkcolor=blue}
error: blank-line-in-keyval
 --> example.tex:1:29
  |
1 |   \hypersetup{colorlinks=true,
  |  _____________________________^
2 | |
3 | | linkcolor=blue}
  | |_^ blank line in `\hypersetup`'s key-value argument; the `\par` aborts the call

After applying the fix:

\hypersetup{colorlinks=true,
linkcolor=blue}

duplicate-label

Flag a \label{key} defined more than once in the same label namespace – within one file, or across files that share a document when a project view is available. LaTeX itself only warns and silently keeps the last definition. Definitions in mutually exclusive branches of a TeX conditional (\iftrue...\else...\fi, \newif-defined conditionals included) are not duplicates and are not flagged. No autofix: resolving a collision (rename vs delete) is the author’s call.

The same key defined twice in one file:

\section{One}\label{sec:x}
\section{Two}\label{sec:x}
warning: duplicate-label
 --> example.tex:2:14
  |
1 | \section{One}\label{sec:x}
  |                     ----- first definition of `sec:x`
2 | \section{Two}\label{sec:x}
  |              ^^^^^^^^^^^^^ label `sec:x` is defined more than once

deprecated-command

Flag the obsolete two-letter font switches (\bf, \it, \rm, \sf, \tt, \sc, \sl) that LaTeX 2e superseded with the \...series/\...shape/\...family declarations. \em is not flagged; it is still the supported emphasis switch. A name the file redefines (\renewcommand{\sl}{…}, \def\rm{…}) is the user’s macro, not the switch, so it is not flagged anywhere. The autofix swaps just the control word (\bf -> \bfseries), leaving any following text untouched, so it is correct by construction; it is withheld where the switch is merely referenced (\let\x\rm, \ifx\rm\y).

An obsolete two-letter font switch:

{\bf important}
warning: deprecated-command
 --> example.tex:1:2
  |
1 | {\bf important}
  |  ^^^ `\bf` is deprecated; use `\bfseries`

After applying the fix:

{\bfseries important}

missing-nonbreaking-space

Flag a plain space where a TeX tie (~) belongs, before a command whose output a line break would orphan: a bare-number reference (Figure \ref{x}, \eqref, \pageref) or a bracketed citation (see \cite{a}, \parencite, \autocite). A tie keeps the reference on the same line. Self-describing references (\autoref, \cref) and textual citations (\textcite, \citet) are not flagged – they emit their own noun, so a break orphans nothing. Both a same-line space and a single source line break before the command are flagged (a blank line is not – that starts a new paragraph). For a same-line space the fix is unsafe – inserting a tie changes line breaking – so --fix leaves it alone; --unsafe-fixes and the editor code action apply it. A line break is report-only: rewriting the newline to ~ would join the two lines, a reflow the formatter owns.

A plain space where a tie belongs before a cross-reference:

see Figure \ref{fig:plot}
warning: missing-nonbreaking-space
 --> example.tex:1:11
  |
1 | see Figure \ref{fig:plot}
  |           ^ missing non-breaking space before `\ref`; use a tie `~` so the reference stays on the same line

obsolete-environment

Flag math environments the community has superseded, naming the modern replacement in the message. The canonical case is eqnarray, which amsmath replaced with align decades ago (it mis-spaces relations and is a perennial l2tabu warning). The autofix renames the \begin/\end pair in place, leaving the body untouched, so it is correct by construction.

The superseded eqnarray environment:

\begin{eqnarray}
  a &=& b
\end{eqnarray}
warning: obsolete-environment
 --> example.tex:1:7
  |
1 | \begin{eqnarray}
  |       ^^^^^^^^^^ `eqnarray` is obsolete; use `align`

After applying the fix:

\begin{align}
  a &=& b
\end{align}

primitive-command

Flag raw plain-TeX primitives discouraged in LaTeX source, naming the LaTeX construct that supersedes each one (ChkTeX 41, lacheck, l2tabu). A sibling of deprecated-command, which covers the obsolete font switches. Most primitives are reported only: their LaTeX replacement restructures arguments (a \over b becomes \frac{a}{b}, \centerline{x} becomes a \centering declaration or a center environment), so no single textual edit can rewrite them correctly by construction. A few carry a Safe autofix — a 1:1 control-word swap for a primitive whose LaTeX form is a single meaning-identical token (\sb/\sp become _/^); the swap replaces just the control word, so it stays lossless and meaning-preserving, and is withheld where the primitive is merely referenced (\let\x\sp, \ifx\sp\y). A name the file redefines (\renewcommand\sp{…}) is the user’s macro, not the primitive, so it is not flagged anywhere.

A plain-TeX fraction primitive (report-only; the LaTeX form restructures its operands):

$a \over b$
warning: primitive-command
 --> example.tex:1:4
  |
1 | $a \over b$
  |    ^^^^^ `\over` is a raw TeX primitive; use `\frac{...}{...}`

The plain-TeX subscript alias, carrying a safe swap to _:

$x\sb2$
warning: primitive-command
 --> example.tex:1:3
  |
1 | $x\sb2$
  |   ^^^ `\sb` is a raw TeX primitive; use `_`

After applying the fix:

$x_2$

dollar-display-math

Flag plain-TeX $$...$$ display math. $$ is a TeX primitive that bypasses amsmath spacing hooks and breaks fleqn/\everydisplay, so LaTeX steers users to \[...\]. The autofix swaps the delimiters in place and leaves the body untouched, so it parses and stays lossless; it is withheld when the display math is unclosed.

Plain-TeX display math:

$$a + b = c$$
warning: dollar-display-math
 --> example.tex:1:1
  |
1 | $$a + b = c$$
  | ^^ `$$…$$` is plain-TeX display math; use `\[…\]`

After applying the fix:

\[a + b = c\]

ellipsis

Flag a literal run of three or more periods (...) where a real ellipsis command belongs. ... sets three tight full stops; LaTeX’s ellipsis commands set correctly spaced dots. In text the fix is a safe swap to \dots (a space is added before a following letter so the control word cannot glue onto the next word). In math \ldots (baseline, for comma lists) and \cdots (centered, for operator chains) are not interchangeable, so the fix is unsafe: it guesses from the neighboring atoms – an operator or relation picks \cdots, otherwise \ldots – and applies only under --unsafe-fixes or as an editor code action. Comments and verbatim are never touched.

Literal dots in text:

See Chapter 2, 3, ... for details.
warning: ellipsis
 --> example.tex:1:19
  |
1 | See Chapter 2, 3, ... for details.
  |                   ^^^ literal `...` ellipsis; use `\dots`

After applying the fix:

See Chapter 2, 3, \dots for details.

Literal dots in a math sum (an operator neighbor picks \cdots):

$a_1 + ... + a_n$
warning: ellipsis
 --> example.tex:1:8
  |
1 | $a_1 + ... + a_n$
  |        ^^^ literal `...` ellipsis; use `\cdots` in math (`\ldots` for lists, `\cdots` for operator chains)

hard-coded-reference

Flag a literal cross-reference written in prose – Figure 3, Table~1, Section 2 – instead of \ref/\cref to a \label (textidote sh:hcfig/hctab/hcsec). Hard-coding the number defeats LaTeX’s automatic numbering: renumbering a float or reordering sections silently breaks the reference and drops the hyperlink. The rule is report-only – the correct rewrite needs the label the number refers to, which is not in the text, so no autofix is offered. To stay conservative it fires only for a capitalized reference word (Figure, Table, Section, Eq., …) matched as a whole word and directly followed, across one space or a tie ~, by an arabic number; plurals, lowercase, Figure~\ref{x}, and Figure three are left alone. It also skips a citation locator (\cite[Section~8.1]{...}, a reference into external work), an environment title (\begin{thm}[Conway's Theorem 0], a proper name), and an \item[label] description-list caption (\item[Part 3.]). It never touches math, comments, or verbatim.

A hard-coded figure number instead of a cross-reference:

See Figure 3 for the results.
warning: hard-coded-reference
 --> example.tex:1:5
  |
1 | See Figure 3 for the results.
  |     ^^^^^^^^ hard-coded reference `Figure 3`; use `\ref`/`\cref` to a `\label` so the number stays in sync

Even tied with ~, the number is still hard-coded:

Table~1 lists the parameters.
warning: hard-coded-reference
 --> example.tex:1:1
  |
1 | Table~1 lists the parameters.
  | ^^^^^^^ hard-coded reference `Table~1`; use `\ref`/`\cref` to a `\label` so the number stays in sync

straight-quotes

Flag a literal ASCII double quote (") used for quotation. In LaTeX a straight " always sets a closing double quote, so an opening one comes out backwards; the correct forms are `` (two backticks) to open and '' (two apostrophes) to close. A quotation is reported once, spanning both quotes, and its fix rewrites the pair in one atomic edit – so a single editor code action repairs it from either end. A quote left unpaired (no closer before the paragraph ends) reports on its own. The fix is unsafe: it infers direction from context – a quote preceded by whitespace, a line break, an opening delimiter ((, [, {), a backtick, or the start of the document opens, anything else closes – and applies only under --unsafe-fixes or as an editor code action, since the guess can flip the typeset glyph. Single straight quotes (') are left alone (they are legitimately apostrophes), and comments, verbatim, math, TeX hex constants ("2D), and \pdfmapline font maps are never touched.

Straight ASCII double quotes around a phrase:

He said "hello world" to me.
warning: straight-quotes
 --> example.tex:1:9
  |
1 | He said "hello world" to me.
  |         ^^^^^^^^^^^^^ straight double quotes; use `` `` `` (opening) and `''` (closing)

An opening quote after a parenthesis:

("quoted")
warning: straight-quotes
 --> example.tex:1:2
  |
1 | ("quoted")
  |  ^^^^^^^^ straight double quotes; use `` `` `` (opening) and `''` (closing)

swallowed-space

Flag a text-producing control word directly followed by a space that TeX eats, gluing the macro’s output to the next word (\LaTeX is renders “LaTeXis”) (ChkTeX 1). When TeX tokenizes a control word it discards following spaces, so the space never reaches the output. To stay conservative the rule fires only for a curated set of argument-less TeX-family logos (\LaTeX, \TeX, \BibTeX, …), only in text mode, and only when the next token is a word beginning with an alphanumeric character – a following period (\LaTeX . -> “LaTeX.”) is what the author wanted. The fix inserts {} after the control word (\LaTeX{} is), ending the macro name so the space survives; it is unsafe because it changes the typeset output, so --fix leaves it alone while --unsafe-fixes and the editor code action apply it.

A logo swallows the following space, gluing it to the next word:

We used \LaTeX to typeset this.
warning: swallowed-space
 --> example.tex:1:15
  |
1 | We used \LaTeX to typeset this.
  |               ^ `\LaTeX` swallows the following space; add `{}` (`\LaTeX{}`) or `\ ` so it prints

space-before-command

Flag a plain space directly before a command that should hug the preceding word – \footnote, \footnotemark, \index, \label (ChkTeX 24/42). A space before \footnote sets a spurious space before the footnote mark (word \footnote{x} -> “word ¹”); a space before a zero-width \index/\label leaves a stray inter-word gap that can shift the recorded page. The fix deletes the space. It is unsafe – removing the space changes the typeset spacing – so --fix leaves it alone while --unsafe-fixes and the editor code action apply it. To stay conservative only the same-line WORD SPACE \cmd shape is flagged (a space at line start or after a brace is left alone), and math is skipped (an inter-token space is insignificant there), covering both $…$ and math environments like equation/align. For the zero-width \index/\label the fix is withheld unless the group is trailed by whitespace, a newline, or paragraph end, since otherwise the leading space is a real interword space to the following content.

A space before a footnote sets a spurious space before the mark:

This is important \footnote{See the appendix.}
warning: space-before-command
 --> example.tex:1:18
  |
1 | This is important \footnote{See the appendix.}
  |                  ^ spurious space before `\footnote`; delete it so no stray space is typeset before the command

mismatched-delimiter

Flag a \left ... \right pair whose delimiter glyphs point the wrong way – a closing glyph opening the pair, or an opening glyph closing it (\left) ... \right(). Deliberately conservative: only an orientation error is flagged, never a mere opener/closer mismatch, since half-open intervals like \left( ... \right] are legitimate. Structural faults (a missing \right) are reported by the parser, not this rule. No autofix: the intended glyphs are ambiguous.

A \left/\right pair whose glyphs point the wrong way:

$\left) x \right($
warning: mismatched-delimiter
 --> example.tex:1:7
  |
1 | $\left) x \right($
  |       ^ `\left)` uses a closing delimiter where an opening one is expected
warning: mismatched-delimiter
 --> example.tex:1:17
  |
1 | $\left) x \right($
  |                 ^ `\right(` uses an opening delimiter where a closing one is expected

dash-length

Flag a dash of the wrong length for its context (ChkTeX 8). LaTeX sets a hyphen from -, an en dash from --, and an em dash from ---. Between two numbers a range takes an en dash, so 5-10 or 5---10 is flagged with an unsafe fix to -- (unsafe because it changes the typeset glyph and a hyphen between numbers is occasionally intentional). Between two words an en dash (--) is almost always a mistake, but whether a hyphen or an em dash was meant is ambiguous, so it is reported without a fix – except when it joins coordinate proper names (Barzilai--Borwein, Newton--Raphson), detected by an uppercase first letter on either flank, where the en dash is correct and the finding is suppressed. To stay conservative the rule only inspects a dash run that sits inside a single word with content on both sides and is the only dash run in that word, so dates (2020-01-15), ISBNs, spaced dashes, and option flags (--verbose) are left alone. Column spans in rule commands (\cline{1-3}, \cmidrule(lr){2-3}) and key arguments (\label{fig:1-3}, \cite{smith2020-1}) are specs and opaque identifiers rather than typeset ranges, so they are skipped too. Comments, verbatim, and math are never touched.

A hyphen where a number range wants an en dash:

See pages 5-10 for the proof.
warning: dash-length
 --> example.tex:1:12
  |
1 | See pages 5-10 for the proof.
  |            ^ hyphen between numbers; use an en dash `--` for a number range

An en dash between words (ambiguous, so reported without a fix):

A well--known result.
warning: dash-length
 --> example.tex:1:7
  |
1 | A well--known result.
  |       ^^ en dash `--` between words; use a hyphen `-` for a compound or an em dash `---` for a break

times-variable

Flag a literal x used as a multiplication sign between two numbers, such as 640x200 or 3x3 (ChkTeX 29). TeX sets that x as an italic letter rather than the \times cross, so it reads wrong. The rule only fires when the whole word is digits x digits – one lowercase x with ASCII digits on both sides and nothing else – so ordinary words (matrix), spaced products (n x m), hex literals (0xFF, 0x12), and key arguments such as \label{fig:3x3} or \ref{fig:3x3} (where the x is part of an opaque identifier) are left alone. The fix is unsafe (a bare x between numbers is usually a cross but occasionally a real variable): inside math it rewrites the x to \times, and in text it wraps it as $\times$ so the result still compiles. So --fix leaves it alone; --unsafe-fixes and the editor code action apply it.

A literal x as a multiplication sign in text (fixed to $\times$):

A 640x200 pixel image.
warning: times-variable
 --> example.tex:1:6
  |
1 | A 640x200 pixel image.
  |      ^ literal `x` as a multiplication sign between numbers; use `\times` for a cross

The same inside math mode (fixed to \times):

The grid is $640x200$ cells.
warning: times-variable
 --> example.tex:1:17
  |
1 | The grid is $640x200$ cells.
  |                 ^ literal `x` as a multiplication sign between numbers; use `\times` for a cross

math-operator-name

Flag a bare log-like function name (sin, cos, log, lim, and the rest of the LaTeX/amsmath set) written in math mode without its backslash, so TeX sets it as italic variables instead of the upright \sin operator with correct spacing (ChkTeX 35). It fires when the name starts a WORD and ends at a word boundary, catching both $sin x$ and the glued $sin(x)$, while leaving words that merely begin with one (since) alone and preferring the longest match (sinh over sin). To stay conservative it only fires inside math mode, never in a subscript or superscript, where max in x_{max} is almost always a label rather than the operator (including inside a command argument such as \frac{x_{max}}{n}, whose body carries no script nodes), and never inside a key argument such as \label{eq:thing_max} or \eqref{eq:max}, whose content is an opaque identifier rather than typeset math. The fix inserts the backslash (sin -> \sin); it is unsafe because it changes the typeset output (upright glyph and operator spacing) and a bare sin is occasionally a real product, so --fix leaves it alone while --unsafe-fixes and the editor code action apply it.

A bare function name typesets as italic variables:

$sin x + cos x = 1$
warning: math-operator-name
 --> example.tex:1:2
  |
1 | $sin x + cos x = 1$
  |  ^^^ bare `sin` in math typesets as italic variables; use `\sin`
warning: math-operator-name
 --> example.tex:1:10
  |
1 | $sin x + cos x = 1$
  |          ^^^ bare `cos` in math typesets as italic variables; use `\cos`

It fires through the glued f(x) form too:

The limit $lim(x)$ diverges.
warning: math-operator-name
 --> example.tex:1:12
  |
1 | The limit $lim(x)$ diverges.
  |            ^^^ bare `lim` in math typesets as italic variables; use `\lim`

makeat-macro

Flag a macro whose name contains @ (\foo@bar, \p@, \@ifnextchar) used outside a \makeatletter/\makeatother region. There @ has its ordinary catcode, so it cannot be part of a control word: \foo@bar is read as \foo followed by the text @bar, not as a call to the internal macro \foo@bar. Usually the enclosing \makeatletter/\makeatother was forgotten. Because the formatter’s lexer already tracks \makeatletter state, this is decided exactly – an in-region name lexes as one token and is never flagged; only the split out-of-region form (control word abutting an @-word, or \@ abutting a letter-word) is. Report-only: a correct fix would mean wrapping the use in \makeatletter/\makeatother, not a tight local edit, so no autofix is offered. The end-of-sentence \@ (as in NASA\@.) is not flagged.

An internal @ macro used without \makeatletter:

\my@command
warning: makeat-macro
 --> example.tex:1:1
  |
1 | \my@command
  | ^^^^^^^^^^^ `\my@command` uses `@` in a macro name outside a `\makeatletter` region; `@` is not a letter here, so this reads as `\my` followed by the text `@command`

A leading-@ macro (a \@-prefixed internal) outside a region:

\@ifstar{\StarredForm}{\PlainForm}
warning: makeat-macro
 --> example.tex:1:1
  |
1 | \@ifstar{\StarredForm}{\PlainForm}
  | ^^^^^^^^ `\@ifstar` uses `@` in a macro name outside a `\makeatletter` region; `@` is not a letter here, so this reads as `\@` followed by the text `ifstar`

sectioning-level-jump

Flag a heading that descends more than one sectioning level below the preceding heading – \section straight to \subsubsection, skipping \subsection (textidote’s sh:secskip). Standard sectioning commands form a fixed ladder (\part, \chapter, \section, \subsection, \subsubsection, \paragraph, \subparagraph); descending it a rung at a time keeps the outline sound, and a jump usually signals the wrong command. Only downward jumps between consecutive headings are flagged – climbing back up to close sections is normal, as are repeated headings at one level. The comparison is relative to the previous heading, never an absolute top level, so an article opening with \section is fine. Report-only: repairing a skip (promote the heading or insert an intermediate one) is a structural choice for the author, not a correct-by-construction edit.

A heading that drops two levels at once (skipping \subsection):

\section{Introduction}
\subsubsection{Details}
warning: sectioning-level-jump
 --> example.tex:2:1
  |
2 | \subsubsection{Details}
  | ^^^^^^^^^^^^^^ `\subsubsection` skips a sectioning level after `\section` (expected `\subsection`)

missing-required-argument

Flag a command invoked with fewer {…} groups than the required arity in its curated built-in signature (ChkTeX warning 14, decided on the parse tree and signature database rather than line heuristics). TeX also accepts unbraced single-token arguments (\frac12), so the rule stays silent whenever a following token could still supply the missing argument and fires only at a hard boundary: the end of the enclosing group, math shell, or environment, an alignment &, a \\ line break, a blank line, or the end of the file. Contexts where a bare command is deliberate are skipped – macro-definition bodies (\newcommand{\bold}{\textbf}), arguments of unknown commands, standalone {…} scope groups, \let-style alias forms, and names the file itself redefines. Report-only: the missing argument’s content is the author’s to write, so no fix is correct by construction.

A fraction missing its denominator:

$\frac{1}$
warning: missing-required-argument
 --> example.tex:1:2
  |
1 | $\frac{1}$
  |  ^^^^^ `\frac` is missing 1 of its 2 required arguments

A command left bare at the end of a group, with nothing to take:

\emph{see \textbf}
warning: missing-required-argument
 --> example.tex:1:11
  |
1 | \emph{see \textbf}
  |           ^^^^^^^ `\textbf` is missing its required argument

undefined-ref

Flag a \ref-family reference to a label defined nowhere in the document. Sound only when the label namespace is complete, so it stays silent unless the project view is closed (every include resolves to an analyzed file) and rooted. Inert on stdin or wherever no cross-file label resolution is available. No autofix.

A reference to a label defined nowhere in the document:

\ref{sec:intro}
warning: undefined-ref
 --> example.tex:1:1
  |
1 | \ref{sec:intro}
  | ^^^^^^^^^^^^^^^ reference to undefined label `sec:intro`

undefined-citation

Flag a \cite-family key matching no entry in the document’s bibliography – the bibliographic analog of undefined-ref. Sound only over a closed, rooted namespace where every .bib resource resolves to an analyzed file, and suppressed entirely by a \nocite{*} wildcard (which marks every key as used). Inert without cross-file citation resolution. No autofix.

A citation of a key that matches no bibliography entry:

\cite{knuth:1984}
warning: undefined-citation
 --> example.tex:1:1
  |
1 | \cite{knuth:1984}
  | ^^^^^^^^^^^^^^^^^ citation of undefined key `knuth:1984`

unreferenced-label

Flag a \label that no \ref-family command targets anywhere in the document. The mirror of undefined-ref, and sound only when the label namespace is complete, so it stays silent unless the project view is closed (every include resolves to an analyzed file) and rooted. Inert on stdin or wherever no cross-file label resolution is available. Report-only: removing the dead label or adding a reference are both valid, so there is no autofix.

A label that no \ref-family command ever targets:

\section{Intro}\label{sec:intro}
warning: unreferenced-label
 --> example.tex:1:16
  |
1 | \section{Intro}\label{sec:intro}
  |                ^^^^^^^^^^^^^^^^^ label `sec:intro` is never referenced

verbatim-trailing-text

Flag non-whitespace text after a verbatim-like environment’s \end{…} on the same line (ChkTeX warning 31). LaTeX closes a verbatim environment by scanning line by line to \end{verbatim} and then gobbling the rest of that line, so \end{verbatim} foo silently drops foo. Scoped to verbatim-like environments — read off the parse tree (an opaque VERBATIM_BODY, or a curated built-in verbatim name for the empty-body case) — because ordinary environments do not gobble their \end line. A trailing % comment is treated as trivia, not flagged. Report-only: whether to move or delete the swallowed text is the author’s call, so no fix is correct by construction.

Text after \end{verbatim} is silently discarded by LaTeX:

\begin{verbatim}
sample
\end{verbatim} and more
warning: verbatim-trailing-text
 --> example.tex:3:16
  |
3 | \end{verbatim} and more
  |                ^^^^^^^^ text after `\end{verbatim}` on the same line is silently discarded

duplicate-package

Flag a package loaded more than once in the same file with \usepackage/\RequirePackage (which share one package namespace). LaTeX loads a given package only once; a second load is redundant and, when the options disagree, an option-clash error. Loads in mutually exclusive branches of a TeX conditional (\iftrue...\else...\fi, \newif-defined conditionals included) are not duplicates and are not flagged; if-named macros that take brace arguments instead of a \fi terminator (\ifthenelse and friends) carry no recognized branches. No autofix: removing a load can drop options the survivor lacks, and which load to keep is the author’s call. Class loads (\documentclass/\LoadClass) are a separate concern and are not flagged.

The same package loaded twice:

\usepackage{amsmath}
\usepackage{amsmath}
warning: duplicate-package
 --> example.tex:2:1
  |
2 | \usepackage{amsmath}
  | ^^^^^^^^^^^^^^^^^^^^ package `amsmath` is loaded more than once

missing-provides

Flag a package or class source (.sty/.cls) that never identifies itself with the matching \ProvidesPackage/\ProvidesClass. Every well-formed package declares its identity so LaTeX can log it and honor date-based compatibility checks; a .sty carrying only \ProvidesClass (wrong kind) still counts as missing. The rule is inert for any other extension – a .tex has nothing to provide, and a .dtx hides its declaration inside guarded macrocode. No autofix: writing a correct \Provides… line (placement, date, version) is the author’s call.

A package source with no self-identification (the docs are rendered against a .sty path):

\NeedsTeXFormat{LaTeX2e}
\RequirePackage{xcolor}
warning: missing-provides
 --> example.sty:1:1
  |
1 | \NeedsTeXFormat{LaTeX2e}
  | ^^^^^^^^^^^^^^^ package file lacks `\ProvidesPackage`

unknown-option

Flag a \usepackage/\RequirePackage option that the loaded package never declares with \DeclareOption, which LaTeX reports as an “Unknown option” error at compile time. Checked only against packages that are analyzed project files (a sibling .sty) — no option data ships for system packages — and only when the package’s declared set is trustworthy: a \DeclareOption* default handler, a key-value option processor (kvoptions, \ProcessKeyOptions, …), option forwarding, or an \input in the package silences the rule, as does a key=value option. Class loads (\documentclass) are not checked: an unknown class option is not an error, it becomes an unused global option. No autofix: dropping or renaming the option is the author’s call.

With a sibling mypkg.sty:

\ProvidesPackage{mypkg}[2026/01/01 v1.0 Demo package]
\DeclareOption{draft}{}
\ProcessOptions\relax

Loading the sibling package with an option it never declares:

\usepackage[final]{mypkg}
warning: unknown-option
 --> example.tex:1:13
  |
1 | \usepackage[final]{mypkg}
  |             ^^^^^ unknown option `final` for package `mypkg`

redundant-script-braces

Flag braces around a single-token sub/superscript argument, which ^/_ bind without them (x^{2} is x^2). The autofix deletes the two braces and leaves the inner token untouched. It is withheld when dropping the braces would let the following character glue onto the argument and change meaning (x^{2}-3 stays braced — unspaced x^2-3 would re-lex 2-3 as one token; y_{\alpha}b stays braced — \alphab is one control word).

Redundant braces around a single-token script argument:

$x^{2}$ and $y_{\alpha}$
help: redundant-script-braces
 --> example.tex:1:4
  |
1 | $x^{2}$ and $y_{\alpha}$
  |    ^^^ redundant braces around a single-token script argument
help: redundant-script-braces
 --> example.tex:1:16
  |
1 | $x^{2}$ and $y_{\alpha}$
  |                ^^^^^^^^ redundant braces around a single-token script argument

After applying the fix:

$x^2$ and $y_\alpha$

unclosed-math-delimiter

Flag a math opener the parser silently demoted to a plain token because no closer was reachable – a $ with no matching $, a \[/\( with no \]/\), or a \left with no \right. Such a shape is routine data in macro code (>{$} array columns, \expandafter\@tempa\[\@nil), so the parser tolerates it without a diagnostic; in prose it is almost always a dropped closer. To stay clear of the macro-code cases the rule is conservative: it reports only an opener in document prose, staying silent when it sits inside a brace group or optional argument (\newcommand{...}{$}, the >{$} column spec), an expl3 region, or a macrocode body. No autofix: the correction (insert a closer, or delete a stray opener) is ambiguous.

An inline-math $ with no matching $:

Let $x = 1 be the base case.
warning: unclosed-math-delimiter
 --> example.tex:1:5
  |
1 | Let $x = 1 be the base case.
  |     ^ `$` has no matching `$` (unclosed inline math)

A display-math \[ with no matching \]:

The bound \[ x + y follows immediately.
warning: unclosed-math-delimiter
 --> example.tex:1:11
  |
1 | The bound \[ x + y follows immediately.
  |           ^^ `\[` has no matching `\]` (unclosed display math)

A \left with no matching \right:

$a + \left( b + c$
warning: unclosed-math-delimiter
 --> example.tex:1:6
  |
1 | $a + \left( b + c$
  |      ^^^^^ `\left` has no matching `\right`

label-before-caption

Flag a \label placed before the \caption inside a float (figure, table, and their starred forms). \label records \@currentlabel, which inside a float is set by \caption; a label above the caption therefore captures whatever the last \refstepcounter left behind — usually the enclosing section number — so \ref silently prints a number unrelated to the float. LaTeX gives no warning. Scoped to statement-level labels, so the recommended \caption{Text\label{x}} idiom and a \subcaptionbox{A\label{x}}{…} subfigure label are never touched; any earlier caption or hand-rolled \refstepcounter/\stepcounter also silences it, and a float with no caption is left alone. The fix moves the label to just after the first statement-level \caption, and is Unsafe because it changes what \ref prints (by design) from an inferred intent.

A \label above its \caption picks up the section counter, not the figure number:

\begin{figure}
  \includegraphics{plot}
  \label{fig:plot}
  \caption{A plot.}
\end{figure}
warning: label-before-caption
 --> example.tex:3:3
  |
3 |   \label{fig:plot}
  |   ^^^^^^^^^^^^^^^^ `\label` before `\caption` in this `figure` captures the enclosing counter, not the float number

Suppression

To suppress a rule at a single site, use a comment directive:

% badness-lint skip deprecated-command: legacy code, leave as-is
{\bf here}

The verb carries the scope. skip covers the next construct, off and on delimit a region, and skip-file covers the whole file wherever it sits:

% badness-lint off deprecated-command: legacy chapter
{\bf here}
{\it and here}
% badness-lint on deprecated-command

Naming the <id> is optional; leaving it out suppresses every rule over that same span. The : <reason> tail is optional everywhere.

% badness skip / off / on / skip-file do the same and turn off the formatter at the same time; see Formatting for the layout-only % badness-format spellings.

Parse diagnostics (rule id parse) are never suppressed by select/ignore.

BibTeX Linter Rules

badness lint runs a parallel set of built-in rules over each .bib file’s parse tree and reports a diagnostic for every finding. This page is the catalogue: one section per rule, keyed by its stable rule id. Bib rules share one id namespace with the LaTeX rules, so the same [lint] select/ignore (and --select/--ignore) target both.

Every rule is on by default; narrowing happens only through select/ignore in the [lint] table (see the Configuration reference). Where a rewrite is unambiguous a rule carries an auto-fix: a safe fix (shown below as “After applying the fix”) is applied by badness lint --fix.

Each example below is linted live to produce its diagnostic and fixed output, so this page never drifts from the rules’ actual behavior.

duplicate-key

Flag a cite key defined by more than one entry in the same .bib file. Keys are compared case-insensitively, matching BibTeX, which silently keeps only one of the colliding entries; every definition after the first is flagged. No autofix: resolving the collision (rename vs delete) is the author’s call.

The same cite key defined by two entries:

@misc{knuth84, title = {Draft}}
@book{knuth84, title = {Book}}
warning: duplicate-key
 --> references.bib:2:7
  |
2 | @book{knuth84, title = {Book}}
  |       ^^^^^^^ cite key `knuth84` is defined more than once

missing-required-field

Flag a regular entry lacking a field its type requires, per the biblatex data model. An alternation like date or year is satisfied by either, and classic-BibTeX aliases count (journal satisfies journaltitle). An entry type the built-in database does not know carries no signature and is never flagged. Report-only – field content cannot be invented.

An @article without its required journaltitle:

@article{doe2020,
  author = {Doe, Jane},
  title  = {A study},
  year   = 2020
}
warning: missing-required-field
 --> references.bib:1:10
  |
1 | @article{doe2020,
  |          ^^^^^^^ entry `article` is missing required field `journaltitle`

unknown-field

Flag a field that is neither required nor optional for its entry type and carries no global field metadata – usually a typo, or data misplaced from another entry type. BibLaTeX silently ignores fields it does not know, so the mistake otherwise vanishes without a trace. Only entry types the built-in database knows are checked. Report-only – deleting the field would discard data.

A typo’d field name (pubisher for publisher):

@book{turing50,
  author   = {Turing, Alan},
  title    = {A book},
  pubisher = {Elsevier},
  year     = 1950
}
warning: unknown-field
 --> references.bib:4:3
  |
4 |   pubisher = {Elsevier},
  |   ^^^^^^^^ unknown field `pubisher` on `book` entry

empty-field

Flag a field whose value is empty or whitespace-only (title = {}, note = ""). An empty field carries no data, and some styles still emit punctuation around it. The safe autofix deletes the field along with its separating comma.

An empty note left behind by an edit:

@misc{knuth84,
  title = {Draft},
  note  = {}
}
warning: empty-field
 --> references.bib:3:3
  |
3 |   note  = {}
  |   ^^^^^^^^^^ field `note` is empty

After applying the fix:

@misc{knuth84,
  title = {Draft}
}

duplicate-field

Flag a field name appearing more than once on a single entry (names compared case-insensitively). BibTeX and Biber keep only one occurrence and silently discard the rest, so a duplicate is almost always a merge or copy-paste mistake; every occurrence after the first is flagged. When the repeated value is byte-identical to the kept one, a safe autofix deletes the redundant copy; when the values differ, which one wins is engine-dependent, so the finding is report-only.

Two note fields with identical values – deleting the redundant copy is safe:

@misc{knuth84,
  note = {Draft},
  note = {Draft}
}
warning: duplicate-field
 --> references.bib:3:3
  |
3 |   note = {Draft}
  |   ^^^^ duplicate field `note` on `misc` entry

After applying the fix:

@misc{knuth84,
  note = {Draft}
}

Differing values are report-only (which copy the engine keeps is style-dependent, so dropping either would change meaning):

@misc{knuth84,
  note = {First draft},
  note = {Second draft}
}
warning: duplicate-field
 --> references.bib:3:3
  |
3 |   note = {Second draft}
  |   ^^^^ duplicate field `note` on `misc` entry

unused-string

Flag an @string macro defined in the file but never referenced by any field value. For the common self-contained .bib an unused macro is dead weight; in a multi-file bibliography it may be referenced from another .bib, so treat cross-file setups with care – cross-file @string resolution is not modeled yet. Report-only: deleting a definition is a meaning-level edit left to the author.

A defined macro no field value references:

@string{cup = {Cambridge University Press}}
@book{turing50, title = {Draft}, publisher = {Springer}}
warning: unused-string
 --> references.bib:1:9
  |
1 | @string{cup = {Cambridge University Press}}
  |         ^^^ `@string` macro `cup` is defined but never used

undefined-string

Flag an @string macro used in a field value but defined nowhere in the file (the twelve month macros jan..dec are predefined). Usually a typo’d macro name or a missing @string definition; BibTeX errors on it at build time. In a multi-file bibliography the definition may live in another .bib, so a use resolved there is a false positive – cross-file @string resolution is not modeled yet. Report-only: the fix (define the macro or correct the name) is a meaning-level edit left to the author.

A typo’d macro name (cpu for cup):

@string{cup = {Cambridge University Press}}
@book{turing50, title = {Draft}, publisher = cpu}
warning: undefined-string
 --> references.bib:2:46
  |
2 | @book{turing50, title = {Draft}, publisher = cpu}
  |                                              ^^^ `@string` macro `cpu` is used but never defined

title-capitalization

Flag an unprotected acronym or mid-word capital in a title-like field (title, booktitle, journaltitle, …). Many bibliography styles lowercase unprotected title text, so DNA renders as dna unless written {DNA}. Flagged are runs of two or more capitals and the camelCase brand pattern (a first capital mid-way through a lowercase-initial word, like iPhone); ordinary Title Case, name particles (McDonald), and mixed-case tokens (LaTeX) stay quiet, as does anything already inside a {...} group. Report-only – choosing what to protect is the author’s call.

An unprotected acronym a title-lowercasing style would render as dna:

@article{watson53, title = {Molecular structure of DNA}}
warning: title-capitalization
 --> references.bib:1:52
  |
1 | @article{watson53, title = {Molecular structure of DNA}}
  |                                                    ^^^ unprotected capitals `DNA` in `title`; wrap in braces (`{DNA}`) to keep case under title-lowercasing styles

encoding-hints

Surface non-ASCII text in a field value as a hint (accented text is perfectly valid in a UTF-8 setup, hence not a warning). Raw non-ASCII renders correctly only when the file is UTF-8 and the document loads a matching input encoding (inputenc with pdfLaTeX, fontspec with Xe/LuaLaTeX); legacy toolchains may mangle it. Either confirm the encoding or use a LaTeX escape (\'e for é). Report-only – the right fix depends on the project’s toolchain.

An accented name entered as raw UTF-8:

@article{erdos47, author = {Erdős, Paul}}
help: encoding-hints
 --> references.bib:1:32
  |
1 | @article{erdos47, author = {Erdős, Paul}}
  |                                ^ non-ASCII text `ő`; ensure the file is UTF-8 and the document loads an input encoding (inputenc/fontspec), or use a LaTeX escape

Suppression

BibTeX has no line-comment token, so per-site suppression rides a structured @comment entry instead of the LaTeX % directive. A plain directive suppresses one rule on the next entry:

@comment{badness-lint skip missing-required-field: publisher long gone}
@book{oldbook, title = {An Orphaned Book}}

The grammar is the LaTeX one, only the carrier differs. off and on delimit a region of entries, and skip-file covers the whole file wherever it sits:

@comment{badness-lint off missing-required-field: imported, incomplete by design}
@book{oldbook, title = {An Orphaned Book}}
@comment{badness-lint on missing-required-field}

Naming the <id> is optional; leaving it out suppresses every rule over that same span. Parse diagnostics (rule id parse) are never suppressed.

Benchmarks

Wall-clock speed of badness against comparable tools, measured with hyperfine: the formatter against tex-fmt and latexindent, and the linter against the classic TeX Live checkers lacheck and chktex.

These numbers measure speed only, never output or diagnostic equivalence, and the tools do genuinely different amounts of work:

  • latexindent is a Perl script that parses LaTeX into a tree and reflows it according a set of highly configurable rules. It is the most featureful formatter here, but also the slowest.
  • tex-fmt breaks overfull lines greedily but does not reflow: it won’t rewrap lines that already fit, so it moves far less text than badness, which reflows each paragraph to the target width.
  • Among the linters, lacheck is a small classic checker, chktex is regex-driven, and badness lint does a full CST parse plus its rule set.

The absolute milliseconds are the real latencies—what you actually wait—but they are machine- and run-dependent. And because the tools do different work, a cross-tool difference is not a claim that one tool is faster at the same job.

The figures below are regenerated manually with task bench and committed as a machine-readable artifact (benches/benchmark_results.json); they are never re-measured when this site is built or in CI.

Formatter

How the formatter is measured

Each tool is invoked exactly as a user would pipe a document through it:

ToolInvocation
badnessbadness format --no-config --stdin-filepath bench.tex
tex-fmttex-fmt --stdin
latexindentlatexindent -g /dev/null -

The corpus is real LaTeX: a committed small.tex baseline plus larger documents (cv.tex, masters_dissertation.tex, phd_dissertation.tex) fetched by benches/documents/download.sh from a pinned tex-fmt release. Documents badness cannot yet format (parser diagnostics) are skipped, as are comparison tools missing from PATH.

The whole-project benchmark below measures recursive folder formatting rather than a single file: each tool walks a real multi-file LaTeX thesis (the pinned kks32/phd-thesis-template, its .tex fragments) and formats every file in read-only --check mode—the folder analog of the stdin -> stdout runs above (full formatting work, nothing written). Only badness and tex-fmt appear there: latexindent has no recursive directory mode, so it is excluded from that comparison by design.

ToolInvocation
badnessbadness format --check <dir>
tex-fmttex-fmt --check --recursive <dir>

The folder benchmark runs against a throwaway copy of the fetched project so both tools walk an identical, un-gitignored, .tex-only tree (badness format is .tex-only, while tex-fmt would otherwise also touch .bib/.cls). Any file badness cannot format yet is dropped from both tools, keeping the comparison symmetric. This is a different mode from the single-file runs, so read its ratio on its own terms, not against them.

Setup

  • badness: 0.7.0
  • tex-fmt: 0.5.7
  • latexindent: 3.24.7
  • backend: hyperfine (min runs: 3)
  • host: linux/x86_64, Intel(R) Core(TM) Ultra 7 155U
  • generated: 2026-07-10T00:50:18Z

Single-file results

Formatting speed relative to badness. Each dot is one document formatted by one tool; the vertical position is mean wall-clock time as a ratio to badness on a log scale, so badness lies on the dashed baseline at 1, faster tools fall below it and slower tools rise above. Color distinguishes documents; hover a dot for the exact millisecond figures.
Data table

small.tex (baseline) (1233 bytes, 48 lines)

ToolMean (ms)Min (ms)Max (ms)Relative
badness2.45450.85735.6567baseline
tex-fmt2.29800.88834.90631.1× faster
latexindent84.761977.052698.643034.5× slower

cv.tex (6273 bytes, 275 lines)

ToolMean (ms)Min (ms)Max (ms)Relative
badness3.05631.45216.5785baseline
tex-fmt2.36561.01575.15151.3× faster
latexindent105.638882.3922161.251834.6× slower

masters_dissertation.tex (95383 bytes, 2458 lines)

ToolMean (ms)Min (ms)Max (ms)Relative
badness22.592419.710726.9194baseline
tex-fmt3.58181.722810.99856.3× faster
latexindent2333.93382330.00662336.7828103.3× slower

Whole-project results

Whole-project formatting speed relative to badness. Each dot is one tool running a recursive --check over a real multi-file LaTeX project; the vertical position is mean wall-clock time as a ratio to badness on a log scale, so badness lies on the dashed baseline at 1, faster tools fall below it and slower tools rise above. This is a different mode from the single-file charts—read its ratio on its own terms. Hover a dot for the exact millisecond figures.
Data table

project (12 files) (47190 bytes, 1005 lines)

ToolMean (ms)Min (ms)Max (ms)Relative
badness4.69282.78719.3865baseline
tex-fmt3.48011.91596.73611.3× faster

Linter

How the linter is measured

The linter runs over the same single-file corpus. Linters are read-only, so each tool is handed the document path directly (no stdin plumbing—lacheck only reliably reads a real file):

ToolInvocation
badnessbadness lint --no-config <file>
chktexchktex -q <file>
lachecklacheck <file>

Findings are the normal case, and the tools signal them differently: chktex exits 2, badness lint exits 1, and lacheck always exits 0. A non-zero exit here is not a run error, so hyperfine is told to ignore it (--ignore-failure); the shell-loop fallback does the same.

There is no folder analog for the linter comparison: neither lacheck nor chktex has a recursive directory mode, so—like latexindent in the formatter folder benchmark—they would have no counterpart to measure against.

Setup

  • badness: 0.7.0
  • lacheck: 1.30
  • chktex: v1.7.9
  • backend: hyperfine (min runs: 3)
  • host: linux/x86_64, Intel(R) Core(TM) Ultra 7 155U
  • generated: 2026-07-10T00:50:18Z

Results

Linting speed relative to badness. Each dot is one document checked by one tool; the vertical position is mean wall-clock time as a ratio to badness on a log scale, so badness lies on the dashed baseline at 1, faster tools fall below it and slower tools rise above. These tools report different problems, so a difference here is not a same-job speed verdict. Color distinguishes documents; hover a dot for the exact millisecond figures.
Data table

small.tex (baseline) (1233 bytes, 48 lines)

ToolMean (ms)Min (ms)Max (ms)Relative
badness2.76151.51627.6755baseline
lacheck10.57847.707114.56983.8× slower
chktex65.400755.176493.524223.7× slower

cv.tex (6273 bytes, 275 lines)

ToolMean (ms)Min (ms)Max (ms)Relative
badness3.24691.84038.2667baseline
lacheck11.65107.185125.75713.6× slower
chktex66.145654.728181.892620.4× slower

masters_dissertation.tex (95383 bytes, 2458 lines)

ToolMean (ms)Min (ms)Max (ms)Relative
badness24.125820.717228.9816baseline
lacheck13.534610.440718.27611.8× faster
chktex68.445561.333490.87542.8× slower

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 is the badness CLI/LSP/linter crate, and 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.

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

If you use Nix with devenv, the dev 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; task --list shows 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 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.

Performance is first-class. Benchmark before optimizing, and never regress losslessness for speed.

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. The CST oracles cannot see the one risk the key-value argument flag takes, where a space token is trivia to the CST and content to TeX, so run this when touching keyval signature data or the optional-argument lowering. It needs a TeX install.

task parse-compat runs texlab’s parser as a differential oracle over a corpus, skeletonizing both trees and comparing. It is a reference we measure against, not one we match, so a divergence is something to explain rather than automatically 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, error-tolerant lexer and parser turn LaTeX into a flat token stream, then an event stream that a tree builder feeds into rowan; a semantic layer assigns meaning on top of the generic tree; and incremental recomputation is salsa-first.

The Architecture page in the book is the full tour, and it is worth reading before a non-trivial 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

These properties are held by construction and enforced as test oracles. 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 tags bare v*; the members tag badness-parser-v* and badness-formatter-v*. Only the bare v* stream carries release assets.

Adding a lint rule

The add-lint-rule workflow automates this, but the shape is fixed:

  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.
  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. Re-sync them through their task rather than hand-editing the mechanical facts. signatures.json, colors.json, and tikz_libraries.json are curated by hand and may be edited directly.

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 are an mdBook under docs/. Preview them locally with task docs:serve (live reload) or build them with task docs. The linter-rules reference and the benchmark page are generated; regenerate them with task docs:rules and task bench respectively rather than editing the rendered pages by hand.

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.

Architecture

Badness parses LaTeX into a lossless concrete syntax tree (CST) and puts a formatter, a linter, and a language server on top of it. The design follows rust-analyzer: a generic, error-tolerant, hand-written parser produces a lossless tree, semantics live in a separate layer above it, and recomputation is incremental via salsa. arity, the same kind of tool for R, was the other influence.

This page is a practical tour of Badness’s design. Its goal is to help contributors understand how the pieces fit together. If you want to build and test the project, start with Contributing.

The document follows data through the system. It begins with the workspace and its inputs, then moves from parsing to formatting, linting, and the language server. The parser and formatter sections are necessarily the most detailed: most of Badness’s safety properties are established at the boundary between those two components.

What it does

At a high level, Badness turns source text into a syntax tree, then uses that tree to produce diagnostics, formatted text, and editor features. It does not typeset documents or run TeX. With the exception of a few language-server features, it does not inspect the machine on which it runs either.

The parser is the foundation of the system. It is a hand-written, recursive-descent parser over a flat token stream, and it builds a lossless concrete syntax tree (CST) for the document. The formatter, linter, and language server all work from this shared representation.

Here’s the pipeline from text to tree:

text → lexer → token stream → parser → event stream → tree_builder → GreenNode

Like rust-analyzer’s parser, ours does not build the tree directly. It emits a small stream of events (Start, Tok(idx), and Finish), which a separate tree builder feeds into rowan’s GreenNodeBuilder. Events refer to tokens by index, while diagnostics travel on a side channel keyed by byte range; consequently, the event stream needs no Error variant. The only specialized event is SubTok, used when math parsing treats part of a WORD token as an operator. The tree builder also reattaches trivia before producing the final green tree.

From there, each subsystem has a different view of the same tree. The formatter lowers it to a Doc intermediate representation and prints that representation. The linter makes one shared traversal and collects diagnostics. The language server answers requests through salsa queries over the tree.

With the explicit declarations described below as its only additional input, the tree is a pure function of source text. Ambient configuration, the signature database, and the filesystem do not influence its shape. This boundary is what makes deterministic parsing and reliable incremental recomputation possible.

The crates

Badness is an edition-2024 Cargo workspace with four crates. The root package, badness, contains the CLI, language server, and linter. Two publishable libraries and an unpublished WebAssembly shim live under crates/.

badness-parser contains the syntax layer (syntax and ast), the parser, and the semantic model. The corresponding BibTeX layers live here too, alongside the generated signature artifacts in data/ and the build.rs script that turns them into PHF tables.

badness-formatter depends on badness-parser and contains the layout engine (core, ir, printer, style, context, colspec, sentence, and perturb) as well as the .bib formatter.

badness-wasm is a publish = false wasm-bindgen shim over the two library crates. It powers the playground and is built with wasm-pack through task playground:wasm.

Both library crates target wasm32-unknown-unknown. As a result, code in those crates cannot depend on the filesystem, threads, or child processes. The formatter is also embedded by the dprint plugin, and a CI job checks that this target continues to build. Because the plugin runs in a filesystem sandbox, it uses an empty runtime signature database; the CLI, by contrast, can include signatures scanned from neighboring .sty and .cls files. This is the one intentional difference from badness format.

The root crate owns linter/, lsp/, project/, and text/, together with incremental.rs (salsa), config.rs, cli.rs, completion.rs, and file_discovery.rs. It re-exports the member crates at their old module paths through small shim modules. For example, src/parser.rs is just pub use badness_parser::parser::*;, which lets existing callers continue to use crate::parser::…. Two modules are genuine bridges rather than shims: src/formatter.rs holds the check batch driver and the disk-backed format_file_with_packages entries, and src/semantic.rs holds load.

The BibTeX side

BibTeX is not implemented as a mode of the LaTeX parser. Instead, .bib files have a parallel pipeline in bib/. It uses the same basic architecture—a lossless rowan CST built from a flat event stream—but defines its own grammar, SyntaxKind, BibLang marker, lexer, parser, tree builder, typed AST, formatter, linter, semantic layer, completion, and outline support. Unless a section says otherwise, the invariants in this document apply equally to both pipelines.

% comments in .bib

There are two plausible ways to interpret % in a bibliography, and the major BibTeX implementations disagree. Classic bibtex 0.99d has no comment syntax and rejects % inside an entry. Biber’s btparse reader, on the other hand, treats it as a comment that ends at the next newline and then resumes parsing. Badness follows biber, consistently with the rest of its BibLaTeX-oriented support (bib_fields.json, for example, tracks blx-dm.def). We verified the difference by compiling examples with both tools.

The difficulty is that the meaning of % depends on context. It begins a comment between a value and the following comma, but remains ordinary text inside a braced or quoted value: title = {50% off} keeps the percent sign. The lexer therefore stays context-free and always emits a bare PERCENT token. The grammar decides whether that token begins a comment. At positions where it skips trivia inside an entry—before a field name, =, #, ,, or the closing delimiter—it wraps % through the end of the line in a COMMENT node. Braced groups, quoted strings, @comment bodies, and top-level junk never take that path, so % remains an ordinary token there. This is the same division of responsibility used by the LaTeX parser, where the grammar rather than the lexer recognizes brace structure.

texlab’s bib parser models no comment at all, so this is a recorded deliberate deviation in bib_parse_compat_allowlist.toml, not a gauge regression.

A % inside a value exposes an awkward boundary between the two languages. BibTeX passes it through as an ordinary character, but LaTeX later interprets it as a comment while typesetting the value. Line breaks in such a value are therefore significant. lower_value_reflowed refuses to reflow any value with an unescaped % and emits it byte for byte. A CST oracle cannot detect this mistake: joining the lines is syntactically lossless, yet changes the typeset result.

The formatter always re-emits comments. A comment sharing a line with the previous field stays on that line, just as a trailing LaTeX comment is never relocated. Other comments bind forward to the field they precede and appear on their own line above it. Binding to a field rather than a byte offset keeps the comment attached when fields are sorted canonically. A comment after the final field appears above the closing delimiter. If an @string, @preamble, or field-less entry has no suitable line on which to place a comment, the formatter preserves the whole block verbatim instead of risking data loss. These rules inspect only whether a comment is on its own line, a property the formatter itself preserves, so a second formatting pass makes the same decision.

Inputs and configuration

The CLI processes .tex, .sty, .cls, .dtx, .ins, and .bib. Directories are walked with ignore, honoring .gitignore and badness.toml excludes.

The lexer’s LatexFlavor picks the starting catcode regime. Package (.sty, .cls, .dtx) begins with @ already a letter, as if under \makeatletter; Document does not. .dtx docstrip surface syntax is parsed.

Wrap mode is not a property of the file kind. Every kind defaults to WrapMode::Reflow, and content that cannot be safely reflowed is refused structurally in every mode; see reflow safety.

badness.toml is found by walking ancestors from each input. The CLI is its only consumer; the library API takes a resolved FormatStyle. Sections are [format] (line-width, indent-width, wrap, math-wrap, lang, no-break-abbreviations), [lint] (select, ignore), [build] (aux-dir), and the declaration maps [environments.<name>] and [commands.<name>]. Excludes follow Ruff: exclude replaces the built-in default, extend-exclude adds to it. wrap is an Option so the LSP can tell “unset” from “set” when merging editor settings over project config, not because the fallback depends on the file.

TEXMF discovery is deliberately not a section here. Where a TeX installation lives is machine state rather than project data, so it arrives through editor settings.

Declarations

Most config only affects behavior after parsing. Declarations are the exception: they feed the parser directly.

[environments.<name>] and [commands.<name>] let a project describe constructs that source text alone cannot reliably reveal (issue #109). Typical examples are alias delimiters like \bea/\eea, environment behavior that should match a built-in, or verbatim-like environments the definition scan cannot infer.

# \begin{myenv} … \end{myenv}, with no built-in counterpart
[environments.myenv]
like = "align"

# extra delimiter spellings for an environment badness already knows
[environments.eqnarray]
begin = ['\bea']
end = ['\eea']

# both: a declared environment reached only through commands
[environments.mytheorem]
like = "theorem"
begin = ['\startmyenv']
end = ['\endmyenv']

Literal strings ('\bea') avoid TOML’s escaping; a leading \ is optional, and a control-word name can never contain one, so there is nothing to disambiguate.

This is a deliberate widening of parser purity, but with strict boundaries. The parser receives a ResolvedDeclarations value, not a full SignatureDb, so it can only see explicit, hand-authored declarations. That keeps parser behavior independent from ambient package scope and scanned runtime data.

Implementation-wise, declarations are seeded into ParseCtx on the first pass. They live in badness-parser so every consumer (CLI, LSP, dprint plugin, wasm) can use the same model. In incremental mode, they are carried through a single high-durability salsa input (incremental::DeclarationsInput), so changing badness.toml invalidates parse results, while normal text edits do not.

In the LSP, declarations are republished in the request dispatcher (not ad hoc inside handlers). This avoids stale cross-workspace state when the active file moves between roots with different config.

The key safety property is simple: a declaration names a spelling; it does not force pairing. Shape gates still decide whether a match is structurally valid. So a wrong declaration degrades to ordinary syntax instead of corrupting the tree.

like is the main mechanism: copy a curated built-in entry of the same kind. Resolution is against curated built-ins only (builtin()), never CWL or scanned definitions. Unknown like targets are config errors.

like also stays category-local. Cross-category relationships (for example, command spellings that stand in for environment delimiters) use explicit keys such as begin/end. Where like is not enough, arity is expressed with xparse argspec (args = "o m m").

The schema is keyed by category, then name. This keeps merging predictable and avoids category-wide switches that could collide with real construct names. Keyed tables are used instead of arrays so layered config can merge by name.

Validation happens at config load time, so broken declarations fail loudly instead of being silently ignored. Rejected forms include empty entries, unknown like targets, conflicting or duplicate spellings, invalid control-word spellings, and delimiter declarations that violate environment constraints.

begin-only and end-only declarations are allowed (issue #117), because literal \begin{X} and \end{X} forms can still provide the missing side.

Two validation checks are especially important: disallowing empty entries (prevents silent no-ops) and disallowing obvious collisions with curated command spellings (prevents accidental global remapping). These are guardrails, not the primary safety mechanism; shape gates remain the ultimate protection.

Declared entries override scanned and built-in tiers. That is intentional: a declaration is an explicit correction from the project author.

Syntax and semantics

Badness deliberately separates syntax from semantics. The syntax layer is a generic CST and, by default, knows nothing about what a command means. The semantic layer enriches that tree with a signature database assembled from curated built-ins, CWL-derived data, and definitions scanned from source. This layer describes properties such as arity, verbatim behavior, sectioning, and argument content kinds.

This is not an absolute wall: a small number of semantic facts may influence parsing when they satisfy both of the following conditions:

  1. The source is curated or explicitly declared.
  2. A wrong fact can be falsified from text shape and demoted by a gate.

Routing and pairing facts meet this test because the source can disprove them. Generic arity does not. An incorrect arity can produce a different attachment while remaining byte-for-byte lossless, so the usual syntax oracles cannot detect the mistake. For generic LaTeX, arity therefore belongs in the semantic layer.

ContentKind::Keyval is the most sensitive semantic claim because it can affect typeset output. It is curated and validated conservatively, since it licenses splits at glued commas in key-value contexts.

The parser

The parser is hand-written recursive descent over a flat token stream. It treats its input as generic TeX surface syntax and always produces a lossless tree.

Resolving macros and catcodes in full generality means running a TeX engine, and we do not do that. Anything we cannot resolve statically degrades to a generic node, with a diagnostic where one is useful, never to a crash or to corrupted output.

Sanctioned lexer modes

Badness does recognize a bounded, gradually growing set of patterns from static source shape. Recognition is deliberately conservative: when the evidence is insufficient, the parser leaves the construct generic. The supported patterns fall into the following categories:

  • Letter modes. \makeatletter makes @ a letter; \ExplSyntaxOn and the \ProvidesExpl* declarations open expl3, where _ and : are letters. The two flags are independent and compose. In a .dtx a file-level signal (a %<@@=…> guard or a \ProvidesExpl* anywhere) puts every macrocode body under expl3 catcodes.
  • Verbatim. \verb, verbatim-like environments, and verbatim-argument commands capture their body as a single token. Built-ins are curated; user-defined ones are found by a bounded two-pass definition scan that fingerprints catcode-othering signals and recognizes definer identities such as \lstnewenvironment.
  • Delimiter isolation. The token after \left or \right is emitted on its own, so the parser can build the LEFT_RIGHT pair.
  • Math environments. An environment the curated table flags math has its body parsed in math mode and wrapped in a MATH node, exactly as \[…\]. This is a grammar decision needing no lexer math state, and it reads the curated flag only, never the bulk or user tiers.
  • Definition bodies. Inside the argument groups of the curated definer set (\newcommand and \newenvironment families, xparse, the LaTeX2e hooks), \begin and \end parse as plain commands, because TeX does not require them to balance within one group. An unbraced control-symbol name after a command definer is likewise consumed as definition data, so declarations such as \DeclareRobustCommand\[ cannot open live display math.
  • Macrocode chunks. A frame-lexed macrocode body is macro code terminated only by the literal frame line, a line-oriented docstrip fact. Unmatched braces inside a chunk are plain tokens, since a \def regularly opens { in one chunk and closes it several chunks later.
  • Short verbs. \MakeShortVerb{\|} toggles a character’s short-verb catcode, so |…| on one line captures as an opaque VERB. Curated doc classes and .dtx mode enable | from the start.
  • Docstrip guards and ^^A doc comments. A line-leading %<…> lexes as a GUARD trivia leaf; on a doc-margin line the literal ^^A comments to end of line, matching ltxdoc’s catcode 14.
  • expl3 regions. In-region, token lists pass \begin and \end around as data, so they parse as plain commands and an orphan \] is data with no diagnostic.
  • Char constants. After a numeric-context primitive from a closed curated set, a backtick opens TeX’s char-constant notation, so \char`$ can never open math.
  • Signatures. \newcommand and xparse signatures are extracted into the semantic database, never executed.
  • Environment aliases. A command whose replacement body is exactly \begin{X} (or \end{X}) stands in for that delimiter, so \bea … \eea pairs as an ENVIRONMENT of X. See below.
  • Picture-body statements. In a curated statementBody environment body (the TikZ/pgf picture family, routed by ParseCtx::is_statement_environment from curated built-ins plus declarations, the math-routing template), each run up to a top-level ;-carrying WORD wraps in a STATEMENT node — retrospectively, by the same precede splice that builds PARAGRAPH, so there is no gate and no scan. A run that never reaches a ; stays plain paragraph content; a genuine \begin is a statement boundary. Only statement extent is modeled — no at/coordinate/path grammar — because extent is what statement boundaries and the continuation hang need; interior statement layout is the semantic layer’s job (§ Statement bodies).

Four shape gates round this out. A $, \[, or \( opens math only when a matching closer is reachable before an unbalanced }, a paragraph break, or EOF, because macro code passes the delimiters around as data at least as often as prose uses them. Environment pairing is gated on brace structure rather than a command set: an environment can never outlive the brace group its \begin opened in, since braces are catcode structure while \begin and \end are only macros. A conditional pairs only when its \fi is reachable, as below. And an environment alias pairs only when its closer is positively located. All four degrade to a plain token with no diagnostic, because parser diagnostics gate the formatter and so must be high precision.

The \begin gate runs on the shared batch driver as EnvGate. Unlike the positive pairing gates, it is a demotion gate, so its answers have the opposite sense: finding an escaping } demotes the environment, while finding none keeps it. Reaching end of file does not count as an escape, which preserves the useful unclosed-environment diagnostic when an author forgets \end.

This inversion has two practical consequences. A stray } closes the scan instead of refuting it, even though positive gates treat the same event as a reason to decline. Math delimiters are not anchors either. A positive gate can safely decline when it encounters one, but doing so here would retain an environment that the scan cannot justify. Finally, the enclosing group_depth and the .dtx documentation-margin exemption belong to the parser’s walk state, not the scan state. They are checked separately for each opener rather than stored in the batch.

The two math gates, DollarGate and DelimMathGate, use the same driver for consistency rather than speed. They are single-entry gates: a batch settles only its seed and opens no nested entry. This follows naturally from the grammar. Once a reachable delimiter claims its closer, it also consumes every potential opener before that closer, leaving no neighboring opener in the same frame to settle.

Their policies differ from the pairing gates in four ways. An unbalanced } always causes refusal, matching the parser walk they guard. A different kind of math delimiter is ordinary content (and, for DollarGate, another $ may be the closer). Environments are counted at every brace depth because math parsing continues to recognize them inside groups. The closing delimiter itself does not require balanced environments, since it ends the math body wherever it appears. DollarGate is also the only gate that is not memoized: after a $$ is demoted, parsing resumes at its second $ and asks a genuinely different question at the same token index and walk state.

The \left…\right gate (LeftRightGate) is the last to join, and the only one whose entries stack rather than count. Every other gate models its nesting as two independent counters — how many nested openers and how many environments stand between an entry and the token at hand — because that is all its per-opener scan ever knew. A \left pairs by count wherever it sits, so its scan reads one LIFO stack of {, \begin, and \left frames alike, and the difference is visible: a frame mismatch (an \end or a \right that reaches a frame of the wrong kind) is seen by every outer \left too, since the innermost frame is common to all of them, so it refuses the whole scan rather than one level of it — while the absence of frames that the blank-line anchor tests is seen only by the innermost \left, so a nested pair shields the ones around it from a paragraph break. Both readings are Nesting::Interleaved in the driver.

Its math anchor inverts too. A conditional lives in text, so what defeats it is math starting; a \left already lives inside a math body, so what defeats it is that body ending$, \], \), exactly the recovery anchors of the left_right walk it guards, while a \[ in the way is ordinary content. And it is the gate whose opener and closer recognition ignores in_macro_code on purpose where the driver’s own \begin/\end counting does not: \left/\right are catcode-neutral math structure that pairs by count no matter what, and a \def body or a macrocode chunk is exactly where package math like $\left#2\right#4$ lives (issue #95). On the driver that is two predicates in a policy; as a hand-written scan it was a comment nothing enforced.

The bracket family closes the migration: three gates (TextBracketGate, MathBracketGate, MacrocodeBracketGate) asking whether a [’s ] is reachable before the token that would make the optional walk bail, in text, in math, and inside a macrocode chunk. Their nesting turned out to need no new model. A per-opener bracket scan counts the ]s owed to the command-abutting [s it passes — such a [ is itself argument-shaped and will claim the next ] when parsed, so that ] cannot also satisfy the outer one (issue #55) — and that claim countdown is the driver’s nested-opener stack once an opener is defined as a command-abutting [, since closer matching is LIFO either way.

The distinctive feature of this family is that both anchors are depth-blind: a \begin/\end refuses rather than counts (an optional never legitimately spans an environment, so either half means a runaway [), and it and the paragraph break fire at any brace depth. Both follow from the walk they guard: optional bails wherever the cursor stands, and a gate stricter or looser than its parse is a bug.

The in-math gate adds two rules of its own. First, it interprets $ according to the enclosing math’s flavor, which belongs to the walk state and therefore forms part of the batch’s memoization key. Inside \[…\] a $ opens a genuine nested inline region, so a balanced $…$ in the bracket is transparent — the entries’ own openers and closers stop counting until the matching $, and everything else reads on — while inside $…$ TeX cannot nest one, so the first $ at the bracket’s own level is that math’s closer and refuses. And the gate is stricter than the optional bail in two preserved respects: its \begin/\end anchor carries no in_macro_code filter, and a chunk-unmatched brace is group structure to it rather than a plain token. Both only ever decline to attach. The second is arguably the faithful reading — optional itself bails at any R_BRACE without consulting plain_braces, so its two siblings, which do consult it, are the loose ones — but unifying either way moves verdicts and is its own commit.

The macrocode gate keeps one divergence of its own: it is the one bracket gate the batch cannot make linear, single-entry by policy, so a chunk of \cmd[ openers whose only ] sits past the frame still scans to the frame per opener.

Its other divergence became the driver’s rule for every gate. A docstrip guard line breaks the paragraph run rather than floating through it: docstrip deletes a guard-only line outright when it strips the file, so %<*dtx> between two lines does not part them (issue #71) — the guard breaks the newline run without being a newline, which is exactly TriviaScan::saw_blank_line_outside_guards. A .dtx doc margin still floats, so a margin-only line is still the blank line of the documentation layer. Only the macrocode gate read guards that way at first, because only its pre-batch scan happened to skip whitespace alone; the other seven inherited the float from the driver’s trivia arm and were the ones diverging from the considered model. rotating.dtx pinned the reading (the date optional of its \ProvidesPackage runs over three guard lines inside one chunk), and unifying paid immediately in the other direction: trace.dtx’s second % \iffalse … % \fi header spans four guard lines, so the float made its \iffalse a plain command and the formatter reflowed the guards into prose — collapsing %<driver> off column 0, a non-trivia content change the two-sided corpus ratchet had recorded as a known failure.

Environment aliases

Badness can infer environment aliases from definitions in the current file. For example, it can recognize \bea ... \eea as shorthand for \begin{eqnarray} ... \end{eqnarray}. Projects may also provide aliases explicitly through declarations.

Inference remains deliberately local and conservative. The parser does not import aliases from neighboring package files, and the target environment’s behavior must come from the curated built-ins. An alias for only one delimiter is still useful: an alias opener may pair with a literal closer, and a literal opener may pair with an alias closer.

Internally, alias and literal closers have separate indexes but share a target lookup. The parser ignores potential alias openers while processing definitions such as \def and \let, preventing the definitions themselves from pairing with one another. Actual pairing uses a positive shape gate: if the scan cannot find a reachable closer, the opener falls back to an ordinary command. As with the other gates, the shared batch driver avoids a separate, potentially quadratic scan for every opener.

Downstream behavior is resolved from the parsed node, not raw spelling. That keeps \begin{bea} distinct from a command alias \bea unless the node itself was parsed as an alias delimiter.

Alias openers are also recognized in math parsing paths where relevant (for example split-style environments), so literal and alias spellings converge to the same environment node shape.

Known conservative gaps are accepted (for example complex \let chains and argument-taking aliases) in exchange for parse safety.

The conditional gate

When it can locate a complete conditional, the parser groups \if … \else/\or … \fi into a CONDITIONAL node with positional branches. This gives the formatter and linter a stable extent for the construct. The node does not try to identify an exact boundary between the test and its body: TeX’s conditional tests are scanner-driven, and static analysis cannot locate that boundary reliably enough to put it in the syntax tree.

Recognition uses a curated opener model from parser::conditional (shared with the linter index), including exclusions for if* macro families and declaration operand slots where \if... text is not live control flow.

The gate requires a reachable \fi at the opener’s own recognized nesting levels (brace/environment/math), with macrocode frame boundaries respected. This prevents the scan from promising closers the structural walk cannot actually consume.

The located closer bounds the parser walk, but nested openers may be demoted when the parser applies their gates again. In that case the walk can finish earlier than the initial scan predicted. For this reason, ast::Conditional::closer is intentionally fallible.

For performance, conditional decisions run through the shared batch gate driver (Parser::gate_batch) instead of per-opener scans. Policy differences remain explicit per gate.

Conditionals differ from environment pairing in a few important respects:

  • EOF without closer demotes conditionals.
  • No .dtx doc-margin exemption is applied.
  • Paragraph breaks anchor conditionals at their own level.
  • Conditionals are not recognized inside expl3-owned regions.

Recursive descent, with Pratt local to math

Hand-written recursive descent is the spine. Precedence climbing is used only for sub- and superscript binding and for \left…\right matching; the text-level parser has no precedence.

Arithmetic operators are catcode-12 “other” characters, so a faithful lexer globs them into WORD runs and a+2*1 is one token. Operator-ness is a math-semantic fact assigned after catcode lexing, which makes it the parser’s job: inside math a WORD is split at operator boundaries into flat sibling atoms, by byte range rather than by re-lexing. Only the trailing operand is the scriptable base, so a+2*1^5 binds ^5 to 1, matching TeX. Operators become atoms so the formatter can space them and the display breaker can break long chains. There is no arithmetic-precedence expression tree.

Argument grouping and bracket policy

The CST greedily attaches trailing {…} and […] groups as argument nodes, texlab-style. Arity is unknown at parse time; the semantic layer refines it.

The load-bearing claim is database independence. Attachment reads the input text plus compiled-in data, never mutable signature inputs such as package scopes, scanned definitions, or the CWL tier. Consulting the signature database during grouping would make the tree a function of something other than the text, and every signature edit would invalidate every parse. For generic LaTeX that forces greed: \foo{a}{b} is either a two-argument call or a zero-argument command followed by two groups, and nothing in the text says which.

Project declarations are the one sanctioned input that is not the text. They are admissible precisely because they do not touch this: a declaration names a construct — a delimiter spelling, an environment’s behavior — and never directs attachment, which stays greedy and generic.

Attachment is therefore text-pure, but not uniform. Deviations read static facts only. Brackets are shape-gated, since [ and ] are not real grouping in TeX: a bracket attaches only when it reads as an argument, which in math means directly abutting the command with its ] reachable before the math ends, and in text mirrors the $ gate. A lone * tight to a command and followed by an argument folds in as a starred-variant marker instead of breaking the run.

expl3 is the one systematic counterexample, and the one place attachment is arity-directed. The argspec suffix rides in the CONTROL_WORD token itself, since in-region : and _ are letters, so arity-directed attachment there is exactly as text-pure as greed. Greed is not neutral in that dialect, it is a systematically wrong guess: every single-token slot breaks the run, so under greed \tl_set:Nn \l_a {x} attached {x} to the definee, and the semantic layer’s peel-back queue existed only to undo that after the fact. In-region colon-suffixed heads therefore attach by their argspec (grammar/expl3.rs): a pure token-level scan consumes the head’s slots — a control-sequence argument keeps a bare COMMAND node of its own, a relation character or #-parameter bumps as tokens, groups and branches attach as ordinary GROUPs — and the walk replays exactly the scanned plan, so the gate mirrors the walk by construction. w/D/colonless heads and the \::n expansion drivers stay greedy, and the scan aborts to greed with no diagnostic wherever it cannot mirror the walk: an in-math head (an N slot would swallow the enclosing math’s closer), a docstrip guard or doc margin mid-unit, a candidate the walk would make a node of, an unreachable closer, a paragraph separator. A blank-line gap inside a brace group instead commits the consumed prefix, the sanctioned partial commit. The trigger keys on token shape alone — a colon-carrying control word can only have lexed inside a region — which also covers the implicit .dtx regions the toggle index cannot see, and the formatter’s positional toggle gate stays the formatter’s alone.

The scan resolves its group slots through a shared matching-brace table rather than a rescan per slot, for the reason the shape gates run in batches: nested call sites ask about spans their enclosing ones already walked, so a per-slot rescan is quadratic in the nesting depth. One stack pass settles every pair in the macrocode frame, keyed on the two facts that decide pairing — the chunk-plain brace set and the frame itself. Bounds that move without changing pairing (an alias closer) filter the answer at query time instead of invalidating the table.

Mis-attachment is unusually hard to detect because it is invisible at the byte level: an incorrect tree can still be lossless and format idempotently. To validate this design, an independent oracle compared grammar attachment with semantic::expl3 consumption across the gate corpora. It covered 67,000 statement-leading heads in 265 files and found no unexplained disagreement; the remaining differences were cases where greedy parsing had harmlessly attached trailing material to an already consumed argument. Corpus fixtures now preserve that coverage. Expl3 regions are allowlisted in the texlab gauge because texlab has no argspec model.

semantic::expl3 still resolves statement extent and handles heads whose shape cannot be derived. Its consumption is independent of CST shape, so it also works for scans that abort and fall back to greedy attachment. Formatter code that once reconciled those two interpretations can now read the attached nodes directly.

Trivia attachment

Comments bind forward, whitespace floats, and a blank line breaks the bind. Trivia is never dropped, so the only question is which node owns it.

By default trivia floats at the nearest enclosing node. A contiguous run of own-line % comments immediately preceding a COMMAND or ENVIRONMENT binds leading into it as a DOC_COMMENT node, with “documentable” decided on node kind alone so no signature lookup leaks into the parser. A same-line trailing comment never binds.

This diverges from rust-analyzer’s n_attached_trivias, which peeks past a blank line when the next comment is an outer doc comment. That peek keys on the /// versus // distinction, and LaTeX’s single catcode-14 % has no equivalent, so we bind only the maximal blank-line-free suffix. Otherwise a license header would glue into the following command’s doc comment.

Error recovery

A single syntactic error never fails the whole parse; errors travel alongside the tree. The recovery anchors are \end{…}, \begin, a blank line, }, $, &, and \\. The parser always makes progress and never loops on unexpected input.

Incrementality

Salsa provides the first level of incrementality across files and queries. Intra-file reparsing is a separate optimization layered on top, described in Intra-file reparse below.

Green nodes are stored in salsa, never red ones, because red trees are not Send, Eq, or salsa::Update. incremental.rs stores rowan::GreenNode under no_eq, unsafe(non_salsa_values), sound because the tree is a pure function of the text, and materializes red cursors on demand.

SourceFile.text is an Arc<str>, not a String, and every setter takes impl Into<Arc<str>>. A keystroke moves a document’s text through several hands — the live buffer, the worker job, the salsa cell, every in-flight read job — and all but the first only read it, so each of those hand-offs is a refcount bump. It also gives the two hot guards a pointer test: upsert_file skips the salsa write when the text is unchanged (salsa’s setter does no equality check of its own, and writing bumps the revision unconditionally), and every read job asks whether the snapshot still holds the buffer it captured. Both go through Arc::ptr_eq / a fat-pointer comparison in front of the content compare, never instead of it: the language server hands back the same allocation it already wrote, while a file re-read from disk is a fresh allocation that may still be equal. See IncrementalDatabase::text_is_current, and the language server for the buffer at the other end.

Salsa’s default input durability is LOW. SourceFile.path is built at Durability::HIGH because it is set once and never mutated; text keeps LOW, since a keystroke rewrites it. The project’s declarations are the first genuinely config-shaped input, and are likewise built and written at HIGH. Any future input promoted from config or package metadata must be constructed at HIGH or MEDIUM, or every keystroke’s global revision bump will invalidate it.

Intra-file reparse

A keystroke used to re-parse the whole file. On a small .tex that is fine; on a 730 KB thesis it was 27 ms, which was 97% of the keystroke. parser::reparse splices the edit into the previous green tree instead: the same keystroke typed into prose now costs 0.71 ms end to end, and a line typed inside an lstlisting ~0.85 ms. Timed on its own, the reparse those keystrokes pay for is ~37 µs and ~40 µs against a ~26 ms full parse — roughly 700x, and the ratio grows with the file, since both leaf tiers are O(depth) where a parse is O(file). It arrives in phases, tracked in TODO.md § Incremental reparse — the token and protected-body tiers are live, the first conservative region slice handles multi-token edits in inert top-level prose, and an edit no tier claims still costs a full parse.

Those numbers are held by benches/reparse.rs (task bench:gate), where every case declares the tier it must reach as well as the speed it claims: a floor alone would still pass after a case silently fell back to a full parse, because declining is always sound and fails nothing else. With the parse this cheap the keystroke’s remaining cost moved to the write phase, which was mostly rebuilding the line table; patching it instead took that keystroke to 91 µs end to end (see The live buffer). task bench:keystroke-gate watches both.

The invariant. A successful reparse yields a green tree and a SyntaxError vector byte-identical to a full parse of the edited text. Nothing weaker is admissible, because the tree feeds the formatter — which writes the user’s file — and the linter, whose fixes rewrite content. Every guard failure returns None and the caller full-parses: never an error, never a best-effort tree. That refusal-first contract is what makes the design extensible. A construct the guards do not understand costs speed and nothing else, so a new guard is always a safe change and an oracle failure is always fixed by adding a bail, never by relaxing the assert.

The shape, and what it does not require. The tiers sit strictly on top of parse and lex. The token tier relexes one leaf in isolation, proves the relex is a single token of the same kind joining its neighbours the same way, and splices with rowan’s SyntaxToken::replace_with — every green node off the leaf-to-root path is shared, so the cost is O(depth), not O(file). The protected-body tier makes the same splice from a different proof (below). The region tier re-runs the ordinary parser over a substring and splices the resulting children under ROOT, using neighbour-sized boundary parses purely as proofs that the substring is decoupled from its context, then discarding them.

What the token tier has to prove, and how. A parse is a function of exactly two things: the token vector and the ParseCtx. Fix both and the grammar is deterministic — the shape gates, the prescan indices, the trivia binding, and the attachment walk all read tokens, never source offsets. So changing one leaf’s text reproduces a full parse when three things hold. The token kind sequence is unchanged: the new text must relex, alone, to a single token of the leaf’s own kind, and two join probes must show it still separates from its neighbours (\foo beside 1ab is two tokens only because the word starts with a non-letter, and editing it to aab merges the pair). The definition scan cannot have moved: it walks only COMMAND nodes whose head names a definition family, so a leaf under none of them changes nothing it found. And no decision that reads a token’s text can flip.

That third one is the interesting one, because it has no compile-time link to the code it describes. It is held by a test that scans the grammar sources for every text comparison and fails on one nobody classified — 42 sites, each carrying a verdict, of which 35 are kind-gated to a control sequence and so can never see a spliced leaf at all. The remaining handful are the real reads: the ; that ends a picture-body statement, the lone * of a starred variant, the math operator split, the expl3 argument slots, and the environment-name assembly. Each is neutralized either by a text guard or by a position ban, and the position matters as much as the text: applied everywhere rather than only in math, the math-split guard refuses every hyphenated word in English prose.

Refusals are free, so they are generous. A .dtx parse is declined outright. So are line terminators, environment names, definition bodies, and a join probe against an oversized neighbour.

What the protected-body tier has to prove instead. An edit inside an lstlisting, a \verb, or a \url is the same one-leaf splice, but the token tier’s proof is unavailable: a raw capture is a kind the lexer only emits once it has seen an opener, so the body lexed on its own comes back as ordinary prose. Rather than restate the catcode rules — a second copy of the lexer, to be kept in step forever — this tier relexes the leaf’s whole enclosing node with its delimiters, which puts the isolated lexer into the capturing mode for free. This proof has four parts. First, faithfulness: the unedited fragment must relex to its original tokens. This demonstrates that the bytes do not depend on the state the file arrived in, and is what rules out a short-verb span, an @-bearing name under \makeatletter, and a name that only lexes whole inside an expl3 region, without enumerating any of them. Locality: a raw capture’s bytes never reach the lexer’s state updates, so it leaves the fragment in the state it entered — a claim about lexer code, and therefore a lexer test, with a counterexample beside it (a body that breaks its capture does move later lexing). Termination: a VERB carries its closer in its own text, but a VERBATIM_BODY’s \end{name} is a sibling, and an unterminated body runs to EOF — so the tier requires that \end to be inside the fragment, or the isolated scan would stop where the file’s does not. The sequence check: the edited fragment must relex to the same tokens with only the leaf’s text changed, which is what catches an \end{verbatim} typed into a body or a brace that unbalances a \url.

Newlines are allowed here, unlike on the token tier. That is the point — inside a raw body a line break restructures nothing, because the grammar sees one opaque token either way, and pressing Enter in a listing is the workload.

.dtx stays refused on both tiers, and the whole-node relex does not lift it. The docstrip mode lexes by line and by column 0, but the deeper problem is that implicit_expl is derived from a scan of the entire input: an isolated fragment can be lexed under a regime the file never had, and can pass the faithfulness check anyway when the difference does not happen to show in those bytes. Faithfulness is evidence about the fragment, not about the file.

So none of the parser’s left-to-right state is checkpointed: not the lexer’s (at_letter, expl_syntax, short_verbs, macrocode, brace depth), not the grammar’s prescan indices, not the gate memo’s token-keyed verdicts. This is worth stating because a first reading of the parser suggests the opposite — those look like the obstacles, and they would be for a parser that resumed mid-stream. They return only at the region tier, where a shape gate’s verdict for a node before the edit can flip because a closer after it appeared or vanished, which is why that tier is last and why it wants the precomputed closer map rather than per-opener scans.

The region tier. Its two conservative slices reparse one top-level prose PARAGRAPH when an edit spans multiple direct prose leaves, and the two paragraphs around a blank-line seam when that seam is deleted or replaced. A faithfulness parse must first reproduce the old fragment under the base’s exact ParseCtx and full-file .dtx implicit-expl signal. That admits unchanged commands inside a paragraph without assuming the fragment’s entry state: edits themselves may touch only direct prose/trivia leaves and may insert no structural or catcode-sensitive spelling, so those commands and their state transitions remain unchanged. The one-paragraph case uses rowan’s node splice; the seam case rebuilds ROOT from shared green children, allowing two paragraph nodes to become one. Diagnostics outside the fragment are shifted (and fragment diagnostics replaced), and the common oracle checks both results. Seam splicing initially refuses .dtx, whose column-sensitive doc layer needs its own proof. Single-leaf edits stay with the cheaper tiers. The direct-reparse benchmark pins both paths to ReparseTier::Region and gives each its own calibrated speedup floor, so a future guard change cannot silently turn either measurement into a full parse or another tier.

Unrestricted regions would require three further proofs: gate isolation, every construct whose forward verdict could flip outside the fragment must be accounted for; boundary-parse verification, unchanged neighbours must prove the fragment is decoupled from its context; and concatenation, token-inclusive seams, replacement diagnostics, and untouched siblings must reproduce the full result. Blank lines alone reset neither every lexer mode nor every forward gate, so they are a candidate partition rather than a proof. The precomputed closer map tracked under Parser is the natural dependency for making the gate proof cheap enough to use in a refusal-first tier. That widening is deliberately deferred until a measured workload justifies the new parser infrastructure; it is not required for the current conservative region tier to be complete.

The salsa side channel. parsed_document needs the previous text, tree, errors, and the edits since — none of which are salsa inputs, and none of which may become any. A base that invalidated on write would defeat the purpose; one that did not would lie to the dependency graph. Instead they live beside salsa, reached through default IncrementalDb methods (reparse_prev, reparse_stage_edits, reparse_pending_edits, reparse_store, reparse_evict), so a database without a cache simply always full-parses. Reading mutable state from inside a tracked query is sound only because of the invariant above: the query returns what parse(text) would whatever the cache holds, so a cold, stale, or evicted cache costs a parse.

Three details are essential to this arrangement. The store happens last, after every fallible step, so a panic or salsa cancellation cannot leave a base whose text and tree disagree. The chain is drained by consumed prefix count rather than cleared, because a stage can land between the peek and the store — and it is drained unconditionally, even when it went unused, since a chain kept back because it failed to verify describes a transform out of a text the base no longer holds and would poison every later parse. And eviction has two classes: an entry is hot once it has shown it benefits, and cold entries go first, because a package_graph or scope_signatures sweep parses every workspace member and stores a base it can never hit — under a plain LRU one project-wide query would cost every open buffer its base.

There is deliberately no whole-text diff_edit in the query. The language server knows the range it spliced and hands it over; re-deriving it costs more than the reparse it feeds. A text that changed by a route carrying no edits — a disk reload, a whole-buffer replace — simply full-parses, and both are shapes a cost guard would decline anyway.

Where the chain comes from. apply_content_changes (lsp.rs) already resolves each didChange range to byte offsets to splice the live buffer, so it returns that as an Edit chain — the clamped offsets it actually used, each edit against the text its predecessors produced, None for a range-less whole-buffer replacement. WorkerJob::Edit carries it to the worker, which stages it against the SourceFile returned by upsert_file, on the line after. Every other upsert_file site — didOpen, the push-mode re-lint sweep, sibling seeding, a watched-file re-read — stages None, so the pairing needs no exceptions.

The ordering matters in two places. First, staging follows the write because upsert_file’s &mut db is what proves no analyze is reading: a chain staged ahead of the text it describes could be peeked by an in-flight parsed_document, which would fail to verify it, perform a full parse, and drain it. Second, the chain is staged even when upsert_file skips its write, because it is anchored at the base, not at the db text — a buffer that round-trips back to what salsa holds still took a transform to get there.

How it is held. A #[cfg(debug_assertions)] oracle compares every successful reparse against a full parse, and every tier returns through a single finish so it cannot skip that or the O(1) check that the tree spans exactly its text. The latter runs in every build and falls back rather than panicking, because the release binary is precisely the one the debug oracle is absent from and also the one whose formatter writes the file. On top sits a seeded harness (crates/badness-parser/tests/incremental_reparse.rs) over hand-written hazard snippets — one per sanctioned lexer mode — and the parser corpus. Both oracles carry should-panic self-tests, since a net nobody has watched catch something is not evidence that it can.

Breadth comes from the corpus sweep (crates/badness-parser/tests/reparse_corpus_sweep.rs, task reparse-corpora:check): the same generator and the same checker, shared as tests/support/reparse_harness.rs, run over the pinned gate corpora — ~6.3k files against the fast suite’s ~90 — with each file parsed under the LexConfig its extension would get. It asserts the invariant and a per-driver splice-rate floor, and records the exact tallies in tests/reparse_baselines/ as a two-sided ratchet in the shape of tests/gate_baselines/. The floor and the record answer different questions: every invariant assertion is vacuously true on a refusal, so a guard that narrowed a tier to nothing would leave the sweep green while testing nothing (panache’s window cutoff cost its fuzzer two thirds of its coverage with every assertion still passing), while the recorded tier columns catch the movement no floor can see — a workload changing tier, which keeps every rate identical because declining is always sound.

Typed AST wrappers

On top of the untyped rowan CST sits a thin typed layer: rust-analyzer-style AstNode and AstToken traits, an identity macro, and one wrapper struct per node kind. Wrappers are a read-only view, never a re-model of the tree. They expose structure and never meaning, so no signature lookup lives here, and because the CST is greedy and generic the accessors are positional and tolerate over-attachment by construction. Command::title() would be a lie, since a \section and a \newcommand share the COMMAND shape.

The formatter deliberately stays raw for structural work, where the lower_node dispatch and the token-classification loops are ordinary tree walking that wrappers would only obscure. It adopts wrappers for field access alone.

The formatter

The formatter alone decides how a document should be laid out. It first lowers the CST into a Wadler/Prettier-style Doc intermediate representation. A separate printer then chooses between flat and broken forms according to the available width. Keeping these steps separate lets lowering describe the possible layouts without committing to a particular line break too early.

It is whitespace-only

The layout engine may change trivia—whitespace, newlines, comments, and .dtx margins and guards—but it never inserts, removes, or rewrites a non-trivia token. In the usual case, lowering replaces each maximal run of whitespace and newline trivia with a break primitive, leaving the printer to choose the line break and indentation.

Meaning-preserving content rewrites therefore do not live here. Stripping redundant braces around a single-token script (x^{2}x^2) and rewriting $$…$$\[…\] are linter autofixes. This mirrors the fix-then-format rule: just as the formatter never runs inside --fix, content rewrites never run inside format. The payoff is a guarantee by construction, checked by the non-trivia-content oracle, instead of a meaning-preservation argument defended one fixture at a time.

The formatter may still change CST shape. The math operator split re-groups a catcode-12 WORD, so inserting insignificant math whitespace makes the output re-lex into separate atoms. The oracle compares the concatenated text of non-trivia tokens rather than their boundaries, so it tolerates the re-grouping while still catching any inserted or deleted non-trivia character.

Trivia-invariant layout

Whitespace-only says what the formatter may write. Trivia-invariant layout says what the lowering may read:

Layout is a function of non-trivia content, config, and only those trivia predicates the formatter itself preserves.

A predicate P is preserved when P(fmt(x)) == P(x). Reading a preserved predicate is safe, because the formatter cannot change the answer; reading an unpreserved one means pass 1’s layout silently edits pass 2’s input.

Three predicates are preserved and may be read: whether a blank line is present, whether a comment is present and whether it is own-line or trailing, and whether a % margin or %<…> guard sits at column 0. One is not, and must never be read: whether a gap is a lone newline or a space. The formatter converts freely in both directions, turning alpha\nbeta into alpha beta and writing a newline where a width wrap needs one.

This makes idempotence a theorem rather than an empirical property. Since the formatter changes only trivia, fmt(x) is by construction a trivia-perturbation of x, so layout invariant under trivia perturbation gives fmt(fmt(x)) == fmt(x) for free. The alternative does not scale: every layout decision that reads the unsafe predicate is an independent latent bug, and the supply of decisions is unbounded. The whole K&R-versus-Allman family of bugs is that one pattern, where a soft width break becomes a hard statement boundary on the reparse and the layout flips with it.

The intended enforcement is to delete the information at the boundary: the lowering would consume a normalized inter-token gap with no Newline variant rather than raw trivia tokens, so a rule could not key on what it cannot see. Rules that legitimately preserve authored breaks — the modes defined by them (WrapMode::Stable, Sentence, Semantic, and ReflowKind::Statement), the expl3 fallback statement, the command-only-line rule’s residue, and the delimited-group block residue on spans_multiple_lines — would take a widened gap, and each owes a written fixed-point argument showing that every layout it can emit re-reads to itself.

The command-only-line rule is the only such exception inside the default Reflow mode. Curated block commands carry a positive CommandSig::block property and are laid out as block-level statements without consulting trivia, so what the rule still decides is the authored break around a command whose block-ness no signature tier can know — an un-signatured or scanned-definition \mymacro on its own line — plus block commands glued to adjacent content. Retiring that would glue every such authored line into the paragraph fill: a policy change, not a fix. So the residue is sanctioned as Tier 2 on the argument written at line_is_command_only: the rule is preservation-only, hardening gaps that already hold a newline and never writing or moving a break, so a kept break re-reads to itself in place, and a fill break it hardens on the next pass — a width wrap that stranded a command alone on a printed line — coincides with the break the first-fit fill chose, which refills identically around a hard stop. The cost is by design: --checks trivia-strict still reports these shapes, because preserving the authored break is the information the rule reads. One scope limit keeps the residue honest: it does not fire inside a signature-proven prose argument body (ReflowKind::ProseArg), where width alone owns the layout — preserving a command-only line there mints a forced break only pass 2 can see, and that bit leaks upward through every contains_forced_break reader, flipping the enclosing group between its inline and block forms across passes.

The last Tier-1 reader — the Opaque-group spans_multiple_lines choice, with lower_optional’s fallbacks to the same — is retired. Under Reflow a brace group is width-driven (lower_opaque_group): flat when it fits, byte-identical to the generic inline path except that a lone-newline run renders as one space, and first-fit wrapped at its authored gaps otherwise. Break opportunities are exactly the perturbation-eligible gaps, so strict invariance holds by construction; a glued junction never gains a break, and delimiter padding rides the flat rendering and vanishes broken — exchanged for the delimiter’s own newline, never deleted, since an opaque argument’s space tokens are typeset. An edge gap joins that vanish-when-broken protocol only when its flat spelling is a single space, the one spelling a break reproduces; any other spelling rides verbatim and never breaks. An interior blank line, a direct comment, a token embedding a newline, or a child carrying a forced break sends the group to the indented block form instead — preserved predicates and content only. An edge blank does not: the block form trims edge blanks away, so declining on one would key on a predicate the emitter destroys, and it erases to padding instead, matching the deletion the block form already performed. The optional-argument lowering makes the mirrored promise: a segment_delimited_body decline takes the block form unconditionally, and a dropped trailing separator re-emits the authored whitespace it replaced. What remains of spans_multiple_lines is the delimited-group residue behind the non-Reflow modes and the doc-margined corner, sanctioned Tier 2 on the fixed-point argument written at the predicate: the block form always ends with a newline before its closer, so its output re-reads multi-line and re-blocks byte-stably, and the inline path emits no newline, so single-line re-reads single-line.

The rule is enforced at the boundary rather than by review. Every trivia run the lowering consumes arrives as a normalized Gap (Glued | Space { flat } | Blank | Comment) with no Newline variant: inline whitespace and a lone newline are one variant, because a rule cannot key on what it cannot see. Gap::flat is what a one-line rendering writes there — a single space wherever the run held a newline, blank line included, since that is the only spelling a break reproduces, and otherwise the authored whitespace verbatim. So a lone newline and a single authored space are indistinguishable, while a wider run (\pgfpoint@oncoil{0 }) still rides verbatim; that is not a leak, because every reader of flat emits it unchanged and so preserves it. Gap::separator is the split-point rendering the two former prototypes agreed on — an Ir::Line at a gap, an Ir::SoftLine at a glued junction — and both (the conditional divider’s DividerGap, the […] split point’s KeyBreak) are folded into the one vocabulary.

The Tier-2 sites take a WideGap, which carries the newline count alongside the normalized gap: the byte-faithful stream (classify_trivia), the preserve-shaped modes (lower_prose_stream, MathWrap::Preserve), and the two reflow drivers (ReflowKind::Statement, the expl3 fallback statement, the command-only-line residue, which reach it through consume_widened_gap_slice). Their names are the warning, and each still owes the written fixed-point argument; the preservation-only ones have the easy version — a hard line prints a newline, which re-reads as a newline and is emitted as a hard line again, and nothing there ever converts between the two spellings, which is what a Tier-1 read would do. Everything width-driven takes consume_gap and the narrow Gap, so it is not merely disciplined out of the unsafe predicate but structurally unable to reach it.

The oracle is formatter::perturb, which generates TeX-identical trivia perturbations of each input. It has two forms. check_trivia_convergence is what gates: every variant must format to a fixed point that parses cleanly, round-trips losslessly, and carries the same non-trivia content — strictly stronger than idempotence, which only ever exercises the single trivia configuration fmt itself produces. check_trivia_invariance is the strict end-state contract, fmt(perturbed) == fmt(original); it cannot gate a corpus until the unsafe predicate is unreadable, but it is the only mechanical way to find a decision that reads it, since such a decision is self-consistent on both spellings and so invisible to convergence and idempotence alike. Its surveying form is badness debug format --checks trivia-strict.

Paragraph line breaks

Paragraph line breaks are controlled by WrapMode, modeled on the sibling panache formatter and mechanized through the Doc IR rather than a separate line filler. All five modes are implemented. Reflow, the default, width-fills. Stable keeps acceptable authored breaks while optimizing overflow, change, displacement, and raggedness against a soft target. Preserve keeps authored breaks. Sentence and Semantic split one sentence per line and ignore width, with Semantic additionally ending a line at every authored newline.

Sentence-boundary detection is a per-language abbreviation profile ported from panache, resolved from [format] lang and [format.no-break-abbreviations].

Display math has its own knob, MathWrap, scoped to single-formula display bodies. Its default resolves against the effective WrapMode, so one wrap setting carries over to math for free.

Statement bodies

Not every environment body is prose. A TikZ or pgfplots picture holds a sequence of ;-terminated path statements, and a greedy prose fill actively damages it: it runs \draw (0,0) -- (1,1); and \node at (0,0) {A}; onto one line, and at a narrow enough width it splits a \foreach header away from its loop variables (issue #114).

The curated statementBody flag in data/signatures.json names that family — tikzpicture, pgfpicture, scope, pgfonlayer, and the pgfplots axis environments — and a paragraph inside one is lowered under ReflowKind::Statement instead of ReflowKind::Prose.

Statement boundaries are structural. The parser wraps each run of a statement body up to a top-level ; in a STATEMENT node (§ Sanctioned lexer modes, the picture-body statement entry), and under WrapMode::Reflow the formatter derives the layout from that node (lower_statement): one statement per line — two statements on one authored line split, one authored across lines joins when it fits — and every continuation line hangs one indent step under its head, so a wrapped \node[…] at (2,3) / {…}; reads as a continuation rather than a sibling. The statement’s interior reflows under ReflowKind::ProseArg (a lone newline is a plain atom boundary the width fill re-decides; a comment still rides and ends its line; a {label} block hangs as its own segment with a glued ; riding its last line), and the whole lowering is Tier 1: the hang is emitted, never read, and the node re-derives from its ; however the emitted layout breaks, so the hanging indent is idempotent by structure — the property whose absence had deferred it (the expl3 call unit is the same move made from the semantic side). A glued statement boundary (…;\draw with no gap) is never split; the statement rides the previous line, the glued-divider principle. Content no ; terminates — a \tikzset line, a lone \foreach header — keeps the authored-line rule: its own logical line, flush width wraps, the Tier-2 fixed-point argument unchanged. Every non-Reflow path splices the wrappers out (flatten_statements) and behaves byte-identically to the pre-statement layout.

Breaks inside a statement come from the TikZ unit model (semantic::tikz::statement_glue) — the vocabulary the extent node cannot carry. It remains in the semantic layer because (a) as a coordinate versus a node-name reference versus prose has no text-shape demotion, so a wrong reading could not be gated in the grammar, while here it degrades to a worse break choice, never a wrong tree (the same staging expl3 went through before its attachment migration). The model is a glue map, not a grammar: for each authored gap between a statement’s top-level elements, one verdict — unit-internal (a single space, never a break) or neutral. Its curated rules, each backed by a survey of ~6000 statements across pgf’s own manual sources and a user corpus: a path operator binds forward (breaks land before operators, the ~3:1 idiom), at binds both sides (split from its coordinate 5 times in 3103 continuation lines), a coordinate binds its operation and an operation its argument ((6,6) circle (3) never splits), a loose […] options run glues except after a comma (the keyval entry convention: edge [loop above] never splits an option mid-phrase, while a long keyval run still breaks per entry), and a comment suppresses every rule. Everything unrecognized — library verbs, axis prose—is neutral and retains the ordinary layout. The wrap policy over the resulting units is a plain greedy fill (the user-corpus lean; Tantau mixes styles).

One more claim rides the statementBody flag: whitespace between a picture body’s statements is insignificant to the package that consumes them, so a statement always opens its own line even at a seam the author glued (…;\draw). That is the one sanctioned breach of the glued-divider principle, licensed the way ContentKind::Keyval licenses the glued comma split — a curated whitespace-safety claim, held to the same standard and proven by a real compile (tests/typeset/statement_seams.tex, task typeset:check). Glued seams are unattested in the surveyed corpora, so in practice the license buys uniformity: one statement per line, however the author spelled it.

Three things keep the flag narrow. It is curated only: a statement terminator is package grammar, not a TeX-surface fact, so neither the CWL codegen nor the runtime definition scan can set it. It is distinct from code, which is the .dtx documentation layer’s macrocode — a fact about re-lexing under the package regime, not about layout; conflating the two would hand a future .dtx consumer a tikzpicture. And it is read from the nearest environment ancestor, never from any of them, so an itemize or a tabular inside a \node’s label still reflows as the prose it is.

The same picture family is curated a second time in the linter (linter::rules::is_pgf_picture_environment), which keeps dash-length off coordinate arithmetic. Merging the two waits on a signature DB reaching RuleContext.

Reflow is safe by construction

Reflow safety cannot be inferred from a file extension. A .sty file may contain ordinary prose that is safe to reflow, while a .tex or .dtx file may contain structures whose whitespace is significant. Older versions selected Reflow for .tex and Preserve for .sty, .cls, and .dtx; that merely hid unsafe paths and still allowed an explicit --wrap reflow to corrupt a document.

The safety is now structural, and every gate is independent of the wrap mode, so an explicit --wrap reflow is exactly as safe as any other mode. A fully margined, line-owning documentation environment is lowered as virtual LaTeX: its DOC_MARGIN tokens remain in the CST, the formatter omits them while laying out the environment, then applies % to each generated content line and % to an empty line. Guards, macrocode, protected bodies, mixed-margin regions, and nodes that do not own their closing line refuse this path. Other relayout arms refuse a node whose subtree carries a .dtx margin or guard, because reflowing one can drop the % margin and on an unmargined line a ^^A doc comment re-lexes as content. A residual margin-escape detector backs that up: when a probe-gated reflow would commit content outside the margin, the paragraph re-lowers on the byte-faithful preserve path. Never re-introduce a file-kind wrap default to paper over a layout bug; fix the gate.

Optional arguments, tables, and math spacing

An optional argument is a plain Wadler group over its top-level comma-separated entries: flat when it fits the width, one entry per line when it does not. Width alone decides. There is deliberately no “expand once the list has more than N keys” rule and no Black-style magic trailing comma, since content steering layout conflicts with the sole-authority tenet.

Which commas are break opportunities is the subtle part. A comma the author already followed by whitespace is free, since flat-to-broken is just a space-to-newline exchange. A comma glued inside a WORD is not: breaking there materializes a space token TeX will see, so it is emitted only for an argument the signature database proves is a key-value list.

That proof, not the delimiter, is what selects the segmented layout, so it extends to a mandatory group as well. The keyval-family setters — \pgfkeys, \tikzset, \lstset, \setlist — carry the whole key list in {…}, and without the routing that body fell to the prose reflow, which word-wrapped it mid-key. It now takes the same shape as the bracket: flat when it fits, one entry per line when it does not, nested commas sealed inside their child group. A mandatory group is the ordinary home of typeset text, though, so it reaches this only through the hand-curated signature tier. The bulk CWL tier still drops a %keyvals mark on a {…}: the mark is mechanical rather than validated, and a wrong claim costs more on a mandatory group than on a bracket.

Table column alignment is layout, so the formatter owns it. The {lcr} column spec is parsed from static argument text only, conservatively bailing to all-left on anything it does not model, and the grid renderer pads cells left, center, or right. Routing to the grid is primarily semantic, through the curated align flag, but one arm additionally routes any remaining environment whose body carries a top-level &, since a & at catcode 4 is a column tab and the signature database cannot name a user-defined alignment.

Math operator spacing is a single space around each binary and relation atom, with unary signs and scripts tight.

Conditionals

When the parser can pair a conditional, formatter layout is all-or-nothing:

  • flat if the full construct fits;
  • fully broken at dividers if it does not.

That keeps conditional formatting coherent and avoids newline-sensitive behavior. The flat vs broken choice is computed from content, not authored single-newline spelling.

There is one important safety carve-out: if any divider is glued in source (\ifmmode y\else z\fi), we preserve authored bytes. Otherwise, splitting a glued divider can change TeX-visible spacing even though CST trivia checks stay green.

Conditional relayout runs only in wrap modes that already own prose layout. WrapMode::Preserve keeps authored line breaks byte-faithfully.

Branch internals are lowered using the nearest non-conditional ancestor context (paragraph-like contexts reflow; group-like contexts preserve). This avoids oscillation in package-code patterns that depend on authored line structure.

Also note: a DOC_COMMENT may be reparented inside CONDITIONAL; lowering must carry it through explicitly.

There is no body indent model because parser structure does not separate \if test and body boundaries with enough certainty (see § The conditional gate).

expl3 code formatting

Inside an expl3 region, source spaces and tabs are catcode 9 (ignored) and ~ is catcode 10. Because inter-token whitespace is provably insignificant there, the formatter owns the layout of in-region code, indentation and line breaks alike, regardless of WrapMode. This is idempotent by construction: the inserted whitespace is itself catcode-insignificant, so re-lexing the output yields the same token sequence.

The target is the LaTeX Project’s own house style, l3styleguide.tex. Its mechanical rules are an 80-column target, a two-space indent per level, single spaces between everything except simple runs of parameter tokens, one conceptually separate step per line, a canonical brace layout, and no tabs. The non-layout rules, such as naming prefixes and expandability, are meaning rather than trivia and belong to a linter.

Two decisions carry most of the weight. Statement boundaries are structural rather than newline-keyed: a call unit is a head command whose argspec suffix gives derivable arity, plus the elements its slots consume, so the formatter owns one-call-per-line and a width wrap re-derives the same unit on the next pass. Whatever the scan cannot resolve degrades to a per-statement fallback that is the authored physical line, which is the old newline rule demoted to a residue and carrying its own fixed-point argument. And layout ownership is positionally gated: the lexer and the formatter share the toggle-name set so a new spelling is recognized in both, but only the formatter additionally requires the toggle to be a top-level statement. A toggle spelling TeX never executes is a false positive of the static model, and mis-owning its layout rewrites real space tokens even though the byte-level oracles stay green. The lexer keeps the naive name-only model on purpose, because mis-lexing a name only splits CST tokens, which is lossless and cosmetic.

Conditionals are the one construct with a layout of its own. The guide’s worked example puts each T/F branch on its own line one indent step under the call, even though joining them would fit the line, so a conditional that starts a statement breaks that way regardless of width. Since arity-directed attachment landed, this is a decision the tree already answers: a recognized conditional owns its branches as the head’s trailing groups, whatever sat between — \tl_if_empty:nTF {#1} {T} {F} and \int_compare:nNnTF {a} = {1} {T} {F} are one shape — so the explosion reads the head node’s own children, and the unit-scoped rescan that re-split greedy sibling scatter is gone (the migration oracle measured zero recognition disagreements, so a head the node cannot resolve has no unit either). The statement-leading/trailing distinction survives as pure layout policy: leading, the explosion is unconditional; trailing, it is width-conditional.

Line endings

The printer always builds output with \n and is the sole authority on where breaks go. FormatStyle::line_ending decides only how those breaks are spelled, as a pass over the finished text: auto (the default) follows the source, lf and crlf are unconditional, and native follows the platform. auto is the default so a CRLF repository does not get a whole-file diff the first time it is formatted.

This is the one carve-out in the protected-regions rule. A verbatim body is emitted from source token text, so without a document-wide conversion a CRLF document came out CRLF inside the protected region and LF everywhere else. Only the \r\n and \n pair is touched; every other byte of the region is still untouched.

Comment directives

badness_parser::directives resolves suppression directives into sorted, non-overlapping byte ranges (one list per axis). Formatter and linter both use that shared resolution path.

The parser crate owns this logic because it is pure tree analysis needed by both consumers (formatter in wasm-clean crate, linter in root crate).

Design rules:

  1. Verb defines scope. % badness-format ..., % badness-lint ..., and % badness ... share one grammar, with skip / off / on / skip-file verbs.
  2. Legacy spellings still work. % badness-ignore ... is deprecated but intentionally still supported.
  3. .bib uses a different carrier. Directives are read from @comment{...} entries because BibTeX has no % line-comment token between entries.

Suppression matching is by containment, not overlap. This avoids accidental “suppress the whole document” behavior when a region starts inside an ancestor node.

Region anchoring follows skip_target semantics and is clamped to the previous directive boundary. This keeps adjacent off/on/off sequences from merging incorrectly.

Suppressed nodes are emitted as verbatim source for preservation. Indentation at the first line may be normalized by placement, but interior bytes remain intact.

The linter

The linter reads the same lossless CST as the formatter. Like the formatter, it is a pure function of the input and data shipped with Badness; it does not depend on ambient machine state. The user-facing catalog of built-in rules lives in the reference section (Linter Rules, BibTeX Linter Rules), generated from each rule’s own description and examples.

Rules and dispatch

Every lint implements Rule, which is Send + Sync so the registry can be shared across the LSP’s read pool. A rule declares a stable kebab-case id, a default_severity, the description and worked examples that generate the rule reference, and whether it can ever emit a fix.

No rule walks the tree on its own. Each participates in the driver’s single shared traversal one of three ways. Node-shape rules name the SyntaxKinds they care about and get called once per matching element. Whole-file rules run once after the walk, which suits rules driven by the semantic model or by cross-file resolution. Streaming rules return a visitor fed every element in document order, for findings that depend on the sequence, such as a running toggle or the previous heading’s level.

Each rule reads a RuleContext assembled once per file. Besides the syntax root and the semantic model it carries the cross-file resolution a project view provides (labels, cite keys, and package options), each None when there is no project view, which makes the corresponding rules inert rather than wrong. It also precomputes two shared side indexes, one of math byte ranges and one of \if…\else…\fi branch paths, so the many rules that need them share one membership test instead of each climbing the ancestor chain per token.

The registry compiles the rule list into a dispatch table indexed by SyntaxKind, so node dispatch is a slice index, and it is cached across files and shared by reference across the CLI’s rayon lint phase. Configuration narrows the active set as a post-filter, so the shared driver stays config-unaware.

Autofixes

A diagnostic may carry a Fix: one or more edits applied atomically, so a paired insertion can never half-apply. Each edit names its target file, so a fix may reach across files, and atomicity then spans files.

A fix decides what to rewrite, never how to lay it out. It owes correctness, so the result still parses and is still lossless, but not line width. When a fix cannot meet that bar for some shape, make it correct by construction or withhold it for that shape while still reporting the finding. Because a fix owes correctness as a raw edit, with no formatter spacing to lean on, such a rule can be strictly more conservative than a layout pass would be: redundant-script-braces withholds the strip when a following character would re-glue the argument, so x^{2}-3 stays braced.

Each fix declares an applicability. Safe fixes preserve meaning and are applied by lint --fix; Unsafe ones, those that could change typeset output, require --unsafe-fixes or an explicit editor code action. The apply engine is a pure function over source, fixes, and that flag, shared by the CLI and the LSP code-action path. It drops any malformed or overlapping fix so the output stays well-formed, and lint --fix runs it to a fixpoint, re-linting between rounds.

Findings are suppressed inline with % badness-lint skip <rule>: <reason>, covering the next meaningful sibling; off/on covers a region and skip-file the whole file, and omitting the <rule> covers every rule. See Comment directives for the shared grammar.

The language server

The language server has a slightly different boundary from the formatter. The formatter is hermetic, but navigation necessarily depends on the user’s local project and TeX installation. The LSP may therefore consult read-only indexes and metadata for editor features. That information must never flow back into formatting or change the syntax tree.

The LSP is built on lsp-server and lsp-types, rust-analyzer’s stack, rather than tower-lsp. Salsa cancellation is a synchronous unwind that composes with lsp-server’s sync main loop plus threadpool and fights tower-lsp’s async &self model.

The live buffer

An open document is a text::TextBuffer: the text as an Arc<str>, the position encoding negotiated at initialize, and the LineTable over them, built on first use behind a OnceLock. The main loop holds it as an Arc<TextBuffer> and so does every buffer-carrying WorkerJob, which is what makes a keystroke’s fan-out cheap in both directions: capturing the buffer for a job is a refcount bump rather than a copy of the document, and the table is built once per document version rather than once per request, on whichever thread asks first. The handlers that index the cursor buffer take &TextBuffer and call line_index(); the ones that walk other project members still build their own index per member, since those texts come off the salsa snapshot and have no buffer.

The table and the queries are separate types, and the split is what makes the table patchable. LineTable is the value — a line-start offset per line, plus a flag per line for “holds a non-ASCII byte” — and LineIndex<'a> is the short-lived pairing of a text with a table, borrowing one where a buffer maintains it and scanning otherwise. So a query reads the text: a UTF-16 column walks the one line concerned, and the flag is what keeps an ASCII line a plain byte distance. Precomputing every wide character instead, which is the shape this had, cost more to build than every conversion it ever answered, and it is the shape that cannot be patched — a table keyed by line number has to be rekeyed wholesale when the line count moves. The one hazard the split adds is LineIndex::with_table, the single place a text and a table are paired: given a table built for other bytes it answers wrong positions rather than panicking.

The buffer is immutable: an edit yields a new one rather than mutating in place. That is not a cost, because an Arc<str> cannot be spliced in place anyway, and it is what lets a job that captured the previous version keep reading a consistent text and index with no lock. It also means the pointer identity is meaningful, which is what the salsa-side staleness guards trade on (see Incrementality).

The line table is patched, not rebuilt, across an edit. It was rebuilt for a long time, and defensibly: the rescan was dwarfed by the full reparse every keystroke paid, so splicing it would have been optimizing the wrong row. Once both leaf tiers landed and the parse fell to ~37 µs, the rebuild was the row — ~580 µs of a ~640 µs keystroke on the thesis, 52 copies of the document where the two linear passes a splice needs would be 2-3.

LineTable::patch splices it instead. Line starts fall into three groups: those before the edit are untouched, those after it keep their verdict and shift by the byte delta, and those at its boundaries are re-derived from the edited text. That third group is the whole subtlety, and it is why the patch cannot be copied from fatou’s. Badness treats a bare \r as a line break, so whether a byte ends a line depends on the byte after it too — meaning an edit can split or join a \r\n without touching either of its bytes. Inserting x into "a\r\nb" at offset 2 gives "a\rx\nb", which has a line the pre-edit table did not. With \n alone the predicate reads one byte, a start at the edit cannot flip, and the new breaks can be read straight out of the insert; here both boundary positions have to be re-read out of the result.

Reuse is structural rather than cached. The table lives in the buffer and the buffer is what an edit derives, so the pair travels together: nothing validates a table against the text it describes, and one patch serves the write phase and every read job off the same edit. Panache, whose index lives in a salsa memo that every keystroke invalidates, needs a side cache keyed by document and an Arc::ptr_eq to know whether an entry is still true — and because that cache is main-thread-only, its readers still rebuild once per revision. A buffer with no table yet stays without one, so a document nobody asks a positional question about never pays; on the keystroke path there is always one, because apply_content_changes resolves the change’s range through line_index() before splicing.

The write phase now costs 2.5 copies of the document — 28 µs on the thesis against 575 µs, with the keystroke at 91 µs end to end. Two of those copies are the text rebuild an Arc<str> cannot avoid; the rest is cloning the table and shifting its tail. A debug_assert rescans after every patch, which makes every test in the suite that edits a buffer an oracle for it, and is also why task bench:keystroke-gate — the row that watches all of this — must never be run in a debug build.

Environment awareness has four sources, all reading static facts only, with no macro meaning and no typesetting.

Shipped CTAN metadata, generated from the pinned tlpdb, maps a package stem to a description and catalogue id, and drives package hover and completion detail. It has the same read-only posture as the name lists and CWL.

A read-only TEXMF file index (project::texmf) indexes the installed .sty, .cls, and .dtx files, delegating root discovery to kpsewhich -var-value since reimplementing kpathsea is out of scope. It is cached to the OS cache directory keyed by a distro fingerprint, and it powers document links, go-to-definition, and installed-set completion. It is gated by editor settings, and it is never wired into signature resolution.

The compile’s .aux artifacts (project::aux) are read by a dedicated line-oriented scanner, never the LaTeX parser, since aux files are written under \makeatletter. It extracts label numbers and toc entries, following \@input chains, with freshness keyed by mtime and length so a recompile is picked up without a watcher. This powers label hover and document-symbol number enrichment. A test guards that the formatter never reads the aux file.

Citation completion returns the entire bibliography namespace rather than prefix-filtering server-side, with each item carrying a filterText of key, title, and authors so the client matches on any of those fields. That is deliberately editor-agnostic: filterText is LSP-standard, so every compliant client filters against it with no client-specific code.

Tenets

  1. Layout is decided solely by the formatter’s rules and the layout engine. The formatter is the sole authority on layout, so push back against hard-coded special cases.
  2. Autofixes are textual edits that never invoke the formatter. A fix decides what to rewrite, never how to lay it out, and owes correctness but not line width. The pipeline is fix-then-format, and the mirror holds: content rewrites never run inside format.
  3. Parser and CST work must keep the salsa reparse path viable.
  4. Parsing is the parser’s job. Never paper over a parser mistake in the formatter, and never let parsing logic creep into the formatter.
  5. Losslessness is the parser’s job. The formatter may assume a lossless CST.

Invariants

These are held by construction and enforced as test oracles. Breaking one is a bug, not a trade-off.

  • Losslessness: reconstruct(text) == text, byte for byte.
  • Idempotence: fmt(fmt(x)) == fmt(x).
  • The formatter is whitespace-only. It changes trivia and nothing else, and never inserts, deletes, or rewrites a non-trivia token.
  • Protected regions (verbatim, lstlisting, \verb, comments) are never altered, with the single line-terminator carve-out described above.
  • Reflow safety is structural, never config-derived, so no wrap mode can corrupt a .dtx.
  • Trivia-invariant layout: layout may read only those trivia predicates the formatter itself preserves. This one is still being rolled out.

There is deliberately no parse-stability invariant. The formatter may change CST shape, and the whitespace-only invariant pins the non-trivia content the tree carries, which is the part that matters. Running the formatter over a corpus is a good way to find parser modeling gaps, so this freedom is useful rather than merely tolerated.

Two oracles sit outside the fast test suite. We run texlab’s parser as a differential parse oracle over a corpus, skeletonizing both trees and comparing; it is a reference we measure against, not one we match. And because the CST cannot see the one risk ContentKind::Keyval takes, where a space token is trivia to the CST and content to TeX, task typeset:check compiles fixtures before and after formatting and diffs the typeset output.

Technology choices

The main dependencies follow directly from the architecture. Rowan provides the lossless CST, while salsa manages incremental queries. Token text uses smol_str, and insta supplies snapshot testing. Diagnostics are rendered with annotate-snippets, and the CLI is built with clap. The root build.rs uses the clap model to generate manual pages, shell completions, and Markdown documentation.

Non-goals

Badness is not a TeX interpreter. It does not expand macros, execute primitives, or implement \def semantics. It may extract common \newcommand, \newenvironment, and xparse signatures into the semantic database, but it never executes those definitions.

For the same reason, Badness does not attempt general \catcode evaluation. It supports only the bounded, statically recognizable patterns listed under sanctioned lexer modes.

Badness does not typeset documents. It never runs latexmk, pdflatex, or any other TeX engine, and it does not parse .synctex.gz files. Forward search is a narrow exception in the language server: in response to an explicit user action, it launches a viewer. That process is not a build step, and none of the information it touches flows back into the formatter or linter.

The formatter never reads the environment. Its output is a function of the input plus shipped data, and it resolves local .sty and .cls files sitting next to the document rather than the installed TEXMF tree, so output cannot depend on what happens to be installed.

Changelog

0.17.0 (2026-08-20)

Breaking changes

  • parser: pair one-sided environment aliases (d757cdc), closes #117
  • parser: arity-directed expl3 attachment (#119) (5f2f9d8)

Features

  • formatter: format DTX doc environments (ac98c95), fixes #127
  • parser: intra-file incremental reparse (#130) (393e0c3)
  • bench: time the keystroke pipeline (b1b2b0e)
  • parser: pair one-sided environment aliases (d757cdc), closes #117
  • parser: arity-directed expl3 attachment (#119) (5f2f9d8)
  • formatter: wrap picture statements at TikZ unit boundaries (5079f96)
  • formatter: hang statement continuations in picture bodies (5266aba)

Bug Fixes

  • parser: isolate command definition names (8d6e274), fixes #133
  • formatter: preserve TeX line semantics (f16188b), fixes #132
  • formatter: handle mixed dtx doc regions (6edfd81), fixes #126
  • preserve dtx documentation math (de0c54c), fixes #138
  • formatter: preserve guarded dtx paragraphs (0373420), fixes #123

Performance Improvements

  • lsp: share document text and line index (3d4a5f8)

Dependencies

  • updated crates/badness-formatter to v0.4.0
  • updated crates/badness-parser to v0.3.0

0.16.0 (2026-08-14)

Features

  • config: declare environments in badness.toml (#115) (a80b5af)
  • formatter: lay out picture bodies as statements (b437091), closes #114
  • linter: add % badness-lint suppression directives (c03114d), refs #114
  • formatter: add suppression comment directives (1810cde), refs #114
  • linter: add blank-line-in-keyval (0758ea4)
  • formatter: segment a mandatory keyval group (d79ec73)
  • formatter: width-driven layout for opaque brace groups (03022a8)
  • cli: add the strict trivia-invariance check (3796d7a)
  • linter: add label-before-caption rule (e8c5b7f)
  • linter: colorize the pretty lint report (e4faf16)
  • parser: pair user-defined environment delimiters (2bbff60), closes #109
  • cli: read stdin from -, not a bare terminal (5bb4788), closes #111
  • formatter: lay conditionals out all-or-nothing (ed84bfe)
  • parser: gated CONDITIONAL node for \if…\else…\or…\fi (e0ca4ef)
  • bib: parse and preserve % comments (e005cc9)
  • skill: add formatter-fixture for construct coverage (a52095a)
  • lsp: add inverse search over IPC (3b829eb)
  • lsp: add textDocument/forwardSearch (8028a40)
  • formatter: explode sibling-attached expl3 branches (d3fc51a)
  • formatter: expand optional arguments to the width (4c28ba4)
  • formatter: add optional serde and schema features (80726c7)
  • formatter: reflow doc-margined out-of-region expl3 runs (aa9445a)
  • formatter: reflow dtx prose around margined blocks (4f118a2)
  • semantic: add curated block-level command property (c54a5ff)

Bug Fixes

  • formatter: break a keyval group’s glued opener (507a982)
  • scripts: keep pdflatex stdout off hyperref’s .out (1e7c4c1)
  • formatter: body a \begin tail past the declared arity (bd7028e)
  • parser: break every gate’s run at a docstrip guard (d682e8f)
  • formatter: guard a prose argument’s edge comments (8976815)
  • formatter: break around curated block-level commands (09b8d4f)
  • linter: pair straight quotes into one finding (e1ff0d1)
  • parser: harden environment-alias pairing (84f11a2)
  • project: link a subfile to its parent document (df3e66c), closes #112
  • lsp: spell decoded URI paths with native separators (f199bd1)
  • formatter: break around sectioning commands (f4be809)
  • formatter: stop deleting ] inside prose arguments (7d2799f)
  • formatter: make optional fallbacks deterministic (9d2095c)
  • formatter: correct what lower_conditional assumes (902dbd9)

Performance Improvements

  • cli: use Histogram for the --check diff, measured on the corpora (29a678a)
  • cli: pick Patience over Histogram for the --check diff (595abb3)
  • cli: diff --check with Histogram, write it buffered (f34abb8)
  • lint: render pretty snippets from a line window (9dffc3d)
  • parser: one batch driver for all nine shape gates (#113) (9e01ee5)
  • parser: bound the environment-alias closer scan (ae83909)
  • formatter: gate the doc-margin scans on cx.is_dtx (4e7babf)
  • parser: answer on_doc_margin_line from a pre-scan (930380b)

Dependencies

  • updated crates/badness-formatter to v0.3.0
  • updated crates/badness-parser to v0.2.0

0.15.0 (2026-08-07)

Features

  • formatter: default every file kind to reflow (ba9f2f9)
  • formatter: add a line-ending style (373a16c)
  • wasm: add badness-wasm playground shim crate (6486890)

Bug Fixes

  • formatter: hug detonating atoms in fallback fills (db63ddb)
  • formatter: accept a relation as an expl3 N slot (4a3d92b), closes #106
  • formatter: gate the expl3 forced-break dispatch in fallback lines (7437f69)
  • linter: skip parameter-template keys in key scans (928aa4a), closes #104
  • formatter: keep fitting math segments flat (903ec3e)

Dependencies

  • updated crates/badness-formatter to v0.2.0
  • updated crates/badness-parser to v0.1.1

0.14.0 (2026-08-06)

Features

  • cli: diff changed files in format --check (eed1537)
  • semantic: curate filecontents and ltxdockit verbatim envs (d805548), closes #98
  • formatter: respace flush expl3 argument braces (c44125c)
  • formatter: add trivia-perturbation invariance oracle (#103) (f22d668)
  • packaging: publish badness-bin to the AUR on release (82a64c2)
  • lint: add --output json machine-readable findings (92b04bd)
  • formatter: explode expl3 conditional branches (R4) (bebdbde)
  • parser: recognize package-defined verbatim envs (696f109)
  • build: bundle man pages and completion in tarballs (8268c88)

Bug Fixes

  • formatter: render expl3 conditionals all-or-nothing (c8b3aef)
  • formatter: keep annotated expl3 branches on the exploded path (ac07506), refs #101
  • formatter: drop expl3 sibling break coupling (836ed83), closes #101
  • packaging: tolerate pre-0.14 release tarballs in PKGBUILD (750493b)
  • installer: detect musl/libc in installation script (eb195a1)
  • formatter: pin forced expl3 block body to break mode (349ccdd)
  • formatter: stabilize trailing expl3 hang group (72a6d35), closes #96
  • npm: fall back to musl when glibc build fails (bd1891e)
  • formatter: stabilize trailing expl3 conditional (b5ed902), refs #96
  • parser: pair \left/\right inside macro code (20a59ef), closes #95
  • parser: bound math bracket gate at dollar closer (703f5f5), closes #99
  • formatter: keep expl3 parameter runs tight (47d6277), exception #1 and #2
  • formatter: sticky-break fill for expl3 statements (107ecb0), closes #94
  • linter: stop TikZ/pgf false positives (aca172f)
  • linter: drop “Part”, gate hard-coded-reference item labels (274c124)
  • linter: gate space-before-command on trailing break (2f66cae)
  • linter: skip hex constants, font maps in straight-quotes (2fb9ee3)
  • linter: skip redefined commands in deprecated/primitive (0490f16)
  • linter: skip starred headings in sectioning-level-jump (befe30e)
  • parser: parse array and tikzcd bodies as math (9db4e8f)

0.13.0 (2026-08-01)

Features

  • semantic: curate codeexample as verbatim env (4fb98f7)
  • lexer: infer expl3 in toggle-less .dtx (caba767)
  • lsp: link package docs via texdoc in hover (40b2665)
  • lsp: filter cite completion by title and author (509cd4a)
  • linter: express and apply cross-file fixes (e9071f8)
  • vscode: add feature toggles for the LSP (bb3f13c), closes #86
  • formatter: align user environments on & (ee88953), closes #84
  • formatter: hang expl3 attached brace arguments (c25c91b)
  • formatter: hang \item continuations under preserve (5edeea7), closes #82
  • incremental: mark SourceFile.path HIGH durability (bca096a)
  • linter: flag unclosed math delimiters as likely typos (4731c18)

Bug Fixes

  • parser: parse .code.tex under package flavor (2694a86)
  • linter: withhold deprecated-command fix in reference position (668647a)
  • linter: skip compound-logo swallowed space (0a546b7)
  • linter: ignore \string-prefixed package loads (e20504e)
  • linter: skip \texttt dashes and \foreach ranges (f605ee5)
  • linter: skip citation locators and env titles in hard-coded-reference (620bde5)
  • linter: skip xypic @ DSL in makeat-macro (81e4c81)
  • parser: attach math optional across balanced $...$ (b5c6c50)
  • formatter: scope preserve spacing collapse to prose (0999610)
  • formatter: normalize inner spacing under preserve (be8d5ba)
  • linter: skip prose rules in Lua code and doc placeholders (bcd4f44)

Performance Improvements

  • line-index: precompute wide-char table, own no text (d9c526f)

0.12.0 (2026-07-30)

Features

  • formatter: break leading \label onto its own line (18b17c5)
  • add stable-diff paragraph wrapping (#41) (5734632)
  • format: indent expl3 continuation groups one step (d53f064)
  • config: add BADNESS_CONFIG env var for config path (b8756e8)
  • cli: add hidden debug format check command (da5959f)
  • config: add global user config fallback (41c4570), closes #40
  • formatter: add math-wrap display-math break policy (dbba5eb), closes #42

Bug Fixes

  • formatter: pin inter-argument docstrip guards to column 0 (3556c79), closes #78
  • formatter: keep stable-wrap break mask aligned on Nil atoms (d4211b1)
  • parser: shape-gate unclosed \left instead of erroring (29aa319), closes #77
  • parser: close the latex2e format-error buckets from the smoke test (#80) (bb9484e)
  • parser: stop the lexer hiding braces, guards, and short-verb bars (#79) (a6e2119), refs #71
  • parser: stop environments escaping their brace group (#75) (7d82f35), refs #71
  • parser: scope the math gates’ paragraph-break anchor to the body’s own level (#74) (ef89b76), closes #70
  • gate expl3 relayout to top-level toggles (81a1a92), closes #69
  • parser: suppress braced verbatim on redefinition (513e963)
  • formatter: preserve fully-guarded expl3 chunks (5d2e46b), closes #72
  • parser: treat escaped backtick char constant as data (d7edf4b), refs #71
  • format: keep glued brace opener on its line (97e7abb)
  • format: treat sign after opener as unary in math (94af6dd)
  • format: keep guarded expl3 code groups broken (417c480), closes #61
  • format: keep doc-margin math environments verbatim (d8e3864), refs #61
  • format: make trailing comments zero-width in expl3 code (db53fc8)
  • format: keep doc-commented expl3 statements whole (fb61e15)
  • parser: treat expl3 regions and v-arg names as macro data (e9deecf), issue #60
  • parser: add ^^A, v-arg, and char-constant lexing (ff2b516)
  • parser: gate \[/\( and isolate \def-family names (bb55149), closes #65
  • lexer: accept comment tail on macrocode end frame (fa01c29), closes #62
  • parser: gate \begin/\end on a name-shaped group (0dfeb0b)
  • parser: gate text-mode brackets on a reachable closer (47f92f9), refs #60
  • lexer: add l3doc to curated doc classes (e34cd7f)
  • parser: gate dollar math on a reachable closer (4a01a6b)
  • formatter: feed run-final trivia to expl3 run separator (ad2ce81), closes #58
  • formatter: own only macrocode bodies in dtx expl3 regions (d0abf21)
  • formatter: refine expl3 group style in regions (1cda543), refs #57
  • parse doc short verbs and chunked dtx macro code (37fbf9c), refs #57
  • formatter: strip script braces before operator atoms (d627794), closes #56
  • parser: count nested optionals in math bracket gate (7b98255), closes #55
  • parser: widen definition bodies to hooks and \newcommand (ab7d809)
  • formatter: keep trailing comments riding their line (71baa30), closes #54
  • parser: make verb delimiter capture opt-in (4788af9), closes #53
  • formatter: keep own-line comments in list bodies (f468619), closes #48
  • formatter: keep grid indent on doc-commented rules (e0ccee0), closes #49
  • formatter: collapse fitting multi-line optional args (cf15d18), closes #47
  • formatter: lift \begin-line % in every env layout (9c7fe0e), closes #38
  • parser: accept split \begin/\end in env definitions (e4f711d), closes #45
  • parser: stop math brackets parsing as optional args (0eb49d5), closes #43
  • formatter: improve display-math break-point detection (9e97826), refs #42
  • formatter: keep multi-line math LHS off the relation column (b7fd62b), closes #39
  • formatter: keep trailing % glued to a block segment (5369c22), closes #38
  • vscode: swap npm-run-all for npm-run-all2 (b631714)

0.11.0 (2026-07-20)

Breaking changes

  • lsp: move texmf config to editor settings (2f83a84)

Features

  • lsp: move texmf config to editor settings (2f83a84)
  • formatter: hang nested blocks in align grids (5103bab)
  • linter: document bib rules in –explain and docs (a2a742c), closes #24

Bug Fixes

  • tests: adapt to lsp-server 0.10 Response API (5f1e889)
  • linter: skip script labels inside argument groups (1f8c0eb), closes #37
  • parser: name blank line as math terminator (2751787), ref #35
  • linter: ignore key arguments in dash-length (506e5f0)
  • linter: ignore rule-command spans in dash-length (adeecf6), closes #34
  • linter: ignore key arguments in math-shape rules (387810a), closes #25
  • parser: keep unmatched [ a plain atom in math (c185c13), closes #23
  • linter: ignore labels in exclusive conditional branches (a6e0c22)
  • linter: ignore package loads in exclusive branches (d16d3d1), closes #27
  • linter: target the whole construct for DOC_COMMENT-bound suppressions (cd647fa), fixes #26

0.10.0 (2026-07-15)

Features

  • add --force-exclude to format and lint (05c8e32)

0.9.0 (2026-07-14)

Features

  • linter: make fixes carry multiple atomic edits (7f52ef5)
  • linter: add diagnostic related information (ada447b)
  • parser: add a release-mode stuck-loop step limiter (f93b1e5)
  • lsp: add diagnostic tags and rule doc links (906b0df)

Bug Fixes

  • lsp: recover poisoned db mutexes instead of panicking (5844ff2)
  • bib: resolve field aliases in missing-required check (f20283b)

0.8.0 (2026-07-11)

Features

  • lsp: show source package in macro hover (eaf030e)
  • lint: add unknown-option rule for local packages (f040033)
  • bib: document links for doi and url fields (e1f15f7)
  • completion: argument-value enum completion (f363499)
  • bench: add linter speed benchmark vs lacheck and chktex (e6a821e)
  • bench: add whole-project folder benchmark (620c7cc)
  • bib: typed AST wrapper layer for BibTeX CST (0abe33a)
  • ast: typed AstNode/AstToken wrapper layer (35eae44)

Performance Improvements

  • cli: parallelize lint –fix across files (133a4c3)

0.7.0 (2026-07-08)

Features

  • lsp: selection ranges from CST hierarchy (2aff55f)
  • formatter: column-spec-aware table alignment (9ab94ba)
  • linter: package-aware duplicate and provides lints (758fac3)
  • semantic: recognize package metadata and options (0cd95ce)
  • lsp: color and TikZ/PGF library completion (1a881f3)
  • lint: add unreferenced-label rule (4d975a1)
  • lint: add verbatim-trailing-text rule (a11358d)
  • lint: flag line-break tie in missing-nonbreaking-space (de2d51f)
  • lint: autofix obsolete-environment eqnarray to align (aa26b13)
  • lint: add missing-required-argument rule (5206ee6)
  • lsp: references, rename, goto-def for user macros (fdeb0e9)
  • lsp: negotiate client capabilities at initialize (36b6ed2)
  • lsp: change-environment refactor command (1f27fab)
  • lsp: glossary/acronym key completion (f73f138)
  • lsp: signature help for command arguments (0c5f649)
  • lsp: label hover and symbol numbers from .aux (3efb7aa)
  • semantic: classify what a \label labels (01a8b0b)
  • project: scan .aux for label numbers and toc (ed48898)
  • config: add [build] section with aux-dir (ad8cc8a)
  • lsp: go-to-definition for include/package file arguments (99927ea)
  • lsp: resolve packages via TEXMF index and CTAN metadata (24ba5c7)

Bug Fixes

  • bib: tighten title-capitalization camelCase heuristic (91de065)
  • ci: rename aux.rs, allow option-ext MPL-2.0 (cc1c834)

Performance Improvements

  • formatter: parallelize the CLI format paths (d38b4d6)
  • linter: cache registry, stream rewalkers, parallelize CLI (5c3813a)
  • signature: bake CTAN metadata via phf, not runtime parse (f635d23)

0.6.0 (2026-07-06)

Features

  • completion: complete \usepackage/\documentclass names (2457147)
  • completion: add baked package/class name lists (ff4906d)
  • lsp: add document links (915aea6)
  • lsp: highlight matching \begin/\end pair (d643518)
  • lsp: re-indent on close via onTypeFormatting (5972340)
  • parser: parse math environments in math mode (9097be3)
  • formatter: implement sentence and semantic wrap modes (17003ba)
  • linter: add hard-coded-reference rule (da66c29)
  • linter: add sectioning-level-jump rule (6ac6def)
  • linter: add makeat-macro rule (2ae6d07)
  • linter: add space-before-command rule (36d5fa3)
  • linter: add abbreviation-spacing rule (2fea8db)
  • linter: add swallowed-space rule (c48aa20)
  • linter: add primitive-command rule (94da7ca)
  • linter: add math-operator-name rule (17cc5f2)
  • linter: add times-variable rule (52de07a)
  • linter: add dash-length rule (a6218e0)
  • linter: add straight-quotes rule for ASCII quotes (adff4ba)
  • linter: add ellipsis rule for literal … (488ebdd)
  • linter: generate rules reference from metadata (74e2234)
  • math: normalize operator spacing (36c9314)
  • semantic: keep built-in over delegating arity-0 redef (9fd50d8)
  • add title, author, date, thanks to signatures db (3c537d1)
  • formatter: stack binary chains under the relation too (0777920)
  • formatter: align relation chains in display math (e69a72e)
  • formatter: join alignment-cell continuation lines (cd3e590)
  • semantic: resolve packages to .dtx sources (249e68e)

Bug Fixes

  • parser: point unclosed-delimiter errors at the opener (1029351)
  • formatter: tight spacing and no paren breaks in display math (7112b8c)
  • linter: allow en dash between proper names in dash-length (2ab4342)
  • formatter: peel over-attached cell off table rules (7c91ac9)

Reverts

  • “feat(formatter): stack binary chains under the relation too” (4a6988b)

0.5.0 (2026-07-01)

Features

  • lsp: add range formatting support (5ad2827)
  • lsp: add workspace symbols support (eb8a111)
  • formatter: format expl3 code (catcode 9/10 model) (ac4ff31)
  • lsp: watch on-disk tex/bib/config and reanalyze (b551c01)
  • dtx: reflow documentation prose under reflow (be57646)
  • lsp: outline entries for dtx documented macros (cba0b01)
  • lsp: add textDocument/documentHighlight (404069b)
  • bench: add formatter speed bench vs tex-fmt & latexindent (82ddeb5)
  • format: reflow brace-group bodies as statements (bb976e0)
  • lsp: discover and apply badness.toml per document (e56a8af)
  • lint: add missing-nonbreaking-space (tie before cite/ref) (4d75da4)
  • lsp: surface linter autofixes as code actions (13c727e)
  • lsp: resolve completion items with signature and citation detail (f9892e6)
  • lsp: add hover for commands, environments and citations (3c6047c)
  • lsp: add pull diagnostics (a73fd7b)

Bug Fixes

  • lsp: honor excludes for siblings (7a50529)

Performance Improvements

  • signature: bake CWL tier into a build-time phf map (a920d4a)

0.4.0 (2026-06-23)

Features

  • semantic: mark the cross-reference family inline (c7c77a7)
  • semantic: ingest CWL corpus as a bulk signature tier (4740bf5)
  • lint: don’t withold lints that disturbs alignment (8ea1efc)
  • bib: diagnose missing field separator; fix value trivia attachment (e14751c)
  • bib: autofix duplicate-field when values are identical (c34bd78)
  • bib: duplicate-field lint rule (f2f6d60)
  • lsp: rename labels and citation keys (textDocument/rename + prepareRename) (7b1d01b)
  • config: badness.toml configuration (CLI) (8c68ca2)
  • project: package load graph + package signatures into scope (f8e6bc7)
  • semantic: doc/ltxdoc prose↔code association query (a52f17c)
  • file-kind: .ins installation-script support (plain code, Preserve) (85c9c7a)
  • formatter: .dtx two-layer formatting (foundation, Preserve) (6c7861f)
  • semantic: doc/ltxdoc signatures + DOC_COMMENT node (M3) (95ec2a2)
  • parser: lex expl3 syntax mode (_/: as letters) (c98e2e8)
  • parser: lex .dtx docstrip guards as GUARD tokens (M2) (b09c507)
  • parser: parse .dtx docstrip surface syntax (M0+M1) (8e54604)
  • lsp: add textDocument/foldingRange (f0ea513)

Bug Fixes

  • cli: fix file-detection in cli linter (7821b6a)

0.3.0 (2026-06-21)

Features

  • lsp: add textDocument/references (find references) (2ef3606)
  • sty/cls: format and lint LaTeX package/class sources (54692cf)
  • lsp: bib-aware completion and \cite key completion (493ad41)
  • bib: add generator to sync bib_fields.json with biblatex data model (189de08)
  • bib: align entry-type required fields to the data model (35b81d9)
  • bib: derive field/entry DB from biblatex’s canonical data model (55a6883)
  • bib: recognize the full standard biblatex field set (e2c2639)
  • semantic: flag user verbatim environments via begin-code catcode scanning (eefc1a1)
  • semantic: scan \def-defined verbatim commands and helper chains (6cad9c1)
  • semantic: flag user verbatim-argument commands via definition scanning (19ef5f1)
  • lsp: go-to-definition for refs and citations (2535199)
  • cli: –stdin-filepath routes lint stdin to the bib pipeline (f8a4831)
  • cli: –stdin-filepath routes format stdin to the bib pipeline (96f1b80)
  • lsp: cross-file project assembly — undefined-ref/citation fire live (38b7f2c)
  • bib: Phase 4 — incremental, LSP, and project-graph integration (b593bdc)
  • bib: linter rules + CLI wiring (Phase 3) (571c2d3)
  • bib: field & entry sorting (Phase 2c) (438a61d)
  • bib: value reflow (Phase 2b) — wrap long field values by category (3cfed27)
  • bib: formatter (Phase 2) — lower bib CST to shared Wadler IR (de48afd)
  • bib: semantic model + field/entry signature DB (b59befc)
  • bib: differential parse oracle vs texlab + phased roadmap (d7360b6)
  • bib: first-stab BibTeX/BibLaTeX parser (6f38675)
  • lsp: add basic completion (20903b7)
  • linter: autofix infra + dollar-display-math $$→[ fix (216f590)
  • linter: obsolete-environment, dollar-display-math, mismatched-delimiter lints (8f89b51)
  • formatter: break wide display math at top-level operators (716612f)
  • linter: cross-file label resolution + undefined-ref / duplicate-label (270a035)
  • formatter: keep appendix environment body flush like document (b1a55f7)
  • formatter: collapse cite-family key lists deterministically (d88e7e3)
  • semantic: extract unbraced \newcommand\foo definition form (f2472d5)
  • parser: bind leading comments into the following construct (0afabeb)
  • lsp: add document symbols (5547650)
  • parser: don’t wrap a lone block environment in a PARAGRAPH (b4a46fe)
  • formatter: use latexindent-style desc hang (46ab231)
  • formatter: reflow inline prose commands inline, not as blocks (5d706b2)
  • collapse blanklines into 1 (b19d8da)
  • formatter: grid-align comments and rule lines; enable tables (4cbb183)
  • formatter: lower display math as an indented block (5e2cefc)
  • parser: lex verbatim-argument commands; fix multi-line VERB formatting (73cf04c)
  • cli: add badness parse command (7735a75)
  • formatter: align itemize blocks (47a2b19)
  • formatter: don’t indent document environment (3cd0d04)
  • linter: add rule layer with duplicate-label and deprecated-command (4aaee37)
  • align & columns in align/matrix environments (d5abdca)
  • match \left … \right delimiter pairs in math (3079875)
  • add structured math model and math formatting (02802f6)
  • support argument-taking verbatim environments (ab8eb74)
  • add file-walk for formatter (1603230)

Bug Fixes

  • lsp: handle Windows file URIs in path completion (5b38f45)
  • formatter: keep a trailing % on the \begin header line (e02413f)
  • formatter: ass JSS/Sweave verbatim environments to signatures (21b5e61)
  • don’t reflow single % (be49170)
  • formatter: don’t push % to next line (de271ae)
  • formatter: fall back when an alignment cell contains a comment (918c592)
  • parser: don’t treat comment-only lines as paragraph breaks (3c83c01)
  • formatter: keep command-only lines on their own line under reflow (739a32f)
  • linter: migrate render.rs to annotate-snippets 0.12 API (602d835)

0.2.0 (2026-06-12)

Features

  • add vscode and open vsx extensions (975f1e4)
  • npm: package for npm (b3a576f)

0.1.0 (2026-06-12)

Breaking changes

Features

  • formatter: reflow signature-marked prose arguments (18c99ee)
  • lsp: ra-style writer/threadpool, cancellation, incremental sync (8628f92)
  • lsp: reuse cached salsa tree for formatting (30cd2d5)
  • implement semantic group scanning (4f5e9ca)
  • parser: model \ line break as a LINE_BREAK node (651e1c5)
  • formatter: paragraph reflow via a Wadler Fill node (0cbe264)
  • semantic: add built-in signature database (e9bf2de)
  • rename fmt to format (1fedc1b)
  • linter: add minimal badness lint command (443fa6a)
  • lsp: add minimal lsp server (7e6f4fe)
  • formatter: indent multi-line group/argument bodies (5e66038)
  • parser: differential parse oracle vs texlab (25e065c)
  • lsp: add semantic model and reference support (61707c1)
  • build project graph (cc81a29)
  • incremental: salsa harness for cached parsing (67a1948)
  • formatter: environment-body indentation (5b3d1b5)
  • formatter: whitespace normalization (first real rule) (00385eb)
  • formatter: Phase 2 formatter MVP — identity round-trip (ab2ef57)
  • parser: Phase 1 recursive-descent grammar with error recovery (511352c)

Bug Fixes

  • attach arguments to environment (a6772d2)
  • parser: stop $-math at group and \end anchors (1319fd8)