Skip to content

Commit d91eeae

Browse files
authored
fix: formatter use newline interference (#7431)
## Description Fixes #7434. ## The Problem 1. Original code: `use utils::{IssuanceParams};` - We have have `use`, `utils`, `::`, `{`, `IssuanceParams`,`}`, ; - Total: 7 leaf spans (tokens) 2. Formatted code: `use utils::IssuanceParams;` (braces removed) - We have `use`, `utils`, `::`, `IssuanceParams`, `;` - Total: 5 leaf spans (no `{` and `}`tokens) 3. handle_newlines() zips unformatted and formatted spans together: for (unformatted_span, formatted_span) in unformatted_spans.zip(formatted_spans) 4. After the use statement, spans are MISALIGNED This PR fixes that by actually tracking removed spans for handling the misalignment.
1 parent 14bb898 commit d91eeae

File tree

4 files changed

+104
-1
lines changed

4 files changed

+104
-1
lines changed

swayfmt/src/formatter/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,17 @@ use sway_types::{SourceEngine, Spanned};
1616

1717
pub(crate) mod shape;
1818

19-
#[derive(Debug, Default, Clone)]
19+
#[derive(Debug, Clone, Default)]
2020
pub struct Formatter {
2121
pub source_engine: Arc<SourceEngine>,
2222
pub shape: Shape,
2323
pub config: Config,
2424
pub comments_context: CommentsContext,
2525
pub experimental: ExperimentalFeatures,
26+
/// Tracks spans that were removed during formatting (e.g., braces from single-element imports).
27+
/// Maps: unformatted_byte_position -> number_of_bytes_removed
28+
/// This allows handle_newlines() to adjust span mapping when AST structure changes.
29+
pub(crate) removed_spans: Vec<(usize, usize)>,
2630
}
2731

2832
pub type FormattedCode = String;

swayfmt/src/items/item_use/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ impl Format for UseTree {
6868
&& !formatter.shape.code_line.line_style.is_multiline()
6969
{
7070
// we can have: path::{single_import}
71+
// Record that we're removing the opening and closing braces
72+
// Format: (byte_position, bytes_removed)
73+
let open_brace_pos = imports.span().start();
74+
let close_brace_pos = imports.span().end() - 1;
75+
formatter.removed_spans.push((open_brace_pos, 1)); // Remove '{'
76+
formatter.removed_spans.push((close_brace_pos, 1)); // Remove '}'
77+
7178
if let Some(single_import) = &imports.inner.final_value_opt {
7279
single_import.format(formatted_code, formatter)?;
7380
}
@@ -77,6 +84,13 @@ impl Format for UseTree {
7784
{
7885
// but we can also have: path::{single_import,}
7986
// note that in the case of multiline we want to keep the trailing comma
87+
let open_brace_pos = imports.span().start();
88+
let close_brace_pos = imports.span().end() - 1;
89+
formatter.removed_spans.push((open_brace_pos, 1)); // Remove '{'
90+
// Also removing the trailing comma (1 byte) before the '}'
91+
formatter.removed_spans.push((close_brace_pos - 1, 1)); // Remove ','
92+
formatter.removed_spans.push((close_brace_pos, 1)); // Remove '}'
93+
8094
let single_import = &imports
8195
.inner
8296
.value_separator_pairs

swayfmt/src/items/item_use/tests.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,44 @@ fmt_test_item!(single_import_multiline_with_braces_with_trailing_comma "use
7070
};",
7171
braced_single_import "use std::tx::{xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx,};"
7272
);
73+
74+
#[test]
75+
fn single_import_with_braces_preserves_following_item() {
76+
// Regression test for: https://github.com/FuelLabs/sway/issues/7434
77+
use crate::Formatter;
78+
use indoc::indoc;
79+
80+
let unformatted = indoc! {r#"
81+
contract;
82+
use utils::{IssuanceParams};
83+
pub struct BridgeRegisteredEvent {
84+
pub bridge_name: String,
85+
pub bridge_id: b256,
86+
}
87+
"#};
88+
89+
let expected = indoc! {r#"
90+
contract;
91+
use utils::IssuanceParams;
92+
pub struct BridgeRegisteredEvent {
93+
pub bridge_name: String,
94+
pub bridge_id: b256,
95+
}
96+
"#};
97+
98+
let mut formatter = Formatter::default();
99+
let first_formatted = Formatter::format(&mut formatter, unformatted.into()).unwrap();
100+
101+
// The critical assertion: "pub struct BridgeRegisteredEvent" should stay on one line
102+
assert!(
103+
!first_formatted.contains("pub struct\n"),
104+
"Bug regression: struct name was split from 'pub struct' keyword"
105+
);
106+
107+
assert_eq!(first_formatted, expected);
108+
109+
// Ensure idempotency
110+
let second_formatted =
111+
Formatter::format(&mut formatter, first_formatted.as_str().into()).unwrap();
112+
assert_eq!(second_formatted, first_formatted);
113+
}

swayfmt/src/utils/map/newline.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ pub fn handle_newlines(
130130
formatted_code,
131131
unformatted_input,
132132
newline_threshold,
133+
&formatter.removed_spans,
133134
)?;
134135
Ok(())
135136
}
@@ -162,6 +163,7 @@ fn add_newlines(
162163
formatted_code: &mut FormattedCode,
163164
unformatted_code: Arc<str>,
164165
newline_threshold: usize,
166+
removed_spans: &[(usize, usize)],
165167
) -> Result<(), FormatterError> {
166168
let mut unformatted_newline_spans = unformatted_module.leaf_spans();
167169
let mut formatted_newline_spans = formatted_module.leaf_spans();
@@ -185,6 +187,29 @@ fn add_newlines(
185187
let mut previous_formatted_newline_span = formatted_newline_spans
186188
.first()
187189
.ok_or(FormatterError::NewlineSequenceError)?;
190+
// Check if AST structure changed during formatting (e.g., braces removed from imports)
191+
if !removed_spans.is_empty() {
192+
// When AST structure changed, directly map newline positions from unformatted to formatted
193+
newline_map.iter().try_fold(
194+
0_i64,
195+
|mut offset, (newline_span, newline_sequence)| -> Result<i64, FormatterError> {
196+
let formatted_pos =
197+
map_unformatted_to_formatted_position(newline_span.end, removed_spans);
198+
199+
offset += insert_after_span(
200+
calculate_offset(formatted_pos, offset),
201+
newline_sequence.clone(),
202+
formatted_code,
203+
newline_threshold,
204+
)?;
205+
206+
Ok(offset)
207+
},
208+
)?;
209+
210+
return Ok(());
211+
}
212+
188213
for (unformatted_newline_span, formatted_newline_span) in unformatted_newline_spans
189214
.iter()
190215
.skip(1)
@@ -387,6 +412,25 @@ fn first_newline_sequence_in_span(
387412
None
388413
}
389414

415+
/// Maps an unformatted byte position to the corresponding formatted byte position
416+
/// by accounting for removed spans during formatting.
417+
///
418+
/// For example, if we removed 2 bytes at position 22 (e.g., '{' and '}'),
419+
/// then position 40 in unformatted maps to position 38 in formatted.
420+
fn map_unformatted_to_formatted_position(
421+
unformatted_pos: usize,
422+
removed_spans: &[(usize, usize)],
423+
) -> usize {
424+
// Sum all bytes removed before this position
425+
let total_removed = removed_spans
426+
.iter()
427+
.filter(|(removed_pos, _)| *removed_pos < unformatted_pos)
428+
.map(|(_, removed_count)| removed_count)
429+
.sum::<usize>();
430+
431+
unformatted_pos.saturating_sub(total_removed)
432+
}
433+
390434
#[cfg(test)]
391435
mod tests {
392436
use crate::utils::map::{byte_span::ByteSpan, newline::first_newline_sequence_in_span};

0 commit comments

Comments
 (0)