Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Badness

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

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

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

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

The Architecture

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

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

Where to Go Next

Installation

Badness is distributed as a single binary, badness. The current version is 0.7.0.

From Source

Badness is written in Rust (edition 2024). 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.7.0.

Getting Started

Badness has three subcommands: format, lint, and lsp. This page walks through the first two 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

With no paths, badness reads from standard input and writes the formatted result to standard output—handy for piping or editor integrations:

cat paper.tex | badness format

Checking Without Writing

In CI you usually want to verify that files are already formatted rather than rewrite them. The --check flag reports which files would change and exits non-zero if any are not already formatted:

badness format --check paper.tex

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 no paths:

cat paper.tex | badness lint

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 Wrap Modes page 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  # report, don't write; non-zero if unformatted

Style Options

FlagDefaultMeaning
--line-width <N>80Maximum line width before the formatter breaks a line.
--indent-width <N>2Spaces per indent step.
--wrap <mode>reflowHow line breaks inside a paragraph are laid out. See Wrap Modes.

A configuration file is not yet read; style is set through these flags. This is expected to change as badness matures.

Guarantees

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

  • Idempotence: format(format(x)) == format(x).
  • Stability: formatting does not change the parsed structure of a document.
  • Protected regions: verbatim-like content (verbatim, lstlisting, \verb, comments) is never altered.

If the formatter ever produces output that violates one of these, that is a bug to report, not behavior to work around.

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 or the matching CLI flags:

[lint]
select = ["deprecated-command", "dollar-display-math"]  # allowlist
ignore = ["missing-nonbreaking-space"]                  # turn off

Suppress a rule at one site with a comment directive:

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

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

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.

Formatter width settings can be supplied as initializationOptions at startup or through workspace/didChangeConfiguration: lineWidth and indentWidth, either as a bare object or namespaced under a badness key. 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.

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.

Other Editors

Any LSP-capable editor can drive 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.

The language server is young; the set of supported LSP requests will expand. Track progress in the Changelog.

Command-line reference

A formatter, linter, and language server for LaTeX

Usage: badness [OPTIONS] <COMMAND>

Options

--config <PATH>
Path to a badness.toml to use instead of discovering one. Applies to format and lint; ignored by parse, lsp, and init
--no-config
Ignore any badness.toml and use built-in defaults

badness format

Format LaTeX source.

With paths, formats each file in place. With no paths, reads stdin and writes the formatted result to stdout.

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

Arguments

<PATHS>...
Files to format. Omit to read from stdin

Options

--check

Report which files would change without writing them. Exits non-zero if any file is not already formatted

--stdin-filepath <PATH>

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

--line-width <LINE_WIDTH>

Maximum line width before the formatter breaks a line

--indent-width <INDENT_WIDTH>

Number of spaces per indent step

--wrap <WRAP>

How to lay out line breaks inside a paragraph

Possible values:

  • reflow: Greedy fill: wrap words to the line width (default)
  • sentence: One sentence per line (line width ignored)
  • semantic: Semantic line breaks (sembr.org): keep authored breaks and add breaks at sentence boundaries
  • preserve: Leave authored line breaks untouched
--exclude <PATTERN>

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

badness lint

Lint LaTeX source, reporting parse diagnostics.

With paths, lints each file. With no paths, reads stdin. Exits non-zero if any diagnostics are reported.

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

Arguments

<PATHS>...
Files to lint. Omit to read from stdin

Options

--fix
Apply safe autofixes in place, then report what remains. Requires path arguments; has no effect on stdin (there is nothing to write)
--unsafe-fixes
Also apply fixes that may change typeset output (requires --fix)
--stdin-filepath <PATH>
Name the stdin buffer so its language is dispatched by extension (.bib → BibTeX, anything else → LaTeX). No file is read or written; only the extension is used. Ignored when paths are given
--exclude <PATTERN>
Gitignore-style pattern to skip during directory discovery (repeatable). Added on top of any exclude/extend-exclude from badness.toml
--select <RULE>
Run only these rules (repeatable). Overrides [lint] select from badness.toml when given
--ignore <RULE>
Disable these rules (repeatable). Overrides [lint] ignore from badness.toml when given
--explain <RULE>
Print the description and examples for a rule id, then exit. Ignores paths, config, and fixes

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. With no path, reads stdin.

