Skip to content

Commit 7434eda

Browse files
committed
fix(autofmt): formatting idempotency for long if/else attrs, empty bodies, and split_line_attributes
Three fixes for non-idempotent formatting where running the formatter twice produces different output: 1. `write_attribute_if_chain` unconditionally inlined if/else attribute values on one line. For long values this exceeds 80 chars, causing ShortOptimization to oscillate. Now checks inline length against a budget and uses a deterministic multiline layout when exceeded. 2. `write_todo_body` emitted a newline even with no comments between braces, causing blank lines in empty component bodies. Now only emits when actual `//` comments are found. 3. The `split_line_attributes` override forced `NoOpt` even for empty blocks, causing `Foo {}` to expand with blank lines. Now skips the override when the block is `Empty`. Fixes #5508
1 parent 2cd5245 commit 7434eda

7 files changed

Lines changed: 178 additions & 18 deletions

File tree

packages/autofmt/src/lib.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ pub fn try_fmt_file(
113113

114114
let span = item.delimiter.span().join();
115115
let mut formatted = writer.out.buf.split_off(0);
116-
117116
let start = collect_macros::byte_offset(contents, span.start()) + 1;
118117
let end = collect_macros::byte_offset(contents, span.end()) - 1;
119118

packages/autofmt/src/writer.rs

Lines changed: 97 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ impl<'a> Writer<'a> {
5959
if let Some(span) = body.span {
6060
self.out.indent_level += 1;
6161
let comments = self.accumulate_full_line_comments(span.span().end());
62-
if !comments.is_empty() {
62+
let has_real_comment = comments
63+
.iter()
64+
.any(|&id| self.src.get(id).is_some_and(|l| l.trim().starts_with("//")));
65+
if has_real_comment {
6366
self.out.new_line()?;
6467
self.apply_line_comments(comments)?;
6568
self.out.buf.pop(); // remove the trailing newline, forcing us to end at the end of the comment
@@ -252,11 +255,19 @@ impl<'a> Writer<'a> {
252255

253256
pub fn write_body_nodes(&mut self, children: &[BodyNode]) -> Result {
254257
let mut iter = children.iter().peekable();
258+
let mut is_first = true;
255259

256260
while let Some(child) = iter.next() {
257261
if self.current_span_is_primary(child.span().start()) {
258-
self.write_comments(child.span().start())?;
262+
let comments = self.accumulate_full_line_comments(child.span().start());
263+
let has_real_comment = comments
264+
.iter()
265+
.any(|&id| self.src.get(id).is_some_and(|l| l.trim().starts_with("//")));
266+
if has_real_comment || !is_first {
267+
self.apply_line_comments(comments)?;
268+
}
259269
};
270+
is_first = false;
260271
self.out.tab()?;
261272
self.write_ident(child)?;
262273
if iter.peek().is_some() {
@@ -360,8 +371,10 @@ impl<'a> Writer<'a> {
360371
self.write_todo_body(brace)?;
361372
}
362373

363-
// multiline handlers bump everything down
364-
if attr_len > 1000 || self.out.indent.split_line_attributes() {
374+
// multiline handlers bump everything down, but not empty blocks
375+
if !matches!(opt_level, ShortOptimization::Empty)
376+
&& (attr_len > 1000 || self.out.indent.split_line_attributes())
377+
{
365378
opt_level = ShortOptimization::NoOpt;
366379
}
367380

@@ -410,7 +423,9 @@ impl<'a> Writer<'a> {
410423
self.write_attributes(attributes, spreads, false, brace, has_children)?;
411424

412425
if !children.is_empty() {
413-
self.out.new_line()?;
426+
if !attributes.is_empty() || !spreads.is_empty() {
427+
self.out.new_line()?;
428+
}
414429
self.write_body_indented(children)?;
415430
}
416431

@@ -425,7 +440,10 @@ impl<'a> Writer<'a> {
425440
) && self.leading_row_is_empty(brace.span.span().end())
426441
{
427442
let comments = self.accumulate_full_line_comments(brace.span.span().end());
428-
if !comments.is_empty() {
443+
let has_real_comment = comments
444+
.iter()
445+
.any(|&id| self.src.get(id).is_some_and(|l| l.trim().starts_with("//")));
446+
if has_real_comment {
429447
// Undo the tab from tabbed_line(). It positioned for the closing
430448
// brace, but trailing comments need child-level indentation
431449
let tab_width = self.out.indent.indent_str().len() * self.out.indent_level;
@@ -526,8 +544,20 @@ impl<'a> Writer<'a> {
526544
fn write_attribute(&mut self, attr: &Attribute) -> Result {
527545
self.write_attribute_name(&attr.name)?;
528546

529-
// if the attribute is a shorthand, we don't need to write the colon, just the name
530547
if !attr.can_be_shorthand() {
548+
if let AttributeValue::IfExpr(if_chain) = &attr.value {
549+
let inline_len = self.attr_value_len(&attr.value);
550+
let line_budget = 80usize.saturating_sub(self.out.indent_level * 4);
551+
if inline_len > line_budget {
552+
write!(self.out, ":")?;
553+
self.out.indent_level += 1;
554+
self.out.new_line()?;
555+
self.out.tab()?;
556+
self.write_attribute_if_chain_multiline(if_chain)?;
557+
self.out.indent_level -= 1;
558+
return Ok(());
559+
}
560+
}
531561
write!(self.out, ": ")?;
532562
self.write_attribute_value(&attr.value)?;
533563
}
@@ -570,14 +600,25 @@ impl<'a> Writer<'a> {
570600
}
571601

572602
fn write_attribute_if_chain(&mut self, if_chain: &IfAttributeValue) -> Result {
603+
let inline_len = self.attr_value_len(&AttributeValue::IfExpr(if_chain.clone()));
604+
let line_budget = 80usize.saturating_sub(self.out.indent_level * 4);
605+
606+
if inline_len <= line_budget {
607+
self.write_attribute_if_chain_inline(if_chain)
608+
} else {
609+
self.write_attribute_if_chain_multiline(if_chain)
610+
}
611+
}
612+
613+
fn write_attribute_if_chain_inline(&mut self, if_chain: &IfAttributeValue) -> Result {
573614
let cond = self.unparse_expr(&if_chain.if_expr.cond);
574615
write!(self.out, "if {cond} {{ ")?;
575616
self.write_attribute_value(&if_chain.then_value)?;
576617
write!(self.out, " }}")?;
577618
match if_chain.else_value.as_deref() {
578619
Some(AttributeValue::IfExpr(else_if_chain)) => {
579620
write!(self.out, " else ")?;
580-
self.write_attribute_if_chain(else_if_chain)?;
621+
self.write_attribute_if_chain_inline(else_if_chain)?;
581622
}
582623
Some(other) => {
583624
write!(self.out, " else {{ ")?;
@@ -586,7 +627,40 @@ impl<'a> Writer<'a> {
586627
}
587628
None => {}
588629
}
630+
Ok(())
631+
}
589632

633+
fn write_attribute_if_chain_multiline(&mut self, if_chain: &IfAttributeValue) -> Result {
634+
let base = self.out.indent_level;
635+
let cond = self.unparse_expr(&if_chain.if_expr.cond);
636+
write!(self.out, "if {cond} {{")?;
637+
self.out.indent_level = base + 1;
638+
self.out.new_line()?;
639+
self.out.tab()?;
640+
self.write_attribute_value(&if_chain.then_value)?;
641+
self.out.indent_level = base;
642+
self.out.new_line()?;
643+
self.out.tab()?;
644+
write!(self.out, "}}")?;
645+
match if_chain.else_value.as_deref() {
646+
Some(AttributeValue::IfExpr(else_if_chain)) => {
647+
write!(self.out, " else ")?;
648+
self.write_attribute_if_chain_multiline(else_if_chain)?;
649+
}
650+
Some(other) => {
651+
write!(self.out, " else {{")?;
652+
self.out.indent_level = base + 1;
653+
self.out.new_line()?;
654+
self.out.tab()?;
655+
self.write_attribute_value(other)?;
656+
self.out.indent_level = base;
657+
self.out.new_line()?;
658+
self.out.tab()?;
659+
write!(self.out, "}}")?;
660+
}
661+
None => {}
662+
}
663+
self.out.indent_level = base;
590664
Ok(())
591665
}
592666

@@ -838,18 +912,24 @@ impl<'a> Writer<'a> {
838912
return Ok(());
839913
}
840914

915+
let comments: Vec<&str> = (start.line..end.line)
916+
.filter_map(|idx| {
917+
let line = self.src.get(idx)?;
918+
line.trim().starts_with("//").then_some(line.trim())
919+
})
920+
.collect();
921+
922+
if comments.is_empty() {
923+
return Ok(());
924+
}
925+
841926
writeln!(self.out)?;
842927

843-
for idx in start.line..end.line {
844-
let Some(line) = self.src.get(idx) else {
845-
continue;
846-
};
847-
if line.trim().starts_with("//") {
848-
for _ in 0..self.out.indent_level + 1 {
849-
write!(self.out, " ")?
850-
}
851-
writeln!(self.out, "{}", line.trim())?;
928+
for comment in &comments {
929+
for _ in 0..self.out.indent_level + 1 {
930+
write!(self.out, " ")?
852931
}
932+
writeln!(self.out, "{comment}")?;
853933
}
854934

855935
for _ in 0..self.out.indent_level {

packages/autofmt/tests/samples.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ twoway![
7474
commented_rsx_block_only,
7575
commented_rsx_block_between,
7676
commented_rsx_block_deep,
77+
long_if_else_attr,
78+
empty_component_body,
79+
empty_braces_oneliner,
7780
];
7881

7982
fn assert_idempotent(src: &str) {
@@ -109,3 +112,46 @@ fn comments_attr_expr_blocks_is_idempotent() {
109112
fn comments_expr_with_strings_is_idempotent() {
110113
assert_idempotent(include_str!("./samples/comments_expr_with_strings.rsx"));
111114
}
115+
116+
#[test]
117+
fn long_if_else_attr_is_idempotent() {
118+
assert_idempotent(include_str!("./samples/long_if_else_attr.rsx"));
119+
}
120+
121+
#[test]
122+
fn empty_component_body_is_idempotent() {
123+
assert_idempotent(include_str!("./samples/empty_component_body.rsx"));
124+
}
125+
126+
#[test]
127+
fn empty_braces_match_arm_is_idempotent() {
128+
let src = include_str!("./samples/empty_braces_match_arm.rsx");
129+
let once = dioxus_autofmt::apply_formats(src, dioxus_autofmt::fmt_file(src, Default::default()));
130+
let twice = dioxus_autofmt::apply_formats(&once, dioxus_autofmt::fmt_file(&once, Default::default()));
131+
let thrice = dioxus_autofmt::apply_formats(&twice, dioxus_autofmt::fmt_file(&twice, Default::default()));
132+
pretty_assertions::assert_eq!(&once, &twice, "pass 1 vs pass 2");
133+
pretty_assertions::assert_eq!(&twice, &thrice, "pass 2 vs pass 3");
134+
}
135+
136+
#[test]
137+
fn empty_braces_no_space_is_idempotent() {
138+
let src = "rsx! { Router::<Route>{}}";
139+
let once = dioxus_autofmt::apply_formats(src, dioxus_autofmt::fmt_file(src, Default::default()));
140+
let twice = dioxus_autofmt::apply_formats(&once, dioxus_autofmt::fmt_file(&once, Default::default()));
141+
let thrice = dioxus_autofmt::apply_formats(&twice, dioxus_autofmt::fmt_file(&twice, Default::default()));
142+
eprintln!("=== ONCE ===\n{once}");
143+
eprintln!("=== TWICE ===\n{twice}");
144+
eprintln!("=== THRICE ===\n{thrice}");
145+
pretty_assertions::assert_eq!(&once, &twice, "pass 1 vs pass 2");
146+
pretty_assertions::assert_eq!(&twice, &thrice, "pass 2 vs pass 3");
147+
}
148+
149+
#[test]
150+
fn empty_braces_oneliner_is_idempotent() {
151+
let src = r#"rsx! { Router::<Route>{}}"#;
152+
let once = dioxus_autofmt::apply_formats(src, dioxus_autofmt::fmt_file(src, Default::default()));
153+
let twice = dioxus_autofmt::apply_formats(&once, dioxus_autofmt::fmt_file(&once, Default::default()));
154+
let thrice = dioxus_autofmt::apply_formats(&twice, dioxus_autofmt::fmt_file(&twice, Default::default()));
155+
pretty_assertions::assert_eq!(&once, &twice, "pass 1 vs pass 2");
156+
pretty_assertions::assert_eq!(&twice, &thrice, "pass 2 vs pass 3");
157+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
rsx! {
2+
{
3+
match state() {
4+
State::Loading => rsx! {
5+
LoadingScreen {}
6+
},
7+
State::Ready => rsx! {
8+
ReadyScreen { name }
9+
},
10+
}
11+
}
12+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
rsx! {
2+
Router::<Route> {}
3+
LoadingScreen {}
4+
AuthenticatingScreen {}
5+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
rsx! {
2+
Pagination {
3+
PaginationContent {
4+
PaginationPrevious { onclick: move |_| on_prev(()) }
5+
PaginationNext { onclick: move |_| on_next(()) }
6+
}
7+
}
8+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
rsx! {
2+
button { class:
3+
if is_active {
4+
"w-full text-left px-3 py-1 text-xs font-mono text-primary bg-select"
5+
} else {
6+
"w-full text-left px-3 py-1 text-xs font-mono text-secondary hover:bg-select hover:text-primary"
7+
},
8+
"Click me"
9+
}
10+
}

0 commit comments

Comments
 (0)