Skip to content

Commit ff2b5bd

Browse files
committed
format: offer one-operand-per-line layout for and/or chains
1 parent a2b3531 commit ff2b5bd

5 files changed

Lines changed: 197 additions & 0 deletions

File tree

crates/emmylua_formatter/src/config/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,15 @@ pub struct LayoutConfig {
154154
/// This applies only to the last direct expression in a statement,
155155
/// including standalone call statements, plus keyed table-field values.
156156
pub prefer_chain_break_on_statement_tail: bool,
157+
/// Offer a one-operand-per-line layout candidate for same-operator binary
158+
/// chains (3 or more operands sharing the same operator, such as a run of
159+
/// `and`/`or` conditions).
160+
///
161+
/// When the chain does not fit compactly, this lets the formatter break at
162+
/// the chain's own operators (one operand per line) instead of breaking
163+
/// inside an individual operand, such as expanding a nested call's
164+
/// argument list.
165+
pub prefer_binary_chain_operand_per_line: bool,
157166
}
158167

159168
impl Default for LayoutConfig {
@@ -167,6 +176,7 @@ impl Default for LayoutConfig {
167176
prefer_call_args_layout_from_source: false,
168177
prefer_table_layout_from_source: false,
169178
prefer_chain_break_on_statement_tail: false,
179+
prefer_binary_chain_operand_per_line: false,
170180
}
171181
}
172182
}

crates/emmylua_formatter/src/formatter/expr.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3231,6 +3231,17 @@ fn try_format_flat_binary_chain(
32313231
let fill_parts =
32323232
build_binary_chain_fill_parts(ctx, plan, &operands, &op_token.syntax().clone(), op);
32333233
let packed = build_binary_chain_packed(ctx, plan, &operands, &op_token.syntax().clone(), op);
3234+
let one_per_line = if ctx.config.layout.prefer_binary_chain_operand_per_line {
3235+
Some(build_binary_chain_one_per_line(
3236+
ctx,
3237+
plan,
3238+
&operands,
3239+
&op_token.syntax().clone(),
3240+
op,
3241+
))
3242+
} else {
3243+
None
3244+
};
32343245

32353246
Some(choose_sequence_layout(
32363247
ctx,
@@ -3239,6 +3250,7 @@ fn try_format_flat_binary_chain(
32393250
fill_parts,
32403251
)])])]),
32413252
packed: Some(packed),
3253+
one_per_line,
32423254
..Default::default()
32433255
},
32443256
SequenceLayoutPolicy {
@@ -3348,6 +3360,31 @@ fn build_binary_chain_packed(
33483360
])]
33493361
}
33503362

