lean-fmt: Lean 4 Formatter and Linter
lean-fmt formats Lean source into one canonical style and adds static-analysis rules for common source-level problems.
Every Lean example in this manual is compiled as the page is built. An example that stopped being valid Lean would break the build rather than sit here quietly being wrong.
1. What lean-fmt does
lean-fmt format rewrites files to the canonical layout. lean-fmt check reports rule findings —
a duplicate import, a leftover debugging option, a line past the margin — and check --fix
applies the ones that are safe to apply. Formatting decides where the bytes go; a finding says
something in the source is wrong. Running one never implies the other.
2. One style, a few switches
lean-fmt has one style. There is no indent width to pick and no brace dialect to argue about in review: layout follows from the structure the compiler parsed and one number, the line width.
A few switches exist for the boundary decisions Lean itself leaves open — where a declaration's
body goes relative to :=, how an empty structure instance is spelled, whether a trailing comma
forces a break. They are listed under Configuration. Everything else is
fixed: two files that parse the same come out the same.
3. What formatting never changes
-
Meaning. A result is validated against the exact module setup before anything is written.
-
Order. Imports, binders, fields, match arms, and tactics stay in the order you wrote them.
-
Text. Comments, strings, and quotations keep their exact characters.
-
Line endings. A file's CRLF or LF is restored on write.
4. Exit codes
Every command uses the same three codes: 0 clean, 1 findings or preview differences, 2 the
run itself failed. The 1/2 distinction matters in CI: a 1 is the tool disagreeing with your
source, a 2 is the tool never having run properly. Treating them the same hides broken builds
behind what look like style failures.
5. Canonical layout
Two rules generate almost every decision. Structure decides the shape: layout comes from how Lean parsed the code, and nothing aligns to a column an earlier token happened to land on. Width decides where it breaks: a construct stays on one line while it fits, and where the breaks fall depends on the width alone, never on where the input was broken.
The default width is 100. The broken examples below are shown at width 34 so the breaks are visible on the page.
5.1. Declarations
A declaration that fits stays flat, except its body, which begins on its own line:
def map (f : α → β) (xs : List α) : List β :=
xs.map f
The next-line body is the layout Lean's own formatter produces, and it keeps the body in one
column however long the signature is. The declaration-body key moves it
back onto the := line.
When the signature does not fit, binders continue at four spaces and the body stays at two:
def combineEverything
(transform : Nat → Nat → Nat)
(firstList : List Nat)
(secondList : List Nat) :
List Nat :=
List.zipWith transform firstList
secondList
Four spaces marks a continuation of the signature; two marks the body. Binders pack onto shared rows while they fit — they split one per line only when each is too wide to share.
5.2. Operators
A chain that fits has one space around each operator. A broken chain keeps the operator with its left operand and indents the next operand two spaces — once, not once per link, so a long chain does not staircase off the margin:
def total (a b c d e : Nat) :
Nat :=
a + b * c + d - e + a + b + c +
d +
e
Precedence and associativity come from the syntax, never from a flat token stream. A parenthesised sub-chain is a chain of its own and gets its own column.
5.3. Conditionals and matches
A broken conditional puts each branch on its own line; a match writes one arm per line, pipes aligned to each other:
def classify (n : Nat) : String :=
if n == 0 then "zero"
else
if n < 10 then "small"
else "large"
def describe (xs : List Nat) :
String :=
match xs with
| [] => "empty"
| [_] => "one"
| _ => "many"
5.4. Structures and records
Fields and constructors go one per line, two spaces in. A record term hugs its braces while it fits:
structure Point where
x : Nat
y : Nat
def origin : Point :=
{ x := 0, y := 0 }
When it breaks, every continuation is two spaces from the {. Separators are preserved exactly —
the formatter never inserts or deletes a comma, so comma-free record syntax stays
layout-separated.
5.5. Lines that cannot be broken
An import, a long string literal, a URL in a comment, or a token from project-defined syntax may
have no break to take. lean-fmt leaves such rows over the margin rather than mangling them.
FMT016 reports every row wider than the configured width; it is off by default
because the unbreakable rows are the common case.
6. Rules and findings
A rule reports one specific problem in one place:
LeanFmt/Scratch.lean:4:1: FMT003 duplicate import of Lean
lean-fmt rules lists the registry; lean-fmt explain FMT003 describes one rule with a worked
example. Each rule carries four facts: a category (selectable as a unit, so --select imports
turns on every import rule), a stability (stable, or preview — off until --preview), a
fix (fixable or report-only), and whether it is on by default.
check --fix applies only fixes that are safe to apply. A rule that can spot a problem but cannot
repair it without guessing stays report-only by design: a fix that is right most of the time moves
the work from writing the change to auditing it.
Selection is the same on every command: --select sets the active rules, --extend-select adds,
--ignore switches off. The same choices live under [lint] in configuration. To silence one
occurrence rather than a rule, suppress it in place with -- lean-fmt: ignore[FMT003] — naming
the rule, so the next reader knows what was silenced.
Every rule has a generated reference page under
docs/rules/, generated from the
registry itself, so the pages cannot drift from the rules that run.
7. Configuration
Configuration is optional: with no file at all, every .lean file outside .lake is checked with
defaults. When files exist, the closest lean-fmt.toml (or .lean-fmt.toml) governs each
source file. Configs do not merge — a nested config replaces its ancestors, so write a nested one
as a complete statement of what that subtree wants. Both spellings in one directory is an error;
--config PATH skips discovery.
extend = "../shared/lean-fmt.toml" exclude = ["Generated/**"] [format] line-width = 100 declaration-body = "next-line" [lint] select = ["all"] ignore = ["FMT004"]
[format] holds the settings that change the bytes on disk; [lint] holds rule selection.
The keys worth knowing first:
-
line-width(1–1000) is the only number that changes layout. -
declaration-body = "same-line"keeps a fitting body on the:=line. -
reflow-comments = truerewraps overflowing--comment blocks, preserving the words and not the lines. Off by default. -
pinned-commentslists phrases marking a comment as immovable. The default is["shake: keep"]; setting the key replaces it. -
force-exclude = trueextendsexcludeto files named explicitly on the command line — what you want when an editor or pre-commit hook passes paths in.
One policy is not configurable: comments never change layout. Break decisions are computed from
the code alone, so a trailing comment overflows the margin rather than pushing the code around.
The alternative would split public import X to make room for a note — and the note would
usually overflow anyway.
lean-fmt config show PATH prints a file's effective settings and where each came from; run it
first when a file formats in a way you did not expect. The full key reference is in
docs/configuration.md.
8. Companion documents
-
Continuous integration — recipes, caching, and upgrading in CI.
-
Editor setup — the language server, with stanzas for VS Code, Neovim, and Emacs.
-
Canonical Lean style — the normative list of layout decisions, each with an identifier that indexes a test. Read it to learn whether a behaviour is intended; read the layout section here to see it.
-
Adding a rule — for contributors extending the rule registry.