Skip to content

fix: measure Format.align at the column the renderer uses - #14693

Open
jcreinhold wants to merge 4 commits into
leanprover:masterfrom
jcreinhold:fix/pp-trailing-whitespace-sepByIndent
Open

fix: measure Format.align at the column the renderer uses#14693
jcreinhold wants to merge 4 commits into
leanprover:masterfrom
jcreinhold:fix/pp-trailing-whitespace-sepByIndent

Conversation

@jcreinhold

@jcreinhold jcreinhold commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

This PR fixes the layout of Format.align (force := true), which sepByIndent emits before the first item of every newline-separated indented sequence, so it reaches tactic blocks, where clauses, source-parsed structure instances, conv, grind, and the Lake DSL.

A forced align pads to the current indentation level when the column is before it, and breaks to it otherwise. Two parts of the engine must agree on which one happens: be, which renders, and spaceUptoLine, which decides whether a group flattens. They did not. spaceUptoLine measures in budget coordinates (w = width - column, m = width - indent), but its align case was written as though those were the column and the indentation level, so it computed the inverse of be's test and charged the width of a break as padding. A group whose flattened form is 10 columns wide was not flattened until width 23, and def foo (a : Nat) : Packet where put where on a line of its own at every width below 71 (#14715). spaceUptoLine' had the same encoding fail a second way, recomputing the indentation budget from the column a group started at after w had already advanced past it. Measuring in the coordinates be compares fixes both and leaves one shared predicate, alignPads, for the question the two parts ask.

