From 89e9dfcadedce4ab6623e3e96d3891cf035393d5 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 3 Oct 2026 14:37:59 +0000 Subject: [PATCH 1/2] Preserve JSX tag syntax in autofmt Nodes written in the tag syntax (`NodeDelimiter::Tag`) are written back out in that syntax by a new `write_tag` instead of being rewritten into the block syntax. - self-closing tags for empty bodies, a single short child is inlined - attributes stay in the open tag when short, otherwise one per line with multiline values indented relative to their attribute - comments before, after and inside of tags, on and between their attributes, and before their closing tag are kept, as are blank lines between sibling tags - attributes that have comments are always put on their own lines so that the output is the same on every pass --- packages/autofmt/src/writer.rs | 221 +++++++++++++++++- packages/autofmt/tests/samples.rs | 2 + packages/autofmt/tests/samples/jsx.rsx | 40 ++++ .../autofmt/tests/samples/jsx_comments.rsx | 59 +++++ packages/autofmt/tests/wrong.rs | 1 + packages/autofmt/tests/wrong/jsx-4sp.rsx | 12 + .../autofmt/tests/wrong/jsx-4sp.wrong.rsx | 13 ++ packages/autofmt/tests/wrong/jsx-comments.rsx | 24 +- .../tests/wrong/jsx-comments.wrong.rsx | 6 +- 9 files changed, 361 insertions(+), 17 deletions(-) create mode 100644 packages/autofmt/tests/samples/jsx.rsx create mode 100644 packages/autofmt/tests/samples/jsx_comments.rsx create mode 100644 packages/autofmt/tests/wrong/jsx-4sp.rsx create mode 100644 packages/autofmt/tests/wrong/jsx-4sp.wrong.rsx diff --git a/packages/autofmt/src/writer.rs b/packages/autofmt/src/writer.rs index 34a7d0393f..89aedb941e 100644 --- a/packages/autofmt/src/writer.rs +++ b/packages/autofmt/src/writer.rs @@ -191,6 +191,10 @@ impl<'a> Writer<'a> { let delimiters = BodyDelimiters::new(delimiter, name.span().end()).ok_or(std::fmt::Error)?; + if delimiters.is_tag { + return self.write_tag(&name.to_string(), attributes, spreads, children, delimiters); + } + write!(self.out, "{name} ")?; self.write_rsx_block(attributes, spreads, children, delimiters)?; @@ -209,29 +213,207 @@ impl<'a> Writer<'a> { .. }: &Component, ) -> Result { + let name_end = match generics { + Some(generics) => generics.gt_token.span().end(), + None => path.span().end(), + }; + let delimiters = BodyDelimiters::new(delimiter, name_end).ok_or(std::fmt::Error)?; + // Write the path by to_tokensing it and then removing all whitespace let mut name = path.to_token_stream().to_string(); name.retain(|c| !c.is_whitespace()); - write!(self.out, "{name}")?; // Same idea with generics, write those via the to_tokens method and then remove all whitespace if let Some(generics) = generics { let mut written = generics.to_token_stream().to_string(); written.retain(|c| !c.is_whitespace()); - write!(self.out, "{written}")?; + + // The tag syntax doesn't use the turbofish: `>` + if delimiters.is_tag { + written = written.trim_start_matches("::").to_string(); + } + + name.push_str(&written); } - write!(self.out, " ")?; - let name_end = match generics { - Some(generics) => generics.gt_token.span().end(), - None => path.span().end(), - }; - let delimiters = BodyDelimiters::new(delimiter, name_end).ok_or(std::fmt::Error)?; + if delimiters.is_tag { + return self.write_tag(&name, fields, spreads, &children.roots, delimiters); + } + + write!(self.out, "{name} ")?; self.write_rsx_block(fields, spreads, &children.roots, delimiters)?; Ok(()) } + /// Write an element or component that was written in the tag syntax, keeping it in that + /// syntax: `
"hello"
` or `` + fn write_tag( + &mut self, + name: &str, + attributes: &[Attribute], + spreads: &[Spread], + children: &[BodyNode], + delimiters: BodyDelimiters, + ) -> Result { + enum AttrType<'a> { + Attr(&'a Attribute), + Spread(&'a Spread), + } + + write!(self.out, "<{name}")?; + + let attrs: Vec<_> = attributes + .iter() + .map(AttrType::Attr) + .chain(spreads.iter().map(AttrType::Spread)) + .collect(); + + // The start and end of each attribute in the source, which is where its comments are + let attr_spans: Vec<_> = attrs + .iter() + .map(|attr| match attr { + AttrType::Attr(attr) => (attr.span().start(), self.end_of_attr(attr, delimiters)), + AttrType::Spread(spread) => ( + self.start_of_tag_spread(spread), + self.end_of_spread(spread, delimiters), + ), + }) + .collect(); + let has_attr_comments = attr_spans.iter().any(|(start, end)| { + self.has_leading_comments(*start) || self.inline_comment(*end, 0).is_some() + }); + + // Decide if the attributes fit in the open tag or need to be split across lines. + // Comments on attributes can only be kept if each attribute is on its own line. + let attr_len = self.is_short_attrs(attributes, spreads); + let is_short_attr_list = (attr_len + self.out.indent_level * 4) < 80 + && !self.out.indent.split_line_attributes() + && !has_attr_comments; + + for (attr, (start, end)) in attrs.iter().zip(attr_spans) { + if is_short_attr_list { + write!(self.out, " ")?; + } else { + self.out.new_line()?; + + if self.current_span_is_primary(start) { + self.out.indent_level += 1; + self.write_comments(start)?; + self.out.indent_level -= 1; + } + + self.out.indented_tab()?; + } + + match attr { + AttrType::Attr(attr) => self.write_tag_attribute(attr, !is_short_attr_list)?, + AttrType::Spread(spread) => { + write!(self.out, "{{")?; + self.write_spread_attribute(&spread.expr)?; + write!(self.out, "}}")?; + } + } + + if !is_short_attr_list { + self.write_inline_comments(end, 0)?; + } + } + + // The `>` or `/>` goes on its own line if the attributes are split across lines + if !is_short_attr_list { + self.out.tabbed_line()?; + } + + let has_open_tag_comment = self.brace_has_trailing_comments(delimiters); + + if children.is_empty() { + // Self-closing tags + if !self.body_has_comments(delimiters) { + if is_short_attr_list { + write!(self.out, " ")?; + } + write!(self.out, "/>")?; + return Ok(()); + } + + // A body that only has comments in it + write!(self.out, ">")?; + self.write_comment_only_body(delimiters)?; + write!(self.out, "")?; + return Ok(()); + } + + write!(self.out, ">")?; + + // Inline a single short child: `

"hello"

` + let children_len = self + .is_short_children(children) + .map_err(|_| std::fmt::Error)?; + let is_small_children = is_short_attr_list + && !has_open_tag_comment + && !self.has_trailing_comments(children, delimiters) + && children_len.is_some_and(|len| { + len + attr_len + name.len() * 2 + self.out.indent_level * 4 < 100 + }); + + if is_small_children { + for child in children { + self.write_ident(child)?; + } + } else { + self.write_inline_comments(delimiters.open, 1)?; + self.out.new_line()?; + self.write_body_indented(children)?; + self.write_closing_line(delimiters)?; + } + + write!(self.out, "")?; + + Ok(()) + } + + /// Write an attribute in the tag syntax: `name`, `name="literal"` or `name={expr}` + fn write_tag_attribute(&mut self, attr: &Attribute, on_own_line: bool) -> Result { + match &attr.name { + // Dashed custom attributes don't need to be quoted in the tag syntax: `data-count="1"` + AttributeName::Custom(name) + if name + .value() + .split('-') + .all(|seg| syn::parse_str::(seg).is_ok()) => + { + write!(self.out, "{}", name.value())? + } + name => self.write_attribute_name(name)?, + } + + if attr.can_be_shorthand() { + return Ok(()); + } + + write!(self.out, "=")?; + + match &attr.value { + AttributeValue::AttrLiteral(value) => write!(self.out, "{value}")?, + // The lines of a multiline if chain are indented relative to the attribute + value @ AttributeValue::IfExpr(_) if on_own_line => { + write!(self.out, "{{")?; + self.out.indent_level += 1; + self.write_attribute_value(value)?; + self.out.indent_level -= 1; + write!(self.out, "}}")?; + } + value => { + write!(self.out, "{{")?; + self.write_attribute_value(value)?; + write!(self.out, "}}")?; + } + } + + Ok(()) + } + fn write_text_node(&mut self, text: &TextNode) -> Result { self.out.write_text(&text.input) } @@ -931,6 +1113,17 @@ impl<'a> Writer<'a> { Ok(()) } + /// The start of a spread in the tag syntax, including the brace it is wrapped in: `{..spread}` + fn start_of_tag_spread(&self, spread: &Spread) -> LineColumn { + let mut start = spread.span().start(); + if let Some(before) = self.text_before(start) + && before.trim_end().ends_with('{') + { + start.column = before.trim_end().chars().count() - 1; + } + start + } + fn write_inline_comments(&mut self, final_span: LineColumn, offset: usize) -> Result { if let Some(comment) = self.inline_comment(final_span, offset) { write!(self.out, " {comment}")?; @@ -1164,6 +1357,18 @@ impl<'a> Writer<'a> { self.out.tab() } + /// Whether a body with no attributes or children has comments in it + fn body_has_comments(&self, delimiters: BodyDelimiters) -> bool { + let BodyDelimiters { open, close, .. } = delimiters; + + self.brace_has_trailing_comments(delimiters) + || (open.line..close.line.saturating_sub(1)).any(|idx| { + self.src + .get(idx) + .is_some_and(|line| line.trim().starts_with("//")) + }) + } + /// Starts the line of a closing delimiter, first writing the comments on the lines above it fn write_closing_line(&mut self, delimiters: BodyDelimiters) -> Result { self.out.new_line()?; diff --git a/packages/autofmt/tests/samples.rs b/packages/autofmt/tests/samples.rs index b7bbc3cf8d..94e82a862b 100644 --- a/packages/autofmt/tests/samples.rs +++ b/packages/autofmt/tests/samples.rs @@ -81,6 +81,8 @@ twoway![ long_if_else_attr, empty_component_body, empty_braces_oneliner, + jsx, + jsx_comments, ]; fn assert_idempotent(src: &str) { diff --git a/packages/autofmt/tests/samples/jsx.rsx b/packages/autofmt/tests/samples/jsx.rsx new file mode 100644 index 0000000000..475f942639 --- /dev/null +++ b/packages/autofmt/tests/samples/jsx.rsx @@ -0,0 +1,40 @@ +use dioxus::prelude::*; + +pub fn app() -> Element { + rsx! { +
+

"Hello, world!"

+ + + +
+ "children" + + >"child"> + + // The regular syntax can be mixed in freely + div { class: "inner", "More content" } + for item in items { + "{item}" + } + if cond { + "conditional" + } +
+ p { class: "regular", "regular child" } + {some_expr} +
    +
  • "one"
  • +
  • "two"
  • +
+
+
+ "Attributes split across lines" +
+
+ } +} diff --git a/packages/autofmt/tests/samples/jsx_comments.rsx b/packages/autofmt/tests/samples/jsx_comments.rsx new file mode 100644 index 0000000000..3709af9f2e --- /dev/null +++ b/packages/autofmt/tests/samples/jsx_comments.rsx @@ -0,0 +1,59 @@ +rsx! { + // Before a tag +
+ // Before the first child +

"Hello"

// After a tag + + // After a self-closing tag +

// After an open tag + "text" +

+ // Before a block + span { "block" } // After a block + // Before the closing tag +
+
+ // Only a comment +
+ div { + // Before a tag in a block + "bold" + + "italic" + // Before the closing brace + } +
+ // Only comments + + // Separated by a blank line +
+
    + for i in 0..3 { // After the opening brace of a loop + // Before a tag in a loop +
  • "{i}"
  • // After a tag in a loop + // Before the closing brace of a loop + } + if a { + // Only a comment + } else { // After the opening brace of an else + // After a self-closing tag in an else + } +
+
// After an open tag with an emoji + "héllo 🦀" // After text with an emoji +
+ +} diff --git a/packages/autofmt/tests/wrong.rs b/packages/autofmt/tests/wrong.rs index 2cdf6028c3..38f50f17ec 100644 --- a/packages/autofmt/tests/wrong.rs +++ b/packages/autofmt/tests/wrong.rs @@ -46,4 +46,5 @@ twoway!("comments-messy-4sp" => comments_messy_4sp (IndentOptions::new(IndentTyp twoway!("comments-messy-tab" => comments_messy_tab (IndentOptions::new(IndentType::Tabs, 4, false))); twoway!("comments-constructs-4sp" => comments_constructs_4sp (IndentOptions::new(IndentType::Spaces, 4, false))); twoway!("comments-constructs-tab" => comments_constructs_tab (IndentOptions::new(IndentType::Tabs, 4, false))); +twoway!("jsx-4sp" => jsx_4sp (IndentOptions::new(IndentType::Spaces, 4, false))); twoway!("jsx-comments" => jsx_comments (IndentOptions::new(IndentType::Spaces, 4, false))); diff --git a/packages/autofmt/tests/wrong/jsx-4sp.rsx b/packages/autofmt/tests/wrong/jsx-4sp.rsx new file mode 100644 index 0000000000..5c0f7e91f8 --- /dev/null +++ b/packages/autofmt/tests/wrong/jsx-4sp.rsx @@ -0,0 +1,12 @@ +use dioxus::prelude::*; + +pub fn app() -> Element { + rsx! { +
+

"Hello, world!"

+ + + "children" +
+ } +} diff --git a/packages/autofmt/tests/wrong/jsx-4sp.wrong.rsx b/packages/autofmt/tests/wrong/jsx-4sp.wrong.rsx new file mode 100644 index 0000000000..5d40c695f7 --- /dev/null +++ b/packages/autofmt/tests/wrong/jsx-4sp.wrong.rsx @@ -0,0 +1,13 @@ +use dioxus::prelude::*; + +pub fn app() -> Element { + rsx! { +
+

"Hello, world!"

+ + + "children" +
+ } +} diff --git a/packages/autofmt/tests/wrong/jsx-comments.rsx b/packages/autofmt/tests/wrong/jsx-comments.rsx index db4366a6dc..cdf6d8bf4a 100644 --- a/packages/autofmt/tests/wrong/jsx-comments.rsx +++ b/packages/autofmt/tests/wrong/jsx-comments.rsx @@ -1,16 +1,24 @@ rsx! { // Before a tag - div { +
// Before the first child - h1 { "Hello" } // After a tag +

"Hello"

// After a tag - img { src: "image.png" } // After a self-closing tag + // After a self-closing tag // Before the closing tag - } - section { +
+
// Only a comment - } +
+ } diff --git a/packages/autofmt/tests/wrong/jsx-comments.wrong.rsx b/packages/autofmt/tests/wrong/jsx-comments.wrong.rsx index da1298ab68..f3c2a22ec4 100644 --- a/packages/autofmt/tests/wrong/jsx-comments.wrong.rsx +++ b/packages/autofmt/tests/wrong/jsx-comments.wrong.rsx @@ -8,10 +8,14 @@ rsx! { // Before the first child

"Hello"

// After a tag - // After a self-closing tag + + // After a self-closing tag // Before the closing tag
// Only a comment
+ } From f1ed4f207610e2ac90eb231b9dcda0e17de5322b Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 3 Oct 2026 15:26:34 +0000 Subject: [PATCH 2/2] Keep comments after the name of a tag and before the value of a tag attribute --- packages/autofmt/src/writer.rs | 51 ++++++++++++++++--- .../autofmt/tests/samples/jsx_comments.rsx | 28 ++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/packages/autofmt/src/writer.rs b/packages/autofmt/src/writer.rs index 89aedb941e..7fb7fca1fd 100644 --- a/packages/autofmt/src/writer.rs +++ b/packages/autofmt/src/writer.rs @@ -52,7 +52,7 @@ impl BodyDelimiters { Some(close) => close.lt.span.end(), None => gt.span.end(), }, - name_end: None, + name_end: Some(name_end), is_tag: true, }) } @@ -263,6 +263,14 @@ impl<'a> Writer<'a> { write!(self.out, "<{name}")?; + // A comment after the name stays there, with the rest of the open tag on the lines below + let name_comment = delimiters + .name_end + .and_then(|name_end| self.inline_comment(name_end, 0)); + if let Some(comment) = name_comment { + write!(self.out, " {comment}")?; + } + let attrs: Vec<_> = attributes .iter() .map(AttrType::Attr) @@ -280,9 +288,13 @@ impl<'a> Writer<'a> { ), }) .collect(); - let has_attr_comments = attr_spans.iter().any(|(start, end)| { - self.has_leading_comments(*start) || self.inline_comment(*end, 0).is_some() - }); + let has_attr_comments = name_comment.is_some() + || attr_spans.iter().any(|(start, end)| { + self.has_leading_comments(*start) || self.inline_comment(*end, 0).is_some() + }) + || attributes + .iter() + .any(|attr| !self.attr_value_comments(attr).is_empty()); // Decide if the attributes fit in the open tag or need to be split across lines. // Comments on attributes can only be kept if each attribute is on its own line. @@ -388,12 +400,32 @@ impl<'a> Writer<'a> { name => self.write_attribute_name(name)?, } - if attr.can_be_shorthand() { + // Comments before the value stay there, with the value on a line of its own below them + let comments = self.attr_value_comments(attr); + let has_comments = !comments.is_empty(); + + if attr.can_be_shorthand() && !has_comments { return Ok(()); } write!(self.out, "=")?; + if has_comments { + let name_line = attr.name.span().end().line; + self.out.indent_level += 1; + for (line, comment) in comments { + if line == name_line { + write!(self.out, " {comment}")?; + } else { + self.out.new_line()?; + self.out.indented_tab()?; + write!(self.out, "{comment}")?; + } + } + self.out.new_line()?; + self.out.indented_tab()?; + } + match &attr.value { AttributeValue::AttrLiteral(value) => write!(self.out, "{value}")?, // The lines of a multiline if chain are indented relative to the attribute @@ -411,6 +443,10 @@ impl<'a> Writer<'a> { } } + if has_comments { + self.out.indent_level -= 1; + } + Ok(()) } @@ -1923,12 +1959,13 @@ impl<'a> Writer<'a> { /// and any between the name and the delimiter, which can't stay where they are fn opening_comments(&self, delimiters: BodyDelimiters) -> Vec<&'a str> { let mut comments: Vec<&str> = match delimiters.name_end { - Some(name_end) => self + // The attributes of a tag come between its name and the delimiter + Some(name_end) if !delimiters.is_tag => self .comments_between(name_end, delimiters.open) .into_iter() .map(|(_, comment)| comment) .collect(), - None => Vec::new(), + _ => Vec::new(), }; comments.extend(self.inline_comment(delimiters.open, 1)); comments diff --git a/packages/autofmt/tests/samples/jsx_comments.rsx b/packages/autofmt/tests/samples/jsx_comments.rsx index 3709af9f2e..6ff7f9c311 100644 --- a/packages/autofmt/tests/samples/jsx_comments.rsx +++ b/packages/autofmt/tests/samples/jsx_comments.rsx @@ -56,4 +56,32 @@ rsx! { // Before the only attribute src="a" // After the only attribute /> + + // Comments in the middle of an open tag +
+ "child" +
+ +
+ for item in items + // inside the header of a for loop + .iter() { + "{item}" + } +
}