Usage: badness parse [PATH]

Arguments

<PATH>
File to parse. Omit to read from stdin

badness lsp

Run the language server over stdio

Usage: badness lsp

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-ignore <id> comment suppresses.

Every rule is on by default; narrowing happens only through select/ignore (see Configuration). 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 but not yet catalogued here.

abbreviation-spacing

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

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

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

An acronym ending a sentence takes intersentence spacing:

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

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. No autofix: resolving a collision (rename vs delete) is the author’s call.

The same key defined twice in one file:

\section{One}\label{sec:x}
\section{Two}\label{sec:x}
warning: duplicate-label
 --> example.tex:2:14
  |
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. The autofix swaps just the control word (\bf -> \bfseries), leaving any following text untouched, so it is correct by construction.

An obsolete two-letter font switch:

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

After applying the fix:

{\bfseries important}

missing-nonbreaking-space

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

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

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

obsolete-environment

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

The superseded eqnarray environment:

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

After applying the fix:

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

primitive-command

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

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 and copies the body verbatim, so it parses and stays lossless; it is withheld when the display math is unclosed.

Plain-TeX display math:

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

After applying the fix:

\[a + b = c\]

ellipsis

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

Literal dots in text:

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

After applying the fix:

See Chapter 2, 3, \dots for details.

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

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

hard-coded-reference

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

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

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

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

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

straight-quotes

Flag a literal ASCII double quote (") used for quotation. In LaTeX a straight " always sets a closing double quote, so an opening one comes out backwards; the correct forms are `` (two backticks) to open and '' (two apostrophes) to close. 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, and math are never touched.

Straight ASCII double quotes around a phrase:

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

An opening quote after a parenthesis:

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

swallowed-space

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

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

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

space-before-command

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

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

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

mismatched-delimiter

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

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

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

dash-length

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

A hyphen where a number range wants an en dash:

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

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

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

times-variable

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

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

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

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

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

math-operator-name

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

A bare function name typesets as italic variables:

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

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

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

makeat-macro

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

An internal @ macro used without \makeatletter:

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

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

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

sectioning-level-jump

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

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

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

missing-required-argument

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

A fraction missing its denominator:

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

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

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

undefined-ref

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

A reference to a label defined nowhere in the document:

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

undefined-citation

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

A citation of a key that matches no bibliography entry:

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

unreferenced-label

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

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

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

verbatim-trailing-text

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

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

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

duplicate-package

Flag a package loaded more than once in the same file with \usepackage/\RequirePackage (which share one package namespace). LaTeX loads a given package only once; a second load is redundant and, when the options disagree, an option-clash error. No autofix: removing a load can drop options the survivor lacks, and which load to keep is the author’s call. Class loads (\documentclass/\LoadClass) are a separate concern and are not flagged.

The same package loaded twice:

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

missing-provides

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

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

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

Configuration

Rules are selected through the [lint] table in badness.toml, or the matching CLI flags:

[lint]
# When present, an allowlist: only these rules run.
select = ["deprecated-command", "dollar-display-math"]
# Applied on top of select (or the default set): these are turned off.
ignore = ["missing-nonbreaking-space"]

An unknown rule id is reported at lint time, not rejected at config-parse time. To suppress a rule at a single site, use a comment directive:

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

% badness-ignore-file <id>: ... suppresses one rule file-wide, and % badness-ignore-file: ... suppresses all rules file-wide. Parse diagnostics (rule id parse) are never suppressed by select/ignore.

Wrap Modes

The --wrap flag controls how the formatter lays out line breaks inside a paragraph. It does not affect structure—only where soft line breaks fall.

badness format --wrap preserve paper.tex
ModeBehavior
reflowDefault. Greedy fill: pack words up to --line-width, breaking only where the next word would overflow.
preserveLeave the authored line breaks untouched.
sentenceOne sentence per line. Line width is ignored—a long sentence stays on one line.
semanticSemantic line breaks: keep the author’s soft breaks and add a break after each sentence.

How reflow works

Reflow is implemented through the formatter’s Doc layout engine as a fill node (in the Wadler/Prettier sense): per-gap greedy break decisions, where each inter-word gap independently becomes a space or a line break depending on whether the next word fits within --line-width. The printer remains the single layout authority—wrap modes are expressed through it rather than as a separate line-filling pass.

Sentence and semantic

Both sentence and semantic split a paragraph at sentence boundaries, one sentence per line, ignoring --line-width. 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., …), an ellipsis (..., ), or a contextual abbreviation whose following word signals that the sentence continues (U.S. Government stays together, U.S. However splits).

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.

