Skip to content

Commit 1d7b524

Browse files
committed
feat(formatter/sort-imports): Implement options.sortSideEffects: bool
1 parent a3a8fc4 commit 1d7b524

File tree

3 files changed

+205
-99
lines changed

3 files changed

+205
-99
lines changed

crates/oxc_formatter/src/ir_transform/sort_imports.rs

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -166,21 +166,11 @@ impl SortImportsTransform {
166166
// const YET_ANOTHER_BOUNDARY = true;
167167
// ```
168168
let (mut import_units, trailing_lines) = chunk.into_import_units(prev_elements);
169-
170-
// Perform sorting if needed
171-
if 1 < import_units.len() {
172-
// TODO: Sort based on `options.groups`, `options.type`, etc...
173-
// TODO: Consider `options.ignore_case`, `special_characters`, removing `?raw`, etc...
174-
import_units.sort_by(|a, b| {
175-
let ord = a.get_source(prev_elements).cmp(b.get_source(prev_elements));
176-
if self.options.order.is_desc() { ord.reverse() } else { ord }
177-
});
178-
}
179-
180-
let preserve_empty_line = self.options.partition_by_newline;
169+
import_units.sort_imports(prev_elements, self.options);
181170

182171
// Output sorted import units
183-
for SortableImport { leading_lines, import_line } in &import_units {
172+
let preserve_empty_line = self.options.partition_by_newline;
173+
for SortableImport { leading_lines, import_line } in import_units {
184174
for line in leading_lines {
185175
line.write(prev_elements, &mut next_elements, preserve_empty_line);
186176
}
@@ -394,10 +384,7 @@ impl PartitionedChunk {
394384
}
395385

396386
#[must_use]
397-
fn into_import_units(
398-
self,
399-
elements: &[FormatElement],
400-
) -> (Vec<SortableImport>, Vec<SourceLine>) {
387+
fn into_import_units(self, elements: &[FormatElement]) -> (ImportUnits, Vec<SourceLine>) {
401388
let Self::Imports(lines) = self else {
402389
unreachable!(
403390
"`into_import_units()` must be called on `PartitionedChunk::Imports` only."
@@ -429,7 +416,88 @@ impl PartitionedChunk {
429416
// Any remaining comments/lines are trailing
430417
let trailing_lines = current_leading_lines;
431418

432-
(units, trailing_lines)
419+
(ImportUnits(units), trailing_lines)
420+
}
421+
}
422+
423+
#[derive(Debug)]
424+
struct ImportUnits(Vec<SortableImport>);
425+
426+
impl IntoIterator for ImportUnits {
427+
type Item = SortableImport;
428+
type IntoIter = std::vec::IntoIter<SortableImport>;
429+
430+
fn into_iter(self) -> Self::IntoIter {
431+
self.0.into_iter()
432+
}
433+
}
434+
435+
impl ImportUnits {
436+
// TODO: Sort based on `options.groups`, `options.type`, etc...
437+
// TODO: Consider `options.ignore_case`, `special_characters`, removing `?raw`, etc...
438+
fn sort_imports(&mut self, elements: &[FormatElement], options: options::SortImports) {
439+
let imports_len = self.0.len();
440+
441+
// Perform sorting only if needed
442+
if imports_len < 2 {
443+
return;
444+
}
445+
446+
// Separate imports into:
447+
// - sortable: indices of imports that should be sorted
448+
// - fixed: indices of side-effect imports when `sort_side_effects: false`
449+
let mut sortable_indices = vec![];
450+
let mut fixed_indices = vec![];
451+
for (idx, si) in self.0.iter().enumerate() {
452+
if options.sort_side_effects || !si.is_side_effect_import() {
453+
sortable_indices.push(idx);
454+
} else {
455+
fixed_indices.push(idx);
456+
}
457+
}
458+
459+
// Sort indices by comparing their corresponding import sources
460+
sortable_indices.sort_by(|&a, &b| {
461+
let ord = self.0[a].get_source(elements).cmp(self.0[b].get_source(elements));
462+
if options.order.is_desc() { ord.reverse() } else { ord }
463+
});
464+
465+
// Create a permutation map
466+
let mut permutation = vec![0; imports_len];
467+
let mut sortable_iter = sortable_indices.into_iter();
468+
for (idx, perm) in permutation.iter_mut().enumerate() {
469+
// NOTE: This is O(n), but side-effect imports are usually few
470+
if fixed_indices.contains(&idx) {
471+
*perm = idx;
472+
} else if let Some(sorted_idx) = sortable_iter.next() {
473+
*perm = sorted_idx;
474+
}
475+
}
476+
debug_assert!(
477+
permutation.iter().copied().collect::<rustc_hash::FxHashSet<_>>().len() == imports_len,
478+
"`permutation` must be a valid permutation, all indices must be unique."
479+
);
480+
481+
// Apply permutation in-place using cycle decomposition
482+
let mut visited = vec![false; imports_len];
483+
for idx in 0..imports_len {
484+
// Already visited or already in the correct position
485+
if visited[idx] || permutation[idx] == idx {
486+
continue;
487+
}
488+
// Follow the cycle
489+
let mut current = idx;
490+
loop {
491+
let next = permutation[current];
492+
visited[current] = true;
493+
if next == idx {
494+
break;
495+
}
496+
self.0.swap(current, next);
497+
current = next;
498+
}
499+
}
500+
debug_assert!(self.0.len() == imports_len, "Length must remain the same after sorting.");
433501
}
434502
}
435503

@@ -460,4 +528,12 @@ impl SortableImport {
460528
),
461529
}
462530
}
531+
532+
/// Check if this import is a side-effect-only import.
533+
fn is_side_effect_import(&self) -> bool {
534+
match self.import_line {
535+
SourceLine::Import(_, _, is_side_effect) => is_side_effect,
536+
_ => unreachable!("`import_line` must be of type `SourceLine::Import`."),
537+
}
538+
}
463539
}

crates/oxc_formatter/tests/ir_transform/_sort-imports-tests.ref.snap

Lines changed: 0 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -581,87 +581,6 @@ describe("alphabetical", () => {
581581
});
582582
});
583583

584-
it("preserves side-effect import order when sorting disabled", async () => {
585-
await valid({
586-
options: [
587-
{
588-
...options,
589-
groups: ["external", "side-effect", "unknown"],
590-
sortSideEffects: false,
591-
},
592-
],
593-
code: dedent`
594-
import a from 'aaaa'
595-
596-
import 'bbb'
597-
import './cc'
598-
import '../d'
599-
`,
600-
});
601-
602-
await valid({
603-
options: [
604-
{
605-
...options,
606-
groups: ["external", "side-effect", "unknown"],
607-
sortSideEffects: false,
608-
},
609-
],
610-
code: dedent`
611-
import 'c'
612-
import 'bb'
613-
import 'aaa'
614-
`,
615-
});
616-
617-
await invalid({
618-
options: [
619-
{
620-
...options,
621-
groups: ["external", "side-effect", "unknown"],
622-
sortSideEffects: false,
623-
},
624-
],
625-
output: dedent`
626-
import a from 'aaaa'
627-
import e from 'e'
628-
629-
import './cc'
630-
import 'bbb'
631-
import '../d'
632-
`,
633-
code: dedent`
634-
import './cc'
635-
import 'bbb'
636-
import e from 'e'
637-
import a from 'aaaa'
638-
import '../d'
639-
`,
640-
});
641-
});
642-
643-
it("sorts side-effect imports when sorting enabled", async () => {
644-
await invalid({
645-
options: [
646-
{
647-
...options,
648-
groups: ["external", "side-effect", "unknown"],
649-
sortSideEffects: true,
650-
},
651-
],
652-
output: dedent`
653-
import 'aaa'
654-
import 'bb'
655-
import 'c'
656-
`,
657-
code: dedent`
658-
import 'c'
659-
import 'bb'
660-
import 'aaa'
661-
`,
662-
});
663-
});
664-
665584
it("preserves original order when side-effect imports are not grouped", async () => {
666585
await invalid({
667586
output: dedent`

crates/oxc_formatter/tests/ir_transform/sort_imports.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,3 +707,114 @@ import { log2 } from "./log2";
707707
"#,
708708
);
709709
}
710+
711+
// ---
712+
713+
#[test]
714+
fn should_sort_side_effects() {
715+
// Side effect imports stay in their original positions by default
716+
assert_format(
717+
r#"
718+
import c from "c";
719+
import b from "b";
720+
import "s";
721+
import a from "a";
722+
import z from "z";
723+
"#,
724+
&FormatOptions {
725+
experimental_sort_imports: Some(SortImports::default()),
726+
..Default::default()
727+
},
728+
r#"
729+
import a from "a";
730+
import b from "b";
731+
import "s";
732+
import c from "c";
733+
import z from "z";
734+
"#,
735+
);
736+
// Side effect imports stay in their original positions if `sort_side_effects: false`
737+
assert_format(
738+
r#"
739+
import c from "c";
740+
import b from "b";
741+
import "s";
742+
import a from "a";
743+
import z from "z";
744+
"#,
745+
&FormatOptions {
746+
experimental_sort_imports: Some(SortImports {
747+
sort_side_effects: false,
748+
..Default::default()
749+
}),
750+
..Default::default()
751+
},
752+
r#"
753+
import a from "a";
754+
import b from "b";
755+
import "s";
756+
import c from "c";
757+
import z from "z";
758+
"#,
759+
);
760+
assert_format(
761+
r#"
762+
import "c";
763+
import "bb";
764+
import "aaa";
765+
"#,
766+
&FormatOptions {
767+
experimental_sort_imports: Some(SortImports {
768+
sort_side_effects: false,
769+
..Default::default()
770+
}),
771+
..Default::default()
772+
},
773+
r#"
774+
import "c";
775+
import "bb";
776+
import "aaa";
777+
"#,
778+
);
779+
// When `sort_side_effects: true`, all imports are sorted
780+
assert_format(
781+
r#"
782+
import y from "y";
783+
import a from "a";
784+
import "z";
785+
import "x";
786+
"#,
787+
&FormatOptions {
788+
experimental_sort_imports: Some(SortImports {
789+
sort_side_effects: true,
790+
..Default::default()
791+
}),
792+
..Default::default()
793+
},
794+
r#"
795+
import a from "a";
796+
import "x";
797+
import y from "y";
798+
import "z";
799+
"#,
800+
);
801+
assert_format(
802+
r#"
803+
import "c";
804+
import "bb";
805+
import "aaa";
806+
"#,
807+
&FormatOptions {
808+
experimental_sort_imports: Some(SortImports {
809+
sort_side_effects: true,
810+
..Default::default()
811+
}),
812+
..Default::default()
813+
},
814+
r#"
815+
import "aaa";
816+
import "bb";
817+
import "c";
818+
"#,
819+
);
820+
}

0 commit comments

Comments
 (0)