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: get the
badnessbinary. - Getting Started: format and lint your first file.
- Playground: try the formatter in your browser.
- Editor Setup: wire up the language server.
Installation
Badness is distributed as a single binary, badness. The current version is
0.23.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.23.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—including --line-width, --indent-width, --item-indent,
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/\eeapair 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:
| Scope | Directive |
|---|---|
| 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}}
The inert-suppression rule warns when a directive cannot act—for example, a
dangling skip, an unmatched on, an unclosed off, a directive written as
typeset prose on a .dtx documentation line, or a format-only directive in a
.bib file.
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, defaulttrue): whether to scan the TEXMF tree at all. Whenfalse, 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 thatkpsewhichcan’t see.useKpsewhich(boolean, defaulttrue): whether to shell out tokpsewhichto discover the TEXMF tree roots. Whenfalse, discovery falls back to default-path heuristics only.
{ "texmf": { "enabled": true, "roots": ["/opt/texmf"], "useKpsewhich": true } }
Forward and inverse search
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:
| Placeholder | Expands to |
|---|---|
%f | the .tex file the cursor is in |
%p | the root document’s PDF |
%l | the line number, counting from 1 |
%%f | a 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:
| Viewer | executable | args |
|---|---|---|
| zathura | zathura | ["--synctex-forward", "%l:1:%f", "%p"] |
| Okular | okular | ["--unique", "file:%p#src:%l%f"] |
| SumatraPDF | SumatraPDF | ["-reuse-instance", "%p", "-forward-search", "%f", "%l"] |
| Skim | displayline | ["%l", "%p", "%f"] |
| Evince | evince-synctex | ["-f", "%l", "%p", "\"code -g %f:%l\""] |
| qpdfview | qpdfview | ["--unique", "%p#src:%f:%l:1"] |
{
"forwardSearch": {
"executable": "zathura",
"args": ["--synctex-forward", "%l:1:%f", "%p"]
}
}
Triggering forward search
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:
| Status | Meaning |
|---|---|
0 | the viewer was launched |
1 | the viewer would not start |
2 | no PDF on disk, or the buffer has no path — build the document first |
3 | no 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.
Inverse search
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.
Table refactoring
With the cursor inside a statically understood tabular, tabular*, or array
environment, the Add column at end code action appends a centered c column
to the preamble and an empty trailing cell to every row. The action is withheld
when the preamble uses unknown column types, a row has an ambiguous width, or
the environment has been redefined, so it never applies a partial table rewrite.
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
# item-indent = "hang" # hang | indent | none
# 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
$XDG_CONFIG_HOME/badness/config.toml~/.config/badness/config.toml- the platform config directory (
%APPDATA%\badness\config.tomlon Windows,~/Library/Application Support/badness/config.tomlon 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-configignores 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.
Editor support
Badness publishes a JSON Schema for badness.toml
so editors with TOML support can provide key and value completion, hover
documentation, and validation.
Schema URL: https://badness.dev/badness.schema.json
The schema is generated from the configuration types and checked against them in the test suite. The version at the URL tracks the latest released version of Badness.
VS Code (Even Better TOML)
With the Even Better
TOML
extension installed, add this association to your user or workspace
settings.json:
{
"evenBetterToml.schema.associations": {
"^(.*/)?badness\\.toml$": "https://badness.dev/badness.schema.json"
}
}
Inline #:schema directive
TOML tooling that supports inline schema directives can select it from the configuration file itself:
#:schema https://badness.dev/badness.schema.json
This is also useful for the global user configuration, whose generic
config.toml file name should not be associated with Badness automatically.
Other editors
Any editor or language server that consumes JSON Schemas—including Helix, Neovim
with taplo-lsp, Zed, and IntelliJ—can use the same URL through its TOML schema
settings.
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
item-indent
How continuation lines in list items are indented relative to the \item
command.
| Mode | Behavior |
|---|---|
hang | Align under the body following a bare \item (the default). |
indent | Add one indent-width step from the \item column. |
none | Align with the \item command. |
Labels and Beamer overlays do not widen the hang offset, so items retain one
continuation edge regardless of marker width.
Default value: "hang"
Type: string
Example:
[format]
item-indent = "indent"
wrap
How the formatter lays out line breaks inside a paragraph. It does not affect structure, only where soft line breaks fall.
| Mode | Behavior |
|---|---|
reflow | Greedy fill: pack words up to line-width, breaking only where the next word would overflow. |
stable | Preserve acceptable authored breaks and rebalance only text that no longer fits (keeps revision diffs small). |
preserve | Leave the authored line breaks untouched. |
sentence | One sentence per line. Line width is ignored—a long sentence stays on one line. |
semantic | Semantic 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.
In sentence mode, citation commands follow their grammatical role.
Parenthetical and other postpositive forms, such as \parencite, \citep, and
\autocite, stay with the preceding sentence—even when an authored line break
has stranded one on the next line. Textual forms, such as \textcite and
\citet, may begin the next sentence. Plain \cite is package- and
style-dependent, so the formatter follows the source: it attaches on the same
line but preserves an authored line break.
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.
| Mode | Behavior |
|---|---|
auto | Derive from the effective wrap: preserve keeps authored math breaks, every other mode breaks (amsmath). |
preserve | Keep the authored line breaks inside the body. Spacing within each line is still normalized. |
single-line | Never insert breaks: the body stays on one line, overflowing line-width if too long (like inline math). |
break | Break 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.
| Mode | Behavior |
|---|---|
auto | Keep the endings the file was written with: CRLF if its first line break is one, LF otherwise. |
lf | Always \n. |
crlf | Always \r\n. |
native | The 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. Most rules are on by default; each
rule’s reference entry states whether it is default-enabled. 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 default-enabled rules run)
Type: array of strings
Example:
[lint]
select = ["deprecated-command", "dollar-display-math"]
To enable the opt-in dash check:
[lint]
select = ["dash-length"]
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"
[commands]
Declares project commands whose first braced argument contains label or citation keys. This covers wrappers that Badness cannot recognize without macro expansion, which remains deliberately out of scope.
Entries are keyed by the command name without its leading backslash:
# A comma-separated list of label keys.
[commands.eqrefs]
like = "cref"
# A comma-separated list of bibliography keys.
[commands.projectcite]
like = "parencite"
The like target must be a curated reference or citation command. It determines
the key behavior: ref/eqref accept one label, cref and its list-valued
siblings split on commas, citation commands split on commas, and nocite
preserves the special * wildcard.
Command declarations affect linting, label/citation navigation, rename, and key
completion. They do not expand the macro, declare its arity, change argument
attachment, or lend formatter layout. Use the command whose observable key
behavior matches the wrapper: an eqrefs command that accepts several labels is
like = "cref", even if its implementation calls \eqref once per key.
Anything that would silently do nothing is a configuration error: an empty
entry, an invalid control-word name, an unknown or non-ref/cite like target,
or an attempt to reclassify a curated built-in command.
Command like
The curated reference or citation command whose key behavior this project command copies.
Type: string
Example:
[commands.eqrefs]
like = "cref"
[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
likenaming 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
likeand 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, notbadness.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.tomlto use instead of discovering one. Applies toformatandlint; ignored byparse,lsp, andinit --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:
autoPossible values:
auto: Colorize when writing to a terminal andNO_COLORis unset (default)always: Always colorizenever: Never colorize
-q,--quiet-
Suppress non-essential output (errors are still shown). Under
format --checkthis 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
--item-indent <ITEM_INDENT>-
How to indent continuation lines in list items
Possible values:
hang: Align continuations under the body following a bare\item(default)indent: Indent continuations by one indent-width stepnone: Align continuations with the\itemcommand
--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 boundariespreserve: 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 bodiessingle-line: Never insert breaks; a long body overflows the line widthbreak: 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-excludefrombadness.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-excludefrombadness.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] selectfrombadness.tomlwhen given --ignore <RULE>-
Disable these rules (repeatable). Overrides
[lint] ignorefrombadness.tomlwhen 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;
jsonwrites to stdoutDefault value:
prettyPossible values:
pretty: Source-snippet output with caret spans, on stderr (default)concise: Onepath:line:col: severity [rule] messageline per finding, on stderrjson: 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
badness inverse-search
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
.texfile the viewer resolved -l,--line <LINE>-
Line number, counting from 1 — what SyncTeX-aware viewers emit.
Required unless
--line0is given. Deliberately not enforced by clap, whose message for that would name only--lineand so send a--line0user 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.
Most rules are on by default. Each rule’s section states its default; enable
an opt-in rule with select, or narrow the default set with 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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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).
This rule is enabled by default.
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}
deprecated-suppression-syntax
Flag the retired % badness-ignore <rule> and % badness-ignore-file [<rule>] suppression spellings, which remain accepted for compatibility but are no longer documented. The Safe autofix rewrites only the family and verb to % badness-lint skip <rule> or % badness-lint skip-file [<rule>]; the selector and reason remain byte-for-byte unchanged, and the edit stays entirely inside a comment. This meta diagnostic is not silenced by the retired directive it reports; use [lint].ignore to disable the rule deliberately.
This rule is enabled by default.
A retired suppression directive:
% badness-ignore deprecated-command: legacy source
{\bf text}
warning: deprecated-suppression-syntax
--> example.tex:1:3
|
1 | % badness-ignore deprecated-command: legacy source
| ^^^^^^^^^^^^^^ retired suppression syntax; use `% badness-lint skip` instead
After applying the fix:
% badness-lint skip deprecated-command: legacy source
{\bf text}
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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)
extra-alignment-tab
Flags a row in a built-in tabular, tabular*, or array environment that consumes more columns than its column preamble declares. LaTeX cannot place the overflowing cell and reports an extra alignment tab. Short rows are valid and are not flagged. Custom column types and dynamic \multicolumn spans are left alone when their width cannot be established statically. No autofix is offered because either the row or the preamble may be wrong.
This rule is enabled by default.
A row that exceeds the declared table width:
\begin{tabular}{ll}
a & b & c \\
\end{tabular}
error: extra-alignment-tab
--> example.tex:2:9
|
1 | \begin{tabular}{ll}
| -- table preamble declares 2 columns
2 | a & b & c \\
| ^ row uses at least 3 columns, but the table preamble declares 2
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.
This rule is enabled by default.
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
indented-docstrip-guard
Flag a syntactically complete %<…> marker in a .dtx file when it is preceded only by horizontal whitespace on its physical line. Docstrip recognizes guards only at column zero, so an indented near match is an ordinary comment and does not select or delimit generated code. No autofix is offered because activating a guard can change generated files.
This rule is enabled by default.
A docstrip guard indented by one space:
%<*package>
\ProvidesPackage{example}
%</package>
warning: indented-docstrip-guard
--> example.dtx:1:2
|
1 | %<*package>
| ^^^^^^^^^^^ docstrip guards are recognized only at column zero
warning: indented-docstrip-guard
--> example.dtx:3:2
|
3 | %</package>
| ^^^^^^^^^^^ docstrip guards are recognized only at column zero
inert-suppression
Flag a suppression directive that cannot take effect: skip with no following construct, on with no matching off, or a directive written on a .dtx documentation-margin line, where % is typeset prose rather than a comment. Also flag an off region left open at EOF; it currently suppresses through the end of the file, but the missing closer is usually accidental. Report-only: moving, deleting, or closing the directive requires knowing the boundary the author intended. Inline suppressions cannot hide this meta diagnostic; use [lint].ignore to disable the rule deliberately.
This rule is enabled by default.
An on directive with no matching open region does nothing:
% badness-lint on deprecated-command
{\bf text}
warning: inert-suppression
--> example.tex:1:1
|
1 | % badness-lint on deprecated-command
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `on` has no matching `off`, so this directive closes no region
invalid-macrocode-frame
Flag a .dtx macrocode or macrocode* closing frame unless exactly four spaces separate its column-one % from \end{…}. The doc package scans for that literal physical delimiter, so a near match does not close the code chunk even though it looks like an ordinary environment to Badness. The safe autofix replaces only the malformed horizontal space with the required four spaces.
This rule is enabled by default.
A macrocode closer with only three spaces after %:
% \begin{macrocode}
\def\example{value}
% \end{macrocode}
error: invalid-macrocode-frame
--> example.dtx:3:2
|
3 | % \end{macrocode}
| ^^^ `macrocode` closing frame requires exactly four spaces after `%`
After applying the fix:
% \begin{macrocode}
\def\example{value}
% \end{macrocode}
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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
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. The same applies to angle-delimited command and environment specifications such as Beamer’s \item<1-2> and \begin{onlyenv}<2-3>. Comments, verbatim, and math are never touched.
This rule is disabled by default; enable it with select.
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.
This rule is enabled by default.
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, and never inside a text-domain or unknown argument. 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.
This rule is enabled by default.
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.
This rule is enabled by default.
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 structural heading that descends more than one level below the preceding structural heading – \section straight to \subsubsection, skipping \subsection (textidote’s sh:secskip). The active ladder follows the document class: \chapter is included only for classes known to provide it or when the source uses it, while unknown classes conservatively omit it. \paragraph and \subparagraph are transparent because technical papers commonly use them as run-in labels rather than outline subdivisions. Only downward jumps are flagged – climbing back up and repeated headings at one level are normal. The comparison is relative to the previous structural heading, never an absolute top level. Report-only: repairing a skip is a structural choice for the author, not a correct-by-construction edit.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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; resource lookup honors BibTeX’s BIBINPUTS/TEXBIB search path. Suppressed entirely by a \nocite{*} wildcard (which marks every key as used). Inert without cross-file citation resolution. No autofix.
This rule is enabled by default.
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 definition 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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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). It also leaves standard named math operators braced because commands such as \max are not valid unbraced script fields.
This rule is enabled by default.
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.
This rule is enabled by default.
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 statement that establishes its intended counter: the outer \caption in a curated float (figure, table, and their starred forms), an explicit \captionof in a curated caption container (minipage), or the first \item in the standard numbered enumerate list. In either position, \label captures the previous \@currentlabel—usually an enclosing section number—so \ref silently prints an unrelated number. LaTeX gives no warning. The list case is limited to statement-level labels before the first item; labels after an item may belong to it, while itemize and description items do not step a reference counter. Attached custom item labels and complete Beamer overlay markers remain intact. The float case likewise skips labels nested in command arguments, and classifies nested counter steps conservatively. The fix moves the label just after the proven caption or item marker, and is Unsafe because it intentionally changes what \ref prints from an inferred intent.
This rule is enabled by default.
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 the outer `\caption` in this `figure` does not capture the float number
A \label before the first \item has not seen the item counter step:
\begin{enumerate}
\label{item:first}
\item First
\end{enumerate}
warning: label-before-caption
--> example.tex:2:3
|
2 | \label{item:first}
| ^^^^^^^^^^^^^^^^^^ `\label` before the first `\item` in this `enumerate` does not capture the item number
A \label above \captionof in a minipage likewise precedes the explicit counter step:
\begin{minipage}{\textwidth}
\label{fig:plot}
\captionof{figure}{A plot.}
\end{minipage}
warning: label-before-caption
--> example.tex:2:3
|
2 | \label{fig:plot}
| ^^^^^^^^^^^^^^^^ `\label` before `\captionof` in this `minipage` does not capture the caption 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.
Most rules are on by default. Each rule’s section states its default; enable
an opt-in rule with select, or narrow the default set with 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.
This rule is enabled by default.
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
deprecated-suppression-syntax
Flag the retired @comment{badness-ignore <rule>} and @comment{badness-ignore-file [<rule>]} suppression spellings, which remain accepted for compatibility but are no longer documented. The Safe autofix rewrites only the family and verb to badness-lint skip or badness-lint skip-file; the selector, reason, delimiters, and remaining entry text stay byte-for-byte unchanged. This meta diagnostic is not silenced by the retired directive it reports; use [lint].ignore to disable the rule deliberately.
This rule is enabled by default.
A retired suppression directive in a structured comment:
@comment{badness-ignore unused-string: intentional}
@string{x = {X}}
warning: deprecated-suppression-syntax
--> references.bib:1:10
|
1 | @comment{badness-ignore unused-string: intentional}
| ^^^^^^^^^^^^^^ retired suppression syntax; use `@comment{badness-lint skip …}` instead
After applying the fix:
@comment{badness-lint skip unused-string: intentional}
@string{x = {X}}
inert-suppression
Flag a structured BibTeX suppression directive that cannot take effect: skip with no following entry, on with no matching off, or any badness-format directive, because the BibTeX formatter does not support format suppression. Also flag an off region left open at EOF; it currently suppresses through the end of the file, but the missing closer is usually accidental. Report-only: repairing the directive requires knowing the entry, boundary, or formatting policy the author intended. Inline suppressions cannot hide this meta diagnostic; use [lint].ignore to disable the rule deliberately.
This rule is enabled by default.
BibTeX recognizes the directive grammar, but its formatter has no suppression mechanism:
@comment{badness-format skip-file: preserve this file}
@book{key}
warning: inert-suppression
--> references.bib:1:1
|
1 | @comment{badness-format skip-file: preserve this file}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the BibTeX formatter does not support format suppression, so this directive does nothing
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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.
This rule is enabled by default.
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:
latexindentis 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-fmtbreaks overfull lines greedily but does not reflow: it won’t rewrap lines that already fit, so it moves far less text thanbadness, which reflows each paragraph to the target width.- Among the linters,
lacheckis a small classic checker,chktexis regex-driven, andbadness lintdoes 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:
| Tool | Invocation |
|---|---|
badness | badness format --no-config --stdin-filepath bench.tex |
tex-fmt | tex-fmt --stdin |
latexindent | latexindent -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.
| Tool | Invocation |
|---|---|
badness | badness format --check <dir> |
tex-fmt | tex-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
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)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| badness | 2.4545 | 0.8573 | 5.6567 | baseline |
| tex-fmt | 2.2980 | 0.8883 | 4.9063 | 1.1× faster |
| latexindent | 84.7619 | 77.0526 | 98.6430 | 34.5× slower |
cv.tex (6273 bytes, 275 lines)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| badness | 3.0563 | 1.4521 | 6.5785 | baseline |
| tex-fmt | 2.3656 | 1.0157 | 5.1515 | 1.3× faster |
| latexindent | 105.6388 | 82.3922 | 161.2518 | 34.6× slower |
masters_dissertation.tex (95383 bytes, 2458 lines)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| badness | 22.5924 | 19.7107 | 26.9194 | baseline |
| tex-fmt | 3.5818 | 1.7228 | 10.9985 | 6.3× faster |
| latexindent | 2333.9338 | 2330.0066 | 2336.7828 | 103.3× slower |
Whole-project results
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)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| badness | 4.6928 | 2.7871 | 9.3865 | baseline |
| tex-fmt | 3.4801 | 1.9159 | 6.7361 | 1.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):
| Tool | Invocation |
|---|---|
badness | badness lint --no-config <file> |
chktex | chktex -q <file> |
lacheck | lacheck <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
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)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| badness | 2.7615 | 1.5162 | 7.6755 | baseline |
| lacheck | 10.5784 | 7.7071 | 14.5698 | 3.8× slower |
| chktex | 65.4007 | 55.1764 | 93.5242 | 23.7× slower |
cv.tex (6273 bytes, 275 lines)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| badness | 3.2469 | 1.8403 | 8.2667 | baseline |
| lacheck | 11.6510 | 7.1851 | 25.7571 | 3.6× slower |
| chktex | 66.1456 | 54.7281 | 81.8926 | 20.4× slower |
masters_dissertation.tex (95383 bytes, 2458 lines)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| badness | 24.1258 | 20.7172 | 28.9816 | baseline |
| lacheck | 13.5346 | 10.4407 | 18.2761 | 1.8× faster |
| chktex | 68.4455 | 61.3334 | 90.8754 | 2.8× slower |
Language-server speed and memory
How the language servers are measured
The harness starts three fresh processes each of badness lsp and texlab run
against the complete, pinned kks32/phd-thesis-template workspace. Each
session initializes the server, waits for background work to settle, opens the
same five documents, obtains diagnostics using the server’s advertised pull or
push model, primes document symbols and meaningful citation/reference hovers,
and waits for the editor workload to settle. It then times warm document-symbol,
hover, definition, references, and rename requests.
The readiness measurements divide that session into three user-visible waits:
- Initialize is the
initializerequest round trip from a fresh process. - Workspace ready runs from process start to the beginning of the final quiet window after initialization and background indexing.
- Open files ready runs from the burst of
didOpennotifications through diagnostics and the beginning of the next quiet window.
Warm document-symbol and hover requests span the same three chapter files,
selected for real citation or reference keys. Definition, references, and rename
use the Aup91 citation in Chapter1/chapter1.tex; its definition is in
References/references.bib. References include the declaration, and rename
constructs a WorkspaceEdit without applying it.
Each target gets two unmeasured warmup rounds and 20 measured rounds in every fresh session. The tables report the median and p95 over all samples. They also show serialized result size and the range of symbols, locations, or edits each server returns, including the number of files involved. Those counts expose cases in which two fast responses did different amounts of work.
On Linux, the harness samples the complete descendant process tree every 150 ms
from /proc. RSS is the resident memory commonly reported by process
monitors; it counts shared pages once in every process. PSS divides shared
pages among the processes that map them, which better estimates how much
physical memory the session occupies. The baseline is recorded after
initialization settles, the settled value after the open-file workload settles,
and the peak is the largest sample through the timed requests. A phase is
settled after five seconds below 5% of one CPU core and fails after 60 seconds.
The memory table reports the median of three fresh runs; the JSON artifact
retains each run’s measurements.
The servers do not provide identical features or analysis, so this compares the user-visible latency, returned work, and resident cost rather than efficiency at the same work. Results also depend on the operating system, allocator, machine, and tool versions; read the absolute figures together with the setup below.
Regenerate this section with task bench:lsp (task bench:memory remains an
alias). The committed benches/memory_results.json artifact is only read while
building the site—the benchmark never runs in CI or during an mdBook build.
Setup
- Badness:
0.21.0 - TexLab:
5.26.0 - corpus:
phd-thesis-template@3ce347686d75(v2.4, 16 source files, 279004 bytes) - session: 3 fresh runs per server; 5 open files (37773 bytes)
- sampling: every 0.15 s; quiet for 5.0 s; 60 s phase timeout
- host: Linux x86_64, AMD Ryzen 9 7900 12-Core Processor (61.9 GiB RAM)
- generated: 2026-08-28T18:56:44Z
- navigation target:
Aup91inChapter1/chapter1.texat line 19
Speed
Readiness
| Server | Initialize | Workspace ready | Open files ready |
|---|---|---|---|
| Badness | 1 ms | 151 ms | 28 ms |
| TexLab | 1 ms | 151 ms | 31 ms |
Warm requests
| Server | Request | Median | p95 | Returned work |
|---|---|---|---|---|
| Badness | Document symbols | 0.20 ms | 0.27 ms | 5–16 symbols (median 14), 3 KiB |
| Badness | Hover | 0.11 ms | 0.14 ms | 1 result, 206 B |
| Badness | Go to definition | 0.08 ms | 0.09 ms | 1 location in 1 file, 175 B |
| Badness | Find references | 0.09 ms | 0.10 ms | 2 locations in 2 files, 346 B |
| Badness | Rename | 0.09 ms | 0.10 ms | 2 edits in 2 files, 418 B |
| TexLab | Document symbols | 0.22 ms | 0.40 ms | 5–33 symbols (median 20), 5 KiB |
| TexLab | Hover | 0.07 ms | 0.11 ms | 1 result, 207 B |
| TexLab | Go to definition | 0.06 ms | 0.07 ms | 1 location in 1 file, 371 B |
| TexLab | Find references | 0.05 ms | 0.07 ms | 2 locations in 2 files, 346 B |
| TexLab | Rename | 0.06 ms | 0.07 ms | 2 edits in 2 files, 418 B |
Warm requests show median / p95 across all samples. Each target ran 20 measured rounds in each of 3 fresh sessions after 2 warmup rounds; symbols and hover span 3 files, while definition, references, and rename use Aup91 in Chapter1/chapter1.tex. Rename constructs the workspace edit but does not apply it.
Memory
| Server | Baseline RSS | Settled RSS | Settled PSS | Peak RSS | Relative settled RSS |
|---|---|---|---|---|---|
| Badness | 9.2 MB | 14.3 MB | 12.5 MB | 14.3 MB | baseline |
| TexLab | 8.7 MB | 15.1 MB | 13.2 MB | 15.1 MB | 1.06× |
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. The published crates support Rust 1.89 and newer; CI
checks that compatibility floor separately from the pinned development
toolchain.
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
| Task | Equivalent | What it does |
|---|---|---|
task build | cargo build | Dev build. |
task test | cargo test | Run the whole test suite. |
task parser-properties | Run parser losslessness properties at nightly depth. | |
task fmt | cargo fmt | Format the code. |
task lint | cargo clippy --all-targets --all-features -- -D warnings | Clippy, warnings as errors. |
task check | Everything CI runs: fmt-check, lint, test, wasm. |
Run task check before opening a pull request; it mirrors CI exactly.
Badness uses insta for snapshot tests. When a change
deliberately alters formatter or parser output, refresh snapshots with
task snapshots and review the diff before committing.
The ordinary test suite runs 256 cases for each parser losslessness property.
task parser-properties raises that to 4,096 cases, matching the scheduled
nightly job. When proptest finds a counterexample, reduce it and preserve it as
a readable regression test; the suite deliberately does not persist opaque seed
files.
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 thedata/signature artifacts.crates/badness-formatter— the layout engine and the.bibformatter.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,
.dtxmargins and guards) and nothing else. Content rewrites such asx^{2}→x^2are 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 ruleorfix(parser): recover at unbalanced brace. TheCHANGELOG.mdis 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-editCHANGELOG.md. - Keep commit subjects short (imperative mood, ideally under 60 characters) and
use the body for rationale. Close issues with
Fixes #123in the body. - A rustfmt git hook rewrites unformatted files and aborts the commit, so run
cargo fmtfirst. 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:
- Implement
Rulein a newsrc/linter/rules/<name>.rs, choosing node-shape, whole-file, or streaming dispatch, with anid, adefault_severity, a description, and at least one triggering example. Emit a losslessness-safe fix where one is warranted, and setemits_fixaccordingly. Rules are enabled by default; overridedefault_enabledonly for useful checks whose unavoidable false positives make them better suited to explicit selection. - Register it in the three lockstep lists in
src/linter/rules.rs: the module declaration, the re-export, and the entry inall_rules(). - Ship unit tests next to the rule and an integration test, plus a losslessness assertion on any fixture.
- 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.
The published badness.toml schema is generated from the Rust configuration
types. Regenerate badness.schema.json with
UPDATE_EXPECTED=1 cargo test --test config_schema, and review the diff rather
than editing the JSON by hand.
Windows CI bites twice
Line endings: the formatter emits LF and tests compare bytes against checked-in
fixtures. When you add a fixture in a new extension under
crates/*/tests/fixtures/** or crates/badness-parser/tests/corpus/**, add a
matching … eol=lf line to .gitattributes. Never normalize line endings in
code to pass a test; fix the attribute instead.
URIs: decode LSP URIs to filesystem paths only through uri_to_fs_path and
path_to_uri in lsp.rs. Tests and snapshots must not assume / versus \.
Documentation
User-facing docs 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 to recover TeX’s one-character script binding from a
coalesced WORD. 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, item-indent, wrap, math-wrap,
lang, no-break-abbreviations), [lint] (select, ignore), [build]
(aux-dir), and the declaration maps [commands.<name>] and
[environments.<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.
The language server caches resolved configuration per document directory, but
does not make cache correctness depend on workspace/didChangeWatchedFiles.
Each entry records the existence, modification time, and length of the
badness.toml candidates examined by the ancestor walk and of any resolved
fallback file. Normal document activity validates that fingerprint before using
the entry. This catches edits, deletion, and creation of a nearer project config
for clients such as Neovim that cannot dynamically register file watchers;
watcher notifications remain the eager invalidation path.
Declarations
Most config only affects behavior after parsing. Environment declarations are the exception: they feed the parser directly. Command declarations remain in the semantic layer.
[commands.<name>] lets a project classify a custom command as a reference or
citation family without expanding its definition. The like target determines
whether a reference accepts one key (eqref) or a comma-separated list
(cref), and whether a citation has nocite’s wildcard behavior:
[commands.eqrefs]
like = "cref"
[commands.mycite]
like = "parencite"
This first command-declaration vocabulary is deliberately semantic-only. It does not declare arity, attach arguments, or lend formatter layout behavior. The alias is consumed by label/citation analysis and completion; the parser sees the same token stream and produces the same tree with or without it.
[environments.<name>] lets 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, the environment subset is seeded into ParseCtx on the
first pass, while semantic-model construction reads the command subset. The
shared types live in badness-parser so every consumer can use the same model.
In incremental mode, both are carried through one high-durability salsa input
(incremental::DeclarationsInput), so changing badness.toml invalidates the
dependent parse and semantic results, while normal text edits do not.
Each subset then reaches its readers through its own firewall query
(parse_declarations, semantic_declarations), which projects out one half and
backdates when that half is unchanged. Both halves sharing a cell is what makes
this necessary: without the split, renaming a command alias would invalidate
every parse in the project, and parsed_document is no_eq, so it could not
backdate its way out — every reparse base would go too, since a base records the
declarations it was parsed under. A command alias provably cannot change a tree,
so that cost buys nothing. The split makes the cost of an edit proportional to
what it actually changed.
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. Environment declarations copy a curated built-in
entry, resolved against builtin(); command declarations copy only a curated
ref/cite command’s semantic family, resolved against the family tables
(ref_command, cite_command). Both are closed tables: spelling alone does not
establish key semantics. The command side deliberately does not consult
builtin(): signatures.json carries layout data and has no entry for most of
the families, \cpageref — the only list-valued page reference — included.
Neither side resolves against CWL or scanned definitions. Unknown like targets
are config errors, and a name either curated source already knows may not be
redeclared, since that would reclassify a command the project never meant to
touch.
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. Declarations do not currently expose command or
environment argspec.
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:
- The source is curated or explicitly declared.
- 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.
Environment signatures separately use the curated labelKey flag when a
top-level label entry in the first optional argument defines a LaTeX label.
The semantic model accepts only flat literal bare or braced values, applies
repeated entries in order, and treats a later dynamic value as unknown rather
than retaining an earlier literal. The initial built-ins are frame and
lstlisting; declarations may inherit the fact through like, while CWL and
source-scanned signatures cannot grant it. This is not inferred from
ContentKind::Keyval, because many key-value processors give label unrelated
meanings.
The linter’s label-before-caption rule uses the independent curated
captionContainer flag for non-float environments whose statement-level
\captionof conventionally owns a preceding label. minipage is the initial
member. Ordinary block environments are not inferred to be caption containers,
and plain \caption remains float-scoped; this keeps the unsafe move fix on the
silent side when the intended counter is ambiguous.
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.
\makeatlettermakes@a letter;\ExplSyntaxOnand the\ProvidesExpl*declarations open expl3, where_and:are letters. The two flags are independent and compose. In a.dtxa file-level signal (a%<@@=…>guard or a\ProvidesExpl*anywhere) puts everymacrocodebody 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. A curated command may instead mark one positional braced argument as verbatim—\hrefuses this for its URL while leaving the visible-text argument parsed. The capture forms only when the marked balanced group is present; local definitions suppress a colliding built-in mode. - Delimiter isolation. The token after
\leftor\rightis emitted on its own, so the parser can build theLEFT_RIGHTpair. - Math environments. An environment the curated table flags
mathhas its body parsed in math mode and wrapped in aMATHnode, 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. Math parsing and alignment layout are separate classifications: for example,gatheredis math-only, whilealignedalso carries thealignflag. Theempheqwrapper is math-only because its required keyval argument selects among AMS equation types; the formatter derives grid layout from the resulting body’s&and\\structure instead of assigning one layout to every selection. - Definition bodies. Inside the argument groups of the curated definer set
(
\newcommandand\newenvironmentfamilies, xparse, the LaTeX2e hooks),\beginand\endparse 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
macrocodebody 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\defregularly 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 opaqueVERB. Curated doc classes and.dtxmode enable|from the start. - Docstrip guards and
^^Adoc comments. A line-leading%<…>lexes as aGUARDtrivia leaf; on a doc-margin line the literal^^Acomments to end of line, matching ltxdoc’s catcode 14. - expl3 regions. In-region, token lists pass
\beginand\endaround 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. The escaped form also stays data when it occupies a whole alignment cell (`\X&), a local shape that covers templates such as\char#without inferring macro expansion. - Signatures.
\newcommandand 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 … \eeapairs as anENVIRONMENTofX. See below. - Picture-body statements. In a curated
statementBodyenvironment body (the TikZ/pgf picture family, routed byParseCtx::is_statement_environmentfrom curated built-ins plus declarations, the math-routing template), each run up to a top-level;-carryingWORDwraps in aSTATEMENTnode — retrospectively, by the sameprecedesplice that buildsPARAGRAPH, so there is no gate and no scan. A run that never reaches a;stays plain paragraph content; a genuine\beginis a statement boundary. Only statement extent is modeled — noat/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: four gates (TextBracketGate,
LongTextBracketGate, MathBracketGate, MacrocodeBracketGate) asking whether
a [’s ] is reachable before the token that would make its optional walk
bail, in text, in the tightly delimited long-text exception, 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 ordinarily bails wherever the cursor stands, and a gate stricter or
looser than its parse is a bug. The long-text gate is the deliberate exception:
it removes only the paragraph anchor, and the parser selects it only when the
opener directly abuts its command and the located closer directly abuts a
structural mandatory group. That tight […]{…} evidence admits mixed long
optional/mandatory slots such as xparse +O followed by m; a standalone long
optional remains generic syntax. The decision is source-shape-driven—signature
arity neither attaches the bracket nor supplies its extent.
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 one preserved respect: its
\begin/\end anchor carries no in_macro_code filter, which can only decline
to attach. All four bracket gates ignore chunk-unmatched braces, matching the
walk they guard: optional treats such braces as ordinary macrocode tokens but
still bails at a structural R_BRACE.
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
.dtxdoc-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.
Ordinary input characters, including catcode-12 arithmetic operators, remain
coalesced in WORD runs, so unscripted a+2*1 is one CST token. A semantic
view exposes one virtual math atom per Unicode scalar without changing that
tree. Only structural script binding refines a run with byte-range sub-tokens:
when a script follows, the final input character is isolated as its base, so
a,b^2 scripts only b. When an unbraced script argument starts with a WORD
run, it likewise consumes one input character; any remainder returns to the
enclosing math list, so x^23_i parses as x^2 followed by 3_i. These are
TeX token boundaries, not arithmetic precedence. There is no
arithmetic-precedence expression tree.
The virtual-atom classifier is static semantic data. Its generated baseline is a
normalized extract of unicode-math v0.8r, pinned by commit and regenerated by
task math-symbols:sync; its LPPL notice and license ship beside the data.
Curated overrides add kernel aliases and the few cases where Badness must differ
from the baseline. It models Ord, Op, Bin, Rel, Open, Close,
Punct, Fence, and Inner, while a separate delimiter role records whether
an atom is actually pairable. This distinction keeps spacing classes such as
\sqrt’s Open and !’s Close out of bracket accounting. Commands and
characters use the same lookup, exact source spans survive multibyte characters,
and unknown commands conservatively fall back to Ord.
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.
One positional refinement is admitted during parsing: curated built-in slots may
declare an ArgumentDomain of Math or Text, independently of formatter
ContentKind. A shared matcher aligns groups with slots while skipping omitted
optionals. A matched Math group uses the ordinary math-element parser, while
Text, Unknown, unmatched, and over-attached groups use generic parsing.
Attachment itself remains greedy.
The same curated slot data may mark a braced argument verbatim. The lexer then
captures that balanced group as one VERB token, and a companion slot matcher
advances past the raw token so later parsed groups keep their positional domains
and content policies. This is a bounded catcode claim, not formatter opacity:
ContentKind::Opaque preserves whitespace in an already parsed group, whereas a
verbatim slot prevents characters such as % from becoming syntax at all.
Mechanical CWL signatures and scanned definitions cannot establish this mode.
The load-bearing claim is independence from mutable signature data. Positional
domain parsing reads only the hand-curated built-in tier—never package scopes,
scanned definitions, declarations, or CWL signatures. The latter sources assign
Unknown to every slot. 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 group that crosses an expl3 lexer-mode toggle, or 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.
The losslessness property suite complements the curated corpus and texlab
differential oracle. It checks arbitrary valid UTF-8 and recursively generated,
syntax-heavy malformed input through both the LaTeX and BibTeX parsers. LaTeX
cases run as documents, packages, .dtx sources, and with fixed declarations.
The sole assertion is byte-for-byte reconstruction; malformed input is not
expected to parse without diagnostics. Ordinary tests run 256 cases per
property, while task parser-properties and the scheduled CI job run 4,096.
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.
Project membership is an explicit ProjectFiles singleton input at MEDIUM
durability. The single writer synchronizes it whenever a normalized path enters
or leaves the tracked-file map; text edits do not touch it. The keyless
workspace_project query derives a canonical Project as plain Eq data, and
the include graph, package graph, label and citation resolvers, package-option
model, and per-file signature scopes are keyless queries over that value. This
shape is deliberate: Salsa retains interned values for the database lifetime, so
an interned Project retained every historical membership and all memos keyed
by it as the language server discovered files. A tracked value instead backdates
when membership is equal and replaces its one memo when membership changes. Read
jobs need no parallel membership vector—their database snapshot already contains
the matching input revision.
The query-execution log is also dormant by default. clear_query_log opens an
observation window for incremental tests; production language-server sessions
never enable recording and therefore cannot retain one log entry per query
execution.
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, delimiter-bearing math fragments cover local
shape changes, 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
math tier reparses the outermost enclosing delimiter-bearing math node and
splices that node. 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 script slicing, the expl3 argument slots, and the environment-name
assembly. Each is neutralized either by a text guard or by a position ban. Math
WORDs need one extra proof: the CST may hold several adjacent WORD leaves
cut from one lexer token by per-scalar script binding. The tier reconstructs
that coalesced word, relexes it as one WORD, probes only its outer neighbours,
and checks the edited leaf’s structural role. A direct SCRIPTED, SUBSCRIPT,
or SUPERSCRIPT word must remain exactly one Unicode scalar; an unscripted
prefix or remainder may change length. A moved boundary declines to the math
tier.
Refusals are free, so they are generous. Line terminators, environment names,
definition bodies, and a join probe against an oversized neighbour are refused.
.dtx is not refused wholesale: a fragment must expose enough of each docstrip
state bit for an isolated relex to disagree, and the source-scanning survey pins
one counterexample per bit.
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.
For .dtx, the fragment relex receives the base parse’s full-file
implicit_expl bit. It still has to reproduce the concrete margin, macrocode,
and lexer-mode tokens around the capture; an edit that changes the full-file
signal is refused before relexing. Faithfulness is evidence about the fragment,
not permission to infer missing file state.
The math tier. A shape-changing edit cannot splice SCRIPTED or MATH
alone: neither carries the delimiter that establishes math mode. The tier takes
the outermost enclosing INLINE_MATH, DISPLAY_MATH, or math ENVIRONMENT,
including all enclosing math gates an inner edit could invalidate. An isolated
faithfulness parse under the base ParseCtx must first reproduce the old node.
The edit may touch only state-neutral math surface syntax; control sequences,
comments, environment names, definition-sensitive positions, @/: mode
ambiguity, and .dtx decline. The edited parse must yield one same-kind node
spanning every fragment byte, and a one-token right-boundary probe proves local
recovery cannot consume an unchanged suffix. Bases whose diagnostics are not in
source order also decline, because a local splice cannot reproduce a global
recovery-stack reorder.
The replacement parse supplies diagnostics inside the fragment; prefix
diagnostics are retained and suffix diagnostics shifted. The direct benchmark
pins partition-preserving edits to Token and an x^23_i boundary move to
Math. On the 730 KB benchmark document the measured math splice is about 10 µs
against a 26 ms full parse, over 2,500x faster.
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. Math operators are virtual atoms
inside a coalesced WORD, so inserting insignificant math whitespace makes the
output re-lex into separate leaves. 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
authored newline immediately after a LINE_BREAK node is the one Tier-2 hard
boundary in a structurally plain, command-only text group: it remains a newline,
while inline and macro-like groups retain their ordinary fill and a same-line or
glued successor remains untouched. The preservation is a fixed point; a break
after every LINE_BREAK and the block-form delimiter framing are re-emitted, so
the same structural gate selects the same layout on the next pass. The narrow
shape avoids claiming that \\ in opaque macro code is semantic. Virtual .dtx
documentation streams are excluded because a forced child break can escape
through their rebuilt % margins and perturb structural framing on the next
pass. 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. Under Break, the operator layout is
precedence-aware where TeX’s Bin and Rel classes are too coarse: additive
operators such as \pm remain continuation points, \cdot stays with its
multiplicative term, and a top-level \mid keeps the following condition’s
relations out of the equation-chain alignment. This may leave a cohesive term a
few columns over width rather than strand a short operator fragment.
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::StatementInterior (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 bound leading
documentation comment and a maximal leading run of comment-terminated
command-only macro invocations stay outside the hang at body indentation. Both
gates read comment presence rather than authored newline shape, and their forced
comment breaks reproduce the same prefix on the next pass; after non-command
statement content begins, a post-comment tail remains a hanging continuation. 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 requires the effective signature scope to
reach RuleContext; its current lazy user_definitions database contains only
definitions scanned from the file, not built-ins, project declarations, or
loaded-package signatures.
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. Such an environment composes as a self-margin-owning block inside a
documentation paragraph, so prose before and after it continues through the
ordinary margin-aware reflow without acquiring a second prefix. Alignment grids
consume that virtual stream recursively before measuring cells: physical margins
and their padding never enter a cell, nested continuation newlines collapse in
virtual coordinates, and the prefix-aware printer accounts for the documentation
margin only after the source columns are laid out. 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.
Commands are otherwise opaque to math lowering. A resolved signature containing
a positional Math domain opens only those matched brace or bracket groups to
recursive math formatting; the control word, trivia, Text and Unknown slots,
unmatched groups, and groups beyond declared arity remain byte-for-byte as
authored. Formatter signature precedence still applies, so a scanned
redefinition shadows the curated built-in with Unknown domains and restores
whole-command preservation.
The license for normalizing math whitespace is correspondingly narrow. It covers
ordinary catcode-10 whitespace delivered directly to a math list, where TeX
discards it. Math ancestry alone grants no such license: whitespace inside text
islands or arbitrary macro argument token lists can be preserved, inspected, or
replayed in a non-math mode. Code, keys, comments, and explicit spacing commands
are likewise outside the license. The formatter therefore crosses a command
boundary only for a signature-proven Math slot; every other argument retains
its authored whitespace. tests/typeset/math_whitespace.tex exercises both a
macro that preserves argument spaces and one that branches on them.
Math lowering consumes the shared virtual-atom view rather than parser token
boundaries. In direct math content and signature-proven Math slots, its policy
places one space around Bin and Rel atoms, preserves compound relation
spellings such as <=, :=, and ::=, and treats a binary atom without a left
operand as unary. Subscript and superscript content instead keeps punctuation
operators compact throughout its nested math subtree (i=1 and n+1) while
retaining spaces around control-word operators (x \in A and x \leq y).
Authored gaps at delimiter edges collapse away, so function application reads
\Gamma(x). Other classes are operands for this stage; in particular,
unicode-math classifies ordinary / as Ord. The formatter preserves a fully
glued slash (a/b) but symmetrizes a gap on either side (a/ b or a /b) to
a / b in both policies. Delimiter depth and unary-after-opener detection use
the classifier’s separate delimiter role, not parallel string tables. The
ellipsis lint consumes the same facts but applies its own policy.
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.
At the lexer boundary, CRLF is one physical end-of-line unit. This remains true
when a preceding backslash swallows the line ending into a CONTROL_SYMBOL: the
token spans both \r and \n, just as its LF counterpart spans the \n.
Keeping that unit atomic gives LF and CRLF the same token-kind and CST shape
while the lossless tree still preserves their original bytes.
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:
- Verb defines scope.
% badness-format ...,% badness-lint ..., and% badness ...share one grammar, withskip/off/on/skip-fileverbs. - Legacy spellings still work.
% badness-ignore ...is deprecated but intentionally still supported. .bibuses a different carrier. Directives are read from@comment{...}entries because BibTeX has no%line-comment token between entries.
The resolver also retains every recognized directive with its carrier range and
an outcome: honored, dangling skip, unmatched on, unclosed off, or
unsupported. This is the single source of truth for inert-suppression; lint
rules do not re-parse comments or repeat the CST attachment walk. Directive-like
text on a .dtx DOC_MARGIN line is retained as unsupported without creating a
suppression range, as is the format-only axis in a BibTeX @comment carrier.
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, whether it is enabled by default, 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 effective mode and one of
\if…\else…\fi branch paths. The mode index partitions token ranges into
Math, Text, and Unknown: document content begins as text, explicit MATH
bodies override it, and command or environment arguments override their ambient
mode with the matched curated positional domain. Unknown commands, unmatched or
over-attached groups, and uncurated slots are Unknown; direct groups inherit.
Nested explicit math may in turn override a text island. Math-only rules require
Math, text-only rules require Text, and rules whose fix differs by mode skip
Unknown.
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. With
no select, resolution starts from rules whose default_enabled value is true;
an explicit select may choose any current rule, including an opt-in one.
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. It also retains braces around
standard named math operators such as \max: those commands expand through
\mathop, which TeX cannot consume as an unbraced script field.
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. Salsa
cancellation is a synchronous unwind that composes with lsp-server’s sync main
loop plus threadpool.
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.
Bibliography resource lookup (project::bibliography) keeps static command
extraction pure, then resolves missing literal .bib paths at the filesystem
boundary. A project-local file wins; plain BIBINPUTS/TEXBIB entries provide
a no-subprocess fallback, and kpsewhich --progname=bibtex --format=bib handles
the full Kpathsea grammar when available. The CLI loads the result only as a
citation dependency, while the language server publishes the written-to-actual
path alias as an explicit salsa input. Parser shape and citation queries
therefore remain independent of ambient environment state, and navigation
retains the real file location.
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
- 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.
- 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. - Parser and CST work must keep the salsa reparse path viable.
- Parsing is the parser’s job. Never paper over a parser mistake in the formatter, and never let parsing logic creep into the formatter.
- 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. The CST also cannot prove that changing a
space leaves TeX’s output alone: a key-value license can materialize a space
token, while an over-broad math license can rewrite one captured by a macro.
task typeset:check therefore 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.23.0 (2026-09-03)
Features
Bug Fixes
- linter: skip positional verbatim arguments (
08b5243), fixes #175 - linter: ignore Beamer overlay ranges (
d57390f), fixes #174 - formatter: stabilize long optionals (
04d8ecf) - parser: parse long mixed optionals (
38adc16) - formatter: format empheq as math (
e7a2140), fixes #172 - linter: retain braces around math operators (
946bf2b)
Dependencies
- updated crates/badness-formatter to v0.8.3
- updated crates/badness-parser to v0.9.0
0.22.1 (2026-08-31)
Bug Fixes
- formatter: attach citations to sentences (
57e9bb8), fixes #163 - formatter: normalize commented env args (
6154eb9)
Dependencies
- updated crates/badness-formatter to v0.8.2
- updated crates/badness-parser to v0.8.1
0.22.0 (2026-08-28)
Features
- lsp: add Beamer frame symbols (
11f2a76)
Bug Fixes
- accept Windows short paths in BIBINPUTS (
3a243cd) - resolve bibliography search paths (
c867a8e), fixes #161 - formatter: align macro statement prefixes (
4e7efa5), fixes #158 - formatter: prioritize math breakpoints (
9f85294) - formatter: preserve postfix limit signs (
658ef51) - loosen test (
5a6eeb8)
Dependencies
- updated crates/badness-formatter to v0.8.1
- updated crates/badness-parser to v0.8.0
0.21.0 (2026-08-27)
Features
- formatter: close display math lines (
7394f25) - formatter: glue inline command arguments (
f1093ff) - formatter: split environment keyvals (
3ea1412) - editors: add Zed extension (
c898e44)
Bug Fixes
- formatter: preserve BibTeX lint directives (
e0567a9), fixes #159 - formatter: split commented begin tails (
5d30318) - formatter: preserve authored line-break rows (
4886414) - lint: remove
\paragraphrule from headings lint (0343e90) - lint: retire mismatched delimiter rule (
a034f0b) - formatter: keep labels with headings (
7ba5fa5) - formatter: match omitted environment slots (
c00340f) - formatter: honor control-word boundaries (
4ee6fc6) - formatter: recognize unary math signs (
6afa14f)
Performance Improvements
- formatter: cache group break state (
4a37963)
Dependencies
- updated crates/badness-formatter to v0.8.0
- updated crates/badness-parser to v0.7.0
0.20.0 (2026-08-26)
Features
- formatter: configure item indentation (
b869f10), closes #150 - formatter: indent commented begin arguments (
1da8350) - report inert suppression directives (
70e57f7) - lint: check minipage caption labels (
6ea84bd) - lint: flag indented docstrip guards (
9aabc58) - drop MSRV to 1.89 (
b5c6250) - linter: warn on retired suppressions (
7c30f9d)
Bug Fixes
- formatter: close environment lines (
c72d71d) - formatter: treat gathered as math (
b31668c) - formatter: align nested math environments (
eeebbbf) - support Rust 1.94 (
a7afd67)
Dependencies
- updated crates/badness-formatter to v0.7.0
- updated crates/badness-parser to v0.6.0
0.19.0 (2026-08-25)
Features
- linter: check labels before list items (
779e9cc) - lint: detect invalid macrocode frames (
9be2612) - ci: scan arXiv source projects (
c5e256b), ref #152 - formatter: separate section headings (
a75a46e) - collect labels from environment options (
df6f1f3) - add command declarations (
c9effb8) - lsp: add table column refactor (
def2998) - formatter: align row terminators (
7277610) - lint: detect extra alignment tabs (
1129686)
Bug Fixes
- formatter: keep scripted colon relations tight (
77833dc) - formatter: preserve colon relations (
dbb64a2) - lsp: refresh cached config (
27f22d0) - parser: close citation command set (
e10aa54) - linter: handle nested subfigure labels (
bb0fed5) - lsp: scope range formatting to selection (
4439eeb), fixes #149 - formatter: wrap long citation lists (
8dbca9e) - honor curated ref/cite families in declarations (
a3aa4ba)
Performance Improvements
- firewall declarations by parse and semantic tier (
ef61239)
Dependencies
- updated crates/badness-formatter to v0.6.0
- updated crates/badness-parser to v0.5.0
0.18.0 (2026-08-24)
Features
- add LSP memory benchmark (
9a975d7) - formatter: glue item overlays (
e7b3efe) - parser: reparse math fragments (
4638eb5) - formatter: unify math spacing (
170da11) - add semantic math atom classification (
3889311) - parser: add argument domains (
f7f4b01)
Bug Fixes
- parser: consume CRLF control symbols atomically (
20dd182) - parser: parse alignment char constants (
78f3cd9) - parser: guard expl3 mode boundaries (
08c0387) - formatter: preserve trailing control newline (
2a7977b), fixes #141 - lsp: replace project snapshot generations (
a1194cb) - lsp: gate query logging (
f212680) - parser: tighten catcode signal (
834c7b6) - parser: pass plain braces through optionals (
82b30f1) - parser: parse href URLs verbatim (
03ad84f) - formatter: preserve glued forced blocks (
4938f6c) - formatter: align virtual dtx tables (
e0a2cde) - parser: preserve argument mode semantics (
63aefa8) - formatter: bound relation alignment (
a560698) - formatter: preserve math line breaks (
a1ad967) - parser: honor TeX script atom boundaries (
e6131b6)
Dependencies
- updated crates/badness-formatter to v0.5.0
- updated crates/badness-parser to v0.4.0
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-lintsuppression 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-captionrule (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
CONDITIONALnode 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
\begintail 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_conditionalassumes (902dbd9)
Performance Improvements
- cli: use Histogram for the
--checkdiff, measured on the corpora (29a678a) - cli: pick Patience over Histogram for the
--checkdiff (595abb3) - cli: diff
--checkwith 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_linefrom 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-wasmplayground shim crate (6486890)
Bug Fixes
- formatter: hug detonating atoms in fallback fills (
db63ddb) - formatter: accept a relation as an expl3
Nslot (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 jsonmachine-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
codeexampleas 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.texunder 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
\textttdashes and\foreachranges (f605ee5) - linter: skip citation locators and env titles in hard-coded-reference (
620bde5) - linter: skip xypic
@DSL inmakeat-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
\labelonto its own line (18b17c5) - add stable-diff paragraph wrapping (#41) (
5734632) - format: indent expl3 continuation groups one step (
d53f064) - config: add
BADNESS_CONFIGenv var for config path (b8756e8) - cli: add hidden
debug formatcheck command (da5959f) - config: add global user config fallback (
41c4570), closes #40 - formatter: add
math-wrapdisplay-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
\leftinstead 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/\endon 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/\endin 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-server0.10ResponseAPI (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-excludetoformatandlint(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-optionrule 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
\labellabels (01a8b0b) - project: scan
.auxfor label numbers and toc (ed48898) - config: add
[build]section withaux-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/\documentclassnames (2457147) - completion: add baked package/class name lists (
ff4906d) - lsp: add document links (
915aea6) - lsp: highlight matching
\begin/\endpair (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
0.1.0 (2026-06-12)
Breaking changes
- rename fmt to format (
1fedc1b)
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 lintcommand (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)