Skip to content

Commit 7818e22

Browse files
committed
feat(formatter/sort-imports): Support options.groups (#15831)
Part of #14253 Now user can specify own `groups` like: ```js groups: [ "type", ["external", "builtin"] ] ``` - [x] Parse config - [x] Implementation - [x] Test - [x] Refactor for readability - [x] Refactor for performance - [x] ~~Report non-defaults?~~ Sorry for the large diffs. Yes, it's incredibly complicated. 😓
1 parent 2b3ddfb commit 7818e22

File tree

15 files changed

+1537
-1324
lines changed

15 files changed

+1537
-1324
lines changed

crates/oxc_formatter/examples/sort_imports.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ fn main() -> Result<(), String> {
2828
sort_side_effects,
2929
ignore_case,
3030
newlines_between,
31-
groups: SortImports::default_groups(),
31+
groups: None,
3232
};
3333

3434
// Read source file
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
use std::{borrow::Cow, path::Path};
2+
3+
use cow_utils::CowUtils;
4+
use phf::phf_set;
5+
6+
use crate::{
7+
formatter::format_element::FormatElement,
8+
ir_transform::sort_imports::{
9+
group_config::{GroupName, ImportModifier, ImportSelector},
10+
source_line::ImportLineMetadata,
11+
},
12+
options,
13+
};
14+
15+
/// Compute all metadata derived from import line metadata.
16+
///
17+
/// Returns `(group_idx, normalized_source, is_ignored)`.
18+
pub fn compute_import_metadata<'a>(
19+
metadata: &ImportLineMetadata<'a>,
20+
groups: &[Vec<GroupName>],
21+
options: &options::SortImports,
22+
) -> (usize, Cow<'a, str>, bool) {
23+
let ImportLineMetadata {
24+
source,
25+
is_side_effect,
26+
is_type_import,
27+
has_default_specifier,
28+
has_namespace_specifier,
29+
has_named_specifier,
30+
} = metadata;
31+
32+
let source = extract_source_path(source);
33+
let is_style_import = is_style(source);
34+
35+
// Create group matcher from import characteristics
36+
let matcher = ImportGroupMatcher {
37+
is_side_effect: *is_side_effect,
38+
is_type_import: *is_type_import,
39+
is_style_import,
40+
path_kind: to_path_kind(source),
41+
is_subpath: is_subpath(source),
42+
has_default_specifier: *has_default_specifier,
43+
has_namespace_specifier: *has_namespace_specifier,
44+
has_named_specifier: *has_named_specifier,
45+
};
46+
let group_idx = matcher.into_match_group_idx(groups);
47+
48+
// Pre-compute normalized source for case-insensitive comparison
49+
let normalized_source =
50+
if options.ignore_case { source.cow_to_lowercase() } else { Cow::Borrowed(source) };
51+
52+
// Determine if this import should be ignored (not moved between groups)
53+
// - If `sort_side_effects: true`, never ignore
54+
// - If `sort_side_effects: false` and this is a side-effect:
55+
// - Check if groups contain `side-effect` or `side-effect-style`
56+
// - If yes, allow regrouping (not ignored)
57+
// - If no, keep in original position (ignored)
58+
let mut should_regroup_side_effect = false;
59+
let mut should_regroup_side_effect_style = false;
60+
for group in groups {
61+
for name in group {
62+
if name.is_plain_selector(ImportSelector::SideEffect) {
63+
should_regroup_side_effect = true;
64+
}
65+
if name.is_plain_selector(ImportSelector::SideEffectStyle) {
66+
should_regroup_side_effect_style = true;
67+
}
68+
}
69+
}
70+
71+
let is_ignored = !options.sort_side_effects
72+
&& *is_side_effect
73+
&& !should_regroup_side_effect
74+
&& (!is_style_import || !should_regroup_side_effect_style);
75+
76+
(group_idx, normalized_source, is_ignored)
77+
}
78+
79+
// ---
80+
81+
/// Helper for matching imports to configured groups.
82+
///
83+
/// Contains all characteristics of an import needed to determine which group it belongs to,
84+
/// such as whether it's a type import, side-effect import, style import, and what kind of path it uses.
85+
#[derive(Debug)]
86+
struct ImportGroupMatcher {
87+
is_side_effect: bool,
88+
is_type_import: bool,
89+
is_style_import: bool,
90+
has_default_specifier: bool,
91+
has_namespace_specifier: bool,
92+
has_named_specifier: bool,
93+
path_kind: ImportPathKind,
94+
is_subpath: bool,
95+
}
96+
97+
impl ImportGroupMatcher {
98+
/// Match this import against the configured groups and return the group index.
99+
///
100+
/// This method generates possible group names in priority order (most specific to least specific)
101+
/// and tries to match them against the configured groups.
102+
/// For example, for a type import from an external package,
103+
/// it tries: "type-external", "external", "type-import", "import".
104+
///
105+
/// Returns:
106+
/// - The index of the first matching group (if found)
107+
/// - The index of the "unknown" group (if no match found and "unknown" is configured)
108+
/// - `groups.len()` (if no match found and no "unknown" group configured)
109+
#[must_use]
110+
fn into_match_group_idx(self, groups: &[Vec<GroupName>]) -> usize {
111+
let possible_names = self.compute_group_names();
112+
let mut unknown_index = None;
113+
114+
// Try each possible name in order (most specific first)
115+
for possible_name in &possible_names {
116+
for (group_idx, group) in groups.iter().enumerate() {
117+
for group_name in group {
118+
// Check if this is the "unknown" group
119+
if group_name.is_plain_selector(ImportSelector::Unknown) {
120+
unknown_index = Some(group_idx);
121+
}
122+
123+
// Check if this possible name matches this group
124+
if possible_name == group_name {
125+
return group_idx;
126+
}
127+
}
128+
}
129+
}
130+
131+
unknown_index.unwrap_or(groups.len())
132+
}
133+
134+
/// Generate all possible group names for this import, ordered by specificity.
135+
/// For each selector (in order), generate all modifier combinations with that selector.
136+
///
137+
/// Example with:
138+
/// - selectors: "style", "parent"
139+
/// - and modifiers: "value", "default"
140+
///
141+
/// Generates:
142+
/// - value-default-style, value-style, default-style, style
143+
/// - value-default-parent, value-parent, default-parent, parent
144+
fn compute_group_names(&self) -> Vec<GroupName> {
145+
let selectors = self.compute_selectors();
146+
let modifiers = self.compute_modifiers();
147+
148+
let mut group_names = vec![];
149+
150+
// For each selector, generate all modifier combinations
151+
for selector in &selectors {
152+
match selector {
153+
// For path selectors, combine with type/value modifier
154+
ImportSelector::Builtin
155+
| ImportSelector::External
156+
| ImportSelector::Internal
157+
| ImportSelector::Parent
158+
| ImportSelector::Sibling
159+
| ImportSelector::Index
160+
| ImportSelector::Subpath => {
161+
let modifier = if self.is_type_import {
162+
ImportModifier::Type
163+
} else {
164+
ImportModifier::Value
165+
};
166+
group_names.push(GroupName::with_modifier(*selector, modifier));
167+
}
168+
// For special selectors (side-effect, style, etc.), combine with all modifiers
169+
ImportSelector::SideEffectStyle
170+
| ImportSelector::SideEffect
171+
| ImportSelector::Style
172+
| ImportSelector::Import => {
173+
for modifier in &modifiers {
174+
group_names.push(GroupName::with_modifier(*selector, *modifier));
175+
}
176+
}
177+
_ => {}
178+
}
179+
group_names.push(GroupName::new(*selector));
180+
}
181+
182+
// Add final "import" catch-all with modifiers
183+
// This generates combinations like "side-effect-import", "type-import", "value-import", etc.
184+
for modifier in &modifiers {
185+
group_names.push(GroupName::with_modifier(ImportSelector::Import, *modifier));
186+
}
187+
group_names.push(GroupName::new(ImportSelector::Import));
188+
189+
group_names
190+
}
191+
192+
/// Compute all selectors for this import, ordered from most to least specific.
193+
///
194+
/// Order matches perfectionist implementation:
195+
/// 1. Special selectors (side-effect-style, side-effect, style) - most specific
196+
/// 2. Path-type selectors (parent-type, external-type, etc.) for type imports
197+
/// 3. Type selector
198+
/// 4. Path-based selectors (builtin, external, internal, parent, sibling, index)
199+
/// 5. Catch-all import selector
200+
fn compute_selectors(&self) -> Vec<ImportSelector> {
201+
let mut selectors = vec![];
202+
203+
// Most specific selectors first
204+
if self.is_side_effect && self.is_style_import {
205+
selectors.push(ImportSelector::SideEffectStyle);
206+
}
207+
if self.is_side_effect {
208+
selectors.push(ImportSelector::SideEffect);
209+
}
210+
if self.is_style_import {
211+
selectors.push(ImportSelector::Style);
212+
}
213+
214+
// For type imports, add path-type selectors (e.g., "parent-type", "external-type")
215+
// These come before the generic "type" selector
216+
if self.is_type_import {
217+
match self.path_kind {
218+
ImportPathKind::Index => selectors.push(ImportSelector::IndexType),
219+
ImportPathKind::Sibling => selectors.push(ImportSelector::SiblingType),
220+
ImportPathKind::Parent => selectors.push(ImportSelector::ParentType),
221+
ImportPathKind::Internal => selectors.push(ImportSelector::InternalType),
222+
ImportPathKind::Builtin => selectors.push(ImportSelector::BuiltinType),
223+
ImportPathKind::External => selectors.push(ImportSelector::ExternalType),
224+
ImportPathKind::Unknown => {}
225+
}
226+
// Type selector
227+
selectors.push(ImportSelector::Type);
228+
}
229+
230+
// Path-based selectors
231+
// Order matches perfectionist: index, sibling, parent, subpath, internal, builtin, external
232+
match self.path_kind {
233+
ImportPathKind::Index => selectors.push(ImportSelector::Index),
234+
ImportPathKind::Sibling => selectors.push(ImportSelector::Sibling),
235+
ImportPathKind::Parent => selectors.push(ImportSelector::Parent),
236+
_ => {}
237+
}
238+
239+
// Subpath selector (independent of path kind, comes after parent)
240+
if self.is_subpath {
241+
selectors.push(ImportSelector::Subpath);
242+
}
243+
244+
// Continue with remaining path-based selectors
245+
match self.path_kind {
246+
ImportPathKind::Internal => selectors.push(ImportSelector::Internal),
247+
ImportPathKind::Builtin => selectors.push(ImportSelector::Builtin),
248+
ImportPathKind::External => selectors.push(ImportSelector::External),
249+
_ => {}
250+
}
251+
252+
// Catch-all selector
253+
selectors.push(ImportSelector::Import);
254+
255+
selectors
256+
}
257+
258+
/// Compute all modifiers for this import.
259+
fn compute_modifiers(&self) -> Vec<ImportModifier> {
260+
let mut modifiers = vec![];
261+
262+
if self.is_side_effect {
263+
modifiers.push(ImportModifier::SideEffect);
264+
}
265+
if self.is_type_import {
266+
modifiers.push(ImportModifier::Type);
267+
} else {
268+
modifiers.push(ImportModifier::Value);
269+
}
270+
if self.has_default_specifier {
271+
modifiers.push(ImportModifier::Default);
272+
}
273+
if self.has_namespace_specifier {
274+
modifiers.push(ImportModifier::Wildcard);
275+
}
276+
if self.has_named_specifier {
277+
modifiers.push(ImportModifier::Named);
278+
}
279+
280+
modifiers
281+
}
282+
}
283+
284+
// ---
285+
286+
/// Extract the import source path.
287+
///
288+
/// This removes quotes and query parameters from the source string.
289+
/// For example, `"./foo.js?bar"` becomes `./foo.js`.
290+
fn extract_source_path(source: &str) -> &str {
291+
let source = source.trim_matches('"').trim_matches('\'');
292+
source.split('?').next().unwrap_or(source)
293+
}
294+
295+
// spellchecker:off
296+
static STYLE_EXTENSIONS: phf::Set<&'static str> = phf_set! {
297+
"css",
298+
"scss",
299+
"sass",
300+
"less",
301+
"styl",
302+
"pcss",
303+
"sss",
304+
};
305+
// spellchecker:on
306+
307+
/// Check if an import source is a style file based on its extension.
308+
fn is_style(source: &str) -> bool {
309+
Path::new(source)
310+
.extension()
311+
.and_then(|ext| ext.to_str())
312+
.is_some_and(|ext| STYLE_EXTENSIONS.contains(ext))
313+
}
314+
315+
static NODE_BUILTINS: phf::Set<&'static str> = phf_set! {
316+
"assert", "async_hooks", "buffer", "child_process", "cluster", "console",
317+
"constants", "crypto", "dgram", "diagnostics_channel", "dns", "domain",
318+
"events", "fs", "http", "http2", "https", "inspector", "module", "net",
319+
"os", "path", "perf_hooks", "process", "punycode", "querystring",
320+
"readline", "repl", "stream", "string_decoder", "sys", "timers", "tls",
321+
"trace_events", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads",
322+
"zlib",
323+
};
324+
325+
/// Check if an import source is a Node.js or Bun builtin module.
326+
fn is_builtin(source: &str) -> bool {
327+
source.starts_with("node:") || source.starts_with("bun:") || NODE_BUILTINS.contains(source)
328+
}
329+
330+
#[derive(Debug, PartialEq, Eq, Default)]
331+
enum ImportPathKind {
332+
/// Node.js builtin module (e.g., `node:fs`, `fs`)
333+
Builtin,
334+
/// External package from node_modules (e.g., `react`, `lodash`)
335+
External,
336+
/// Internal module matching internal patterns (e.g., `~/...`, `@/...`)
337+
Internal,
338+
/// Parent directory relative import (e.g., `../foo`)
339+
Parent,
340+
/// Sibling directory relative import (e.g., `./foo`)
341+
Sibling,
342+
/// Index file import (e.g., `./`, `../`)
343+
Index,
344+
/// Unknown or unclassified
345+
#[default]
346+
Unknown,
347+
}
348+
349+
/// Determine the path kind for an import source.
350+
fn to_path_kind(source: &str) -> ImportPathKind {
351+
if is_builtin(source) {
352+
return ImportPathKind::Builtin;
353+
}
354+
355+
if source.starts_with('.') {
356+
if matches!(
357+
source,
358+
"." | "./" | "./index" | "./index.js" | "./index.ts" | "./index.d.ts" | "./index.d.js"
359+
) {
360+
return ImportPathKind::Index;
361+
}
362+
if source.starts_with("../") {
363+
return ImportPathKind::Parent;
364+
}
365+
return ImportPathKind::Sibling;
366+
}
367+
368+
// TODO: This can be changed via `options.internalPattern`
369+
if source.starts_with('~') || source.starts_with('@') {
370+
return ImportPathKind::Internal;
371+
}
372+
373+
// Subpath imports (e.g., `#foo`) are also considered external
374+
ImportPathKind::External
375+
}
376+
377+
/// Check if an import source is a subpath import (starts with '#').
378+
fn is_subpath(source: &str) -> bool {
379+
source.starts_with('#')
380+
}

0 commit comments

Comments
 (0)