3363+
fn build_binary_chain_one_per_line(
3364+
ctx: &FormatContext,
3365+
plan: &FormatPlan,
3366+
operands: &[LuaExpr],
3367+
op_token: &LuaSyntaxToken,
3368+
op: BinaryOperator,
3369+
) -> Vec<DocIR> {
3370+
let first_line = format_expr(ctx, plan, &operands[0]);
3371+
3372+
let mut tail = Vec::new();
3373+
let mut previous = &operands[0];
3374+
for operand in operands.iter().skip(1) {
3375+
let (_space_before, segment) =
3376+
build_binary_chain_segment(ctx, plan, previous, operand, op_token, op);
3377+
tail.push(ir::hard_line());
3378+
tail.extend(segment);
3379+
previous = operand;
3380+
}
3381+
3382+
vec![ir::group_break(vec![
3383+
ir::list(first_line),
3384+
ir::indent(tail),
3385+
])]
3386+
}
3387+
33513388
fn build_binary_chain_segment(
33523389
ctx: &FormatContext,
33533390
plan: &FormatPlan,

crates/emmylua_formatter/src/test/breaking_tests.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,148 @@ local t = {
106106
r#"local t = { user = { name = "a", age = 1 }, enabled = true }
107107
"#,
108108
r#"local t = { user = { name = "a", age = 1 }, enabled = true }
109+
"#,
110+
config
111+
);
112+
}
113+
114+
#[test]
115+
fn test_binary_chain_operand_per_line_fixes_nested_call_breaking() {
116+
// Without the flag, the `and` chain does not fit, and the formatter
117+
// falls back to breaking inside the nested `getStorageValue` call
118+
// argument list instead of breaking at the chain's own operators.
119+
let config = LuaFormatConfig {
120+
layout: LayoutConfig {
121+
max_line_width: 120,
122+
prefer_binary_chain_operand_per_line: true,
123+
..Default::default()
124+
},
125+
..Default::default()
126+
};
127+
assert_format_with_config!(
128+
r#"if player:getStorageValue(Storage.ExplorerSociety.TheIceMusic) >= 62 and player:getStorageValue(Storage.ExplorerSociety.QuestLine) >= 62 and player:removeItem(5022, 1) then
129+
player:getPosition():sendMagicEffect(CONST_ME_TELEPORT)
130+
player:teleportTo(carvingTP.position)
131+
end
132+
"#,
133+
r#"
134+
if player:getStorageValue(Storage.ExplorerSociety.TheIceMusic) >= 62
135+
and player:getStorageValue(Storage.ExplorerSociety.QuestLine) >= 62
136+
and player:removeItem(5022, 1) then
137+
player:getPosition():sendMagicEffect(CONST_ME_TELEPORT)
138+
player:teleportTo(carvingTP.position)
139+
end
140+
"#,
141+
config
142+
);
143+
}
144+
145+
#[test]
146+
fn test_binary_chain_operand_per_line_disabled_keeps_legacy_behavior() {
147+
// Regression guard: with the flag off (the default), behavior is
148+
// unchanged from before this option existed, including the
149+
// less-than-ideal nested-call break.
150+
let config = LuaFormatConfig {
151+
layout: LayoutConfig {
152+
max_line_width: 120,
153+
..Default::default()
154+
},
155+
..Default::default()
156+
};
157+
assert_format_with_config!(
158+
r#"if player:getStorageValue(Storage.ExplorerSociety.TheIceMusic) >= 62 and player:getStorageValue(Storage.ExplorerSociety.QuestLine) >= 62 and player:removeItem(5022, 1) then
159+
player:getPosition():sendMagicEffect(CONST_ME_TELEPORT)
160+
player:teleportTo(carvingTP.position)
161+
end
162+
"#,
163+
r#"
164+
if player:getStorageValue(Storage.ExplorerSociety.TheIceMusic) >= 62 and player:getStorageValue(
165+
Storage.ExplorerSociety.QuestLine
166+
)
167+
>= 62 and player:removeItem(5022, 1) then
168+
player:getPosition():sendMagicEffect(CONST_ME_TELEPORT)
169+
player:teleportTo(carvingTP.position)
170+
end
171+
"#,
172+
config
173+
);
174+
}
175+
176+
#[test]
177+
fn test_binary_chain_operand_per_line_keeps_short_chain_flat() {
178+
// A chain that already fits on one line stays flat even with the
179+
// option enabled; the new candidate only wins when it is needed.
180+
let config = LuaFormatConfig {
181+
layout: LayoutConfig {
182+
max_line_width: 120,
183+
prefer_binary_chain_operand_per_line: true,
184+
..Default::default()
185+
},
186+
..Default::default()
187+
};
188+
assert_format_with_config!(
189+
r#"if a and b and c then
190+
work()
191+
end
192+
"#,
193+
r#"
194+
if a and b and c then
195+
work()
196+
end
197+
"#,
198+
config
199+
);
200+
}
201+
202+
#[test]
203+
fn test_binary_chain_operand_per_line_does_not_override_shorter_fill_layout() {
204+
// When the existing fill/packed layout already fits without
205+
// overflowing max_line_width in fewer lines than one-operand-per-line
206+
// would need, the scorer keeps preferring the more compact layout.
207+
let config = LuaFormatConfig {
208+
layout: LayoutConfig {
209+
max_line_width: 100,
210+
prefer_binary_chain_operand_per_line: true,
211+
..Default::default()
212+
},
213+
..Default::default()
214+
};
215+
assert_format_with_config!(
216+
r#"if get_value(namespace_alpha_beta) >= threshold_value and get_value(namespace_gamma_delta) >= threshold_value and remove_item(item_id_number, item_count) then
217+
do_something()
218+
end
219+
"#,
220+
r#"
221+
if get_value(namespace_alpha_beta) >= threshold_value and get_value(namespace_gamma_delta)
222+
>= threshold_value and remove_item(item_id_number, item_count) then
223+
do_something()
224+
end
225+
"#,
226+
config
227+
);
228+
}
229+
230+
#[test]
231+
fn test_binary_chain_operand_per_line_works_for_or_operator() {
232+
let config = LuaFormatConfig {
233+
layout: LayoutConfig {
234+
max_line_width: 120,
235+
prefer_binary_chain_operand_per_line: true,
236+
..Default::default()
237+
},
238+
..Default::default()
239+
};
240+
assert_format_with_config!(
241+
r#"if player:getStorageValue(Storage.QuestSystem.FirstPartCompleted) == 1 or player:getStorageValue(Storage.QuestSystem.SecondPartCompleted) == 1 or player:getStorageValue(Storage.QuestSystem.ThirdPartCompleted) == 1 then
242+
work()
243+
end
244+
"#,
245+
r#"
246+
if player:getStorageValue(Storage.QuestSystem.FirstPartCompleted) == 1
247+
or player:getStorageValue(Storage.QuestSystem.SecondPartCompleted) == 1
248+
or player:getStorageValue(Storage.QuestSystem.ThirdPartCompleted) == 1 then
249+
work()
250+
end
109251
"#,
110252
config
111253
);

docs/emmylua_formatter/options_CN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ width = 4
5959
- `prefer_call_args_layout_from_source`:是否优先保留显式多行调用参数列表的源码排版目标
6060
- `prefer_table_layout_from_source`:是否优先保留显式多行纯数组 table 的源码排版目标
6161
- `prefer_chain_break_on_statement_tail`:是否优先把语句尾部的 fluent chain 断成多行
62+
- `prefer_binary_chain_operand_per_line`:为同一运算符的多元二元表达式链(3 个及以上操作数,例如一串 `and`/`or` 条件)提供“每个操作数一行”的候选布局
6263

6364
默认值:
6465

@@ -72,6 +73,7 @@ func_params_expand = "Auto"
7273
prefer_call_args_layout_from_source = false
7374
prefer_table_layout_from_source = false
7475
prefer_chain_break_on_statement_tail = false
76+
prefer_binary_chain_operand_per_line = false
7577
```
7678

7779
行为说明:
@@ -87,6 +89,8 @@ prefer_chain_break_on_statement_tail = false
8789
- `prefer_chain_break_on_statement_tail = true` 会影响语句中的最后一个直接表达式,包括独立调用语句,以及带 key 的 table field value;当它是一个足够长的 fluent chain 时,会优先改成链式断行。
8890
- 这里的 chain head 定义为:`root` 加上前导命名空间/字段访问,再加上第一个调用段;但如果 `root` 本身已经是一个调用,则 head 就停在这个调用本身。例如 `Builder:new():add():add()` 的 head 是 `Builder:new()``ConsoleFormattingBuilder():setColor():build()` 的 head 是 `ConsoleFormattingBuilder()``vim.api.nvim_set_keymap(...)` 的 head 是整个 `vim.api.nvim_set_keymap(...)`
8991
- 这里的换行起点定义为:head 之后的第一个 continuation 段。也就是说,只有第一个调用之后仍然继续链下去的部分才会成为链式换行候选;纯命名空间限定调用不会因为这个选项被误判成 chain。
92+
- `prefer_binary_chain_operand_per_line = true` 适用于由同一个运算符连接的 3 个及以上操作数,最常见的场景是 `if`/`while` 条件头里的一串 `and`/`or`。它只是新增一个候选布局;格式化器仍然会在 flat、fill、packed、one-operand-per-line 之间挑选行数最少且不超过 `max_line_width` 的那个,所以短链条或者用 fill/packed 就已经能放下的链条不受影响。
93+
- 不开启这个选项时,如果整条链放不下,格式化器可能会转而在其中某个操作数内部断行(比如展开某个嵌套调用的参数列表),而不是在链自身的运算符处断行。开启后格式化器多了一个干净的备选方案:每个操作数单独一行,运算符放在每个续行的开头(`and`/`or` 在行首,与本项目现有二元表达式的前置运算符风格一致)。
9094

9195
## output
9296

docs/emmylua_formatter/options_EN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ width = 4
5959
- `prefer_call_args_layout_from_source`: prefer keeping the source layout goal for explicit multiline call argument lists
6060
- `prefer_table_layout_from_source`: prefer keeping the source layout goal for explicit multiline pure-array tables
6161
- `prefer_chain_break_on_statement_tail`: prefer multiline breaking for fluent chains in statement-tail position
62+
- `prefer_binary_chain_operand_per_line`: offer a one-operand-per-line layout candidate for same-operator binary chains (3 or more operands), such as a run of `and`/`or` conditions
6263

6364
Default:
6465

@@ -72,6 +73,7 @@ func_params_expand = "Auto"
7273
prefer_call_args_layout_from_source = false
7374
prefer_table_layout_from_source = false
7475
prefer_chain_break_on_statement_tail = false
76+
prefer_binary_chain_operand_per_line = false
7577
```
7678

7779
Behavior notes:
@@ -87,6 +89,8 @@ Behavior notes:
8789
- `prefer_chain_break_on_statement_tail = true` applies to the last direct expression in a statement, including standalone call statements, and also to keyed table-field values; when that expression is a long enough fluent chain, the formatter prefers chain-style breaking.
8890
- The chain head is defined as the `root`, plus any leading namespace or field accesses, plus the first call segment; however, if the `root` itself already ends in a call, the head stops at that call. For example, the head of `Builder:new():add():add()` is `Builder:new()`, the head of `ConsoleFormattingBuilder():setColor():build()` is `ConsoleFormattingBuilder()`, and the head of `vim.api.nvim_set_keymap(...)` is the whole `vim.api.nvim_set_keymap(...)` call.
8991
- The break start is defined as the first continuation segment after that head. In other words, only segments that continue after the first call are eligible for chain-style line breaking; a namespace-qualified terminal call is not treated as a fluent chain by this option.
92+
- `prefer_binary_chain_operand_per_line = true` applies to a run of 3 or more operands joined by the same operator, most commonly a chain of `and`/`or` conditions in an `if`/`while` header. It only adds a candidate layout; the formatter still picks whichever candidate (flat, fill, packed, or one-operand-per-line) produces the fewest lines without overflowing `max_line_width`, so short chains and chains that already fit with fill/packed are unaffected.
93+
- Without this option, a chain that does not fit can end up breaking inside one of its own operands (for example, expanding a nested call's argument list) instead of breaking at the chain's operators. Enabling it gives the formatter a clean alternative: one operand per line, with the operator leading each continuation line (`and`/`or` at the start of the line, matching this project's existing leading-operator style for binary expressions).
9094

9195
## output
9296

0 commit comments

Comments
 (0)