Skip to content

Query based formatter with max line width support - #12768

Draft
LeonMatthes wants to merge 42 commits into
slint-ui:masterfrom
LeonMatthes:query-based-formatter-max-line-width
Draft

Query based formatter with max line width support#12768
LeonMatthes wants to merge 42 commits into
slint-ui:masterfrom
LeonMatthes:query-based-formatter-max-line-width

Conversation

@LeonMatthes

Copy link
Copy Markdown
Member

This is a variant of #12451 that also adds maximum line width support.

It basically uses the current rule set and extends it by allowing the formatter to decide whether a given set of soft lines should be broken onto multiple lines, not just by determining whether the soft line group was multi-line in the input, but also whether it is needed to break it over multiple lines to prevent the document from going over the maximum line width.

To decide which output is optimal in terms of what needs line breaks and what does not, it uses this algorithm here, which is also linked in the corresponding issue in the topiary repository (topiary/topiary#700):

A Pretty Expressive Printer — Sorawee Porncharoenwase, Justin Pombrio,
Emina Torlak. OOPSLA 2023. https://doi.org/10.1145/3622837
(full paper with appendices:
https://sorawee.github.io/pretty-expressive-oopsla23-artifact/full-paper.pdf)

The score of every part of the document is lexicographical by:

  1. squared number of characters over the maximum line width
  2. number of deviations from the authors layout
  3. number of newlines in the output

(lower is better)

This ensures as long as the number of characters is less than the max line width, we strictly follow the authors layout (as 1. is 0, so we compare by 2. first and then 3.).

This is inspired by the Topiary Crate which uses Tree-sitter syntax
nodes that it annotates with specific formatting annotations that it
then uses to format the entire document. We now use a similar approach
here but based on our own grammar which is based on the Rowan Crate.
Comments in a gap now split it into sub-gaps that are resolved
independently instead of the whole gap being kept verbatim. Trailing
comments hang on their line, own-line comments re-indent to the
current level (except at column 0, which compiler syntax tests rely
on), blank lines above comments are preserved and capped at one, and
re-indented multiline block comments shift their continuation lines
to keep internal alignment. Rules can now re-indent a comment but
never move it onto a different line.

Also convert the rules.rs integration tests to multi-line string
literals for readability, and reconcile API_DESIGN.md's Instruction
section with the implemented slot-indexed shape.
…er, at)

Positional and conditional rule logic stays in plain Rust: children()
yields the significant direct children, iter() exposes them as
NodeOrToken, and at() re-enters the selection API from whatever the
iteration found. token_matching() covers kind-set matches (e.g. the
operator of a BinaryExpression); token() now delegates to it.

child_tokens() and at() also structurally exclude the Eof terminator,
which would otherwise anchor atoms past the last real gap.
any_node() registers rules that run for every node at Tier::Wildcard,
between Token and Node in the override order. Dispatch reuses the node's
selection across both tiers: atoms record their tier at attach time, so
retargeting the selection afterwards is invisible to what the wildcard
rules produced.

InputSoftline at a boundary with no input newline now abstains instead
of pushing a tier-bearing Nothing. A Node-tier Nothing would otherwise
veto a weaker-tier Space (e.g. a wildcard adjacent-node space), gluing
single-line constructs together. It still counts toward gap engagement.
A rule can mark a node as a leaf (Selection::leaf()) to emit its interior
verbatim: the gaps strictly inside its significant span are kept as
written no matter what rules fired there, while the boundary gaps just
before and after still resolve so the leaf spaces against its
surroundings. Indentation still accrues across a leaf so a balanced
brace pair inside it keeps the running level exact.

The sink now collects markers alongside boundary atoms (Annotations);
resolve normalizes the leaf ranges to outermost disjoint spans and
suppresses interior gaps. make_rules leafs @rust-attr contents and
string-template interpolations, whose foreign tokens the global
colon/comma rules would otherwise mangle.
A `// slint-fmt:ignore` comment leafs the construct starting at the next
significant token, emitting it verbatim. apply_ignore_directives runs
between annotate and finish, producing the same Marker::Leaf the leaf
rules do; leaf normalization dedups it against any overlapping rule-leaf.

ignored_span walks the token's ancestors for the outermost node that
still begins at that token, capped below the Document so a top-level
directive ignores one item rather than the whole file. The match
tolerates trailing whitespace (invisible in a line comment) but is
otherwise exact, mirroring the reference ruleset.
Atom::Literal(String) injects fixed text at a boundary (append-literals
hug the left token, prepend-literals the right); Selection::delete()
marks a token for removal. Together they manage a list's trailing comma:
append Literal(",") to the last argument when the list breaks across
lines, delete the comma when it collapses onto one line.

A deleted token emits nothing (still passing the writer once) and its
gap collapses; the next surviving gap sources its append-side atoms from
the last emitted token, so the two gaps around a deleted token merge
without any structural change to the sub-gap machinery. A delete inside
a leaf range defers to the leaf.

Adding Literal costs Atom/AtomInstance/Instruction their Copy (Clone is
kept); new EmitLiteral/DeleteToken instructions render the two effects.
SyntaxToken::next_token relied on rowan's first_token() to descend into
the next node, but rowan only descends into the FIRST child: a node
whose first child is an empty node (e.g. an empty leading QualifiedName
from `inherits {`, or the empty leading Expression of a wildcard match
case with no prior case) reported no first token, so the walk skipped
the entire subtree — panicking the query formatter's linearization in
debug and silently dropping that code in release.

Replace the shallow first_token() with a properly recursive first-token
search that skips empty children. Regression test covers both error-tree
shapes.
EmitLiteral instructions for append/prepend literals were pushed
unconditionally, before the leaf_internal check, so a Literal attached
to a token strictly inside a Leaf range (including ranges leafed by
// slint-fmt:ignore) leaked into supposedly-verbatim output — unlike the
paired delete(), which was already suppressed. Gate both literal loops
on !leaf_internal so all boundary effects are suppressed uniformly
inside a leaf.

Also adds the two next_token error-tree shapes to
linearize_visits_every_token as engine-level regressions.
API_DESIGN.md phase-2 step 8 / constraint 5 require that no Hardline
resolve strictly inside a softline measure span that itself resolved
single-line — such a newline would flip the span to multiline on the
next run, breaking format(format(x)) == format(x).

resolve now records (debug builds only) the single-line-resolved
softline spans and the positions where a Hardline actually produced a
newline, then panics if any Hardline newline lands strictly inside a
single-line span. All tracking is #[cfg(debug_assertions)], so release
builds are unaffected. A #[should_panic] test with a deliberately
violating ruleset confirms the guard fires.
The formatter is no longer a no-op except where rules fire. Every gap is
now resolved: a rule's atoms decide the boundary, or a default applies —
a single space between two tokens, nothing at the document edges (before
the first token and before Eof). The only input-preserving paths left
are the deliberate Leaf feature (@rust-attr, string templates,
// slint-fmt:ignore) and the comment never-move policy.

This makes the formatter diff-testable against the repository: it
reformats everything, so a run shows exactly what the (still minimal)
ruleset does and does not yet cover, and the diff shrinks as rules are
added.

resolve drops the has_spacing_atom gate; resolve_gap takes a default
Strength and loses its 'keep as written' branch; Instruction::KeepSubGap
is removed (KeepGap remains only for leaf interiors and a deleted token's
comment-bearing gap). Tests, API_DESIGN.md and comments updated to drop
the concept.
Port the Topiary-experiment's corpus-testing harness
(origin/experiment-slint-fmt-topiary) to the new query-based formatter,
as a `slint-lsp` example so it stays out of normal builds and installs.
`diff` reports whole-file line churn against a corpus; `canonicalize`
checks that indentation-perturbed multiline blocks reconverge to the
same formatted output. Both call format_document_query through the
existing writer API without touching the formatter itself.
Translate the highest-signal slint.scm layout rules onto the query engine:
top-level items on their own lines, element/code-block bodies breaking one
item per line, struct/enum/object-literal/array bodies, and comma-driven
list breaks. Fast-corpus churn drops from 37477 to 22994.
Dot/member-access gluing, property angle brackets, call/index/callback
parentheses, unary operators, repeated-element indices, and ternary
operator spacing. Fast-corpus churn drops from 22994 to 10690.
Transition/animation/transitions-list bodies break like element bodies,
import lists keep inner spaces, and @-builtins (@image-url, @children, @tr,
gradients, @keys) bind tight. Fast-corpus churn drops from 10690 to 9493.
Element and nested bodies now break across lines, so the prototype tests'
expected outputs are updated to the fully-formatted results. Comment, leaf
and ignore-directive behaviors are unchanged.
An empty element/struct/enum body now formats as {} inline (the dominant
corpus convention) while a body already broken across lines keeps its open
pair. Fast-corpus churn drops from 9493 to 9379.
The global comma softline measured the comma's parent, which for functions,
callbacks and callback connections includes a multiline body — wrongly
breaking inline parameter lists. Commas now keep a newline only where the
input had one; container-wide breaks come from each body's per-item rule.
Fast-corpus churn drops from 9379 to 9011.
Fast-corpus churn drops from 9011 to 8280.
The LSP formatting request and the format subcommand now both use the
query-based formatter; the SLINT_FMT_QUERY opt-in gate is gone. The old
imperative formatter stays temporarily as dead code until its tests are
ported.

Two behavior fixes surfaced by the switch: the Document rule now ends
every non-empty file with exactly one newline, and the
nothing-at-the-document-edge default is scoped to the edge-touching
sub-gap so a hanging comment before Eof (or a leading comment before
the first token) keeps its space instead of gluing to the code.
The old imperative formatter's 52 end-to-end tests move from fmt.rs to
the new fmt/tests.rs, now running against format_document_query. The
expected outputs are re-blessed where the two formatters deliberately
disagree (input-driven layout instead of forced breaking, no line-width
breaking, no trailing-comma management, tight empty braces, dropped
leading blank lines, guaranteed trailing newline), so the suite records
the before/after difference. The query formatter's own overlapping
end-to-end tests in rules.rs are removed; only its two leaf-rule tests
survive, appended to the ported suite, because no old test covers those
constructs.

Two rule fixes surfaced by the port: a dot no longer glues onto a bare
integer literal (42.log(x) re-lexes as `42.` `log` and would change
the expression), and stray error-recovery token children of a document
or braced body keep their input line structure instead of being broken
one token per line.
The query-based formatter is the default everywhere and the test suite
is ported, so fmt/fmt.rs goes. TokenWriter loses insert_before — only
the old formatter called it — and the docs drop their not-yet-
implemented and kept-until-parity qualifiers.
First step of the max-line-width search (MAX_LINE_WIDTH_DESIGN.md): the
PAGE_WIDTH/COMPUTATION_WIDTH constants, the group vocabulary (GroupId,
Variant), and the lexicographic cost triple (overflow, deviation, height)
with the splitting contract the search's pruning relies on.

Not wired into the pipeline yet; covered by unit tests.
The candidate bookkeeping from MAX_LINE_WIDTH_DESIGN.md: Measure (cost +
last-line width + decisions), the O(1)-append persistent DecisionList
(with an iterative Drop — the chains grow one link per decided group),
lazily evaluated tainted fallbacks, and the dedup/merge pair that keeps
only the Pareto frontier with the deterministic left tie-break the
idempotency argument relies on.

Not wired into the pipeline yet; covered by unit tests.
The search core from MAX_LINE_WIDTH_DESIGN.md: the Doc tree (text runs,
fixed newlines, concatenation, per-group either/or choices) in an arena,
and the memoized resolver that finds the cheapest variant per group —
per-prefix suffix resolution merged Set-preferring, the deviation
penalty applied on branch-local copies, and lazily bundled greedy
fallbacks once a layout blows past the computation width.

Not wired into the pipeline yet; covered by unit tests, including
mutation-verified cases for per-prefix tainting and the greedy
fallback's author-layout bias.
Behavior-preserving refactor that opens the seam the width search needs.
route_atom gains a SoftlineMode: measured softlines resolve either from
the input layout (as today) or from a group's chosen variant;
InputSoftline keeps its input meaning in both. resolve_gap now takes the
indentation level by value and returns a GapResolution instead of
mutating the caller's vector and counter, so the document builder can run
it once per (gap, variant) and the emitter can replay it.

Output is byte-for-byte unchanged (verified against the fmt-corpus).
Step 5a of the document builder: collect every distinct measured-softline
span into a Group, map it to the token slots it covers, and link the
groups into a containment forest (the spans nest or are disjoint). Each
group records whether its input was multiline, which fixes the variant the
search penalizes for deviating.

Not consumed yet; the document builder that walks the forest is next.
Walk the group forest and emit a Doc per token, gap and group: each group
becomes a choice between its flat and multiline bodies, or just the
multiline body when a newline (a comment, say) forbids flattening. Gaps
resolve through the engine's own gap resolver, so the search measures the
same layout the renderer will produce -- including the column shift
applied to a re-indented multi-line comment's continuation lines.

The builder needs several of the engine's phase-2 helpers, so expose them
pub(crate) rather than move the builder into engine.rs.

Not wired into the pipeline yet.
Switch the formatter's second phase from resolving every gap against the
input layout to build -> search -> emit: build a choice document of groups,
let the width search pick single-line or multiline per group to keep lines
within PAGE_WIDTH (100), then emit the plan replaying those decisions.

A group absent from the search's decisions was either inlined into an
ancestor's chosen flat body (render single-line) or never had a single-line
body (render multiline); BuiltDocument now reports the latter set. The
IdempotencyGuard classifies a span single-line by the search's decision
rather than by input measurement.

A body whose flat layout would contain a newline (a comment, a Hardline)
has no single-line variant, so it is forced multiline -- which removes the
old Hardline-inside-a-single-line-span idempotency hazard rather than
panicking on it.
Let a rule hand the width search both layouts of a dynamic construct
instead of branching on input multilineness at annotate time. An
Atom::Literal and the delete() marker now carry an optional Condition
{ span, variant }: the literal is emitted, or the token deleted, only when
the group spanning `span` resolves to `variant`. Selection sugar
`literal_if_multiline` / `delete_if_single_line` pairs the condition with
the group's softlines so they flip together.

Builder and emitter resolve a condition through one shared `condition_active`
keyed on the atom's controlling-group variant, so the width the search
optimizes is the width emitted. The builder now collapses a deleted token's
gap the way the emitter does, and debug assertions enforce the invariants
the shared resolution relies on: a condition shares a group's span, and a
deleted token sits strictly inside its group carrying no append atoms.

The trailing-comma engine test is re-expressed with the conditional API —
same four outputs, now decided by the search. The shipped ruleset does not
use conditional atoms yet, so real output is unchanged.
One call on a Selection expands to the primitives: the first-to-last-item
span becomes a group, each inter-item separator gains the group's
softline, and the trailing separator follows the chosen layout — deleted
when the list collapses, added after the last item when it breaks.

The trailing separator sits textually after the group's measure span, so
the builder widens each group's slot range into an extent covering the
tokens its conditional atoms occupy. Conditional literals anchored past
the extent (the separator a broken list gains) are emitted at the end of
the group's region, keeping the group's width and its choice one unit.
The emitter resolves each condition by the group its span identifies;
debug assertions enforce the placement invariant that keeps the builder's
region-mode accounting in agreement — a debug run is the ruleset
validator, as the design document now spells out.

A list holding a token-less error-recovery item (a doubled comma) is
left as written: which separator is trailing is ambiguous there.
Migrate the shipped ruleset to the width-search list primitives:
arrays, call arguments, callback-declaration and callback-connection
parameters and function parameters go through break_delimited_list —
the delimiters and the items form two separate groups, so a moderately
wide list breaks at its delimiters alone while a too-wide one goes on
to one item per line, gaining a trailing comma. The delimiter span,
not the node's, forms the group: a declaration's multiline body must
not force its parameter list open.

The trailing separator the ruleset now conditionally deletes carries
the global comma rule's InputSoftline, so the emitter no longer
discards a deleted token's append atoms — it carries them to the next
surviving gap. They then act in the same physical gap whether or not
the token survived, which is exactly where the deletion-unaware
builder already counts them; the corresponding clause of
debug_assert_deletable is obsolete. Deleted tokens' prepend indent
atoms remain unsound (collapsed unapplied) and are now asserted
against; both design documents are updated to the carry semantics.

A newline inside one list item counts as the author breaking the
list, so the whole list goes one item per line — the last multiline
argument is not hugged against the parens; a test pins this deliberate
departure from other formatters.
This ensures that if the function arguments are split onto multiple
lines, the codeblock is as well, so we don't end up with:

function foo(
    a: int,
    b: int,
) { return 5; }

Which will now become:

function foo(
    a: int,
    b: int,
) {
    return 5;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant