Skip to content

Commit d1c2fb6

Browse files
committed
feat(formatter/sort_imports): Support customGroups attributes(selector and modifiers) (#19356)
Fixes #19264 The original issue only mentioned `modifiers`, but I've implemented `selector` as well. While the original implementation also supports more attributes, I believe this is sufficient for now.
1 parent be0ce50 commit d1c2fb6

File tree

14 files changed

+681
-31
lines changed

14 files changed

+681
-31
lines changed

apps/oxfmt/src-js/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,12 @@ export type SortImportsOptions = {
127127
*/
128128
groups?: (string | string[])[];
129129
/** Define custom groups for matching specific imports. */
130-
customGroups?: { groupName: string; elementNamePattern: string[] }[];
130+
customGroups?: {
131+
groupName: string;
132+
elementNamePattern?: string[];
133+
selector?: string;
134+
modifiers?: string[];
135+
}[];
131136
};
132137

133138
/**

apps/oxfmt/src/core/oxfmtrc.rs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ use serde_json::Value;
66

77
use oxc_formatter::{
88
ArrowParentheses, AttributePosition, BracketSameLine, BracketSpacing, CustomGroupDefinition,
9-
EmbeddedLanguageFormatting, Expand, FormatOptions, IndentStyle, IndentWidth, LineEnding,
10-
LineWidth, QuoteProperties, QuoteStyle, Semicolons, SortImportsOptions, SortOrder,
11-
TailwindcssOptions, TrailingCommas,
9+
EmbeddedLanguageFormatting, Expand, FormatOptions, ImportModifier, ImportSelector, IndentStyle,
10+
IndentWidth, LineEnding, LineWidth, QuoteProperties, QuoteStyle, Semicolons,
11+
SortImportsOptions, SortOrder, TailwindcssOptions, TrailingCommas,
1212
};
1313
use oxc_toml::Options as TomlFormatterOptions;
1414

@@ -428,6 +428,13 @@ impl FormatConfig {
428428
.map(|c| CustomGroupDefinition {
429429
group_name: c.group_name,
430430
element_name_pattern: c.element_name_pattern,
431+
selector: c.selector.as_deref().and_then(ImportSelector::parse),
432+
modifiers: c
433+
.modifiers
434+
.unwrap_or_default()
435+
.iter()
436+
.filter_map(|s| ImportModifier::parse(s))
437+
.collect(),
431438
})
432439
.collect();
433440
}
@@ -650,10 +657,6 @@ pub struct SortImportsConfig {
650657
/// - `default` — Imports containing the default specifier.
651658
/// - `wildcard` — Imports containing the wildcard (`* as`) specifier.
652659
/// - `named` — Imports containing at least one named specifier.
653-
/// - `multiline` — Imports on multiple lines.
654-
/// - `singleline` — Imports on a single line.
655-
///
656-
/// See also <https://perfectionist.dev/rules/sort-imports#groups> for details.
657660
///
658661
/// - Default: See below
659662
/// ```json
@@ -677,6 +680,9 @@ pub struct SortImportsConfig {
677680
/// If you want a predefined group to take precedence over a custom group,
678681
/// you must write a custom group definition that does the same as what the predefined group does, and put it first in the list.
679682
///
683+
/// If you specify multiple conditions like `elementNamePattern`, `selector`, and `modifiers`,
684+
/// all conditions must be met for an import to match the custom group (AND logic).
685+
///
680686
/// - Default: `[]`
681687
#[serde(skip_serializing_if = "Option::is_none")]
682688
pub custom_groups: Option<Vec<CustomGroupItemConfig>>,
@@ -712,6 +718,18 @@ pub struct CustomGroupItemConfig {
712718
pub group_name: String,
713719
/// List of glob patterns to match import sources for this group.
714720
pub element_name_pattern: Vec<String>,
721+
/// Selector to match the import kind.
722+
///
723+
/// Possible values: `"type"`, `"side-effect-style"`, `"side-effect"`, `"style"`, `"index"`,
724+
/// `"sibling"`, `"parent"`, `"subpath"`, `"internal"`, `"builtin"`, `"external"`, `"import"`
725+
#[serde(skip_serializing_if = "Option::is_none")]
726+
pub selector: Option<String>,
727+
/// Modifiers to match the import characteristics.
728+
/// All specified modifiers must be present (AND logic).
729+
///
730+
/// Possible values: `"side-effect"`, `"type"`, `"value"`, `"default"`, `"wildcard"`, `"named"`
731+
#[serde(skip_serializing_if = "Option::is_none")]
732+
pub modifiers: Option<Vec<String>>,
715733
}
716734

717735
// ---

apps/oxfmt/test/api/sort_imports.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,42 @@ import { store } from "~/stores/store";
2323
import { store } from "~/stores/store";
2424
import { util } from "~/utils/util";
2525
import { foo } from "./foo";
26+
`.trimStart(),
27+
);
28+
expect(result.errors).toStrictEqual([]);
29+
});
30+
31+
it("should sort with customGroups using selector and modifiers", async () => {
32+
const input = `import { bar } from "@scope/bar";
33+
import type { FooType } from "@scope/foo";
34+
import { foo } from "@scope/foo";
35+
import type { BarType } from "@scope/bar";
36+
`;
37+
const result = await format("a.ts", input, {
38+
experimentalSortImports: {
39+
customGroups: [
40+
{
41+
groupName: "scope-types",
42+
elementNamePattern: ["@scope/**"],
43+
modifiers: ["type"],
44+
},
45+
{
46+
groupName: "scope-values",
47+
elementNamePattern: ["@scope/**"],
48+
modifiers: ["value"],
49+
},
50+
],
51+
groups: ["scope-types", "scope-values", "unknown"],
52+
},
53+
});
54+
55+
expect(result.code).toBe(
56+
`
57+
import type { BarType } from "@scope/bar";
58+
import type { FooType } from "@scope/foo";
59+
60+
import { bar } from "@scope/bar";
61+
import { foo } from "@scope/foo";
2662
`.trimStart(),
2763
);
2864
expect(result.errors).toStrictEqual([]);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"experimentalSortImports": {
3+
"customGroups": [
4+
{
5+
"groupName": "scope-types",
6+
"elementNamePattern": ["@scope/**"],
7+
"modifiers": ["type"]
8+
},
9+
{
10+
"groupName": "scope-values",
11+
"elementNamePattern": ["@scope/**"],
12+
"modifiers": ["value"]
13+
},
14+
{
15+
"groupName": "externals",
16+
"selector": "external"
17+
}
18+
],
19+
"groups": ["scope-types", "scope-values", "externals", "unknown"]
20+
}
21+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { BarType } from "@scope/bar";
2+
import type { FooType } from "@scope/foo";
3+
4+
import { bar } from "@scope/bar";
5+
import { foo } from "@scope/foo";
6+
7+
import { ext } from "external-lib";
8+
9+
import { sibling } from "./sibling";

apps/oxfmt/test/cli/sort_imports/sort_imports.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,11 @@ describe("sort_imports", () => {
1111

1212
expect(result.exitCode).toBe(0);
1313
});
14+
15+
it("should sort imports with customGroups using selector and modifiers", async () => {
16+
const cwd = join(fixturesDir, "custom_groups_selector_modifiers");
17+
const result = await runCli(cwd, ["--check", "input.ts"]);
18+
19+
expect(result.exitCode).toBe(0);
20+
});
1421
});

crates/oxc_formatter/src/ir_transform/sort_imports/group_config.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,19 @@ impl ImportModifier {
197197
ImportModifier::Named,
198198
];
199199

200+
/// Parse a string into an ImportModifier.
201+
pub fn parse(s: &str) -> Option<Self> {
202+
match s {
203+
"side-effect" => Some(Self::SideEffect),
204+
"type" => Some(Self::Type),
205+
"value" => Some(Self::Value),
206+
"default" => Some(Self::Default),
207+
"wildcard" => Some(Self::Wildcard),
208+
"named" => Some(Self::Named),
209+
_ => None,
210+
}
211+
}
212+
200213
pub fn name(&self) -> &str {
201214
match self {
202215
ImportModifier::SideEffect => "side-effect",

crates/oxc_formatter/src/ir_transform/sort_imports/group_matcher.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,9 @@ pub struct ImportMetadata<'a> {
1313
pub struct GroupMatcher {
1414
// Custom groups that are used in `options.groups`
1515
custom_groups: Vec<(CustomGroupDefinition, usize)>,
16-
1716
// Predefined groups sorted by priority,
1817
// so that we don't need to enumerate all possible group names of a given import.
1918
predefined_groups: Vec<(GroupName, usize)>,
20-
2119
// The index of "unknown" in groups or `groups.len()` if absent
2220
unknown_group_index: usize,
2321
}
@@ -62,10 +60,21 @@ impl GroupMatcher {
6260

6361
pub fn compute_group_index(&self, import_metadata: &ImportMetadata) -> usize {
6462
for (custom_group, index) in &self.custom_groups {
65-
let is_match = custom_group
66-
.element_name_pattern
67-
.iter()
68-
.any(|pattern| fast_glob::glob_match(pattern, import_metadata.source));
63+
let is_match = {
64+
let name_matches = custom_group.element_name_pattern.is_empty()
65+
|| custom_group
66+
.element_name_pattern
67+
.iter()
68+
.any(|pattern| fast_glob::glob_match(pattern, import_metadata.source));
69+
let selector_matches =
70+
custom_group.selector.is_none_or(|s| import_metadata.selectors.contains(&s));
71+
let modifiers_match =
72+
custom_group.modifiers.iter().all(|m| import_metadata.modifiers.contains(m));
73+
74+
// These are AND logic
75+
name_matches && selector_matches && modifiers_match
76+
};
77+
6978
if is_match {
7079
return *index;
7180
}

crates/oxc_formatter/src/ir_transform/sort_imports/options.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use std::fmt;
22
use std::str::FromStr;
33

4+
pub use super::group_config::{ImportModifier, ImportSelector};
5+
46
#[derive(Clone, Debug, Eq, PartialEq)]
57
pub struct SortImportsOptions {
68
/// Partition imports by newlines.
@@ -101,6 +103,10 @@ pub struct CustomGroupDefinition {
101103
pub group_name: String,
102104
/// List of glob patterns to match import sources for this group.
103105
pub element_name_pattern: Vec<String>,
106+
/// When specified, the import's selectors must contain this selector.
107+
pub selector: Option<ImportSelector>,
108+
/// When specified, **all** modifiers must be present in the import's modifiers (AND logic).
109+
pub modifiers: Vec<ImportModifier>,
104110
}
105111

106112
/// Returns default prefixes for identifying internal imports: `["~/", "@/"]`.

crates/oxc_formatter/tests/ir_transform/mod.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
mod sort_imports;
22

33
use oxc_formatter::{
4-
CustomGroupDefinition, FormatOptions, QuoteStyle, Semicolons, SortImportsOptions, SortOrder,
4+
CustomGroupDefinition, FormatOptions, ImportModifier, ImportSelector, QuoteStyle, Semicolons,
5+
SortImportsOptions, SortOrder,
56
};
67
use serde::Deserialize;
78

@@ -60,7 +61,10 @@ struct TestConfig {
6061
#[serde(rename_all = "camelCase")]
6162
struct TestCustomGroupDefinition {
6263
group_name: String,
64+
#[serde(default)]
6365
element_name_pattern: Vec<String>,
66+
selector: Option<String>,
67+
modifiers: Option<Vec<String>>,
6468
}
6569

6670
#[derive(Debug, Default, Deserialize)]
@@ -158,6 +162,13 @@ fn parse_test_config(json: &str) -> FormatOptions {
158162
.map(|value| CustomGroupDefinition {
159163
group_name: value.group_name,
160164
element_name_pattern: value.element_name_pattern,
165+
selector: value.selector.as_deref().and_then(ImportSelector::parse),
166+
modifiers: value
167+
.modifiers
168+
.unwrap_or_default()
169+
.iter()
170+
.filter_map(|s| ImportModifier::parse(s))
171+
.collect(),
161172
})
162173
.collect();
163174
}

0 commit comments

Comments
 (0)