On top of that: a line flattened into a space immediately before a forced align now breaks rather than stranding the space, and a forced align on a row that is still blank and already at its indentation level no longer emits an empty row (#14692); the lookahead that decides the first of those now also sees past groups, empty text, and unforced aligns, and into the enclosing groups; and every break that is not a group's own flattening decision — a hard newline, a breaking align, a vetoed line — re-decides that group from the column it actually reached, so a group can no longer render a row it never measured.

Over 600 random align-bearing documents at four widths, rows carrying trailing whitespace go from 559 to 0 and rows wider than the target from 92 to 1, with no rendering acquiring one. Over 400 align-free documents at widths 0–30 the output is unchanged.

Closes #14692, closes #14715.

@jcreinhold
jcreinhold requested a review from kim-em as a code owner August 5, 2026 19:06
@jcreinhold
jcreinhold force-pushed the fix/pp-trailing-whitespace-sepByIndent branch from d829102 to 4c8cc87 Compare August 5, 2026 19:08
@github-actions github-actions Bot added the toolchain-available A toolchain is available for this PR, at leanprover/lean4-pr-releases:pr-release-NNNN label Aug 5, 2026
@mathlib-lean-pr-testing

mathlib-lean-pr-testing Bot commented Aug 5, 2026

Copy link
Copy Markdown

Mathlib CI status (docs):

  • ❗ Batteries/Mathlib CI will not be attempted unless your PR branches off the nightly-with-mathlib branch. Try git rebase 4c29de6f2cb93ffdabd2838c1eed6f55061bb605 --onto f2bcf2e8660ab2d16cf3cb50c8e127de0439a337. You can force Mathlib CI using the force-mathlib-ci label. (2026-08-05 19:36:51)
  • ❗ Batteries/Mathlib CI will not be attempted unless your PR branches off the nightly-with-mathlib branch. Try git rebase 4c29de6f2cb93ffdabd2838c1eed6f55061bb605 --onto bd97ed8e35639e17a11d1490cbc46d994e7b4513. You can force Mathlib CI using the force-mathlib-ci label. (2026-08-07 16:05:32)

@leanprover-bot

leanprover-bot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Reference manual CI status:

  • ❗ Reference manual CI will not be attempted unless your PR branches off the nightly-with-manual branch. Try git rebase 4c29de6f2cb93ffdabd2838c1eed6f55061bb605 --onto 23393b959b33e3a8d15796b2397f8a04c315b9f4. You can force reference manual CI using the force-manual-ci label. (2026-08-05 19:36:53)
  • ❗ Reference manual CI will not be attempted unless your PR branches off the nightly-with-manual branch. Try git rebase 4c29de6f2cb93ffdabd2838c1eed6f55061bb605 --onto c4e6b62c3d955ef20da94310797072f7c4c5fa2b. You can force reference manual CI using the force-manual-ci label. (2026-08-07 16:05:33)

@jcreinhold
jcreinhold force-pushed the fix/pp-trailing-whitespace-sepByIndent branch from 4c8cc87 to 1ec0b5f Compare August 5, 2026 19:46
…` arguments

This PR fixes the pretty printer emitting a trailing space before a
multi-tactic indented `tacticSeq` argument, as in `iterate 1␣` followed by
an indented sequence, and the whitespace-only blank line produced when the
separator broke at narrow widths.

A multi-tactic `tacticSeq` begins with a forced `align` (a hard line break)
emitted by `sepByIndent.formatter`. When a flattenable separator such as
`ppSpace` precedes the sequence, the enclosing group could flatten the
separator into a space even though the `align` then broke immediately,
stranding the space at the end of the row. The renderer now tracks whether
the current row is still fresh (no output since the last line break): a
`line` immediately followed by a forced `align` that will break is broken
instead of flattened, and an `align` already at its indentation level on a
fresh row is a no-op rather than emitting a second, whitespace-only line.
@jcreinhold
jcreinhold force-pushed the fix/pp-trailing-whitespace-sepByIndent branch from 1ec0b5f to a48d2b5 Compare August 5, 2026 21:23
`Format.align (force := true)` pads to the current indentation level when
the column is before it, and breaks to it otherwise. `be`, which renders,
and `spaceUptoLine`, which decides whether a group flattens, must agree on
which one happens. They did not: `spaceUptoLine` measures in budget
coordinates (`w = width - column`, `m = width - indent`), but its `align`
case was written as though those were the column and the indentation
level, so it computed the inverse of `be`'s test. `spaceUptoLine'` had the
same encoding fail a second way, recomputing the indentation budget from
the column a group started at after `w` had already advanced.

Measure in the coordinates `be` compares instead, leaving one shared
predicate, `alignPads`, for the question both parts ask. `fill` reserves
the column for its separating space through a new `colOffset` argument to
`pushGroup` rather than by shrinking `w`, which under column coordinates
would also move the `merge` short-circuit bound.

Two further disagreements between the lookahead and the renderer: extend
`nextForcedAlign?` past groups, empty text, and unforced `align`s, and
into the enclosing groups, since the next item to render is often the
first item of one of those. And re-decide a group after every break that
is not its own flattening decision -- a hard newline already did this, but
a breaking `align` and a vetoed `line` did not, so the group could render
a row it had never measured.
Five cases in `formatHardLineBreaks.lean` for the corrected measure: a
group flattening as soon as its flattened form fits, the column being
followed across work items rather than only within the first, a forced
`align` at the head of a group, and the two re-decision cases, which are
invisible except as a row wider than the target width.

The three regenerated expectations all join a line that the old measure
broke: `def foo : ... where`, `frobnicate {`, `let x := {`, `by try`, and
`. grind =>`.
@jcreinhold jcreinhold changed the title fix: stop emitting trailing whitespace before multi-tactic tacticSeq arguments fix: measure Format.align at the column the renderer uses Aug 7, 2026
Inline `alignPads`. Once the measurer is in column coordinates its test is
literally `be`'s, `col < indent`, so wrapping it in a predicate gained
nothing -- the name had to be read to learn which way round "pads" was,
and it turned `be`'s line into a diff hunk where inlining leaves it
identical to master. The invariant it carried is now a comment at the
measurer's `align` case, which is the site that was wrong.

Shorten the docstrings added to the private helpers, which the rest of
this file does not carry, and drop three test cases that discriminate
against nothing: `formatTacticSeq`'s user-defined `myiter` syntax and its
narrow-width repeat, both of which restate the `iterate` case one layer
above a `Format` fix, and `formatHardLineBreaks`'s width-23 rendering,
already bracketed by widths 10 and 22.
jcreinhold added a commit to jcreinhold/lean-fmt that referenced this pull request Aug 12, 2026
`sepByIndent` opens each item's row with a forced `align` for the first and a
`text "\n"` for the rest, then spells the item itself as `ppGroup`, whose
leading soft `line` fits and flattens to a space. So every multi-item `where`
body sat one column right of the column the list picked -- right of its own
docstring, and right of where `docs/style.md` says it goes. Not an edge case:
it is every `where` clause with more than one binding, in every project, at
every width. This tree had four of them and `lake lint` called them clean.
The column is `checkColGe`'s reference, so it is not only cosmetic.

The fix is the mirror of `dropTrailingBreak`, which the file's own docstring
recorded as tried and unsafe. The objection was measured and correct, and it
was an objection to *half* the rule: the earlier attempt covered `text "\n"`
only, so it fired on items 2..n and left item 1 a column to their right,
moving the reference column away from the block. Covering `align force` too
moves every item together. Safety is not empirical here -- `be`'s forced-align
case is `if k < indent then pad (indent - k) else pushNewline indent`, so it
emits at least one space or starts a row, and a break dropped behind either
cannot join two tokens. `Mathlib/Util/Superscript.lean:312`, the file the
recorded failure was measured on, reparses.

It is a correction on the append rather than an entry in `boundaryStarts`
because no boundary can reach the offending leaf: `constrainBoundary` applies
a collected layout to the first boundary leaf at a terminal, which here is the
`align`, and the break is the one behind it -- inside the `ppGroup`'s `nest 2`,
where `.hard` would land the item at the nest's column rather than the list's.

`native-layout`'s three-space assertion was the defect written down; it moves
to two. `Families.lean` gains a two-item clause beside the one-item one, whose
row-hugging is what makes the two-item column a claim about `sepByIndent`'s
`align` rather than about `where` clauses generally.

Also here, because it instructs the next maintainer to delete working code:
the note scheduling the align-compensation collectors for removal at the 4.34
bump said leanprover/lean4#14693 landed there. It did not. `Init/Data/Format/
Basic.lean` and `Lean/Parser/Extra.lean` are byte-identical between v4.33.0
and the pinned v4.34.0-rc1, and the shattering reproduces on the pin.

And `docs/style.md`'s flat `decl.signature` example, which only holds under
`declaration-body = "same-line"` and contradicted the documented default.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

toolchain-available A toolchain is available for this PR, at leanprover/lean4-pr-releases:pr-release-NNNN

Projects

None yet

2 participants