diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e32903..f213fa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5eb17fa..356d41a 100644 --- a/README.md +++ b/README.md @@ -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 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", ...] } diff --git a/rustywind-cli/src/main.rs b/rustywind-cli/src/main.rs index 9667284..0df8a2f 100644 --- a/rustywind-cli/src/main.rs +++ b/rustywind-cli/src/main.rs @@ -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", ...] }. diff --git a/rustywind-cli/src/options.rs b/rustywind-cli/src/options.rs index 686b3ed..7531b32 100644 --- a/rustywind-cli/src/options.rs +++ b/rustywind-cli/src/options.rs @@ -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 { diff --git a/rustywind-core/CHANGELOG.md b/rustywind-core/CHANGELOG.md index 2f1b333..24ee17e 100644 --- a/rustywind-core/CHANGELOG.md +++ b/rustywind-core/CHANGELOG.md @@ -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 diff --git a/rustywind-core/src/app.rs b/rustywind-core/src/app.rs index 8f28205..636c4c2 100644 --- a/rustywind-core/src/app.rs +++ b/rustywind-core/src/app.rs @@ -165,6 +165,8 @@ pub struct RustyWind { pub class_wrapping: ClassWrapping, /// Tailwind prefix normalized while computing sort order pub tailwind_prefix: Option, + /// Preserve the original whitespace around classes when sorting + pub preserve_whitespace: bool, } impl Default for RustyWind { @@ -175,6 +177,7 @@ impl Default for RustyWind { allow_duplicates: false, class_wrapping: ClassWrapping::NoWrapping, tailwind_prefix: None, + preserve_whitespace: false, } } } @@ -204,6 +207,7 @@ impl RustyWind { allow_duplicates, class_wrapping, tailwind_prefix, + preserve_whitespace: false, } } @@ -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 { @@ -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)); @@ -718,6 +755,7 @@ mod tests { allow_duplicates: false, class_wrapping: ClassWrapping::NoWrapping, tailwind_prefix: None, + preserve_whitespace: false, }; trait TestRustyWindExt { @@ -1008,6 +1046,7 @@ mod tests { regex: FinderRegex::DefaultRegex, class_wrapping: ClassWrapping::NoWrapping, tailwind_prefix: None, + preserve_whitespace: false, }; let input = r#"
"#; @@ -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); @@ -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"; @@ -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#"
"#; + let expected = r#"
"#; + 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 = "
"; + assert_eq!( + app.sort_file_contents(input), + "
" + ); + } } diff --git a/rustywind-core/tests/test_tailwind_prefix.rs b/rustywind-core/tests/test_tailwind_prefix.rs index 8912e0f..7d29d60 100644 --- a/rustywind-core/tests/test_tailwind_prefix.rs +++ b/rustywind-core/tests/test_tailwind_prefix.rs @@ -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#"
"#; @@ -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!( @@ -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!( @@ -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!(