diff --git a/crates/oxc_formatter/examples/sort_imports.rs b/crates/oxc_formatter/examples/sort_imports.rs index fe4c27ef7ccb7..61b0aca6230e5 100644 --- a/crates/oxc_formatter/examples/sort_imports.rs +++ b/crates/oxc_formatter/examples/sort_imports.rs @@ -28,7 +28,7 @@ fn main() -> Result<(), String> { sort_side_effects, ignore_case, newlines_between, - groups: SortImports::default_groups(), + groups: None, }; // Read source file diff --git a/crates/oxc_formatter/src/ir_transform/sort_imports/compute_metadata.rs b/crates/oxc_formatter/src/ir_transform/sort_imports/compute_metadata.rs new file mode 100644 index 0000000000000..5a61c8cb9e948 --- /dev/null +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/compute_metadata.rs @@ -0,0 +1,380 @@ +use std::{borrow::Cow, path::Path}; + +use cow_utils::CowUtils; +use phf::phf_set; + +use crate::{ + formatter::format_element::FormatElement, + ir_transform::sort_imports::{ + group_config::{GroupName, ImportModifier, ImportSelector}, + source_line::ImportLineMetadata, + }, + options, +}; + +/// Compute all metadata derived from import line metadata. +/// +/// Returns `(group_idx, normalized_source, is_ignored)`. +pub fn compute_import_metadata<'a>( + metadata: &ImportLineMetadata<'a>, + groups: &[Vec], + options: &options::SortImports, +) -> (usize, Cow<'a, str>, bool) { + let ImportLineMetadata { + source, + is_side_effect, + is_type_import, + has_default_specifier, + has_namespace_specifier, + has_named_specifier, + } = metadata; + + let source = extract_source_path(source); + let is_style_import = is_style(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, + path_kind: to_path_kind(source), + is_subpath: is_subpath(source), + has_default_specifier: *has_default_specifier, + has_namespace_specifier: *has_namespace_specifier, + has_named_specifier: *has_named_specifier, + }; + let group_idx = matcher.into_match_group_idx(groups); + + // Pre-compute normalized source for case-insensitive comparison + let normalized_source = + if options.ignore_case { source.cow_to_lowercase() } else { Cow::Borrowed(source) }; + + // Determine if this import should be ignored (not moved between groups) + // - If `sort_side_effects: true`, never ignore + // - If `sort_side_effects: false` and this is a side-effect: + // - Check if groups contain `side-effect` or `side-effect-style` + // - If yes, allow regrouping (not ignored) + // - If no, keep in original position (ignored) + let mut should_regroup_side_effect = false; + let mut should_regroup_side_effect_style = false; + for group in groups { + for name in group { + if name.is_plain_selector(ImportSelector::SideEffect) { + should_regroup_side_effect = true; + } + if name.is_plain_selector(ImportSelector::SideEffectStyle) { + should_regroup_side_effect_style = true; + } + } + } + + let is_ignored = !options.sort_side_effects + && *is_side_effect + && !should_regroup_side_effect + && (!is_style_import || !should_regroup_side_effect_style); + + (group_idx, normalized_source, is_ignored) +} + +// --- + +/// 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)] +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, + is_subpath: bool, +} + +impl ImportGroupMatcher { + /// Match this import against the configured groups and return the group index. + /// + /// This method generates possible group names in priority order (most specific to least specific) + /// and tries to match them against the configured groups. + /// For example, for a type import from an external package, + /// it tries: "type-external", "external", "type-import", "import". + /// + /// Returns: + /// - The index of the first matching group (if found) + /// - The index of the "unknown" group (if no match found and "unknown" is configured) + /// - `groups.len()` (if no match found and no "unknown" group configured) + #[must_use] + fn into_match_group_idx(self, groups: &[Vec]) -> usize { + let possible_names = self.compute_group_names(); + let mut unknown_index = None; + + // Try each possible name in order (most specific first) + for possible_name in &possible_names { + for (group_idx, group) in groups.iter().enumerate() { + for group_name in group { + // Check if this is the "unknown" group + if group_name.is_plain_selector(ImportSelector::Unknown) { + unknown_index = Some(group_idx); + } + + // Check if this possible name matches this group + if possible_name == group_name { + return group_idx; + } + } + } + } + + unknown_index.unwrap_or(groups.len()) + } + + /// Generate all possible group names for this import, ordered by specificity. + /// For each selector (in order), generate all modifier combinations with that selector. + /// + /// Example with: + /// - selectors: "style", "parent" + /// - and modifiers: "value", "default" + /// + /// Generates: + /// - value-default-style, value-style, default-style, style + /// - value-default-parent, value-parent, default-parent, parent + fn compute_group_names(&self) -> Vec { + let selectors = self.compute_selectors(); + let modifiers = self.compute_modifiers(); + + let mut group_names = vec![]; + + // For each selector, generate all modifier combinations + for selector in &selectors { + match selector { + // For path selectors, combine with type/value modifier + ImportSelector::Builtin + | ImportSelector::External + | ImportSelector::Internal + | ImportSelector::Parent + | ImportSelector::Sibling + | ImportSelector::Index + | ImportSelector::Subpath => { + let modifier = if self.is_type_import { + ImportModifier::Type + } else { + ImportModifier::Value + }; + group_names.push(GroupName::with_modifier(*selector, modifier)); + } + // For special selectors (side-effect, style, etc.), combine with all modifiers + ImportSelector::SideEffectStyle + | ImportSelector::SideEffect + | ImportSelector::Style + | ImportSelector::Import => { + for modifier in &modifiers { + group_names.push(GroupName::with_modifier(*selector, *modifier)); + } + } + _ => {} + } + group_names.push(GroupName::new(*selector)); + } + + // Add final "import" catch-all with modifiers + // This generates combinations like "side-effect-import", "type-import", "value-import", etc. + for modifier in &modifiers { + group_names.push(GroupName::with_modifier(ImportSelector::Import, *modifier)); + } + group_names.push(GroupName::new(ImportSelector::Import)); + + group_names + } + + /// Compute all selectors for this import, ordered from most to least specific. + /// + /// Order matches perfectionist implementation: + /// 1. Special selectors (side-effect-style, side-effect, style) - most specific + /// 2. Path-type selectors (parent-type, external-type, etc.) for type imports + /// 3. Type selector + /// 4. Path-based selectors (builtin, external, internal, parent, sibling, index) + /// 5. Catch-all import selector + fn compute_selectors(&self) -> Vec { + let mut selectors = vec![]; + + // Most specific selectors first + if self.is_side_effect && self.is_style_import { + selectors.push(ImportSelector::SideEffectStyle); + } + if self.is_side_effect { + selectors.push(ImportSelector::SideEffect); + } + if self.is_style_import { + selectors.push(ImportSelector::Style); + } + + // For type imports, add path-type selectors (e.g., "parent-type", "external-type") + // These come before the generic "type" selector + if self.is_type_import { + match self.path_kind { + ImportPathKind::Index => selectors.push(ImportSelector::IndexType), + ImportPathKind::Sibling => selectors.push(ImportSelector::SiblingType), + ImportPathKind::Parent => selectors.push(ImportSelector::ParentType), + ImportPathKind::Internal => selectors.push(ImportSelector::InternalType), + ImportPathKind::Builtin => selectors.push(ImportSelector::BuiltinType), + ImportPathKind::External => selectors.push(ImportSelector::ExternalType), + ImportPathKind::Unknown => {} + } + // Type selector + selectors.push(ImportSelector::Type); + } + + // Path-based selectors + // Order matches perfectionist: index, sibling, parent, subpath, internal, builtin, external + match self.path_kind { + ImportPathKind::Index => selectors.push(ImportSelector::Index), + ImportPathKind::Sibling => selectors.push(ImportSelector::Sibling), + ImportPathKind::Parent => selectors.push(ImportSelector::Parent), + _ => {} + } + + // Subpath selector (independent of path kind, comes after parent) + if self.is_subpath { + selectors.push(ImportSelector::Subpath); + } + + // Continue with remaining path-based selectors + match self.path_kind { + ImportPathKind::Internal => selectors.push(ImportSelector::Internal), + ImportPathKind::Builtin => selectors.push(ImportSelector::Builtin), + ImportPathKind::External => selectors.push(ImportSelector::External), + _ => {} + } + + // Catch-all selector + selectors.push(ImportSelector::Import); + + selectors + } + + /// Compute all modifiers for this import. + fn compute_modifiers(&self) -> Vec { + let mut modifiers = vec![]; + + if self.is_side_effect { + modifiers.push(ImportModifier::SideEffect); + } + if self.is_type_import { + modifiers.push(ImportModifier::Type); + } else { + modifiers.push(ImportModifier::Value); + } + if self.has_default_specifier { + modifiers.push(ImportModifier::Default); + } + if self.has_namespace_specifier { + modifiers.push(ImportModifier::Wildcard); + } + if self.has_named_specifier { + modifiers.push(ImportModifier::Named); + } + + modifiers + } +} + +// --- + +/// Extract the import source path. +/// +/// This removes quotes and query parameters from the source string. +/// For example, `"./foo.js?bar"` becomes `./foo.js`. +fn extract_source_path(source: &str) -> &str { + 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", + "scss", + "sass", + "less", + "styl", + "pcss", + "sss", +}; +// 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() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| STYLE_EXTENSIONS.contains(ext)) +} + +static NODE_BUILTINS: phf::Set<&'static str> = phf_set! { + "assert", "async_hooks", "buffer", "child_process", "cluster", "console", + "constants", "crypto", "dgram", "diagnostics_channel", "dns", "domain", + "events", "fs", "http", "http2", "https", "inspector", "module", "net", + "os", "path", "perf_hooks", "process", "punycode", "querystring", + "readline", "repl", "stream", "string_decoder", "sys", "timers", "tls", + "trace_events", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads", + "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) +} + +#[derive(Debug, PartialEq, Eq, Default)] +enum ImportPathKind { + /// Node.js builtin module (e.g., `node:fs`, `fs`) + Builtin, + /// External package from node_modules (e.g., `react`, `lodash`) + External, + /// Internal module matching internal patterns (e.g., `~/...`, `@/...`) + Internal, + /// Parent directory relative import (e.g., `../foo`) + Parent, + /// Sibling directory relative import (e.g., `./foo`) + Sibling, + /// Index file import (e.g., `./`, `../`) + Index, + /// Unknown or unclassified + #[default] + Unknown, +} + +/// 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 matches!( + source, + "." | "./" | "./index" | "./index.js" | "./index.ts" | "./index.d.ts" | "./index.d.js" + ) { + return ImportPathKind::Index; + } + if source.starts_with("../") { + return ImportPathKind::Parent; + } + return ImportPathKind::Sibling; + } + + // TODO: This can be changed via `options.internalPattern` + if source.starts_with('~') || source.starts_with('@') { + return ImportPathKind::Internal; + } + + // Subpath imports (e.g., `#foo`) are also considered external + ImportPathKind::External +} + +/// Check if an import source is a subpath import (starts with '#'). +fn is_subpath(source: &str) -> bool { + source.starts_with('#') +} diff --git a/crates/oxc_formatter/src/ir_transform/sort_imports/group_config.rs b/crates/oxc_formatter/src/ir_transform/sort_imports/group_config.rs new file mode 100644 index 0000000000000..4949030979b41 --- /dev/null +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/group_config.rs @@ -0,0 +1,215 @@ +/// Default groups configuration. +pub fn default_groups() -> Vec> { + use ImportModifier as M; + use ImportSelector as S; + + vec![ + vec![GroupName::with_modifier(S::Import, M::Type)], + vec![ + GroupName::with_modifier(S::Builtin, M::Value), + GroupName::with_modifier(S::External, M::Value), + ], + vec![GroupName::with_modifier(S::Internal, M::Type)], + vec![GroupName::with_modifier(S::Internal, M::Value)], + vec![ + GroupName::with_modifier(S::Parent, M::Type), + GroupName::with_modifier(S::Sibling, M::Type), + GroupName::with_modifier(S::Index, M::Type), + ], + vec![ + GroupName::with_modifier(S::Parent, M::Value), + GroupName::with_modifier(S::Sibling, M::Value), + GroupName::with_modifier(S::Index, M::Value), + ], + vec![GroupName::new(S::Unknown)], + ] +} + +/// Parse groups from string-based configuration. +/// If parsing fails (= undefined), it falls back to `Unknown` selector. +pub fn parse_groups_from_strings(string_groups: &Vec>) -> Vec> { + let mut groups = Vec::with_capacity(string_groups.len()); + for group in string_groups { + let mut parsed_group = Vec::with_capacity(group.len()); + for name in group { + parsed_group.push( + GroupName::parse(name).unwrap_or_else(|| GroupName::new(ImportSelector::Unknown)), + ); + } + groups.push(parsed_group); + } + groups +} + +/// Represents a group name pattern for matching imports. +/// A group name consists of 1 selector and N modifiers. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GroupName { + pub modifiers: Vec, + pub selector: ImportSelector, +} + +impl GroupName { + /// Create a new group name with no modifiers. + pub fn new(selector: ImportSelector) -> Self { + Self { modifiers: vec![], selector } + } + + /// Create a new group name with one modifier. + pub fn with_modifier(selector: ImportSelector, modifier: ImportModifier) -> Self { + Self { modifiers: vec![modifier], selector } + } + + /// Check if this is a plain selector (no modifiers). + pub fn is_plain_selector(&self, selector: ImportSelector) -> bool { + self.selector == selector && self.modifiers.is_empty() + } + + /// Parse a group name string into a GroupName. + /// + /// Format: `(modifier-)*selector` + /// Examples: + /// - "external" -> modifiers: (empty), selector: External + /// - "type-external" -> modifiers: Type, selector: External + /// - "value-builtin" -> modifiers: Value, selector: Builtin + /// - "internal-type" -> modifiers: (empty), selector: InternalType + /// - "side-effect-import" -> modifiers: SideEffect, selector: Import + /// - "side-effect-type-external" -> modifiers: SideEffect, Type, selector: External + pub fn parse(s: &str) -> Option { + // Try to parse as a selector without modifiers first + if let Some(selector) = ImportSelector::parse(s) { + return Some(Self { modifiers: vec![], selector }); + } + + // Split by '-' and try parsing as modifier(s) + selector + let parts: Vec<&str> = s.split('-').collect(); + if parts.len() < 2 { + return None; + } + + // Last part should be the selector + let selector = ImportSelector::parse(parts[parts.len() - 1])?; + + // Everything before should be modifier(s) + let modifier_parts = &parts[..parts.len() - 1]; + let mut modifiers = vec![]; + + // Try to parse the entire modifier string first (handles "side-effect") + let modifier_str = modifier_parts.join("-"); + if let Some(modifier) = ImportModifier::parse(&modifier_str) { + modifiers.push(modifier); + } else { + // Otherwise, parse each part individually + for &part in modifier_parts { + modifiers.push(ImportModifier::parse(part)?); + } + } + + Some(Self { modifiers, selector }) + } +} + +/// Selector types for import categorization. +/// Selectors identify the type or location of an import. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ImportSelector { + /// Type-only imports (`import type { ... }`) + Type, + /// Side-effect style imports (CSS, SCSS, etc. without bindings) + SideEffectStyle, + /// Side-effect imports (imports without bindings) + SideEffect, + /// Style file imports (CSS, SCSS, etc.) + Style, + /// Type import from index file + IndexType, + /// Type import from sibling module + SiblingType, + /// Type import from parent module + ParentType, + /// Type import from internal module + InternalType, + /// Type import from built-in module + BuiltinType, + /// Type import from external module + ExternalType, + /// Index file imports (`./`, `../`) + Index, + /// Sibling module imports (`./foo`) + Sibling, + /// Parent module imports (`../foo`) + Parent, + /// Subpath imports (package.json imports field, e.g., `#foo`) + Subpath, + /// Internal module imports (matching internal patterns like `~/`, `@/`) + Internal, + /// Built-in module imports (`node:fs`, `fs`) + Builtin, + /// External module imports (from node_modules) + External, + /// Catch-all selector + Import, + /// Unknown/fallback group + Unknown, +} + +impl ImportSelector { + /// Parse a string into an ImportSelector. + pub fn parse(s: &str) -> Option { + match s { + "type" => Some(Self::Type), + "side-effect-style" => Some(Self::SideEffectStyle), + "side-effect" => Some(Self::SideEffect), + "style" => Some(Self::Style), + "index-type" => Some(Self::IndexType), + "sibling-type" => Some(Self::SiblingType), + "parent-type" => Some(Self::ParentType), + "internal-type" => Some(Self::InternalType), + "builtin-type" => Some(Self::BuiltinType), + "external-type" => Some(Self::ExternalType), + "index" => Some(Self::Index), + "sibling" => Some(Self::Sibling), + "parent" => Some(Self::Parent), + "subpath" => Some(Self::Subpath), + "internal" => Some(Self::Internal), + "builtin" => Some(Self::Builtin), + "external" => Some(Self::External), + "import" => Some(Self::Import), + "unknown" => Some(Self::Unknown), + _ => None, + } + } +} + +/// Modifier types for import categorization. +/// Modifiers describe characteristics of how an import is declared. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ImportModifier { + /// Side-effect imports + SideEffect, + /// Type-only imports + Type, + /// Value imports (non-type) + Value, + /// Default specifier present + Default, + /// Namespace/wildcard specifier present (`* as`) + Wildcard, + /// Named specifiers present + Named, +} + +impl ImportModifier { + /// Parse a string into an ImportModifier. + pub fn parse(s: &str) -> Option { + match s { + "side-effect" => Some(Self::SideEffect), + "type" => Some(Self::Type), + "value" => Some(Self::Value), + "default" => Some(Self::Default), + "wildcard" => Some(Self::Wildcard), + "named" => Some(Self::Named), + _ => None, + } + } +} diff --git a/crates/oxc_formatter/src/ir_transform/sort_imports/import_unit.rs b/crates/oxc_formatter/src/ir_transform/sort_imports/import_unit.rs deleted file mode 100644 index e96ac3b0b91e1..0000000000000 --- a/crates/oxc_formatter/src/ir_transform/sort_imports/import_unit.rs +++ /dev/null @@ -1,434 +0,0 @@ -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::{ImportLineMetadata, SourceLine}; - -#[derive(Debug, Clone)] -pub struct SortableImport<'a> { - pub leading_lines: Vec, - pub import_line: SourceLine, - pub group_idx: usize, - pub normalized_source: Cow<'a, str>, - pub is_ignored: bool, -} - -impl<'a> SortableImport<'a> { - pub fn new(leading_lines: Vec, import_line: SourceLine) -> Self { - Self { - leading_lines, - import_line, - // These will be computed by `collect_sort_keys()` - group_idx: 0, - normalized_source: Cow::Borrowed(""), - is_ignored: false, - } - } - - /// 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`."); - }; - - let source = extract_source_text(elements, *source_idx); - - // 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: to_path_kind(source), - }; - self.group_idx = matcher.match_group(&options.groups); - - // TODO: Check ignore comments? - self.is_ignored = !options.sort_side_effects && *is_side_effect; - - self - } -} - -// --- - -/// 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)] -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 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. - /// - /// Matching prioritizes more specific group names (e.g., "type-external" over "type-import"). - pub fn match_group(&self, groups: &[Vec]) -> usize { - let possible_names = self.generate_group_names(); - let mut unknown_index = None; - - // Try each possible name in order (most specific first) - for possible_name in &possible_names { - for (group_idx, group) in groups.iter().enumerate() { - for group_name in group { - // Check if this is the "unknown" group - if group_name == "unknown" { - unknown_index = Some(group_idx); - } - - // Check if this possible name matches this group - if possible_name == group_name { - return group_idx; - } - } - } - } - - // No match found - use "unknown" group if present, otherwise return last + 1 - unknown_index.unwrap_or(groups.len()) - } - - /// Generate all possible group names for this import, ordered by specificity. - /// Returns group names in the format used by perfectionist. - /// - /// Perfectionist format examples: - /// - `type-external` - type modifier + path selector - /// - `value-internal` - value modifier + path selector - /// - `type-import` - type modifier + import selector - /// - `external` - path selector only - fn generate_group_names(&self) -> Vec { - let selectors = self.selectors(); - let modifiers = self.modifiers(); - - let mut group_names = Vec::new(); - - // Most specific: type/value modifier combined with path selectors - // e.g., "type-external", "value-internal", "type-parent" - let type_or_value_modifier = if self.is_type_import { "type" } else { "value" }; - - for selector in &selectors { - // Skip the generic "type" selector since it's already in the modifier - if matches!(selector, ImportSelector::Type) { - continue; - } - - // For path-based selectors, combine with type/value modifier - if matches!( - selector, - ImportSelector::Builtin - | ImportSelector::External - | ImportSelector::Internal - | ImportSelector::Parent - | ImportSelector::Sibling - | ImportSelector::Index - ) { - let name = format!("{}-{}", type_or_value_modifier, selector.as_str()); - group_names.push(name); - } - } - - // Add other modifier combinations for special selectors - for selector in &selectors { - // Skip path-based selectors (already handled above) and "import" selector - if matches!( - selector, - ImportSelector::Builtin - | ImportSelector::External - | ImportSelector::Internal - | ImportSelector::Parent - | ImportSelector::Sibling - | ImportSelector::Index - | ImportSelector::Import - | ImportSelector::Type - ) { - continue; - } - - // For special selectors like side-effect, side-effect-style, style - // combine with relevant modifiers - for modifier in &modifiers { - let name = format!("{}-{}", modifier.as_str(), selector.as_str()); - group_names.push(name); - } - - // Selector-only name - group_names.push(selector.as_str().to_string()); - } - - // Add "type-import" or "value-import" or just "import" - if self.is_type_import { - group_names.push("type-import".to_string()); - } - - group_names.push("import".to_string()); - - group_names - } - - /// Compute all selectors for this import, ordered from most to least specific. - fn selectors(&self) -> Vec { - let mut selectors = Vec::new(); - - // Most specific selectors first - if self.is_side_effect && self.is_style_import { - selectors.push(ImportSelector::SideEffectStyle); - } - if self.is_side_effect { - selectors.push(ImportSelector::SideEffect); - } - if self.is_style_import { - selectors.push(ImportSelector::Style); - } - // Type selector - if self.is_type_import { - selectors.push(ImportSelector::Type); - } - // Path-based selectors - match self.path_kind { - ImportPathKind::Index => selectors.push(ImportSelector::Index), - ImportPathKind::Sibling => selectors.push(ImportSelector::Sibling), - ImportPathKind::Parent => selectors.push(ImportSelector::Parent), - ImportPathKind::Internal => selectors.push(ImportSelector::Internal), - ImportPathKind::Builtin => selectors.push(ImportSelector::Builtin), - ImportPathKind::External => selectors.push(ImportSelector::External), - ImportPathKind::Unknown => {} - } - // Catch-all selector - selectors.push(ImportSelector::Import); - - selectors - } - - /// Compute all modifiers for this import. - fn modifiers(&self) -> Vec { - let mut modifiers = Vec::new(); - - if self.is_side_effect { - modifiers.push(ImportModifier::SideEffect); - } - if self.is_type_import { - modifiers.push(ImportModifier::Type); - } else { - modifiers.push(ImportModifier::Value); - } - if self.has_default_specifier { - modifiers.push(ImportModifier::Default); - } - if self.has_namespace_specifier { - modifiers.push(ImportModifier::Wildcard); - } - if self.has_named_specifier { - modifiers.push(ImportModifier::Named); - } - - modifiers - } -} - -/// Selector types for import categorization. -/// Selectors identify the type or location of an import. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ImportSelector { - /// Type-only imports (`import type { ... }`) - Type, - /// Side-effect style imports (CSS, SCSS, etc. without bindings) - SideEffectStyle, - /// Side-effect imports (imports without bindings) - SideEffect, - /// Style file imports (CSS, SCSS, etc.) - Style, - /// Index file imports (`./`, `../`) - Index, - /// Sibling module imports (`./foo`) - Sibling, - /// Parent module imports (`../foo`) - Parent, - /// Internal module imports (matching internal patterns like `~/`, `@/`) - Internal, - /// Built-in module imports (`node:fs`, `fs`) - Builtin, - /// External module imports (from node_modules) - External, - /// Catch-all selector - Import, -} - -impl ImportSelector { - /// Returns the string representation used in group names. - const fn as_str(self) -> &'static str { - match self { - Self::Type => "type", - Self::SideEffectStyle => "side-effect-style", - Self::SideEffect => "side-effect", - Self::Style => "style", - Self::Index => "index", - Self::Sibling => "sibling", - Self::Parent => "parent", - Self::Internal => "internal", - Self::Builtin => "builtin", - Self::External => "external", - Self::Import => "import", - } - } -} - -/// Modifier types for import categorization. -/// Modifiers describe characteristics of how an import is declared. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ImportModifier { - /// Side-effect imports - SideEffect, - /// Type-only imports - Type, - /// Value imports (non-type) - Value, - /// Default specifier present - Default, - /// Namespace/wildcard specifier present (`* as`) - Wildcard, - /// Named specifiers present - Named, -} - -impl ImportModifier { - /// Returns the string representation used in group names. - const fn as_str(self) -> &'static str { - match self { - Self::SideEffect => "side-effect", - Self::Type => "type", - Self::Value => "value", - Self::Default => "default", - Self::Wildcard => "wildcard", - Self::Named => "named", - } - } -} - -// --- - -/// 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", - "scss", - "sass", - "less", - "styl", - "pcss", - "sss", -}; -// 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() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| STYLE_EXTENSIONS.contains(ext)) -} - -static NODE_BUILTINS: phf::Set<&'static str> = phf_set! { - "assert", "async_hooks", "buffer", "child_process", "cluster", "console", - "constants", "crypto", "dgram", "diagnostics_channel", "dns", "domain", - "events", "fs", "http", "http2", "https", "inspector", "module", "net", - "os", "path", "perf_hooks", "process", "punycode", "querystring", - "readline", "repl", "stream", "string_decoder", "sys", "timers", "tls", - "trace_events", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads", - "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) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ImportPathKind { - /// Node.js builtin module (e.g., `node:fs`, `fs`) - Builtin, - /// External package from node_modules (e.g., `react`, `lodash`) - External, - /// Internal module matching internal patterns (e.g., `~/...`, `@/...`) - Internal, - /// Parent directory relative import (e.g., `../foo`) - Parent, - /// Sibling directory relative import (e.g., `./foo`) - Sibling, - /// Index file import (e.g., `./`, `../`) - Index, - /// Unknown or unclassified - #[default] - Unknown, -} - -/// 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 ImportPathKind::Index; - } - if source.starts_with("../") { - return ImportPathKind::Parent; - } - return ImportPathKind::Sibling; - } - - // TODO: This can be changed via `options.internalPattern` - if source.starts_with('~') || source.starts_with('@') { - return ImportPathKind::Internal; - } - - ImportPathKind::External -} diff --git a/crates/oxc_formatter/src/ir_transform/sort_imports/mod.rs b/crates/oxc_formatter/src/ir_transform/sort_imports/mod.rs index 59a043597d920..6aad9bebfc08f 100644 --- a/crates/oxc_formatter/src/ir_transform/sort_imports/mod.rs +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/mod.rs @@ -1,27 +1,35 @@ -mod import_unit; +mod compute_metadata; +mod group_config; mod partitioned_chunk; +mod sortable_imports; mod source_line; -use std::mem; - use oxc_allocator::{Allocator, Vec as ArenaVec}; use crate::{ formatter::format_element::{FormatElement, LineMode, document::Document}, + ir_transform::sort_imports::{ + group_config::{GroupName, default_groups, parse_groups_from_strings}, + partitioned_chunk::PartitionedChunk, + source_line::SourceLine, + }, options, }; -use import_unit::SortableImport; -use partitioned_chunk::PartitionedChunk; -use source_line::SourceLine; - pub struct SortImportsTransform { options: options::SortImports, + groups: Vec>, } impl SortImportsTransform { pub fn new(options: options::SortImports) -> Self { - Self { options } + // Parse string based groups into our internal representation for performance + let groups = if let Some(groups) = &options.groups { + parse_groups_from_strings(groups) + } else { + default_groups() + }; + Self { options, groups } } /// Transform the given `Document` by sorting import statements according to the specified options. @@ -58,7 +66,7 @@ impl SortImportsTransform { // And this implementation is based on the following assumptions: // - Only `Line(Hard|Empty)` is used for joining `Program.body` in the output // - `Line(Hard|Empty)` does not appear inside an `ImportDeclaration` formatting - // - In case of this, we should check `Tag::StartLabelled(JsLabels::ImportDeclaration)` + // - If this is the case, we should check `Tag::StartLabelled(JsLabels::ImportDeclaration)` let mut lines = vec![]; let mut current_line_start = 0; for (idx, el) in prev_elements.iter().enumerate() { @@ -87,6 +95,10 @@ impl SortImportsTransform { // Next, partition `SourceLine`s into `PartitionedChunk`s. // + // Chunking is done by detecting boundaries. + // By default, only non-import lines are considered boundaries. + // And depending on options, empty lines and comment-only lines can also be boundaries. + // // Within each chunk, we will sort import lines. // e.g. // ``` @@ -170,29 +182,35 @@ impl SortImportsTransform { // // const YET_ANOTHER_BOUNDARY = true; // ``` - let (mut sortable_imports, trailing_lines) = - chunk.into_import_units(prev_elements, &self.options); - - sort_imports(&mut sortable_imports, &self.options); + let (mut sorted_imports, trailing_lines) = + chunk.into_sorted_import_units(&self.groups, &self.options); // Output sorted import units - let preserve_empty_line = self.options.partition_by_newline; let mut prev_group_idx = None; - for sorted_import in sortable_imports { - // Insert blank line between different groups if enabled + let mut prev_was_ignored = false; + for sorted_import in sorted_imports { + // Insert newline when: + // 1. Group changes + // 2. Previous import was not ignored (don't insert after ignored) if self.options.newlines_between { let current_group_idx = sorted_import.group_idx; if let Some(prev_idx) = prev_group_idx && prev_idx != current_group_idx + && !prev_was_ignored { next_elements.push(FormatElement::Line(LineMode::Empty)); } prev_group_idx = Some(current_group_idx); + prev_was_ignored = sorted_import.is_ignored; } // Output leading lines and import line for line in sorted_import.leading_lines { - line.write(prev_elements, &mut next_elements, preserve_empty_line); + line.write( + prev_elements, + &mut next_elements, + self.options.partition_by_newline, + ); } sorted_import.import_line.write(prev_elements, &mut next_elements, false); } @@ -222,9 +240,11 @@ impl SortImportsTransform { for (idx, line) in trailing_lines.iter().enumerate() { let is_last_empty_line = idx == trailing_lines.len() - 1 && matches!(line, SourceLine::Empty); - let preserve_empty_line = - if is_last_empty_line { next_chunk_is_boundary } else { true }; - line.write(prev_elements, &mut next_elements, preserve_empty_line); + line.write( + prev_elements, + &mut next_elements, + if is_last_empty_line { next_chunk_is_boundary } else { true }, + ); } } } @@ -233,78 +253,3 @@ impl SortImportsTransform { Document::from(next_elements) } } - -/// Sort a list of imports in-place according to the given options. -fn sort_imports(imports: &mut [SortableImport], options: &options::SortImports) { - let imports_len = imports.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 imports.iter().enumerate() { - if si.is_ignored { - 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| { - // Always sort by groups array order first - let group_ord = imports[a].group_idx.cmp(&imports[b].group_idx); - if group_ord != std::cmp::Ordering::Equal { - return group_ord; - } - - // Within the same group, sort by source respecting the order option - let source_ord = - natord::compare(&imports[a].normalized_source, &imports[b].normalized_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::>().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; - } - imports.swap(current, next); - current = next; - } - } - debug_assert!(imports.len() == imports_len, "Length must remain the same after sorting."); -} diff --git a/crates/oxc_formatter/src/ir_transform/sort_imports/partitioned_chunk.rs b/crates/oxc_formatter/src/ir_transform/sort_imports/partitioned_chunk.rs index 5b1191c920e9a..a5d247f057cec 100644 --- a/crates/oxc_formatter/src/ir_transform/sort_imports/partitioned_chunk.rs +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/partitioned_chunk.rs @@ -1,26 +1,33 @@ -use crate::{formatter::format_element::FormatElement, options}; +use crate::{ + formatter::format_element::FormatElement, + ir_transform::sort_imports::{ + compute_metadata::compute_import_metadata, + group_config::GroupName, + sortable_imports::{SortSortableImports, SortableImport}, + source_line::SourceLine, + }, + options, +}; -use super::{import_unit::SortableImport, source_line::SourceLine}; - -#[derive(Debug, Clone)] -pub enum PartitionedChunk { +#[derive(Debug)] +pub enum PartitionedChunk<'a> { /// A chunk containing import statements, /// and possibly leading/trailing comments or empty lines. - Imports(Vec), + Imports(Vec>), /// A boundary chunk. /// Always contains `SourceLine::Others`, /// or optionally `SourceLine::Empty|CommentOnly` depending on partition options. - Boundary(SourceLine), + Boundary(SourceLine<'a>), } -impl Default for PartitionedChunk { +impl Default for PartitionedChunk<'_> { fn default() -> Self { Self::Imports(vec![]) } } -impl PartitionedChunk { - pub fn add_imports_line(&mut self, line: SourceLine) { +impl<'a> PartitionedChunk<'a> { + pub fn add_imports_line(&mut self, line: SourceLine<'a>) { debug_assert!( !matches!(line, SourceLine::Others(..)), "`line` must not be of type `SourceLine::Others`." @@ -41,11 +48,11 @@ impl PartitionedChunk { /// Convert this import chunk into a list of sortable import units and trailing lines. /// Returns a tuple of `(sortable_imports, trailing_lines)`. #[must_use] - pub fn into_import_units<'a>( + pub fn into_sorted_import_units( self, - elements: &'a [FormatElement], + groups: &[Vec], options: &options::SortImports, - ) -> (Vec>, Vec) { + ) -> (Vec>, Vec>) { let Self::Imports(lines) = self else { unreachable!( "`into_import_units()` must be called on `PartitionedChunk::Imports` only." @@ -56,11 +63,18 @@ impl PartitionedChunk { let mut current_leading_lines = vec![]; for line in lines { match line { - SourceLine::Import(..) => { - sortable_imports.push( - SortableImport::new(std::mem::take(&mut current_leading_lines), line) - .collect_sort_keys(elements, options), - ); + SourceLine::Import(_, ref metadata) => { + let is_side_effect = metadata.is_side_effect; + let (group_idx, normalized_source, is_ignored) = + compute_import_metadata(metadata, groups, options); + sortable_imports.push(SortableImport { + leading_lines: std::mem::take(&mut current_leading_lines), + import_line: line, + is_side_effect, + group_idx, + normalized_source, + is_ignored, + }); } SourceLine::CommentOnly(..) | SourceLine::Empty => { current_leading_lines.push(line); @@ -76,6 +90,9 @@ impl PartitionedChunk { // Any remaining comments/lines are trailing let trailing_lines = current_leading_lines; + // Let's sort this chunk! + sortable_imports.sort(options); + (sortable_imports, trailing_lines) } } diff --git a/crates/oxc_formatter/src/ir_transform/sort_imports/sortable_imports.rs b/crates/oxc_formatter/src/ir_transform/sort_imports/sortable_imports.rs new file mode 100644 index 0000000000000..5e621a036aafa --- /dev/null +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/sortable_imports.rs @@ -0,0 +1,184 @@ +use std::borrow::Cow; + +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::{ir_transform::sort_imports::source_line::SourceLine, options}; + +#[derive(Debug)] +pub struct SortableImport<'a> { + pub leading_lines: Vec>, + pub import_line: SourceLine<'a>, + // These are used for sorting and computed by `compute_import_metadata()` + pub group_idx: usize, + pub normalized_source: Cow<'a, str>, + pub is_side_effect: bool, + pub is_ignored: bool, +} + +// --- + +pub trait SortSortableImports { + fn sort(&mut self, options: &options::SortImports); +} + +impl SortSortableImports for Vec> { + fn sort(&mut self, options: &options::SortImports) { + let imports_len = self.len(); + + // Perform sorting only if needed + if imports_len < 2 { + return; + } + + // Stage 1: Separate ignored and non-ignored imports + let (ignored_indices, sortable_indices): (Vec, Vec) = + (0..imports_len).partition(|&idx| self[idx].is_ignored); + + // If all imports are ignored, no sorting needed + if sortable_indices.is_empty() { + return; + } + + // Stage 2: Group non-ignored imports by `group_idx` + let mut imports_by_group: FxHashMap> = FxHashMap::default(); + for &idx in &sortable_indices { + imports_by_group.entry(self[idx].group_idx).or_default().push(idx); + } + + // Stage 3: Sort within each group and build sorted list + // Need to process `groups` in order by `group_idx` + let mut groups: Vec<_> = imports_by_group.iter_mut().collect(); + groups.sort_unstable_by_key(|(gidx, _)| *gidx); + + let mut sorted_indices = Vec::with_capacity(sortable_indices.len()); + for (_, group_indices) in groups { + sort_within_group(group_indices, self, options); + sorted_indices.extend_from_slice(group_indices); + } + + // Stage 4: Build final permutation by inserting ignored imports at their original positions + // If no ignored imports, we can skip the permutation step + if ignored_indices.is_empty() { + apply_permutation(self, &sorted_indices); + } else { + let ignored_set: FxHashSet = ignored_indices.into_iter().collect(); + let mut permutation = vec![0; imports_len]; + let mut sorted_iter = sorted_indices.into_iter(); + for (target_pos, perm) in permutation.iter_mut().enumerate() { + if ignored_set.contains(&target_pos) { + // Ignored import stays at its original position + *perm = target_pos; + } else if let Some(source_idx) = sorted_iter.next() { + *perm = source_idx; + } + } + + apply_permutation(self, &permutation); + } + + debug_assert!(self.len() == imports_len, "Length must remain the same after sorting."); + } +} + +// --- + +/// Sort imports within a single group, respecting side-effect preservation rules. +/// +/// - If `sort_side_effects: true`: sorts all imports by source +/// - If `sort_side_effects: false`: preserves relative order of side-effect imports +/// - While sorting non-side-effect imports, then merges them back together +fn sort_within_group( + indices: &mut [usize], + imports: &[SortableImport], + options: &options::SortImports, +) { + if indices.len() < 2 { + return; + } + + debug_assert!( + indices.iter().all(|&idx| !imports[idx].is_ignored), + "All imports in `indices` must be non-ignored" + ); + + // If `sort_side_effects: true`, sort all imports together + if options.sort_side_effects { + sort_indices_by_source(indices, imports, options); + return; + } + + // Otherwise, preserve side-effect order while sorting non-side-effects + let mut side_effect_indices = vec![]; + let mut non_side_effect_indices = vec![]; + for (pos, &idx) in indices.iter().enumerate() { + if imports[idx].is_side_effect { + side_effect_indices.push((pos, idx)); + } else { + non_side_effect_indices.push(idx); + } + } + + // Sort only non-side-effect imports + sort_indices_by_source(&mut non_side_effect_indices, imports, options); + + // Merge side-effects back at their original relative positions + let mut result = Vec::with_capacity(indices.len()); + let mut side_effect_iter = side_effect_indices.into_iter(); + let mut non_side_effect_iter = non_side_effect_indices.into_iter(); + let mut next_side_effect = side_effect_iter.next(); + + for pos in 0..indices.len() { + match next_side_effect { + Some((se_pos, se_idx)) if se_pos == pos => { + result.push(se_idx); + next_side_effect = side_effect_iter.next(); + } + _ => { + if let Some(non_se_idx) = non_side_effect_iter.next() { + result.push(non_se_idx); + } + } + } + } + + indices.copy_from_slice(&result); +} + +/// Sort indices by their normalized source. +fn sort_indices_by_source( + indices: &mut [usize], + imports: &[SortableImport], + options: &options::SortImports, +) { + indices.sort_by(|&a, &b| { + natord::compare(&imports[a].normalized_source, &imports[b].normalized_source) + }); + + if options.order.is_desc() { + indices.reverse(); + } +} + +// --- + +/// Apply permutation in-place using cycle decomposition. +fn apply_permutation(imports: &mut [SortableImport], permutation: &[usize]) { + 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; + } + imports.swap(current, next); + current = next; + } + } +} diff --git a/crates/oxc_formatter/src/ir_transform/sort_imports/source_line.rs b/crates/oxc_formatter/src/ir_transform/sort_imports/source_line.rs index 8c08d64d433de..a2119f0f312d6 100644 --- a/crates/oxc_formatter/src/ir_transform/sort_imports/source_line.rs +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/source_line.rs @@ -10,14 +10,13 @@ use crate::{ }, }; -#[derive(Debug, Clone)] -pub enum SourceLine { +#[derive(Debug)] +pub enum SourceLine<'a> { /// Line that contains an import statement. /// May have leading comments like `/* ... */ import ...`. /// And also may have trailing comments like `import ...; // ...`. - /// /// Never be a boundary. - Import(Range, ImportLineMetadata), + Import(Range, ImportLineMetadata<'a>), /// Empty line. /// May be used as a boundary if `options.partition_by_newline` is true. Empty, @@ -28,9 +27,9 @@ pub enum SourceLine { Others(Range, LineMode), } -impl SourceLine { +impl<'a> SourceLine<'a> { pub fn from_element_range( - elements: &[FormatElement], + elements: &[FormatElement<'a>], range: Range, line_mode: LineMode, ) -> Self { @@ -55,7 +54,6 @@ impl SourceLine { _ => false, }); if is_comment_only { - // TODO: Check it contains ignore comment? return SourceLine::CommentOnly(range, line_mode); } @@ -67,7 +65,7 @@ impl SourceLine { // import ... // ``` let mut has_import = false; - let mut source_idx = None; + let mut source = None; let mut is_side_effect = true; let mut is_type_import = false; let mut has_default_specifier = false; @@ -92,7 +90,9 @@ impl SourceLine { FormatElement::Token { text } => match *text { "import" => { // Look ahead to determine import type (skip spaces) + // Continue scanning to find all specifier types (default, namespace, named) let mut offset = 1; + let mut first_token = true; // Track if this is the first token after "import" while idx + offset < elements.len() { if matches!(elements[idx + offset], FormatElement::Space) { offset += 1; @@ -101,9 +101,10 @@ impl SourceLine { match &elements[idx + offset] { FormatElement::Token { text } => match *text { - "type" => is_type_import = true, + "type" if first_token => is_type_import = true, "*" => has_namespace_specifier = true, "{" => has_named_specifier = true, + "from" => break, // Stop when we reach "from" _ => {} }, FormatElement::Text { .. } => { @@ -111,30 +112,31 @@ impl SourceLine { } _ => {} } - break; + first_token = false; + offset += 1; } } "from" => { is_side_effect = false; - source_idx = None; + source = None; } _ => {} }, - FormatElement::Text { .. } => { - if source_idx.is_none() { - source_idx = Some(idx); + FormatElement::Text { text, .. } => { + if source.is_none() { + source = Some(text); } } _ => {} } } - if has_import && let Some(source_idx) = source_idx { + if has_import && let Some(source) = source { // TODO: Check line has trailing ignore comment? return SourceLine::Import( range, ImportLineMetadata { - source_idx, + source, is_side_effect, is_type_import, has_default_specifier, @@ -152,7 +154,7 @@ impl SourceLine { SourceLine::Others(range, line_mode) } - pub fn write<'a>( + pub fn write( &self, prev_elements: &[FormatElement<'a>], next_elements: &mut ArenaVec<'a, FormatElement<'a>>, @@ -183,10 +185,11 @@ impl SourceLine { } /// Import line metadata extracted during parsing. -#[derive(Debug, Clone)] -pub struct ImportLineMetadata { +/// Just holds the information found, without interpretation. +#[derive(Debug)] +pub struct ImportLineMetadata<'a> { /// Index of the import source in the original `elements` slice. - pub source_idx: usize, + pub source: &'a str, /// Whether this is a side-effect-only import (e.g., `import "foo"`). pub is_side_effect: bool, /// Whether this is a type-only import (e.g., `import type { Foo } from "foo"`). diff --git a/crates/oxc_formatter/src/options.rs b/crates/oxc_formatter/src/options.rs index 7f7a9149b0f4d..9f646ccdf7060 100644 --- a/crates/oxc_formatter/src/options.rs +++ b/crates/oxc_formatter/src/options.rs @@ -988,7 +988,8 @@ pub struct SortImports { pub newlines_between: bool, /// Groups configuration for organizing imports. /// Each inner `Vec` represents a group, and multiple group names in the same `Vec` are treated as one. - pub groups: Vec>, + /// If `None`, uses the default groups. + pub groups: Option>>, } impl Default for SortImports { @@ -1000,30 +1001,11 @@ impl Default for SortImports { order: SortOrder::default(), ignore_case: true, newlines_between: true, - groups: Self::default_groups(), + groups: None, } } } -impl SortImports { - pub fn default_groups() -> Vec> { - vec![ - vec!["type-import".to_string()], - vec!["value-builtin".to_string(), "value-external".to_string()], - vec!["type-internal".to_string()], - vec!["value-internal".to_string()], - vec!["type-parent".to_string(), "type-sibling".to_string(), "type-index".to_string()], - vec![ - "value-parent".to_string(), - "value-sibling".to_string(), - "value-index".to_string(), - ], - // vec!["ts-equals-import".to_string()], - vec!["unknown".to_string()], - ] - } -} - #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] pub enum SortOrder { /// Sort in ascending order (A-Z). diff --git a/crates/oxc_formatter/src/service/oxfmtrc.rs b/crates/oxc_formatter/src/service/oxfmtrc.rs index 31f0714b2acdd..ecdaca70fdf7b 100644 --- a/crates/oxc_formatter/src/service/oxfmtrc.rs +++ b/crates/oxc_formatter/src/service/oxfmtrc.rs @@ -1,7 +1,7 @@ use std::path::Path; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{ ArrowParentheses, AttributePosition, BracketSameLine, BracketSpacing, @@ -155,12 +155,64 @@ pub struct SortImportsConfig { pub ignore_case: bool, #[serde(default = "default_true")] pub newlines_between: bool, + /// Custom groups configuration for organizing imports. + /// Each array element represents a group, and multiple group names in the same array are treated as one. + /// Accepts both `string` and `string[]` as group elements. + #[serde(skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_groups")] + pub groups: Option>>, } fn default_true() -> bool { true } +/// Custom deserializer for groups field to support both `string` and `string[]` as group elements +fn deserialize_groups<'de, D>(deserializer: D) -> Result>>, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + use serde_json::Value; + + let value: Option = Option::deserialize(deserializer)?; + + match value { + None => Ok(None), + Some(Value::Array(arr)) => { + let mut groups = Vec::new(); + for item in arr { + match item { + // Single string becomes a single-element group + Value::String(s) => { + groups.push(vec![s]); + } + // Array of strings becomes a group + Value::Array(group_arr) => { + let mut group = Vec::new(); + for g in group_arr { + if let Value::String(s) = g { + group.push(s); + } else { + return Err(D::Error::custom( + "groups array elements must contain only strings", + )); + } + } + groups.push(group); + } + _ => { + return Err(D::Error::custom( + "groups must be an array of strings or arrays of strings", + )); + } + } + } + Ok(Some(groups)) + } + Some(_) => Err(D::Error::custom("groups must be an array")), + } +} + #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum SortOrderConfig { @@ -332,8 +384,7 @@ impl Oxfmtrc { }), ignore_case: sort_imports_config.ignore_case, newlines_between: sort_imports_config.newlines_between, - // TODO: Make this configurable later - groups: SortImports::default_groups(), + groups: sort_imports_config.groups, }); } @@ -498,5 +549,26 @@ mod tests { ) .unwrap(); assert!(config.into_format_options().is_err_and(|e| e.contains("newlinesBetween"))); + + let config: Oxfmtrc = serde_json::from_str( + r#"{ + "experimentalSortImports": { + "groups": [ + "builtin", + ["external", "internal"], + "parent", + "sibling", + "index" + ] + } + }"#, + ) + .unwrap(); + let sort_imports = config.into_format_options().unwrap().experimental_sort_imports.unwrap(); + let groups = sort_imports.groups.as_ref().unwrap(); + assert_eq!(groups.len(), 5); + assert_eq!(groups[0], vec!["builtin".to_string()]); + assert_eq!(groups[1], vec!["external".to_string(), "internal".to_string()]); + assert_eq!(groups[4], vec!["index".to_string()]); } } diff --git a/crates/oxc_formatter/tests/ir_transform/_sort-imports-tests.ref.snap b/crates/oxc_formatter/tests/ir_transform/_sort-imports-tests.ref.snap index 91d6c6f0faf3c..75594752d071d 100644 --- a/crates/oxc_formatter/tests/ir_transform/_sort-imports-tests.ref.snap +++ b/crates/oxc_formatter/tests/ir_transform/_sort-imports-tests.ref.snap @@ -1,733 +1,5 @@ // @ts-nocheck // vim: set filetype=typescript: -describe('natural', () => { - let options = { - type: 'natural', - order: 'asc', - } as const - - it('groups all type imports together when specific type groups not configured', async () => { - await valid({ - options: [ - { - ...options, - groups: [ - 'type', - ['builtin', 'external'], - 'internal', - ['parent', 'sibling', 'index'], - ], - }, - ], - code: dedent` - import type { T } from '../t' - import type { V } from 'v' - import type { U } from '~/u' - `, - }) - - await invalid({ - options: [ - { - ...options, - groups: [ - 'type', - ['builtin', 'external'], - 'internal', - ['parent', 'sibling', 'index'], - ], - }, - ], - code: dedent` - import type { T } from '../t' - - import type { U } from '~/u' - - import type { V } from 'v' - `, - output: dedent` - import type { T } from '../t' - import type { V } from 'v' - import type { U } from '~/u' - `, - }) - }) - - it('groups style imports separately when configured', async () => { - await valid({ - options: [ - { - ...options, - groups: [ - 'type', - ['builtin', 'external'], - 'internal-type', - 'internal', - ['parent-type', 'sibling-type', 'index-type'], - ['parent', 'sibling', 'index'], - 'style', - 'unknown', - ], - }, - ], - code: dedent` - import { a1, a2 } from 'a' - - import styles from '../s.css' - import './t.css' - `, - }) - }) - - it('groups side-effect imports separately when configured', async () => { - await valid({ - options: [ - { - ...options, - groups: [ - 'type', - ['builtin', 'external'], - 'internal-type', - 'internal', - ['parent-type', 'sibling-type', 'index-type'], - ['parent', 'sibling', 'index'], - 'side-effect', - 'unknown', - ], - }, - ], - code: dedent` - import { A } from '../a' - import { b } from './b' - - import '../c.js' - import './d' - `, - }) - }) - - it('groups builtin types separately from other type imports', async () => { - await valid({ - options: [ - { - ...options, - groups: ['builtin-type', 'type'], - }, - ], - code: dedent` - import type { Server } from 'http' - - import a from 'a' - `, - }) - }) - - it('handles imports with semicolons correctly', async () => { - await invalid({ - options: [ - { - ...options, - groups: [ - 'type', - ['builtin', 'external'], - 'internal-type', - 'internal', - ['parent-type', 'sibling-type', 'index-type'], - ['parent', 'sibling', 'index'], - 'unknown', - ], - }, - ], - output: dedent` - import a from 'a'; - - import b from './index'; - `, - code: dedent` - import a from 'a'; - import b from './index'; - `, - }) - }) - - it('preserves side-effect import order when sorting disabled', async () => { - await valid({ - options: [ - { - ...options, - groups: ['external', 'side-effect', 'unknown'], - sortSideEffects: false, - }, - ], - code: dedent` - import a from 'aaaa' - - import 'bbb' - import './cc' - import '../d' - `, - }) - - await valid({ - options: [ - { - ...options, - groups: ['external', 'side-effect', 'unknown'], - sortSideEffects: false, - }, - ], - code: dedent` - import 'c' - import 'bb' - import 'aaa' - `, - }) - - await invalid({ - options: [ - { - ...options, - groups: ['external', 'side-effect', 'unknown'], - sortSideEffects: false, - }, - ], - output: dedent` - import a from 'aaaa' - import e from 'e' - - import './cc' - import 'bbb' - import '../d' - `, - code: dedent` - import './cc' - import 'bbb' - import e from 'e' - import a from 'aaaa' - import '../d' - `, - }) - }) - - it('sorts side-effect imports when sorting enabled', async () => { - await invalid({ - options: [ - { - ...options, - groups: ['external', 'side-effect', 'unknown'], - sortSideEffects: true, - }, - ], - output: dedent` - import 'aaa' - import 'bb' - import 'c' - `, - code: dedent` - import 'c' - import 'bb' - import 'aaa' - `, - }) - }) - - it('preserves original order when side-effect imports are not grouped', async () => { - await invalid({ - output: dedent` - import "./z-side-effect.scss"; - import a from "./a"; - import './b-side-effect' - import "./g-side-effect.css"; - import './a-side-effect' - import b from "./b"; - `, - code: dedent` - import "./z-side-effect.scss"; - import b from "./b"; - import './b-side-effect' - import "./g-side-effect.css"; - import './a-side-effect' - import a from "./a"; - `, - options: [ - { - ...options, - groups: ['unknown'], - }, - ], - }) - }) - - it('groups side-effect imports together without sorting them', async () => { - await invalid({ - output: dedent` - import "./z-side-effect.scss"; - import './b-side-effect' - import "./g-side-effect.css"; - import './a-side-effect' - - import a from "./a"; - import b from "./b"; - `, - code: dedent` - import "./z-side-effect.scss"; - import b from "./b"; - import './b-side-effect' - import "./g-side-effect.css"; - import './a-side-effect' - import a from "./a"; - `, - options: [ - { - ...options, - groups: ['side-effect', 'unknown'], - }, - ], - }) - }) - - it('groups side-effect and style imports together in same group without sorting', async () => { - await invalid({ - output: dedent` - import "./z-side-effect.scss"; - import './b-side-effect' - import "./g-side-effect.css"; - import './a-side-effect' - - import a from "./a"; - import b from "./b"; - `, - code: dedent` - import "./z-side-effect.scss"; - import b from "./b"; - import './b-side-effect' - import "./g-side-effect.css"; - import './a-side-effect' - import a from "./a"; - `, - options: [ - { - ...options, - groups: [['side-effect', 'side-effect-style'], 'unknown'], - }, - ], - }) - }) - - it('separates side-effect and style imports into distinct groups without sorting', async () => { - await invalid({ - output: dedent` - import './b-side-effect' - import './a-side-effect' - - import "./z-side-effect.scss"; - import "./g-side-effect.css"; - - import a from "./a"; - import b from "./b"; - `, - code: dedent` - import "./z-side-effect.scss"; - import b from "./b"; - import './b-side-effect' - import "./g-side-effect.css"; - import './a-side-effect' - import a from "./a"; - `, - options: [ - { - ...options, - groups: ['side-effect', 'side-effect-style', 'unknown'], - }, - ], - }) - }) - - it('groups style side-effect imports separately without sorting', async () => { - await invalid({ - output: dedent` - import "./z-side-effect"; - import './b-side-effect.scss' - import './a-side-effect.css' - - import "./g-side-effect"; - import a from "./a"; - import b from "./b"; - `, - code: dedent` - import "./z-side-effect"; - import b from "./b"; - import './b-side-effect.scss' - import "./g-side-effect"; - import './a-side-effect.css' - import a from "./a"; - `, - options: [ - { - ...options, - groups: ['side-effect-style', 'unknown'], - }, - ], - }) - }) - - it('ignores fallback sorting for side-effect imports', async () => { - await valid({ - options: [ - { - groups: ['side-effect', 'side-effect-style'], - fallbackSort: { type: 'alphabetical' }, - }, - ], - code: dedent` - import 'b'; - import 'a'; - - import 'b.css'; - import 'a.css'; - `, - }) - }) - - it('handles newlines and comments after fixes', async () => { - await invalid({ - output: dedent` - import { a } from './a' // Comment after - - import { b } from 'b' - import { c } from 'c' - `, - code: dedent` - import { b } from 'b' - import { a } from './a' // Comment after - - import { c } from 'c' - `, - options: [ - { - groups: ['unknown', 'external'], - newlinesBetween: 'always', - }, - ], - }) - }) - - it('supports style imports with query parameters', async () => { - await valid({ - code: dedent` - import b from './b.css?raw' - import c from './c.css' - - import a from './a.js' - `, - options: [ - { - ...options, - groups: ['style', 'unknown'], - }, - ], - }) - - await invalid({ - output: dedent` - import b from './b.css?raw' - import c from './c.css' - - import a from './a.js' - `, - code: dedent` - import a from './a.js' - import b from './b.css?raw' - import c from './c.css' - `, - options: [ - { - ...options, - groups: ['style', 'unknown'], - }, - ], - }) - }) - - it('prioritizes index types over sibling types', async () => { - await invalid({ - options: [ - { - ...options, - groups: ['index-type', 'sibling-type'], - }, - ], - output: dedent` - import type b from './index' - - import type a from './a' - `, - code: dedent` - import type a from './a' - - import type b from './index' - `, - }) - }) - - it('prioritizes specific type selectors over generic type group', async () => { - await invalid({ - options: [ - { - ...options, - groups: [ - [ - 'index-type', - 'internal-type', - 'external-type', - 'sibling-type', - 'builtin-type', - ], - 'type', - ], - }, - ], - output: dedent` - import type b from './b' - import type c from './index' - import type d from 'd' - import type e from 'timers' - - import type a from '../a' - `, - code: dedent` - import type a from '../a' - - import type b from './b' - import type c from './index' - import type d from 'd' - import type e from 'timers' - `, - }) - }) - - it('prioritizes index imports over sibling imports', async () => { - await invalid({ - options: [ - { - ...options, - groups: ['index', 'sibling'], - }, - ], - output: dedent` - import b from './index' - - import a from './a' - `, - code: dedent` - import a from './a' - - import b from './index' - `, - }) - }) - - it('prioritizes style side-effects over generic side-effects', async () => { - await invalid({ - options: [ - { - ...options, - groups: ['side-effect-style', 'side-effect'], - }, - ], - output: dedent` - import 'style.css' - - import 'something' - `, - code: dedent` - import 'something' - - import 'style.css' - `, - }) - }) - - it('prioritizes side-effects over style imports with default exports', async () => { - await invalid({ - options: [ - { - ...options, - groups: ['side-effect', 'style'], - }, - ], - output: dedent` - import 'something' - - import style from 'style.css' - `, - code: dedent` - import style from 'style.css' - - import 'something' - `, - }) - }) - - it('prioritizes style imports over other import types', async () => { - await invalid({ - options: [ - { - ...options, - groups: [ - 'style', - [ - 'index', - 'internal', - 'subpath', - 'external', - 'sibling', - 'builtin', - 'parent', - 'tsconfig-path', - ], - ], - tsconfigRootDir: '.', - }, - ], - output: dedent` - import style from 'style.css' - - import subpath from '#subpath' - import tsConfigPath from '$path' - import a from '../a' - import b from './b' - import c from './index' - import d from 'd' - import e from 'timers' - `, - code: dedent` - import a from '../a' - import b from './b' - import c from './index' - import subpath from '#subpath' - import tsConfigPath from '$path' - import d from 'd' - import e from 'timers' - - import style from 'style.css' - `, - before: () => { - mockReadClosestTsConfigByPathWith({ - paths: { - $path: ['./path'], - }, - }) - }, - }) - }) - - it('prioritizes external imports over generic import group', async () => { - await invalid({ - options: [ - { - ...options, - groups: ['external', 'import'], - }, - ], - output: dedent` - import b from 'b' - - import a from './a' - `, - code: dedent` - import a from './a' - - import b from 'b' - `, - }) - }) - - it('prioritizes side-effect imports over value imports', async () => { - await invalid({ - options: [ - { - ...options, - groups: ['side-effect-import', 'external', 'value-import'], - sortSideEffects: true, - }, - ], - output: dedent` - import "./z" - - import f from 'f' - `, - code: dedent` - import f from 'f' - - import "./z" - `, - }) - }) - - it('prioritizes default imports over named imports', async () => { - await invalid({ - options: [ - { - ...options, - groups: ['default-import', 'external', 'named-import'], - }, - ], - output: dedent` - import z, { z } from "./z" - - import f from 'f' - `, - code: dedent` - import f from 'f' - - import z, { z } from "./z" - `, - }) - }) - - it('prioritizes wildcard imports over named imports', async () => { - await invalid({ - options: [ - { - ...options, - groups: ['wildcard-import', 'external', 'named-import'], - }, - ], - output: dedent` - import z, * as z from "./z" - - import f from 'f' - `, - code: dedent` - import f from 'f' - - import z, * as z from "./z" - `, - }) - }) - - it('treats @ symbol pattern as internal imports', async () => { - await invalid({ - options: [ - { - ...options, - groups: ['external', 'internal'], - newlinesBetween: 'always', - }, - ], - output: dedent` - import { b } from 'b' - - import { a } from '@/a' - `, - code: dedent` - import { b } from 'b' - import { a } from '@/a' - `, - }) - }) -}) describe("misc", () => { it("recognizes Node.js built-in modules with node: prefix", async () => { @@ -811,7 +83,6 @@ describe("misc", () => { options: [ { groups: ["builtin", "external", "side-effect"], - newlinesBetween: "never", }, ], }); diff --git a/crates/oxc_formatter/tests/ir_transform/sort_imports.rs b/crates/oxc_formatter/tests/ir_transform/sort_imports.rs index 7dff3dd55c1ba..2015567b86d73 100644 --- a/crates/oxc_formatter/tests/ir_transform/sort_imports.rs +++ b/crates/oxc_formatter/tests/ir_transform/sort_imports.rs @@ -1060,3 +1060,557 @@ import z from "~/z"; "#, ); } + +// --- + +#[test] +fn should_sort_by_specific_groups() { + assert_format( + r#" +import type { T } from "../t"; + +import type { U } from "~/u"; + +import type { V } from "v"; +"#, + r#"{ + "experimentalSortImports": { + "groups": [ + "type", + ["builtin", "external"], + "internal", + ["parent", "sibling", "index"] + ] + } +}"#, + r#" +import type { T } from "../t"; +import type { V } from "v"; +import type { U } from "~/u"; +"#, + ); + // Style imports in separate group + assert_format( + r#" +import { a1, a2 } from "a"; + +import styles from "../s.css"; +import "./t.css"; +"#, + r#"{ + "experimentalSortImports": { + "groups": [ + "type", + ["builtin", "external"], + "internal-type", + "internal", + ["parent-type", "sibling-type", "index-type"], + ["parent", "sibling", "index"], + "style", + "unknown" + ] + } +}"#, + r#" +import { a1, a2 } from "a"; + +import styles from "../s.css"; +import "./t.css"; +"#, + ); + // Side-effect imports in separate group + assert_format( + r#" +import { A } from "../a"; +import { b } from "./b"; + +import "../c.js"; +import "./d"; +"#, + r#"{ + "experimentalSortImports": { + "groups": [ + "type", + ["builtin", "external"], + "internal-type", + "internal", + ["parent-type", "sibling-type", "index-type"], + ["parent", "sibling", "index"], + "side-effect", + "unknown" + ] + } +}"#, + r#" +import { A } from "../a"; +import { b } from "./b"; + +import "../c.js"; +import "./d"; +"#, + ); + // Builtin type imports in separate group + assert_format( + r#" +import type { Server } from "http"; + +import a from "a"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["builtin-type", "type"] + } +}"#, + r#" +import type { Server } from "http"; + +import a from "a"; +"#, + ); + // Side-effect imports preserve order when sortSideEffects: false + assert_format( + r#" +import a from "aaaa"; + +import "bbb"; +import "./cc"; +import "../d"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["external", "side-effect", "unknown"], + "sortSideEffects": false + } +}"#, + r#" +import a from "aaaa"; + +import "bbb"; +import "./cc"; +import "../d"; +"#, + ); + // preserves side-effect import order when sorting disabled + assert_format( + r#" +import "./cc"; +import "bbb"; +import e from "e"; +import a from "aaaa"; +import "../d"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["external", "side-effect", "unknown"], + "sortSideEffects": false + } +}"#, + r#" +import a from "aaaa"; +import e from "e"; + +import "./cc"; +import "bbb"; +import "../d"; +"#, + ); + assert_format( + r#" +import "c"; +import "bb"; +import "aaa"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["external", "side-effect", "unknown"], + "sortSideEffects": true + } +}"#, + r#" +import "aaa"; +import "bb"; +import "c"; +"#, + ); + // Side-effects stay in original position, only non-side-effects are sorted + assert_format( + r#" +import "./z-side-effect.scss"; +import b from "./b"; +import "./b-side-effect"; +import "./g-side-effect.css"; +import "./a-side-effect"; +import a from "./a"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["unknown"] + } +}"#, + r#" +import "./z-side-effect.scss"; +import a from "./a"; +import "./b-side-effect"; +import "./g-side-effect.css"; +import "./a-side-effect"; +import b from "./b"; +"#, + ); + // Groups side-effect imports together without sorting them + assert_format( + r#" +import "./z-side-effect.scss"; +import b from "./b"; +import "./b-side-effect"; +import "./g-side-effect.css"; +import "./a-side-effect"; +import a from "./a"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["side-effect", "unknown"] + } +}"#, + r#" +import "./z-side-effect.scss"; +import "./b-side-effect"; +import "./g-side-effect.css"; +import "./a-side-effect"; + +import a from "./a"; +import b from "./b"; +"#, + ); + // Groups side-effect and style imports together in same group without sorting + assert_format( + r#" +import "./z-side-effect.scss"; +import b from "./b"; +import "./b-side-effect"; +import "./g-side-effect.css"; +import "./a-side-effect"; +import a from "./a"; +"#, + r#"{ + "experimentalSortImports": { + "groups": [["side-effect", "side-effect-style"], "unknown"] + } +}"#, + r#" +import "./z-side-effect.scss"; +import "./b-side-effect"; +import "./g-side-effect.css"; +import "./a-side-effect"; + +import a from "./a"; +import b from "./b"; +"#, + ); + // Separates side-effect and style imports into distinct groups without sorting + assert_format( + r#" +import "./z-side-effect.scss"; +import b from "./b"; +import "./b-side-effect"; +import "./g-side-effect.css"; +import "./a-side-effect"; +import a from "./a"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["side-effect", "side-effect-style", "unknown"] + } +}"#, + r#" +import "./b-side-effect"; +import "./a-side-effect"; + +import "./z-side-effect.scss"; +import "./g-side-effect.css"; + +import a from "./a"; +import b from "./b"; +"#, + ); + // Groups style side-effect imports separately without sorting + assert_format( + r#" +import "./z-side-effect"; +import b from "./b"; +import "./b-side-effect.scss"; +import "./g-side-effect"; +import "./a-side-effect.css"; +import a from "./a"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["side-effect-style", "unknown"] + } +}"#, + r#" +import "./z-side-effect"; +import "./b-side-effect.scss"; +import "./a-side-effect.css"; + +import "./g-side-effect"; +import a from "./a"; +import b from "./b"; +"#, + ); + // handles newlines and comments after fixes + assert_format( + r#" +import { b } from "b"; +import { a } from "./a"; // Comment after + +import { c } from "c"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["unknown", "external"], + "newlinesBetween": true + } +}"#, + r#" +import { a } from "./a"; // Comment after + +import { b } from "b"; +import { c } from "c"; +"#, + ); + // prioritizes index types over sibling types + assert_format( + r#" +import type a from "./a"; + +import type b from "./index"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["index-type", "sibling-type"] + } +}"#, + r#" +import type b from "./index"; + +import type a from "./a"; +"#, + ); + // prioritizes specific type selectors over generic type group + assert_format( + r#" +import type a from "../a"; + +import type b from "./b"; +import type c from "./index"; +import type d from "d"; +import type e from "timers"; +"#, + r#"{ + "experimentalSortImports": { + "groups": [ + [ + "index-type", + "internal-type", + "external-type", + "sibling-type", + "builtin-type" + ], + "type" + ] + } +}"#, + r#" +import type b from "./b"; +import type c from "./index"; +import type d from "d"; +import type e from "timers"; + +import type a from "../a"; +"#, + ); + // prioritizes index imports over sibling imports + assert_format( + r#" +import a from "./a"; + +import b from "./index"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["index", "sibling"] + } +}"#, + r#" +import b from "./index"; + +import a from "./a"; +"#, + ); + // prioritizes style side-effects over generic side-effects + assert_format( + r#" +import "something"; + +import "style.css"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["side-effect-style", "side-effect"] + } +}"#, + r#" +import "style.css"; + +import "something"; +"#, + ); + // prioritizes side-effects over style imports with default exports + assert_format( + r#" +import style from "style.css"; + +import "something"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["side-effect", "style"] + } +}"#, + r#" +import "something"; + +import style from "style.css"; +"#, + ); + // prioritizes external imports over generic import group + assert_format( + r#" +import a from "./a"; + +import b from "b"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["external", "import"] + } +}"#, + r#" +import b from "b"; + +import a from "./a"; +"#, + ); + // prioritizes side-effect imports over value imports + assert_format( + r#" +import f from "f"; + +import "./z"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["side-effect-import", "external", "value-import"], + "sortSideEffects": true + } +}"#, + r#" +import "./z"; + +import f from "f"; +"#, + ); + // prioritizes default imports over named imports + assert_format( + r#" +import f from "f"; + +import z, { z } from "./z"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["default-import", "external", "named-import"] + } +}"#, + r#" +import z, { z } from "./z"; + +import f from "f"; +"#, + ); + // prioritizes wildcard imports over named imports + assert_format( + r#" +import f from "f"; + +import * as z from "./z"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["wildcard-import", "external", "named-import"] + } +}"#, + r#" +import * as z from "./z"; + +import f from "f"; +"#, + ); + // treats @ symbol pattern as internal imports + assert_format( + r#" +import { b } from "b"; +import { a } from "@/a"; +"#, + r#"{ + "experimentalSortImports": { + "groups": ["external", "internal"], + "newlinesBetween": true + } +}"#, + r#" +import { b } from "b"; + +import { a } from "@/a"; +"#, + ); + // Supports subpath + assert_format( + r##" +import a from "../a"; +import b from "./b"; +import subpath from "#subpath"; +import e from "timers"; +import c from "./index"; +import d from "d"; + +import style from "style.css"; +"##, + r#"{ + "experimentalSortImports": { + "groups": [ + "style", + [ + "index", + "internal", + "subpath", + "external", + "sibling", + "builtin", + "parent" + ] + ] + } +}"#, + r##" +import style from "style.css"; + +import subpath from "#subpath"; +import a from "../a"; +import b from "./b"; +import c from "./index"; +import d from "d"; +import e from "timers"; +"##, + ); +} diff --git a/crates/oxc_formatter/tests/snapshots/schema_json.snap b/crates/oxc_formatter/tests/snapshots/schema_json.snap index ad6a33742c11d..498538b8fa717 100644 --- a/crates/oxc_formatter/tests/snapshots/schema_json.snap +++ b/crates/oxc_formatter/tests/snapshots/schema_json.snap @@ -224,6 +224,19 @@ expression: json "SortImportsConfig": { "type": "object", "properties": { + "groups": { + "description": "Custom groups configuration for organizing imports.\nEach array element represents a group, and multiple group names in the same array are treated as one.\nAccepts both `string` and `string[]` as group elements.", + "type": [ + "array", + "null" + ], + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, "ignoreCase": { "default": true, "type": "boolean" diff --git a/napi/playground/src/lib.rs b/napi/playground/src/lib.rs index 21400414182a7..9b330ab8d5ae2 100644 --- a/napi/playground/src/lib.rs +++ b/napi/playground/src/lib.rs @@ -508,8 +508,6 @@ impl Oxc { .as_ref() .and_then(|o| o.parse::().ok()) .unwrap_or_default(); - // TODO: support from options - let groups = SortImports::default_groups(); format_options.experimental_sort_imports = Some(SortImports { partition_by_newline: sort_imports_config.partition_by_newline.unwrap_or(false), @@ -518,7 +516,7 @@ impl Oxc { order, ignore_case: sort_imports_config.ignore_case.unwrap_or(true), newlines_between: sort_imports_config.newlines_between.unwrap_or(true), - groups, + groups: sort_imports_config.groups.clone(), }); } diff --git a/npm/oxfmt/configuration_schema.json b/npm/oxfmt/configuration_schema.json index be9a0fe8cf926..ba00ecdd7696e 100644 --- a/npm/oxfmt/configuration_schema.json +++ b/npm/oxfmt/configuration_schema.json @@ -220,6 +220,19 @@ "SortImportsConfig": { "type": "object", "properties": { + "groups": { + "description": "Custom groups configuration for organizing imports.\nEach array element represents a group, and multiple group names in the same array are treated as one.\nAccepts both `string` and `string[]` as group elements.", + "type": [ + "array", + "null" + ], + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, "ignoreCase": { "default": true, "type": "boolean"