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
269 changes: 99 additions & 170 deletions crates/oxc_formatter/src/ir_transform/sort_imports/import_unit.rs
Original file line number Diff line number Diff line change
@@ -1,186 +1,98 @@
use std::path::Path;
use std::{borrow::Cow, path::Path};

use cow_utils::CowUtils;
use phf::phf_set;

use crate::{formatter::format_element::FormatElement, options};

use super::source_line::{ImportLine, SourceLine};

#[derive(Debug)]
pub struct ImportUnits(pub Vec<SortableImport>);

impl IntoIterator for ImportUnits {
type Item = SortableImport;
type IntoIter = std::vec::IntoIter<SortableImport>;

fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}

impl ImportUnits {
pub fn sort_imports(&mut self, elements: &[FormatElement], options: &options::SortImports) {
let imports_len = self.0.len();

// Perform sorting only if needed
if imports_len < 2 {
return;
}

// Separate imports into:
// - sortable: indices of imports that should be sorted
// - fixed: indices of imports that should be ignored
// - e.g. side-effect imports when `sort_side_effects: false`, with ignore comments, etc...
let mut sortable_indices = vec![];
let mut fixed_indices = vec![];
for (idx, si) in self.0.iter().enumerate() {
if si.is_ignored(options) {
fixed_indices.push(idx);
} else {
sortable_indices.push(idx);
}
}

// Sort indices by comparing their corresponding import groups, then sources
sortable_indices.sort_by(|&a, &b| {
let metadata_a = self.0[a].get_metadata(elements);
let metadata_b = self.0[b].get_metadata(elements);

// First, compare by group index
let group_idx_a = metadata_a.match_group(&options.groups);
let group_idx_b = metadata_b.match_group(&options.groups);

let group_ord = group_idx_a.cmp(&group_idx_b);
if group_ord != std::cmp::Ordering::Equal {
return if options.order.is_desc() { group_ord.reverse() } else { group_ord };
}

// Within the same group, compare by source
let source_ord = if options.ignore_case {
metadata_a.source.cow_to_lowercase().cmp(&metadata_b.source.cow_to_lowercase())
} else {
metadata_a.source.cmp(metadata_b.source)
};

if options.order.is_desc() { source_ord.reverse() } else { source_ord }
});

// Create a permutation map
let mut permutation = vec![0; imports_len];
let mut sortable_iter = sortable_indices.into_iter();
for (idx, perm) in permutation.iter_mut().enumerate() {
// NOTE: This is O(n), but side-effect imports are usually few
if fixed_indices.contains(&idx) {
*perm = idx;
} else if let Some(sorted_idx) = sortable_iter.next() {
*perm = sorted_idx;
}
}
debug_assert!(
permutation.iter().copied().collect::<rustc_hash::FxHashSet<_>>().len() == imports_len,
"`permutation` must be a valid permutation, all indices must be unique."
);

// Apply permutation in-place using cycle decomposition
let mut visited = vec![false; imports_len];
for idx in 0..imports_len {
// Already visited or already in the correct position
if visited[idx] || permutation[idx] == idx {
continue;
}
// Follow the cycle
let mut current = idx;
loop {
let next = permutation[current];
visited[current] = true;
if next == idx {
break;
}
self.0.swap(current, next);
current = next;
}
}
debug_assert!(self.0.len() == imports_len, "Length must remain the same after sorting.");
}
}

// ---
use super::source_line::{ImportLineMetadata, SourceLine};

#[derive(Debug, Clone)]
pub struct SortableImport {
pub struct SortableImport<'a> {
pub leading_lines: Vec<SourceLine>,
pub import_line: SourceLine,
pub group_idx: usize,
pub normalized_source: Cow<'a, str>,
pub is_ignored: bool,
}

impl SortableImport {
impl<'a> SortableImport<'a> {
pub fn new(leading_lines: Vec<SourceLine>, import_line: SourceLine) -> Self {
Self { leading_lines, import_line }
Self {
leading_lines,
import_line,
// These will be computed by `collect_sort_keys()`
group_idx: 0,
normalized_source: Cow::Borrowed(""),
is_ignored: false,
}
}

/// Get all import metadata in one place.
pub fn get_metadata<'a>(&self, elements: &'a [FormatElement]) -> ImportMetadata<'a> {
let SourceLine::Import(ImportLine {
source_idx,
is_side_effect,
is_type_import,
has_default_specifier,
has_namespace_specifier,
has_named_specifier,
..
}) = &self.import_line
/// Pre-compute keys needed for sorting.
#[must_use]
pub fn collect_sort_keys(
mut self,
elements: &'a [FormatElement],
options: &options::SortImports,
) -> Self {
let SourceLine::Import(
_,
ImportLineMetadata {
source_idx,
is_side_effect,
is_type_import,
has_default_specifier,
has_namespace_specifier,
has_named_specifier,
},
) = &self.import_line
else {
unreachable!("`import_line` must be of type `SourceLine::Import`.");
};

// Strip quotes and params
let source = match &elements[*source_idx] {
FormatElement::Text { text, .. } => *text,
_ => unreachable!(
"`source_idx` must point to either `LocatedTokenText` or `Text` in the `elements`."
),
};
let source = source.trim_matches('"').trim_matches('\'');
let source = source.split('?').next().unwrap_or(source);
let source = extract_source_text(elements, *source_idx);

ImportMetadata {
source,
// Pre-compute normalized source for case-insensitive comparison
self.normalized_source =
if options.ignore_case { source.cow_to_lowercase() } else { Cow::Borrowed(source) };

// Create group matcher from import characteristics
let matcher = ImportGroupMatcher {
is_side_effect: *is_side_effect,
is_type_import: *is_type_import,
is_style_import: is_style(source),
has_default_specifier: *has_default_specifier,
has_namespace_specifier: *has_namespace_specifier,
has_named_specifier: *has_named_specifier,
path_kind: ImportPathKind::new(source),
}
}
path_kind: to_path_kind(source),
};
self.group_idx = matcher.match_group(&options.groups);

/// Check if this import should be ignored (not sorted).
pub fn is_ignored(&self, options: &options::SortImports) -> bool {
match self.import_line {
SourceLine::Import(ImportLine { is_side_effect, .. }) => {
// TODO: Check ignore comments?
!options.sort_side_effects && is_side_effect
}
_ => unreachable!("`import_line` must be of type `SourceLine::Import`."),
}
// TODO: Check ignore comments?
self.is_ignored = !options.sort_side_effects && *is_side_effect;

self
}
}

