Repository navigation
fix(formatter): estimate call-arg and attached-table line widths from stable columns - #1305
mareklangiewicz wants to merge 1 commit into
Conversation
… stable columns The width checks added a node's source column to its flat width. For call arguments that column was the first argument's, which is right after `(` in a packed layout and on a line of its own in an expanded one, so each layout formatted into the other and the output never stabilized (EmmyLuaLs#1302). They are now measured from the `(`, where the candidates start. A table with key = value fields checked its fit from its own source column. After `=` or `return` the formatter attaches it to that token, so a table on the next line in the source was joined flat past the limit (a 123-column line at 120), then expanded on the next pass. Its column is now taken from the token it attaches to. test_non_first_multiline_callback_tail_refills_after_end moves from width 28 to 27: its packed first line is exactly 28 columns, which the old estimate counted as 29. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCiE5WPKMyzj1s6ytKuoD2
There was a problem hiding this comment.
Code Review
Summary
The change addresses a real idempotency bug (#1302) where width estimates depended on the source layout, causing the formatter to oscillate between two layouts. The approach is sound: introduce attached_source_prefix_width for nodes that the formatter always attaches to =/return, and use the opening token's column for call-arg lists. Overall the change looks correct and well-tested.
Issues & Suggestions
1. attached_source_prefix_width — potential off-by-one / width semantics
return source_token_prefix_width(&token) + token.text().chars().count() + 1;- The
+ 1assumes a single space between the token and the node. This is consistent with the formatter attaching with one space, but it's a magic constant. Consider a named constant or a comment explaining the+ 1is the separating space. token.text().chars().count()counts characters, not display columns. For=/returnthis is fine (ASCII), but if the helper is ever reused for other tokens, this could diverge fromsource_token_prefix_widthwhich presumably measures width differently. Worth a doc note that this is only valid for ASCII tokens.
2. source_token_prefix_width clones the token unnecessarily
let mut token = token.clone();LuaSyntaxToken is a cheap rowan handle (Rc-based), so the clone is fine, but the original code took node.first_token() by value. Since the function only needs prev_token() traversal, you could take &LuaSyntaxToken and avoid the clone entirely:
fn source_token_prefix_width(token: &LuaSyntaxToken) -> usize {
let mut width = 0usize;
let mut cur = token.clone(); // or restructure to avoid
...
}Minor, but the clone is avoidable if you restructure the loop to start from token.prev_token().
3. attached_source_prefix_width — behavior when previous token is on a different line
The loop skips TkWhitespace and TkEndOfLine, then checks for TkAssign/TkReturn. If the = is on a previous line (e.g. local x =\n {...}), the function still returns the ='s column + width + 1, which is the column the { would occupy if placed on the = line. That matches the documented intent ("the formatter always attaches it to"), so this is correct — but the doc comment could be clearer that it deliberately ignores the source line break.
4. analyze_call_arg_list_layout — semantic change worth confirming
let first_line_prefix_width = source_line_prefix_width(args.syntax());Previously this used the first argument's column; now it uses the ('s column. The comment explains the reasoning well. However:
args.syntax()is theLuaCallArgList;source_line_prefix_widthcallsfirst_token(), which should be the(. Confirm thatLuaCallArgList's first token is indeed(and not something else (e.g. a leading trivia token). If the node's first token could be whitespace/comment, the width would be wrong.- The variable name
first_line_prefix_widthis now slightly misleading since it's no longer derived from the first argument. Consider renaming toopen_paren_prefix_widthorlist_prefix_widthfor clarity.
5. Test change in expression_tests.rs
- max_line_width: 28,
+ max_line_width: 27,This tightens the limit by 1. Since the width computation changed, this is likely a necessary adjustment, but it's worth verifying the test still exercises the intended boundary (i.e., that 27 is the exact threshold and 28 would now produce a different layout). A brief comment explaining why 27 is the boundary would help future maintainers.
6. New tests — good coverage, minor nits
format_twiceis a nice helper. Consider assertingfirst == secondinside it to make the idempotency contract explicit, rather than relying on each caller to do so.- The tests assert
line.len() <= 120, butlen()is bytes, not display columns. For ASCII test content this is fine; if non-ASCII is ever added, this would be misleading. Consider using a column-aware check or noting the ASCII assumption. - The
expandedfixture intest_call_args_layout_does_not_depend_on_first_arg_source_columnis a hand-written expected output. If the formatter's output changes slightly, this test becomes brittle. That's acceptable for a regression test, but consider derivingexpandedfrompackedvia a second config if feasible.
Verdict
The core logic is correct and the fix is well-motivated. Main actionable items:
- Clarify the
+ 1magic constant and ASCII assumption inattached_source_prefix_width. - Rename
first_line_prefix_widthto reflect it's now the(column. - Verify
args.syntax().first_token()is the((not trivia). - Add a comment on the
28 → 27test threshold change.
Summary
Fixes #1302. Layout choices add a node's source column to a candidate's flat width. Twice that column moved with the layout being chosen, so the next pass chose differently:
local CODES =\n { red = '31', … }was joined flat to 123 columns atmax_line_width = 120, then expanded on the next pass. Now both source shapes give the expanded table at once. Only=andreturnare used: after,or(the table may go on its own line, and using those made things worse.test_non_first_multiline_callback_tail_refills_after_endmoves from width 28 to 27: its packed first linetest(1, "hello", function ()is exactly 28 columns, which the old estimate counted as 29. At 27 it still expects the one-per-line layout it was written for.Evidence
misc_tests.rsfail: each formats both source shapes of a case and requires the same output and a stable second pass.After:
cargo test -p emmylua_formatter: 492 passed;cargo clippy -p emmylua_formatter --all-targets: clean.Not fixed here: most of the remaining second-pass changes are a callback that is one line in the source getting chain indentation on pass 1 and being attached on pass 2 (
is_multiline_block_like_exprreads the source's newlines). That converges by pass 2 or 3 and is a different mechanism.Merge Danger
Door: two-way
Blast Radius: layout of call argument lists and of
key = valuetables after=/return, near the line-width limit🤖 Generated with Claude Code
https://claude.ai/code/session_01WCiE5WPKMyzj1s6ytKuoD2