Language and abbreviations

The abbreviation profile is chosen by document language. Built-in profiles cover English (default), Czech, German, Spanish, and French; an unknown or unset language falls back to English. Set the language and extend the no-break list in badness.toml:

[format]
wrap = "sentence"
lang = "de"                 # BCP-47-style code; the region subtag is folded away

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

An abbreviation listed here never ends a sentence, so no line break is inserted after it. (Automatic language detection from babel/polyglossia is not yet implemented; set lang explicitly.)

Benchmarks

Wall-clock formatting speed of badness against tex-fmt and latexindent, measured with hyperfine. Every tool formats stdin → stdout, so the comparison is free of file-mutation and exit-code noise.

This is not a quality gate and not a parity target. Timings are machine- and run-dependent, and these numbers measure speed only, never output equivalence. The tools also do different work: latexindent only indents by default and does no line reflow, while badness and tex-fmt wrap—so a raw speed comparison is a snapshot of each tool at its defaults, not equal work. Treat the ratios, not the absolute milliseconds, as the takeaway.

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.

How it is measured

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

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

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

Setup

  • badness: 0.4.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-06-25T14:42:00Z

Results

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

ToolMean (ms)Min (ms)Max (ms)Relative
badness7.49364.479710.1637baseline
tex-fmt2.26350.98346.79843.3× faster
latexindent98.756085.0572129.181413.2× slower

cv.tex (6273 bytes, 275 lines)

ToolMean (ms)Min (ms)Max (ms)Relative
badness8.26525.480514.5927baseline
tex-fmt2.33831.36184.20653.5× faster
latexindent101.054590.7838137.200712.2× slower

masters_dissertation.tex (95383 bytes, 2458 lines)

ToolMean (ms)Min (ms)Max (ms)Relative
badness22.599919.496529.5256baseline
tex-fmt3.52941.85976.76916.4× faster
latexindent2410.02072398.46192430.3737106.6× slower

Contributing

Badness is an open-source project. The authoritative guide for working in the codebase (architecture, tenets, and conventions) lives in CONTRIBUTING.md at the repo root, written for both human and AI contributors.

Architecture

Badness follows the rust-analyzer model:

  • A hand-written, error-tolerant lexer and parser turn LaTeX into a flat token stream, then an event stream (Start/Tok/Finish), which a tree builder re-attaches trivia to and feeds into rowan to produce a lossless concrete syntax tree.
  • A semantic layer: a signature database—assigns meaning (arity, verbatim-ness, sectioning) on top of the generic tree. Meaning never leaks into the parser.
  • The formatter lowers the tree into a Wadler-style Doc IR, which a printer lays out under a flat/break fit model.
  • Incremental recomputation is salsa-first: green nodes are stored in salsa and red cursors are materialized on demand.

Ground rules

  • Keep the syntactic layer free of semantic knowledge.
  • New parser features need corpus and snapshot tests and a losslessness assertion.
  • Keep code rustfmt-clean; clippy warnings are errors.

See CONTRIBUTING.md for more details on the architecture, tenets, and conventions.

Changelog

0.7.0 (2026-07-08)

Features

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

Bug Fixes

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

Performance Improvements

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

0.6.0 (2026-07-06)

Features

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

Bug Fixes

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

Reverts

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

0.5.0 (2026-07-01)

Features

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

Bug Fixes

  • lsp: honor excludes for siblings (7a50529)

Performance Improvements

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

0.4.0 (2026-06-23)

Features

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

Bug Fixes

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

0.3.0 (2026-06-21)

Features

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

Bug Fixes

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

0.2.0 (2026-06-12)

Features

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

0.1.0 (2026-06-12)

Breaking changes

Features

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

Bug Fixes

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