/// Metadata about an import for sorting purposes.
// ---

/// Helper for matching imports to configured groups.
///
/// Contains all characteristics of an import needed to determine which group it belongs to,
/// such as whether it's a type import, side-effect import, style import, and what kind of path it uses.
#[derive(Debug, Clone)]
pub struct ImportMetadata<'a> {
pub source: &'a str,
pub is_side_effect: bool,
pub is_type_import: bool,
pub is_style_import: bool,
pub has_default_specifier: bool,
pub has_namespace_specifier: bool,
pub has_named_specifier: bool,
pub path_kind: ImportPathKind,
struct ImportGroupMatcher {
is_side_effect: bool,
is_type_import: bool,
is_style_import: bool,
has_default_specifier: bool,
has_namespace_specifier: bool,
has_named_specifier: bool,
path_kind: ImportPathKind,
}

impl ImportMetadata<'_> {
impl ImportGroupMatcher {
/// Match this import against the configured groups and return the group index.
/// Returns the index of the first matching group, or the index of "unknown" group if present,
/// or the last index + 1 if no match found.
Expand Down Expand Up @@ -427,6 +339,22 @@ impl ImportModifier {
}
}

// ---

/// Extract the import source text from format elements.
///
/// This removes quotes and query parameters from the source string.
/// For example, `"./foo?bar"` becomes `./foo`.
fn extract_source_text<'a>(elements: &'a [FormatElement], source_idx: usize) -> &'a str {
let source = match &elements[source_idx] {
FormatElement::Text { text, .. } => *text,
_ => unreachable!("`source_idx` must point to the `Text` in the `elements`."),
};

let source = source.trim_matches('"').trim_matches('\'');
source.split('?').next().unwrap_or(source)
}

// spellchecker:off
static STYLE_EXTENSIONS: phf::Set<&'static str> = phf_set! {
"css",
Expand All @@ -439,6 +367,7 @@ static STYLE_EXTENSIONS: phf::Set<&'static str> = phf_set! {
};
// spellchecker:on

/// Check if an import source is a style file based on its extension.
fn is_style(source: &str) -> bool {
Path::new(source)
.extension()
Expand All @@ -456,12 +385,12 @@ static NODE_BUILTINS: phf::Set<&'static str> = phf_set! {
"zlib",
};

/// Check if an import source is a Node.js or Bun builtin module.
fn is_builtin(source: &str) -> bool {
source.starts_with("node:") || source.starts_with("bun:") || NODE_BUILTINS.contains(source)
}

/// Classification of import path types for grouping.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ImportPathKind {
/// Node.js builtin module (e.g., `node:fs`, `fs`)
Builtin,
Expand All @@ -476,30 +405,30 @@ pub enum ImportPathKind {
/// Index file import (e.g., `./`, `../`)
Index,
/// Unknown or unclassified
#[default]
Unknown,
}

impl ImportPathKind {
fn new(source: &str) -> Self {
if is_builtin(source) {
return Self::Builtin;
}
/// Determine the path kind for an import source.
fn to_path_kind(source: &str) -> ImportPathKind {
if is_builtin(source) {
return ImportPathKind::Builtin;
}

if source.starts_with('.') {
if source == "." || source == ".." || source.ends_with('/') {
return Self::Index;
}
if source.starts_with("../") {
return Self::Parent;
}
return Self::Sibling;
if source.starts_with('.') {
if source == "." || source == ".." || source.ends_with('/') {
return ImportPathKind::Index;
}

// TODO: This can be changed via `options.internalPattern`
if source.starts_with('~') || source.starts_with('@') {
return Self::Internal;
if source.starts_with("../") {
return ImportPathKind::Parent;
}
return ImportPathKind::Sibling;
}

Self::External
// TODO: This can be changed via `options.internalPattern`
if source.starts_with('~') || source.starts_with('@') {
return ImportPathKind::Internal;
}

ImportPathKind::External
}
Loading
Loading