diff --git a/crates/oxc_formatter/examples/sort_imports.rs b/crates/oxc_formatter/examples/sort_imports.rs index 2dd502dc47bcf..fe4c27ef7ccb7 100644 --- a/crates/oxc_formatter/examples/sort_imports.rs +++ b/crates/oxc_formatter/examples/sort_imports.rs @@ -28,6 +28,7 @@ fn main() -> Result<(), String> { sort_side_effects, ignore_case, newlines_between, + groups: SortImports::default_groups(), }; // Read source file @@ -50,7 +51,7 @@ fn main() -> Result<(), String> { // Format the parsed code let options = FormatOptions { - experimental_sort_imports: Some(sort_imports_options), + experimental_sort_imports: Some(sort_imports_options.clone()), ..Default::default() }; 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 index 690b8df752e43..6a035d16870e9 100644 --- a/crates/oxc_formatter/src/ir_transform/sort_imports/import_unit.rs +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/import_unit.rs @@ -20,7 +20,7 @@ impl IntoIterator for ImportUnits { } impl ImportUnits { - pub fn sort_imports(&mut self, elements: &[FormatElement], options: options::SortImports) { + pub fn sort_imports(&mut self, elements: &[FormatElement], options: &options::SortImports) { let imports_len = self.0.len(); // Perform sorting only if needed @@ -47,8 +47,11 @@ impl ImportUnits { let metadata_a = self.0[a].get_metadata(elements); let metadata_b = self.0[b].get_metadata(elements); - // First, compare by group - let group_ord = metadata_a.group().cmp(&metadata_b.group()); + // 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 }; } @@ -153,7 +156,7 @@ impl SortableImport { } /// Check if this import should be ignored (not sorted). - pub fn is_ignored(&self, options: options::SortImports) -> bool { + pub fn is_ignored(&self, options: &options::SortImports) -> bool { match self.import_line { SourceLine::Import(ImportLine { is_side_effect, .. }) => { // TODO: Check ignore comments? @@ -164,37 +167,6 @@ impl SortableImport { } } -/// Import group classification for sorting. -/// -/// NOTE: The order of variants in this enum determines the sort order when comparing groups. -/// Groups are sorted in the order they appear here (TypeImport first, Unknown last). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum ImportGroup { - /// Type-only imports from builtin or external packages - /// e.g., `import type { Foo } from 'react'` - TypeImport, - /// Value imports from Node.js builtin modules or external packages - /// Corresponds to `['value-builtin', 'value-external']` in perfectionist - /// e.g., `import fs from 'node:fs'`, `import React from 'react'` - ValueBuiltinOrExternal, - /// Type-only imports from internal modules - /// e.g., `import type { Config } from '~/types'`, `import type { User } from '@/models'` - TypeInternal, - /// Value imports from internal modules - /// e.g., `import { config } from '~/config'`, `import { utils } from '@/utils'` - ValueInternal, - /// Type-only imports from relative paths (parent, sibling, or index) - /// Corresponds to `['type-parent', 'type-sibling', 'type-index']` in perfectionist - /// e.g., `import type { Props } from '../types'`, `import type { State } from './types'` - TypeRelative, - /// Value imports from relative paths (parent, sibling, or index) - /// Corresponds to `['value-parent', 'value-sibling', 'value-index']` in perfectionist - /// e.g., `import { helper } from '../parent'`, `import { Component } from './sibling'` - ValueRelative, - /// Unclassified imports (fallback) - Unknown, -} - /// Metadata about an import for sorting purposes. #[derive(Debug, Clone)] pub struct ImportMetadata<'a> { @@ -209,28 +181,248 @@ pub struct ImportMetadata<'a> { } impl ImportMetadata<'_> { - /// Determine the import group based on metadata. - pub fn group(&self) -> ImportGroup { - if self.is_type_import { - return match self.path_kind { - ImportPathKind::Builtin | ImportPathKind::External => ImportGroup::TypeImport, - ImportPathKind::Internal => ImportGroup::TypeInternal, - ImportPathKind::Parent | ImportPathKind::Sibling | ImportPathKind::Index => { - ImportGroup::TypeRelative + /// 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; + } } - ImportPathKind::Unknown => ImportGroup::Unknown, - }; + } } - match self.path_kind { - ImportPathKind::Builtin | ImportPathKind::External => { - ImportGroup::ValueBuiltinOrExternal + // 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; } - ImportPathKind::Internal => ImportGroup::ValueInternal, - ImportPathKind::Parent | ImportPathKind::Sibling | ImportPathKind::Index => { - ImportGroup::ValueRelative + + // 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); } - ImportPathKind::Unknown => ImportGroup::Unknown, + + // 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", } } } @@ -264,6 +456,10 @@ static NODE_BUILTINS: phf::Set<&'static str> = phf_set! { "zlib", }; +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)] pub enum ImportPathKind { @@ -285,10 +481,7 @@ pub enum ImportPathKind { impl ImportPathKind { fn new(source: &str) -> Self { - if source.starts_with("node:") - || source.starts_with("bun:") - || NODE_BUILTINS.contains(source) - { + if is_builtin(source) { return Self::Builtin; } 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 b0ab6b2e60f18..4237e8bf3d173 100644 --- a/crates/oxc_formatter/src/ir_transform/sort_imports/mod.rs +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/mod.rs @@ -167,21 +167,22 @@ impl SortImportsTransform { // const YET_ANOTHER_BOUNDARY = true; // ``` let (mut import_units, trailing_lines) = chunk.into_import_units(prev_elements); - import_units.sort_imports(prev_elements, self.options); + import_units.sort_imports(prev_elements, &self.options); // Output sorted import units let preserve_empty_line = self.options.partition_by_newline; - let mut prev_group = None; + let mut prev_group_idx = None; for sortable_import in import_units { // Insert blank line between different groups if enabled if self.options.newlines_between { - let current_group = sortable_import.get_metadata(prev_elements).group(); - if let Some(prev) = prev_group - && prev != current_group + let metadata = sortable_import.get_metadata(prev_elements); + let current_group_idx = metadata.match_group(&self.options.groups); + if let Some(prev_idx) = prev_group_idx + && prev_idx != current_group_idx { next_elements.push(FormatElement::Line(LineMode::Empty)); } - prev_group = Some(current_group); + prev_group_idx = Some(current_group_idx); } // Output leading lines and import line diff --git a/crates/oxc_formatter/src/lib.rs b/crates/oxc_formatter/src/lib.rs index 010271c97a8e1..bbe3d01654754 100644 --- a/crates/oxc_formatter/src/lib.rs +++ b/crates/oxc_formatter/src/lib.rs @@ -80,7 +80,7 @@ impl<'a> Formatter<'a> { let source_text = program.source_text; self.source_text = source_text; - let experimental_sort_imports = self.options.experimental_sort_imports; + let experimental_sort_imports = self.options.experimental_sort_imports.clone(); let mut context = FormatContext::new( program.source_text, diff --git a/crates/oxc_formatter/src/options.rs b/crates/oxc_formatter/src/options.rs index f6273dcdff038..7f7a9149b0f4d 100644 --- a/crates/oxc_formatter/src/options.rs +++ b/crates/oxc_formatter/src/options.rs @@ -963,7 +963,7 @@ impl fmt::Display for EmbeddedLanguageFormatting { // --- -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct SortImports { /// Partition imports by newlines. /// Default is `false`. @@ -986,6 +986,9 @@ pub struct SortImports { /// /// NOTE: Cannot be used together with `partition_by_newline: true`. 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>, } impl Default for SortImports { @@ -997,10 +1000,30 @@ impl Default for SortImports { order: SortOrder::default(), ignore_case: true, newlines_between: true, + groups: Self::default_groups(), } } } +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 a409a519b6f83..31f0714b2acdd 100644 --- a/crates/oxc_formatter/src/service/oxfmtrc.rs +++ b/crates/oxc_formatter/src/service/oxfmtrc.rs @@ -332,6 +332,8 @@ 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(), }); } diff --git a/napi/playground/src/lib.rs b/napi/playground/src/lib.rs index 44852d2306cd1..a7dadcf9a589d 100644 --- a/napi/playground/src/lib.rs +++ b/napi/playground/src/lib.rs @@ -514,6 +514,8 @@ 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), @@ -522,6 +524,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, }); } diff --git a/napi/playground/src/options.rs b/napi/playground/src/options.rs index 0a568ab85fe29..eb4d974d8797f 100644 --- a/napi/playground/src/options.rs +++ b/napi/playground/src/options.rs @@ -146,7 +146,7 @@ pub struct OxcFormatterOptions { pub single_attribute_per_line: Option, /// Operator position: "start" | "end" (default: "end") pub experimental_operator_position: Option, - /// Sort imports configuration + /// Sort imports configuration (default: None) pub experimental_sort_imports: Option, } @@ -165,4 +165,6 @@ pub struct OxcSortImportsOptions { pub ignore_case: Option, /// Add newlines between import groups (default: true) pub newlines_between: Option, + /// Custom groups of imports + pub groups: Option>>, }