Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## [Unreleased]

### Added

- Add `--preserve-whitespace` to keep the original whitespace between classes
instead of collapsing a sorted class list onto a single line,
[#153](https://github.com/avencera/rustywind/pull/153)

## [0.27.0] - 2026-08-10

### Added
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ Options:
--allow-duplicates
When set, RustyWind will not delete duplicated classes

--preserve-whitespace
When set, RustyWind will keep the original whitespace between classes, e.g. multiline class lists

--config-file <CONFIG_FILE>
When set, RustyWind will use the config file to derive configurations. The config file current only supports json with one property sortOrder, e.g. { "sortOrder": ["class1", ...] }

Expand Down
3 changes: 3 additions & 0 deletions rustywind-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ pub struct Cli {
/// When set, RustyWind will not delete duplicated classes.
#[arg(long)]
allow_duplicates: bool,
/// When set, RustyWind will keep the original whitespace between classes, e.g. multiline class lists.
#[arg(long)]
preserve_whitespace: bool,
/// When set, RustyWind will use the config file to derive configurations. The config file
/// current only supports json with one property sortOrder, e.g.
/// { "sortOrder": ["class1", ...] }.
Expand Down
1 change: 1 addition & 0 deletions rustywind-cli/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ impl Options {
allow_duplicates: cli.allow_duplicates,
class_wrapping: get_class_wrapping_from_cli(&cli),
tailwind_prefix: cli.tailwind_prefix.clone(),
preserve_whitespace: cli.preserve_whitespace,
};

Ok(Options {
Expand Down
13 changes: 13 additions & 0 deletions rustywind-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## Unreleased

### Added

- Add `RustyWind::preserve_whitespace`, which reuses the original separators
between classes instead of rejoining a sorted run with single spaces, so
multiline class lists keep their line structure

### Changed

- **Breaking**: `RustyWind` gained the `preserve_whitespace` field, so
exhaustive struct literals no longer compile; build them with
`..RustyWind::default()` or the `RustyWind::new*` constructors, which keep the
previous behavior

## [0.6.0] - 2026-08-10

### Added
Expand Down
109 changes: 107 additions & 2 deletions rustywind-core/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ pub struct RustyWind {
pub class_wrapping: ClassWrapping,
/// Tailwind prefix normalized while computing sort order
pub tailwind_prefix: Option<String>,
/// Preserve the original whitespace around classes when sorting
pub preserve_whitespace: bool,
}

impl Default for RustyWind {
Expand All @@ -175,6 +177,7 @@ impl Default for RustyWind {
allow_duplicates: false,
class_wrapping: ClassWrapping::NoWrapping,
tailwind_prefix: None,
preserve_whitespace: false,
}
}
}
Expand Down Expand Up @@ -204,6 +207,7 @@ impl RustyWind {
allow_duplicates,
class_wrapping,
tailwind_prefix,
preserve_whitespace: false,
}
}

Expand Down Expand Up @@ -418,13 +422,18 @@ impl RustyWind {
}

fn sort_class_run(&self, class_string: &str) -> String {
let mut sorted = self.sort_classes_vec(split_class_tokens(class_string).into_iter());
let tokens = split_class_tokens(class_string);
let mut sorted = self.sort_classes_vec(tokens.iter().copied());

if !self.allow_duplicates {
deduplicate_classes(&mut sorted);
}

sorted.join(" ")
if self.preserve_whitespace {
interleave_separators(class_string, &tokens, &sorted)
} else {
sorted.join(" ")
}
}

fn sort_wrapped_classes(&self, class_list: &WrappedClassList<'_>) -> String {
Expand Down Expand Up @@ -696,6 +705,34 @@ fn split_class_tokens(class_string: &str) -> Vec<&str> {
tokens
}

/// Join the sorted classes of a run, reusing the original whitespace instead of single spaces.
///
/// Whitespace is reused positionally: the Nth sorted class is preceded by the whitespace that preceded
/// the Nth original class, and the trailing whitespace is kept as-is. A class list spread over several
/// indented lines thus keeps its exact line structure — only the class names move between the slots.
///
/// When deduplication dropped classes, `sorted` is shorter than `tokens` and the surplus slots are
/// dropped along with their separators.
fn interleave_separators(original: &str, tokens: &[&str], sorted: &[&str]) -> String {
// `tokens` are subslices of `original`, so an offset marks the end of the preceding whitespace.
let offset = |token: &str| token.as_ptr() as usize - original.as_ptr() as usize;
debug_assert!(sorted.len() <= tokens.len());

// The output is a subset of the original separators and tokens, so this capacity never reallocates.
let mut out = String::with_capacity(original.len());
let mut pos = 0;
let mut replacements = sorted.iter();
for token in tokens {
if let Some(replacement) = replacements.next() {
out.push_str(&original[pos..offset(token)]);
out.push_str(replacement);
}
pos = offset(token) + token.len();
}
out.push_str(&original[pos..]);
out
}

fn deduplicate_classes(classes: &mut Vec<&str>) {
let mut seen = HashSet::new();
classes.retain(|class| is_ellipsis_placeholder(class) || seen.insert(*class));
Expand All @@ -718,6 +755,7 @@ mod tests {
allow_duplicates: false,
class_wrapping: ClassWrapping::NoWrapping,
tailwind_prefix: None,
preserve_whitespace: false,
};

trait TestRustyWindExt {
Expand Down Expand Up @@ -1008,6 +1046,7 @@ mod tests {
regex: FinderRegex::DefaultRegex,
class_wrapping: ClassWrapping::NoWrapping,
tailwind_prefix: None,
preserve_whitespace: false,
};

let input = r#"<div class="flex flex m-4 m-4"></div>"#;
Expand Down Expand Up @@ -1347,6 +1386,7 @@ mod tests {
allow_duplicates: false,
class_wrapping,
tailwind_prefix: None,
preserve_whitespace: false,
};

assert_eq!(app.sort_file_contents(input), output);
Expand All @@ -1360,6 +1400,7 @@ mod tests {
allow_duplicates: false,
class_wrapping: ClassWrapping::NoWrapping,
tailwind_prefix: None,
preserve_whitespace: false,
};
let input = "even-columns empty-state hovercraft event.status status_color even:flex";

Expand All @@ -1368,4 +1409,68 @@ mod tests {
"even:flex even-columns empty-state hovercraft event.status status_color"
);
}

#[test]
/// The one-class-per-line layout from avencera/rustywind#25, which default
/// sorting flattens to a single line.
fn preserve_whitespace_keeps_one_class_per_line() {
let app = RustyWind {
preserve_whitespace: true,
..RustyWind::default()
};
let input = r#"<div
class="
grid
border
fixed
top-0
right-0
z-20
grid-flow-col
gap-2
justify-start
my-12
mx-8
text-red-800
bg-red-50
rounded
border-red-100
shadow-2xl
"
>"#;
let expected = r#"<div
class="
fixed
top-0
right-0
z-20
mx-8
my-12
grid
grid-flow-col
justify-start
gap-2
rounded
border
border-red-100
bg-red-50
text-red-800
shadow-2xl
"
>"#;
assert_eq!(app.sort_file_contents(input), expected);
}

#[test]
fn preserve_whitespace_drops_surplus_separators_on_dedup() {
let app = RustyWind {
preserve_whitespace: true,
..RustyWind::default()
};
let input = "<div class=\"flex\n flex p-4\"></div>";
assert_eq!(
app.sort_file_contents(input),
"<div class=\"flex\n p-4\"></div>"
);
}
}
4 changes: 4 additions & 0 deletions rustywind-core/tests/test_tailwind_prefix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ fn rustywind_flag_preserves_original_prefixed_classes_in_output() {
allow_duplicates: false,
class_wrapping: Default::default(),
tailwind_prefix: Some("tw".to_string()),
preserve_whitespace: false,
};

let input = r#"<div class="tw:p-4 tw:bg-white tw:md:text-xl tw:hover:-mr-4"></div>"#;
Expand All @@ -117,6 +118,7 @@ fn custom_sorter_uses_normalized_prefixed_fallback_after_exact_lookup() {
allow_duplicates: false,
class_wrapping: Default::default(),
tailwind_prefix: Some("tw".to_string()),
preserve_whitespace: false,
};

assert_eq!(
Expand All @@ -141,6 +143,7 @@ fn custom_sorter_variant_fallback_keeps_v3_prefixed_exact_order() {
allow_duplicates: false,
class_wrapping: Default::default(),
tailwind_prefix: Some("tw".to_string()),
preserve_whitespace: false,
};

assert_eq!(
Expand All @@ -161,6 +164,7 @@ fn custom_sorter_variant_fallback_keeps_v4_prefixed_exact_order() {
allow_duplicates: false,
class_wrapping: Default::default(),
tailwind_prefix: Some("tw".to_string()),
preserve_whitespace: false,
};

assert_eq!(
Expand Down
Loading