From 8134a903c7f789622534ffe8332315043223d8fd Mon Sep 17 00:00:00 2001 From: Rigidity Date: Mon, 27 Jul 2026 18:01:01 -0400 Subject: [PATCH 1/3] Fix formatting issue --- crates/rue-formatter/src/emit.rs | 31 +++++++++++++---------- crates/rue-formatter/src/ordering.rs | 12 +++++++-- crates/rue-formatter/src/tests/imports.rs | 17 +++++++++++++ 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/crates/rue-formatter/src/emit.rs b/crates/rue-formatter/src/emit.rs index e3d53bc4..0c734693 100644 --- a/crates/rue-formatter/src/emit.rs +++ b/crates/rue-formatter/src/emit.rs @@ -46,21 +46,26 @@ impl<'a> Formatter<'a> { Separator::None } else { let previous = &items[position - 1]; - if previous.trailing.gap.comments.is_empty() { - match (previous.import_group, item.import_group) { - (Some(left), Some(right)) if left == right => Separator::Hard, - (None, None) - if previous.compact_group.is_some() - && previous.compact_group == item.compact_group - && (item.span.start().index() == 0 - || self.stream.gap_before(item.span.start()).newlines <= 1) => - { - Separator::Hard - } - _ => Separator::Empty, + let requested = match (previous.import_group, item.import_group) { + (Some(left), Some(right)) if left == right => Separator::Hard, + (None, None) + if previous.compact_group.is_some() + && previous.compact_group == item.compact_group + && (item.span.start().index() == 0 + || self.stream.gap_before(item.span.start()).newlines <= 1) => + { + Separator::Hard + } + _ => Separator::Empty, + }; + if trivia_ends_line(&previous.trailing) { + match requested { + Separator::Hard => Separator::None, + Separator::Empty => Separator::Hard, + _ => requested, } } else { - Separator::None + requested } }; let mut leading = item.leading.clone(); diff --git a/crates/rue-formatter/src/ordering.rs b/crates/rue-formatter/src/ordering.rs index 7f000990..db554b5d 100644 --- a/crates/rue-formatter/src/ordering.rs +++ b/crates/rue-formatter/src/ordering.rs @@ -122,14 +122,22 @@ pub(crate) fn plan_document( let (header, first_leading) = split_file_header(stream.gap_before(items[0].span.start())); items[0].leading = first_leading; for index in 1..items.len() { - let (trailing, leading) = split_between(stream.gap_before(items[index].span.start())); + let (mut trailing, leading) = split_between(stream.gap_before(items[index].span.start())); + if items[index - 1].import_group != items[index].import_group + && !trailing.gap.comments.is_empty() + { + trailing.gap.newlines = trailing.gap.newlines.min(1); + } items[index - 1].trailing = trailing; items[index].leading = leading; } let last = items .last_mut() .expect("non-empty document plan has a final item"); - let (trailing, footer) = split_between(stream.final_gap()); + let (mut trailing, footer) = split_between(stream.final_gap()); + if last.import_group.is_some() && !trailing.gap.comments.is_empty() { + trailing.gap.newlines = trailing.gap.newlines.min(1); + } last.trailing = trailing; items.sort_by( diff --git a/crates/rue-formatter/src/tests/imports.rs b/crates/rue-formatter/src/tests/imports.rs index 4b891da8..b40fa705 100644 --- a/crates/rue-formatter/src/tests/imports.rs +++ b/crates/rue-formatter/src/tests/imports.rs @@ -68,6 +68,23 @@ fn trailing_comments_stay_with_sorted_imports() { import zeta; // zeta tail "#]], ); + check( + "import b::b; // b\nimport a::a; // a\n\nimport super::shared::*;", + expect![[r#" + import a::a; // a + import b::b; // b + + import super::shared::*; + "#]], + ); + check( + "import a::a; // a\nimport super::shared::*;\nimport b::b; // b\n\n", + expect![[r#" + import a::a; // a + import b::b; // b + import super::shared::*; + "#]], + ); } #[test] From 65c24946b48cc385d74f5ba542a4d5885229282c Mon Sep 17 00:00:00 2001 From: Rigidity Date: Mon, 27 Jul 2026 18:13:28 -0400 Subject: [PATCH 2/3] Some simplifications --- crates/rue-formatter/src/analysis.rs | 52 ++++----- crates/rue-formatter/src/document.rs | 28 ++--- crates/rue-formatter/src/emit.rs | 91 +++++++-------- crates/rue-formatter/src/equivalence.rs | 33 +----- crates/rue-formatter/src/format.rs | 5 +- crates/rue-formatter/src/lib.rs | 3 +- crates/rue-formatter/src/ordering.rs | 109 +++++++----------- crates/rue-formatter/src/renderer.rs | 2 +- crates/rue-formatter/src/token_stream.rs | 138 ++++++++++++++--------- crates/rue-formatter/src/trivia.rs | 40 ++----- 10 files changed, 221 insertions(+), 280 deletions(-) diff --git a/crates/rue-formatter/src/analysis.rs b/crates/rue-formatter/src/analysis.rs index 455d4d30..e40349fb 100644 --- a/crates/rue-formatter/src/analysis.rs +++ b/crates/rue-formatter/src/analysis.rs @@ -4,11 +4,11 @@ use rue_parser::{SyntaxKind, SyntaxNode, T}; use crate::{ FormatError, ordering::{DocumentPlan, ImportGroupPlan, plan_document, plan_import_groups}, - token_stream::{TokenId, TokenStream}, + token_stream::{TokenId, TokenStream, significant_tokens}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum DelimiterStyle { +pub enum DelimiterStyle { Block, Braced, ConditionalBraced, @@ -26,25 +26,25 @@ enum SingleArgumentLayout { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ItemBoundary { +pub enum ItemBoundary { Document, Module, Block, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ContinuationOperator { - pub(crate) token: TokenId, - pub(crate) depth: usize, +pub struct ContinuationOperator { + pub token: TokenId, + pub depth: usize, } #[derive(Debug, Clone, Default)] -pub(crate) struct TokenFacts { - pub(crate) pair: Option, - pub(crate) delimiter_style: Option, - pub(crate) item_boundary: Option, - pub(crate) group_end: Option, - pub(crate) continuation_operators: Vec, +pub struct TokenFacts { + pub pair: Option, + pub delimiter_style: Option, + pub item_boundary: Option, + pub group_end: Option, + pub continuation_operators: Vec, flags: TokenFlags, } @@ -69,40 +69,40 @@ impl TokenFlags { } impl TokenFacts { - pub(crate) fn supports_trailing_comma(&self) -> bool { + pub fn supports_trailing_comma(&self) -> bool { self.flags.contains(TokenFlags::TRAILING_COMMA) } - pub(crate) fn is_generic(&self) -> bool { + pub fn is_generic(&self) -> bool { self.flags.contains(TokenFlags::GENERIC) } - pub(crate) fn is_prefix_operator(&self) -> bool { + pub fn is_prefix_operator(&self) -> bool { self.flags.contains(TokenFlags::PREFIX_OPERATOR) } - pub(crate) fn is_attached_opener(&self) -> bool { + pub fn is_attached_opener(&self) -> bool { self.flags.contains(TokenFlags::ATTACHED_OPENER) } - pub(crate) fn is_absolute_path_start(&self) -> bool { + pub fn is_absolute_path_start(&self) -> bool { self.flags.contains(TokenFlags::ABSOLUTE_PATH_START) } - pub(crate) fn is_conditional_group(&self) -> bool { + pub fn is_conditional_group(&self) -> bool { self.flags.contains(TokenFlags::CONDITIONAL_GROUP) } } #[derive(Debug)] -pub(crate) struct Layout { +pub struct Layout { facts: Vec, - pub(crate) document: DocumentPlan, - pub(crate) import_groups: std::collections::HashMap, + pub document: DocumentPlan, + pub import_groups: std::collections::HashMap, } impl Layout { - pub(crate) fn new(document: &AstDocument, stream: &TokenStream) -> Result { + pub fn new(document: &AstDocument, stream: &TokenStream) -> Result { let mut facts = vec![TokenFacts::default(); stream.len()]; pair_delimiters(stream, &mut facts)?; analyze_nodes(document.syntax(), stream, &mut facts)?; @@ -116,7 +116,7 @@ impl Layout { }) } - pub(crate) fn facts(&self, token: TokenId) -> &TokenFacts { + pub fn facts(&self, token: TokenId) -> &TokenFacts { &self.facts[token.index()] } } @@ -607,12 +607,6 @@ fn collect_union_operators(node: &SyntaxNode, operators: &mut Vec impl Iterator + '_ { - node.descendants_with_tokens() - .filter_map(rowan::NodeOrToken::into_token) - .filter(|token| !token.kind().is_trivia()) -} - fn is_generic_punctuation(kind: SyntaxKind) -> bool { matches!(kind, T![<] | T![>] | T![,]) } diff --git a/crates/rue-formatter/src/document.rs b/crates/rue-formatter/src/document.rs index a703c57f..d3225da9 100644 --- a/crates/rue-formatter/src/document.rs +++ b/crates/rue-formatter/src/document.rs @@ -1,7 +1,7 @@ //! A deliberately small, private pretty-printing document model. #[derive(Debug, Clone)] -pub(crate) enum Doc { +pub enum Doc { Nil, Text(String), Concat(Vec), @@ -25,18 +25,18 @@ pub(crate) enum Doc { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum LineKind { +pub enum LineKind { Soft, Hard, Empty, } impl Doc { - pub(crate) fn text(text: impl Into) -> Self { + pub fn text(text: impl Into) -> Self { Self::Text(text.into()) } - pub(crate) fn concat(docs: impl IntoIterator) -> Self { + pub fn concat(docs: impl IntoIterator) -> Self { let mut flattened = Vec::new(); for doc in docs { match doc { @@ -48,15 +48,15 @@ impl Doc { Self::Concat(flattened) } - pub(crate) fn space() -> Self { + pub fn space() -> Self { Self::text(" ") } - pub(crate) fn soft_line() -> Self { + pub fn soft_line() -> Self { Self::Line(LineKind::Soft) } - pub(crate) fn fill(doc: Self, indent_levels_on_break: usize) -> Self { + pub fn fill(doc: Self, indent_levels_on_break: usize) -> Self { Self::Fill { flat: Box::new(doc.clone()), broken: Box::new(doc), @@ -65,7 +65,7 @@ impl Doc { } } - pub(crate) fn fill_choice( + pub fn fill_choice( flat: Self, broken: Self, space_when_flat: bool, @@ -79,27 +79,27 @@ impl Doc { } } - pub(crate) fn hard_line() -> Self { + pub fn hard_line() -> Self { Self::Line(LineKind::Hard) } - pub(crate) fn empty_line() -> Self { + pub fn empty_line() -> Self { Self::Line(LineKind::Empty) } - pub(crate) fn indent(self) -> Self { + pub fn indent(self) -> Self { Self::Indent(Box::new(self)) } - pub(crate) fn outdent(self) -> Self { + pub fn outdent(self) -> Self { Self::Outdent(Box::new(self)) } - pub(crate) fn group(self) -> Self { + pub fn group(self) -> Self { Self::Group(Box::new(self)) } - pub(crate) fn if_break(broken: Self, flat: Self) -> Self { + pub fn if_break(broken: Self, flat: Self) -> Self { Self::IfBreak { broken: Box::new(broken), flat: Box::new(flat), diff --git a/crates/rue-formatter/src/emit.rs b/crates/rue-formatter/src/emit.rs index 0c734693..4ff4a7f4 100644 --- a/crates/rue-formatter/src/emit.rs +++ b/crates/rue-formatter/src/emit.rs @@ -6,7 +6,6 @@ use crate::{ document::Doc, ordering::ImportGroupPlan, token_stream::{Comment, CommentPlacement, Gap, TokenId, TokenSpan, TokenStream}, - trivia::Trivia, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -18,7 +17,19 @@ enum Separator { Empty, } -pub(crate) struct Formatter<'a> { +impl Separator { + fn after(self, gap: &Gap) -> Self { + if !gap.ends_line() { + return self; + } + match self { + Self::Empty => Self::Hard, + _ => Self::None, + } + } +} + +pub struct Formatter<'a> { stream: &'a TokenStream, layout: Layout, consumed_tokens: usize, @@ -26,7 +37,7 @@ pub(crate) struct Formatter<'a> { } impl<'a> Formatter<'a> { - pub(crate) fn new(stream: &'a TokenStream, layout: Layout) -> Self { + pub fn new(stream: &'a TokenStream, layout: Layout) -> Self { Self { stream, layout, @@ -35,11 +46,11 @@ impl<'a> Formatter<'a> { } } - pub(crate) fn format(mut self) -> Result { + pub fn format(mut self) -> Result { let items = self.layout.document.items.clone(); let header = self.layout.document.header.clone(); let footer = self.layout.document.footer.clone(); - let mut docs = vec![self.trivia_doc(&header, Separator::None)]; + let mut docs = vec![self.gap_doc(&header, Separator::None)]; for (position, item) in items.iter().enumerate() { let separator = if position == 0 { @@ -58,28 +69,20 @@ impl<'a> Formatter<'a> { } _ => Separator::Empty, }; - if trivia_ends_line(&previous.trailing) { - match requested { - Separator::Hard => Separator::None, - Separator::Empty => Separator::Hard, - _ => requested, - } - } else { - requested - } + requested.after(&previous.trailing) }; let mut leading = item.leading.clone(); if position == 0 - && let Some(first) = leading.gap.comments.first_mut() + && let Some(first) = leading.comments.first_mut() { first.newlines_before = 0; first.placement = CommentPlacement::Leading; } docs.push(self.movable_leading_doc(&leading, separator)); docs.push(self.span(item.span)?); - docs.push(self.trivia_doc(&item.trailing, Separator::None)); + docs.push(self.gap_doc(&item.trailing, Separator::None)); } - docs.push(self.trivia_doc(&footer, Separator::None)); + docs.push(self.gap_doc(&footer, Separator::None)); if self.consumed_tokens != self.stream.len() { return Err(FormatError::Internal(format!( @@ -411,27 +414,25 @@ impl<'a> Formatter<'a> { plan: &ImportGroupPlan, ) -> Result { let mut opening = plan.opening.clone(); - if let Some(first) = opening.gap.comments.first_mut() { + if let Some(first) = opening.comments.first_mut() { first.newlines_before = 0; } - let mut docs = vec![self.trivia_doc(&opening, Separator::None)]; + let mut docs = vec![self.gap_doc(&opening, Separator::None)]; for (position, item) in plan.items.iter().enumerate() { let mut leading = item.leading.clone(); if position == 0 - && let Some(first) = leading.gap.comments.first_mut() + && let Some(first) = leading.comments.first_mut() { first.newlines_before = 0; } - docs.push(self.movable_leading_doc( - &leading, - if position == 0 { - Separator::None - } else { - Separator::Soft - }, - )); + let separator = if position == 0 { + Separator::None + } else { + Separator::Soft.after(&plan.items[position - 1].trailing) + }; + docs.push(self.movable_leading_doc(&leading, separator)); docs.push(self.span(item.span)?); - docs.push(self.trivia_doc(&item.before_comma, Separator::None)); + docs.push(self.gap_doc(&item.before_comma, Separator::None)); if item.comma.is_some() { self.consumed_tokens += 1; } @@ -441,18 +442,18 @@ impl<'a> Formatter<'a> { docs.push(Doc::if_break(Doc::text(","), Doc::Nil)); } let suppress_final_line = position + 1 == plan.items.len() - && plan.closing.gap.comments.is_empty() - && trivia_ends_line(&item.trailing); - docs.push(self.trivia_doc_with_final_line( + && plan.closing.comments.is_empty() + && item.trailing.ends_line(); + docs.push(self.gap_doc_with_final_line( &item.trailing, Separator::None, !suppress_final_line, )); } - docs.push(self.trivia_doc_with_final_line( + docs.push(self.gap_doc_with_final_line( &plan.closing, Separator::None, - !trivia_ends_line(&plan.closing), + !plan.closing.ends_line(), )); Ok(Doc::concat([ open, @@ -518,15 +519,11 @@ impl<'a> Formatter<'a> { } } - fn trivia_doc(&mut self, trivia: &Trivia, requested: Separator) -> Doc { - self.gap_doc(&trivia.gap, requested) - } - - fn movable_leading_doc(&mut self, trivia: &Trivia, requested: Separator) -> Doc { - if trivia.gap.comments.is_empty() { + fn movable_leading_doc(&mut self, gap: &Gap, requested: Separator) -> Doc { + if gap.comments.is_empty() { separator_doc(requested) } else { - self.trivia_doc(trivia, requested) + self.gap_doc(gap, requested) } } @@ -534,13 +531,13 @@ impl<'a> Formatter<'a> { self.gap_doc_with_comments(gap, requested, false, false) } - fn trivia_doc_with_final_line( + fn gap_doc_with_final_line( &mut self, - trivia: &Trivia, + gap: &Gap, requested: Separator, include_final_line: bool, ) -> Doc { - self.gap_doc_with_comments(&trivia.gap, requested, false, !include_final_line) + self.gap_doc_with_comments(gap, requested, false, !include_final_line) } fn gap_doc_with_comments( @@ -628,12 +625,6 @@ fn comment_separator(comment: &Comment, requested: Separator, ignore_trailing: b separator_doc(requested) } -fn trivia_ends_line(trivia: &Trivia) -> bool { - trivia.gap.comments.last().is_some_and(|comment| { - comment.kind == SyntaxKind::LineComment || comment.multiline || trivia.gap.newlines > 0 - }) -} - fn no_space_after(kind: SyntaxKind) -> bool { matches!(kind, T!['('] | T!['['] | T![::] | T![.] | T![...] | T![,]) } diff --git a/crates/rue-formatter/src/equivalence.rs b/crates/rue-formatter/src/equivalence.rs index 03806adb..421aa33b 100644 --- a/crates/rue-formatter/src/equivalence.rs +++ b/crates/rue-formatter/src/equivalence.rs @@ -5,14 +5,13 @@ use rue_ast::{AstDocument, AstNode}; use crate::{ FormatError, ordering::{plan_document, plan_import_groups}, - token_stream::{TokenSpan, TokenStream}, - trivia::Trivia, + token_stream::{Gap, TokenSpan, TokenStream}, }; /// Produces a canonical, ownership-sensitive comment signature. Reorderable /// imports and paths are keyed by their significant-token content, while the /// role and order of each comment within that unit remain significant. -pub(crate) fn comment_signature( +pub fn comment_signature( document: &AstDocument, stream: &TokenStream, ) -> Result, FormatError> { @@ -49,7 +48,7 @@ pub(crate) fn comment_signature( append_trivia(&mut signature, &anchor, "dangling-open", &group.opening); append_trivia(&mut signature, &anchor, "dangling-close", &group.closing); covered_gaps.insert(open.index() + 1); - let close = find_close(*open, stream)?; + let close = stream.matching_brace(*open)?; covered_gaps.insert(close.index()); for item in &group.items { @@ -99,8 +98,8 @@ pub(crate) fn comment_signature( Ok(signature) } -fn append_trivia(signature: &mut Vec, anchor: &str, role: &str, trivia: &Trivia) { - for (order, comment) in trivia.gap.comments.iter().enumerate() { +fn append_trivia(signature: &mut Vec, anchor: &str, role: &str, gap: &Gap) { + for (order, comment) in gap.comments.iter().enumerate() { signature.push(format!( "{anchor}\0{role}:{order}\0{:?}\0{}", comment.kind, comment.text @@ -132,25 +131,3 @@ fn is_optional_trailing_comma(stream: &TokenStream, index: usize) -> bool { fn contains_gap(span: TokenSpan, gap: usize) -> bool { span.start().index() < gap && gap < span.end().index() } - -fn find_close( - open: crate::token_stream::TokenId, - stream: &TokenStream, -) -> Result { - let mut depth = 0; - for (id, token) in stream.token_ids().skip(open.index()) { - match token.kind { - rue_parser::T!['{'] => depth += 1, - rue_parser::T!['}'] => { - depth -= 1; - if depth == 0 { - return Ok(id); - } - } - _ => {} - } - } - Err(FormatError::Internal( - "import path group has no closing delimiter".to_string(), - )) -} diff --git a/crates/rue-formatter/src/format.rs b/crates/rue-formatter/src/format.rs index 6e1fb02c..ac5958c3 100644 --- a/crates/rue-formatter/src/format.rs +++ b/crates/rue-formatter/src/format.rs @@ -4,10 +4,7 @@ use crate::{ FormatError, analysis::Layout, document::Doc, emit::Formatter, token_stream::TokenStream, }; -pub(crate) fn format_document( - document: &AstDocument, - stream: &TokenStream, -) -> Result { +pub fn format_document(document: &AstDocument, stream: &TokenStream) -> Result { let layout = Layout::new(document, stream)?; Formatter::new(stream, layout).format() } diff --git a/crates/rue-formatter/src/lib.rs b/crates/rue-formatter/src/lib.rs index 062e51ba..996014c7 100644 --- a/crates/rue-formatter/src/lib.rs +++ b/crates/rue-formatter/src/lib.rs @@ -23,10 +23,11 @@ use std::sync::Arc; use rue_ast::{AstDocument, AstNode}; use rue_diagnostic::{Diagnostic, Source, SourceKind}; use rue_lexer::Lexer; -pub use rue_options::FormatOptions; use rue_parser::{Parser, SyntaxKind, SyntaxNode}; use thiserror::Error; +pub use rue_options::FormatOptions; + use crate::{ equivalence::comment_signature, format::format_document, renderer::render, token_stream::TokenStream, diff --git a/crates/rue-formatter/src/ordering.rs b/crates/rue-formatter/src/ordering.rs index db554b5d..a6b2b0c2 100644 --- a/crates/rue-formatter/src/ordering.rs +++ b/crates/rue-formatter/src/ordering.rs @@ -5,56 +5,56 @@ use rue_parser::{SyntaxKind, SyntaxNode, T}; use crate::{ FormatError, - token_stream::{TokenId, TokenSpan, TokenStream}, - trivia::{Trivia, split_between, split_file_header, split_group_opening}, + token_stream::{Gap, TokenId, TokenSpan, TokenStream, significant_tokens}, + trivia::{split_between, split_file_header, split_group_opening}, }; #[derive(Debug, Clone)] -pub(crate) struct DocumentItem { - pub(crate) span: TokenSpan, - pub(crate) import_group: Option, - pub(crate) compact_group: Option, - pub(crate) leading: Trivia, - pub(crate) trailing: Trivia, - pub(crate) identity_key: String, +pub struct DocumentItem { + pub span: TokenSpan, + pub import_group: Option, + pub compact_group: Option, + pub leading: Gap, + pub trailing: Gap, + pub identity_key: String, sort_key: String, original_index: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CompactGroup { +pub enum CompactGroup { Constant, TypeAlias, ExternDeclaration, } #[derive(Debug)] -pub(crate) struct DocumentPlan { - pub(crate) header: Trivia, - pub(crate) items: Vec, - pub(crate) footer: Trivia, +pub struct DocumentPlan { + pub header: Gap, + pub items: Vec, + pub footer: Gap, } #[derive(Debug, Clone)] -pub(crate) struct ImportPathItem { - pub(crate) span: TokenSpan, - pub(crate) comma: Option, - pub(crate) leading: Trivia, - pub(crate) before_comma: Trivia, - pub(crate) trailing: Trivia, - pub(crate) identity_key: String, +pub struct ImportPathItem { + pub span: TokenSpan, + pub comma: Option, + pub leading: Gap, + pub before_comma: Gap, + pub trailing: Gap, + pub identity_key: String, sort_key: String, original_index: usize, } #[derive(Debug, Clone)] -pub(crate) struct ImportGroupPlan { - pub(crate) opening: Trivia, - pub(crate) items: Vec, - pub(crate) closing: Trivia, +pub struct ImportGroupPlan { + pub opening: Gap, + pub items: Vec, + pub closing: Gap, } -pub(crate) fn plan_document( +pub fn plan_document( document: &AstDocument, stream: &TokenStream, ) -> Result { @@ -103,8 +103,8 @@ pub(crate) fn plan_document( span, import_group, compact_group, - leading: Trivia::default(), - trailing: Trivia::default(), + leading: Gap::default(), + trailing: Gap::default(), identity_key: canonical_node_key(item.syntax()), sort_key: format!("{path_key}\0{keyword_key}"), original_index, @@ -113,31 +113,23 @@ pub(crate) fn plan_document( if items.is_empty() { return Ok(DocumentPlan { - header: Trivia::new(stream.first_gap().clone()), + header: stream.first_gap().clone(), items, - footer: Trivia::default(), + footer: Gap::default(), }); } let (header, first_leading) = split_file_header(stream.gap_before(items[0].span.start())); items[0].leading = first_leading; for index in 1..items.len() { - let (mut trailing, leading) = split_between(stream.gap_before(items[index].span.start())); - if items[index - 1].import_group != items[index].import_group - && !trailing.gap.comments.is_empty() - { - trailing.gap.newlines = trailing.gap.newlines.min(1); - } + let (trailing, leading) = split_between(stream.gap_before(items[index].span.start())); items[index - 1].trailing = trailing; items[index].leading = leading; } let last = items .last_mut() .expect("non-empty document plan has a final item"); - let (mut trailing, footer) = split_between(stream.final_gap()); - if last.import_group.is_some() && !trailing.gap.comments.is_empty() { - trailing.gap.newlines = trailing.gap.newlines.min(1); - } + let (trailing, footer) = split_between(stream.final_gap()); last.trailing = trailing; items.sort_by( @@ -159,7 +151,7 @@ pub(crate) fn plan_document( }) } -pub(crate) fn plan_import_groups( +pub fn plan_import_groups( root: &SyntaxNode, stream: &TokenStream, ) -> Result, FormatError> { @@ -176,7 +168,7 @@ pub(crate) fn plan_import_groups( continue; }; let open = stream.token_id_at_offset(usize::from(open_token.text_range().start()))?; - let close = find_matching_close(open, stream)?; + let close = stream.matching_brace(open)?; let paths: Vec<_> = node .children() .filter(|child| child.kind() == SyntaxKind::ImportPath) @@ -197,14 +189,14 @@ pub(crate) fn plan_import_groups( .map(|token| token.text.as_str()) .collect(); let before_comma = comma - .map(|comma| Trivia::new(stream.gap_before(comma).clone())) + .map(|comma| stream.gap_before(comma).clone()) .unwrap_or_default(); items.push(ImportPathItem { span, comma, - leading: Trivia::default(), + leading: Gap::default(), before_comma, - trailing: Trivia::default(), + trailing: Gap::default(), identity_key: canonical_node_key(path), sort_key, original_index, @@ -237,7 +229,7 @@ pub(crate) fn plan_import_groups( ImportGroupPlan { opening, items, - closing: Trivia::dangling(closing.gap), + closing: closing.dangling(), }, ); } @@ -266,25 +258,6 @@ fn node_span(node: &SyntaxNode, stream: &TokenStream) -> Result Result { - let mut depth = 0; - for (id, token) in stream.token_ids().skip(open.index()) { - match token.kind { - T!['{'] => depth += 1, - T!['}'] => { - depth -= 1; - if depth == 0 { - return Ok(id); - } - } - _ => {} - } - } - Err(FormatError::Internal( - "import path group has no closing delimiter".to_string(), - )) -} - fn gap_has_blank_line(gap: &crate::token_stream::Gap) -> bool { gap.newlines > 1 || gap @@ -293,12 +266,6 @@ fn gap_has_blank_line(gap: &crate::token_stream::Gap) -> bool { .any(|comment| comment.newlines_before > 1) } -fn significant_tokens(node: &SyntaxNode) -> impl Iterator + '_ { - node.descendants_with_tokens() - .filter_map(rowan::NodeOrToken::into_token) - .filter(|token| !token.kind().is_trivia()) -} - fn canonical_node_key(node: &SyntaxNode) -> String { let mut key = format!("n:{:?}[", node.kind()); let mut sorted_paths = if node.kind() == SyntaxKind::ImportPathSegment { diff --git a/crates/rue-formatter/src/renderer.rs b/crates/rue-formatter/src/renderer.rs index ca4f1794..455baf44 100644 --- a/crates/rue-formatter/src/renderer.rs +++ b/crates/rue-formatter/src/renderer.rs @@ -16,7 +16,7 @@ struct Command<'a> { doc: &'a Doc, } -pub(crate) fn render(doc: &Doc, options: &FormatOptions) -> String { +pub fn render(doc: &Doc, options: &FormatOptions) -> String { let mut output = String::new(); let mut column = 0; let mut commands = vec![Command { diff --git a/crates/rue-formatter/src/token_stream.rs b/crates/rue-formatter/src/token_stream.rs index b59117d6..1b0fc11f 100644 --- a/crates/rue-formatter/src/token_stream.rs +++ b/crates/rue-formatter/src/token_stream.rs @@ -1,82 +1,97 @@ use std::collections::HashMap; -use rue_parser::{SyntaxKind, SyntaxNode}; +use rue_parser::{SyntaxKind, SyntaxNode, T}; use crate::FormatError; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(crate) struct TokenId(usize); +pub struct TokenId(usize); impl TokenId { - pub(crate) fn index(self) -> usize { + pub fn index(self) -> usize { self.0 } } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(crate) struct TokenBoundary(usize); +pub struct TokenBoundary(usize); impl TokenBoundary { - pub(crate) fn index(self) -> usize { + pub fn index(self) -> usize { self.0 } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct TokenSpan { +pub struct TokenSpan { start: TokenId, end: TokenBoundary, } impl TokenSpan { - pub(crate) fn start(self) -> TokenId { + pub fn start(self) -> TokenId { self.start } - pub(crate) fn end(self) -> TokenBoundary { + pub fn end(self) -> TokenBoundary { self.end } } #[derive(Debug, Clone)] -pub(crate) struct Token { - pub(crate) kind: SyntaxKind, - pub(crate) text: String, - pub(crate) start: usize, +pub struct Token { + pub kind: SyntaxKind, + pub text: String, + pub start: usize, } #[derive(Debug, Clone, Default)] -pub(crate) struct Gap { - pub(crate) comments: Vec, - pub(crate) newlines: usize, +pub struct Gap { + pub comments: Vec, + pub newlines: usize, +} + +impl Gap { + pub fn dangling(mut self) -> Self { + for comment in &mut self.comments { + comment.placement = CommentPlacement::Dangling; + } + self + } + + pub fn ends_line(&self) -> bool { + self.comments.last().is_some_and(|comment| { + comment.kind == SyntaxKind::LineComment || comment.multiline || self.newlines > 0 + }) + } } #[derive(Debug, Clone)] -pub(crate) struct Comment { - pub(crate) text: String, - pub(crate) kind: SyntaxKind, - pub(crate) newlines_before: usize, - pub(crate) placement: CommentPlacement, - pub(crate) multiline: bool, +pub struct Comment { + pub text: String, + pub kind: SyntaxKind, + pub newlines_before: usize, + pub placement: CommentPlacement, + pub multiline: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub(crate) enum CommentPlacement { +pub enum CommentPlacement { Leading, Trailing, Dangling, } #[derive(Debug, Clone)] -pub(crate) struct TokenStream { +pub struct TokenStream { tokens: Vec, gaps: Vec, - pub(crate) comment_count: usize, + pub comment_count: usize, token_by_offset: HashMap, } impl TokenStream { - pub(crate) fn from_syntax(root: &SyntaxNode) -> Result { + pub fn from_syntax(root: &SyntaxNode) -> Result { let raw_tokens: Vec<_> = root .descendants_with_tokens() .filter_map(rowan::NodeOrToken::into_token) @@ -180,61 +195,61 @@ impl TokenStream { }) } - pub(crate) fn token(&self, id: TokenId) -> &Token { + pub fn token(&self, id: TokenId) -> &Token { self.tokens .get(id.index()) .expect("TokenId was validated by this token stream") } - pub(crate) fn len(&self) -> usize { + pub fn len(&self) -> usize { self.tokens.len() } - pub(crate) fn token_ids(&self) -> impl Iterator { + pub fn token_ids(&self) -> impl Iterator { self.tokens .iter() .enumerate() .map(|(index, token)| (TokenId(index), token)) } - pub(crate) fn tokens_in(&self, span: TokenSpan) -> &[Token] { + pub fn tokens_in(&self, span: TokenSpan) -> &[Token] { self.tokens .get(span.start().index()..span.end().index()) .expect("TokenSpan was validated by this token stream") } - pub(crate) fn gap_before(&self, id: TokenId) -> &Gap { + pub fn gap_before(&self, id: TokenId) -> &Gap { self.gap(TokenBoundary(id.index())) } - pub(crate) fn gap(&self, boundary: TokenBoundary) -> &Gap { + pub fn gap(&self, boundary: TokenBoundary) -> &Gap { self.gaps .get(boundary.index()) .expect("TokenBoundary was validated by this token stream") } - pub(crate) fn first_gap(&self) -> &Gap { + pub fn first_gap(&self) -> &Gap { self.gap(TokenBoundary(0)) } - pub(crate) fn final_gap(&self) -> &Gap { + pub fn final_gap(&self) -> &Gap { self.gaps .last() .expect("a token stream always has a final gap") } - pub(crate) fn gaps(&self) -> impl Iterator { + pub fn gaps(&self) -> impl Iterator { self.gaps .iter() .enumerate() .map(|(index, gap)| (TokenBoundary(index), gap)) } - pub(crate) fn end_boundary(&self) -> TokenBoundary { + pub fn end_boundary(&self) -> TokenBoundary { TokenBoundary(self.tokens.len()) } - pub(crate) fn token_id(&self, index: usize) -> Result { + pub fn token_id(&self, index: usize) -> Result { (index < self.tokens.len()) .then_some(TokenId(index)) .ok_or_else(|| { @@ -245,7 +260,7 @@ impl TokenStream { }) } - pub(crate) fn boundary(&self, index: usize) -> Result { + pub fn boundary(&self, index: usize) -> Result { (index <= self.tokens.len()) .then_some(TokenBoundary(index)) .ok_or_else(|| { @@ -256,34 +271,30 @@ impl TokenStream { }) } - pub(crate) fn boundary_before(&self, id: TokenId) -> TokenBoundary { + pub fn boundary_before(&self, id: TokenId) -> TokenBoundary { let _ = self.token(id); TokenBoundary(id.index()) } - pub(crate) fn boundary_after(&self, id: TokenId) -> TokenBoundary { + pub fn boundary_after(&self, id: TokenId) -> TokenBoundary { let _ = self.token(id); TokenBoundary(id.index() + 1) } - pub(crate) fn token_at(&self, boundary: TokenBoundary) -> Option { + pub fn token_at(&self, boundary: TokenBoundary) -> Option { (boundary.index() < self.tokens.len()).then_some(TokenId(boundary.index())) } - pub(crate) fn next_token(&self, id: TokenId) -> Option { + pub fn next_token(&self, id: TokenId) -> Option { self.token_at(self.boundary_after(id)) } - pub(crate) fn previous_token(&self, id: TokenId) -> Option { + pub fn previous_token(&self, id: TokenId) -> Option { let _ = self.token(id); id.index().checked_sub(1).map(TokenId) } - pub(crate) fn span( - &self, - start: TokenId, - end: TokenBoundary, - ) -> Result { + pub fn span(&self, start: TokenId, end: TokenBoundary) -> Result { if end.index() > self.tokens.len() { return Err(FormatError::Internal(format!( "span end {} is out of range for {} tokens", @@ -301,21 +312,42 @@ impl TokenStream { Ok(TokenSpan { start, end }) } - pub(crate) fn span_through( - &self, - start: TokenId, - last: TokenId, - ) -> Result { + pub fn span_through(&self, start: TokenId, last: TokenId) -> Result { self.span(start, self.boundary(last.index() + 1)?) } - pub(crate) fn token_id_at_offset(&self, offset: usize) -> Result { + pub fn token_id_at_offset(&self, offset: usize) -> Result { self.token_by_offset.get(&offset).copied().ok_or_else(|| { FormatError::Internal(format!( "syntax token at source offset {offset} has no significant token ID" )) }) } + + pub fn matching_brace(&self, open: TokenId) -> Result { + let mut depth = 0; + for (id, token) in self.token_ids().skip(open.index()) { + match token.kind { + T!['{'] => depth += 1, + T!['}'] => { + depth -= 1; + if depth == 0 { + return Ok(id); + } + } + _ => {} + } + } + Err(FormatError::Internal( + "import path group has no closing delimiter".to_string(), + )) + } +} + +pub fn significant_tokens(node: &SyntaxNode) -> impl Iterator + '_ { + node.descendants_with_tokens() + .filter_map(rowan::NodeOrToken::into_token) + .filter(|token| !token.kind().is_trivia()) } fn newline_count(text: &str) -> usize { diff --git a/crates/rue-formatter/src/trivia.rs b/crates/rue-formatter/src/trivia.rs index 9ee1516a..ff078871 100644 --- a/crates/rue-formatter/src/trivia.rs +++ b/crates/rue-formatter/src/trivia.rs @@ -1,27 +1,9 @@ use crate::token_stream::{CommentPlacement, Gap}; -#[derive(Debug, Clone, Default)] -pub(crate) struct Trivia { - pub(crate) gap: Gap, -} - -impl Trivia { - pub(crate) fn new(gap: Gap) -> Self { - Self { gap } - } - - pub(crate) fn dangling(mut gap: Gap) -> Self { - for comment in &mut gap.comments { - comment.placement = CommentPlacement::Dangling; - } - Self { gap } - } -} - /// Splits trivia between two movable units without guessing that the whole gap /// belongs to the unit on its right. Inline comments form the trailing prefix; /// comments beginning on their own line form the leading suffix. -pub(crate) fn split_between(gap: &Gap) -> (Trivia, Trivia) { +pub fn split_between(gap: &Gap) -> (Gap, Gap) { let split = gap .comments .iter() @@ -38,11 +20,11 @@ pub(crate) fn split_between(gap: &Gap) -> (Trivia, Trivia) { }; if trailing.comments.is_empty() { - return (Trivia::default(), Trivia::new(leading)); + return (Gap::default(), leading); } if leading.comments.is_empty() { - trailing.newlines = gap.newlines; - return (Trivia::new(trailing), Trivia::default()); + trailing.newlines = gap.newlines.min(1); + return (trailing, Gap::default()); } let first_leading = leading @@ -51,17 +33,17 @@ pub(crate) fn split_between(gap: &Gap) -> (Trivia, Trivia) { .expect("leading trivia is known to contain a comment"); trailing.newlines = first_leading.newlines_before; first_leading.newlines_before = 0; - (Trivia::new(trailing), Trivia::new(leading)) + (trailing, leading) } /// Separates comments that are visually a file banner from documentation /// attached to the first item. The closest contiguous comment block is leading /// trivia; earlier blocks remain anchored at the file head. -pub(crate) fn split_file_header(gap: &Gap) -> (Trivia, Trivia) { +pub fn split_file_header(gap: &Gap) -> (Gap, Gap) { let (_, item_leading) = split_between(gap); - let gap = &item_leading.gap; + let gap = &item_leading; if gap.comments.is_empty() || gap.newlines > 1 { - return (Trivia::new(gap.clone()), Trivia::default()); + return (gap.clone(), Gap::default()); } let mut attached_start = gap.comments.len() - 1; @@ -81,13 +63,13 @@ pub(crate) fn split_file_header(gap: &Gap) -> (Trivia, Trivia) { header.newlines = first.newlines_before; first.newlines_before = 0; } - (Trivia::new(header), Trivia::new(leading)) + (header, leading) } /// Splits trivia after an import-group opener. Inline opener comments and /// standalone banner blocks stay dangling on `{`; only the closest comment /// block without a blank line before the first path becomes path documentation. -pub(crate) fn split_group_opening(gap: &Gap) -> (Trivia, Trivia) { +pub fn split_group_opening(gap: &Gap) -> (Gap, Gap) { let trailing_count = gap .comments .iter() @@ -116,5 +98,5 @@ pub(crate) fn split_group_opening(gap: &Gap) -> (Trivia, Trivia) { } else { opening.newlines = gap.newlines; } - (Trivia::dangling(opening), Trivia::new(leading)) + (opening.dangling(), leading) } From 5077773ccc11597ca76f6a2cd549e96343291b53 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Mon, 27 Jul 2026 18:20:36 -0400 Subject: [PATCH 3/3] Fixes --- crates/rue-formatter/src/emit.rs | 24 ++- crates/rue-formatter/src/tests.rs | 1 + crates/rue-formatter/src/tests/comments.rs | 112 ++++++++++++ crates/rue-formatter/src/tests/imports.rs | 106 ++++++++++++ crates/rue-formatter/src/tests/regressions.rs | 161 ++++++++++++++++++ crates/rue-formatter/src/trivia.rs | 2 +- 6 files changed, 392 insertions(+), 14 deletions(-) create mode 100644 crates/rue-formatter/src/tests/regressions.rs diff --git a/crates/rue-formatter/src/emit.rs b/crates/rue-formatter/src/emit.rs index 4ff4a7f4..9f0f3e0f 100644 --- a/crates/rue-formatter/src/emit.rs +++ b/crates/rue-formatter/src/emit.rs @@ -413,11 +413,13 @@ impl<'a> Formatter<'a> { close: Doc, plan: &ImportGroupPlan, ) -> Result { - let mut opening = plan.opening.clone(); - if let Some(first) = opening.comments.first_mut() { - first.newlines_before = 0; - } - let mut docs = vec![self.gap_doc(&opening, Separator::None)]; + let opening = self.gap_doc(&plan.opening, Separator::None); + let before_items = if plan.opening.ends_line() { + Doc::Nil + } else { + Doc::if_break(Doc::hard_line(), Doc::Nil) + }; + let mut docs = Vec::new(); for (position, item) in plan.items.iter().enumerate() { let mut leading = item.leading.clone(); if position == 0 @@ -436,7 +438,7 @@ impl<'a> Formatter<'a> { if item.comma.is_some() { self.consumed_tokens += 1; } - if position + 1 < plan.items.len() { + if position + 1 < plan.items.len() || !item.before_comma.comments.is_empty() { docs.push(Doc::text(",")); } else { docs.push(Doc::if_break(Doc::text(","), Doc::Nil)); @@ -450,14 +452,10 @@ impl<'a> Formatter<'a> { !suppress_final_line, )); } - docs.push(self.gap_doc_with_final_line( - &plan.closing, - Separator::None, - !plan.closing.ends_line(), - )); + docs.push(self.gap_doc_with_final_line(&plan.closing, Separator::None, false)); Ok(Doc::concat([ open, - Doc::concat([Doc::if_break(Doc::hard_line(), Doc::Nil), Doc::concat(docs)]).indent(), + Doc::concat([opening, before_items, Doc::concat(docs)]).indent(), Doc::if_break(Doc::hard_line(), Doc::Nil), close, ]) @@ -566,7 +564,7 @@ impl<'a> Formatter<'a> { docs.push(Doc::text(comment.text.clone())); let followed_inline = match gap.comments.get(index + 1) { Some(next) => next.newlines_before == 0, - None => gap.newlines == 0, + None => gap.newlines == 0 && !suppress_final_line, }; if comment.kind == SyntaxKind::BlockComment && !comment.multiline && followed_inline { docs.push(Doc::space()); diff --git a/crates/rue-formatter/src/tests.rs b/crates/rue-formatter/src/tests.rs index 2f0ee744..029c35b1 100644 --- a/crates/rue-formatter/src/tests.rs +++ b/crates/rue-formatter/src/tests.rs @@ -4,6 +4,7 @@ use crate::{FormatError, FormatOptions, format_source}; mod comments; mod imports; +mod regressions; #[allow(clippy::needless_pass_by_value)] fn check(input: &str, expected: Expect) { diff --git a/crates/rue-formatter/src/tests/comments.rs b/crates/rue-formatter/src/tests/comments.rs index 384ac7bf..b98db5fa 100644 --- a/crates/rue-formatter/src/tests/comments.rs +++ b/crates/rue-formatter/src/tests/comments.rs @@ -138,3 +138,115 @@ fn excessive_blank_lines_are_normalized_around_comments() { "#]], ); } + +#[test] +fn comment_only_files_and_comments_at_physical_eof() { + check( + "// only", + expect![[r#" + // only + "#]], + ); + check( + "/* only */", + expect![[r#" + /* only */ + "#]], + ); + check( + "fn main() {} // eof", + expect![[r#" + fn main() {} // eof + "#]], + ); +} + +#[test] +fn comment_line_endings_are_normalized() { + check( + "// header\r\n\r\nfn main(){// body\r\n1}\r\n", + expect![[r#" + // header + + fn main() { + // body + 1 + } + "#]], + ); +} + +#[test] +fn trailing_comments_do_not_absorb_item_boundaries() { + check( + "const FIRST:Int=1; // first\n\nconst SECOND:Int=2; /* second */\n\nfn main(){FIRST+SECOND}", + expect![[r#" + const FIRST: Int = 1; // first + + const SECOND: Int = 2; /* second */ + + fn main() { + FIRST + SECOND + } + "#]], + ); +} + +#[test] +fn comments_between_postfix_operations_keep_their_position() { + check( + "fn main(){value/* call */(/* arg */1)/* field */.field/* cast */as Int}", + expect![[r#" + fn main() { + value /* call */ (/* arg */ 1) /* field */ .field /* cast */ as Int + } + "#]], + ); +} + +#[test] +fn comments_inside_empty_delimiters_are_preserved() { + check( + "fn main(){consume(/* no arguments */);[/* no items */]}", + expect![[r#" + fn main() { + consume( /* no arguments */ ); + [ /* no items */ ] + } + "#]], + ); +} + +#[test] +fn multiline_comments_force_stable_expression_boundaries() { + check( + "fn main(){let value=1;/* first\nsecond */value/* third\nfourth */+1}", + expect![[r#" + fn main() { + let value = 1; /* first + second */ + value /* third + fourth */ + + 1 + } + "#]], + ); +} + +#[test] +fn comments_between_conditional_branches_are_not_duplicated() { + check( + "fn main(value:Int){if value>0{1}/* between */else if value<0{2}/* final */else{3}}", + expect![[r#" + fn main(value: Int) { + if value > 0 { + 1 + } /* between */ else if value < 0 { + 2 + } /* final */ else { + 3 + } + } + "#]], + ); +} diff --git a/crates/rue-formatter/src/tests/imports.rs b/crates/rue-formatter/src/tests/imports.rs index b40fa705..258b95f3 100644 --- a/crates/rue-formatter/src/tests/imports.rs +++ b/crates/rue-formatter/src/tests/imports.rs @@ -147,3 +147,109 @@ fn file_header_is_not_first_import_documentation() { "#]], ); } + +#[test] +fn absolute_and_super_import_paths_sort_canonically() { + check( + "import ::zeta::item;\nimport root::super::thing;\nimport ::alpha::item;", + expect![[r#" + import ::alpha::item; + import ::zeta::item; + import root::super::thing; + "#]], + ); +} + +#[test] +fn import_and_export_of_the_same_path_have_stable_order() { + check( + "import shared::item; // import\nexport shared::item; // export", + expect![[r#" + export shared::item; // export + import shared::item; // import + "#]], + ); +} + +#[test] +fn block_comments_move_with_sorted_imports() { + check( + "/* zeta docs */ import zeta; /* zeta tail */\n/* alpha docs */ import alpha; /* alpha tail */", + expect![[r#" + /* alpha docs */ import alpha; /* alpha tail */ + /* zeta docs */ import zeta; /* zeta tail */ + "#]], + ); +} + +#[test] +fn nested_comments_before_commas_move_with_their_paths() { + check( + "import root::{zeta /* zeta comma */,alpha /* alpha comma */};", + expect![[r#" + import root::{alpha, /* alpha comma */ zeta /* zeta comma */ ,}; + "#]], + ); +} + +#[test] +fn comments_on_group_delimiters_remain_dangling() { + check( + "import root::{ // opening\nzeta,\nalpha\n/* closing */};", + expect![[r#" + import root::{ // opening + alpha, + zeta, + /* closing */ + }; + "#]], + ); +} + +#[test] +fn empty_and_single_path_import_groups_are_stable() { + check( + "import root::{};\nimport other::{item,};", + expect![[r#" + import other::{item}; + import root::{}; + "#]], + ); +} + +#[test] +fn blank_lines_define_import_sorting_groups() { + check( + "import zeta;\nimport beta;\n\nimport alpha;\nimport gamma;", + expect![[r#" + import beta; + import zeta; + + import alpha; + import gamma; + "#]], + ); +} + +#[test] +fn deeply_nested_import_groups_sort_recursively() { + check( + "import root::{zeta::{three::{c,a,b},one},alpha::{last,first},middle};", + expect![[r#" + import root::{alpha::{first, last}, middle, zeta::{one, three::{a, b, c}}}; + "#]], + ); +} + +#[test] +fn duplicate_import_paths_keep_distinct_comment_owners() { + check( + "// first\nimport same::item;\n// second\nimport same::item;", + expect![[r#" + // first + import same::item; + // second + import same::item; + "#]], + ); +} diff --git a/crates/rue-formatter/src/tests/regressions.rs b/crates/rue-formatter/src/tests/regressions.rs new file mode 100644 index 00000000..211b531c --- /dev/null +++ b/crates/rue-formatter/src/tests/regressions.rs @@ -0,0 +1,161 @@ +use super::*; + +#[test] +fn nested_modules_format_mixed_item_kinds() { + check( + "mod outer{const Z:Int=2;mod inner{type Pair=(T,T);fn make(x:Int){x}}struct Value{item:Int}}", + expect![[r#" + mod outer { + const Z: Int = 2; + mod inner { + type Pair = (T, T); + fn make(x: Int) { + x + } + } + struct Value { + item: Int, + } + } + "#]], + ); +} + +#[test] +fn item_modifier_combinations_have_canonical_spacing() { + check( + "export inline const VALUE:Int=1; export test fn verify(){assert true;} export extern fn host(x:Int)->Int from \"host.hex\";", + expect![[r#" + export inline const VALUE: Int = 1; + + export test fn verify() { + assert true; + } + + export extern fn host(x: Int) -> Int from "host.hex"; + "#]], + ); +} + +#[test] +fn nested_destructuring_bindings_format_recursively() { + check( + "fn unpack(value:Any){let ([first,...rest],{left:right,...tail})=value;[first,right,...rest]}", + expect![[r#" + fn unpack(value: Any) { + let ([first, ...rest], { left: right, ...tail }) = value; + [first, right, ...rest] + } + "#]], + ); +} + +#[test] +fn struct_defaults_spreads_and_initializers_are_distinct() { + check( + "struct Config{value:T=default,count=1,...rest:T} fn make(value:Int){Config::{count:2,value}}", + expect![[r#" + struct Config { + value: T = default, + count = 1, + ...rest: T, + } + + fn make(value: Int) { + Config:: { count: 2, value } + } + "#]], + ); +} + +#[test] +fn pair_and_group_expressions_keep_their_shape() { + check( + "fn pairs(a:Int,b:Int){let grouped=(a+b);let pair=(a,b,);[(grouped),pair]}", + expect![[r#" + fn pairs(a: Int, b: Int) { + let grouped = (a + b); + let pair = (a, b); + [(grouped), pair] + } + "#]], + ); +} + +#[test] +fn postfix_chains_format_after_struct_and_list_expressions() { + check( + "fn chain(value:Int){Wrapper{value}.field(value).next as Int is Int}", + expect![[r#" + fn chain(value: Int) { + Wrapper { value }.field(value).next as Int is Int + } + "#]], + ); +} + +#[test] +fn prefix_operators_remain_unambiguous_next_to_binary_operators() { + check( + "fn operators(a:Int,b:Int){a+-b;a--b;a*!b;!!a==!b}", + expect![[r#" + fn operators(a: Int, b: Int) { + a + -b; + a - -b; + a * !b; + !!a == !b + } + "#]], + ); +} + +#[test] +fn lambdas_support_generics_spreads_and_destructuring() { + check( + "fn make(){fn(...[first,...rest]:[T,...T]):T=>first}", + expect![[r#" + fn make() { + fn(...[first, ...rest]: [T, ...T]): T => first + } + "#]], + ); +} + +#[test] +fn nested_function_pair_and_union_types_format_consistently() { + check( + "type Handler=fn(value:T,rest:List)->(T|nil,fn(item:T)->T);", + expect![[r#" + type Handler = fn(value: T, rest: List) -> (T | nil, fn(item: T) -> T); + "#]], + ); +} + +#[test] +fn empty_and_value_statements_keep_required_semicolons() { + check( + "fn statements(value:Int){inline let copy=value;debug copy;assert true;raise;return;}", + expect![[r#" + fn statements(value: Int) { + inline let copy = value; + debug copy; + assert true; + raise; + return; + } + "#]], + ); +} + +#[test] +fn absolute_generic_paths_and_shift_expressions_do_not_conflict() { + check( + "fn paths(value:Int){::root::Type::::make(value);value<<2>1}", + expect![[r#" + fn paths(value: Int) { + ::root::Type::::make(value); + value << 2 > 1 + } + "#]], + ); +} diff --git a/crates/rue-formatter/src/trivia.rs b/crates/rue-formatter/src/trivia.rs index ff078871..57aeb50e 100644 --- a/crates/rue-formatter/src/trivia.rs +++ b/crates/rue-formatter/src/trivia.rs @@ -98,5 +98,5 @@ pub fn split_group_opening(gap: &Gap) -> (Gap, Gap) { } else { opening.newlines = gap.newlines; } - (opening.dangling(), leading) + (opening, leading) }