Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/oxfmt/src-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,12 @@ export type SortImportsOptions = {
*/
groups?: (string | string[])[];
/** Define custom groups for matching specific imports. */
customGroups?: { groupName: string; elementNamePattern: string[] }[];
customGroups?: {
groupName: string;
elementNamePattern?: string[];
selector?: string;
modifiers?: string[];
}[];
};

/**
Expand Down
32 changes: 25 additions & 7 deletions apps/oxfmt/src/core/oxfmtrc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ use serde_json::Value;

use oxc_formatter::{
ArrowParentheses, AttributePosition, BracketSameLine, BracketSpacing, CustomGroupDefinition,
EmbeddedLanguageFormatting, Expand, FormatOptions, IndentStyle, IndentWidth, LineEnding,
LineWidth, QuoteProperties, QuoteStyle, Semicolons, SortImportsOptions, SortOrder,
TailwindcssOptions, TrailingCommas,
EmbeddedLanguageFormatting, Expand, FormatOptions, ImportModifier, ImportSelector, IndentStyle,
IndentWidth, LineEnding, LineWidth, QuoteProperties, QuoteStyle, Semicolons,
SortImportsOptions, SortOrder, TailwindcssOptions, TrailingCommas,
};
use oxc_toml::Options as TomlFormatterOptions;

Expand Down Expand Up @@ -428,6 +428,13 @@ impl FormatConfig {
.map(|c| CustomGroupDefinition {
group_name: c.group_name,
element_name_pattern: c.element_name_pattern,
selector: c.selector.as_deref().and_then(ImportSelector::parse),
modifiers: c
.modifiers
.unwrap_or_default()
.iter()
.filter_map(|s| ImportModifier::parse(s))
.collect(),
})
.collect();
}
Expand Down Expand Up @@ -650,10 +657,6 @@ pub struct SortImportsConfig {
/// - `default` — Imports containing the default specifier.
/// - `wildcard` — Imports containing the wildcard (`* as`) specifier.
/// - `named` — Imports containing at least one named specifier.
/// - `multiline` — Imports on multiple lines.
/// - `singleline` — Imports on a single line.
///
/// See also <https://perfectionist.dev/rules/sort-imports#groups> for details.
///
/// - Default: See below
/// ```json
Expand All @@ -677,6 +680,9 @@ pub struct SortImportsConfig {
/// If you want a predefined group to take precedence over a custom group,
/// you must write a custom group definition that does the same as what the predefined group does, and put it first in the list.
///
/// If you specify multiple conditions like `elementNamePattern`, `selector`, and `modifiers`,
/// all conditions must be met for an import to match the custom group (AND logic).
///
/// - Default: `[]`
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_groups: Option<Vec<CustomGroupItemConfig>>,
Expand Down Expand Up @@ -712,6 +718,18 @@ pub struct CustomGroupItemConfig {
pub group_name: String,
/// List of glob patterns to match import sources for this group.
pub element_name_pattern: Vec<String>,
/// Selector to match the import kind.
///
/// Possible values: `"type"`, `"side-effect-style"`, `"side-effect"`, `"style"`, `"index"`,
/// `"sibling"`, `"parent"`, `"subpath"`, `"internal"`, `"builtin"`, `"external"`, `"import"`
#[serde(skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
/// Modifiers to match the import characteristics.
/// All specified modifiers must be present (AND logic).
///
/// Possible values: `"side-effect"`, `"type"`, `"value"`, `"default"`, `"wildcard"`, `"named"`
#[serde(skip_serializing_if = "Option::is_none")]
pub modifiers: Option<Vec<String>>,
}

// ---
Expand Down
36 changes: 36 additions & 0 deletions apps/oxfmt/test/api/sort_imports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,42 @@ import { store } from "~/stores/store";
import { store } from "~/stores/store";
import { util } from "~/utils/util";
import { foo } from "./foo";
`.trimStart(),
);
expect(result.errors).toStrictEqual([]);
});

it("should sort with customGroups using selector and modifiers", async () => {
const input = `import { bar } from "@scope/bar";
import type { FooType } from "@scope/foo";
import { foo } from "@scope/foo";
import type { BarType } from "@scope/bar";
`;
const result = await format("a.ts", input, {
experimentalSortImports: {
customGroups: [
{
groupName: "scope-types",
elementNamePattern: ["@scope/**"],
modifiers: ["type"],
},
{
groupName: "scope-values",
elementNamePattern: ["@scope/**"],
modifiers: ["value"],
},
],
groups: ["scope-types", "scope-values", "unknown"],
},
});

expect(result.code).toBe(
`
import type { BarType } from "@scope/bar";
import type { FooType } from "@scope/foo";

import { bar } from "@scope/bar";
import { foo } from "@scope/foo";
`.trimStart(),
);
expect(result.errors).toStrictEqual([]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"experimentalSortImports": {
"customGroups": [
{
"groupName": "scope-types",
"elementNamePattern": ["@scope/**"],
"modifiers": ["type"]
},
{
"groupName": "scope-values",
"elementNamePattern": ["@scope/**"],
"modifiers": ["value"]
},
{
"groupName": "externals",
"selector": "external"
}
],
"groups": ["scope-types", "scope-values", "externals", "unknown"]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { BarType } from "@scope/bar";
import type { FooType } from "@scope/foo";

import { bar } from "@scope/bar";
import { foo } from "@scope/foo";

import { ext } from "external-lib";

import { sibling } from "./sibling";
7 changes: 7 additions & 0 deletions apps/oxfmt/test/cli/sort_imports/sort_imports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,11 @@ describe("sort_imports", () => {

expect(result.exitCode).toBe(0);
});

it("should sort imports with customGroups using selector and modifiers", async () => {
const cwd = join(fixturesDir, "custom_groups_selector_modifiers");
const result = await runCli(cwd, ["--check", "input.ts"]);

expect(result.exitCode).toBe(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,19 @@ impl ImportModifier {
ImportModifier::Named,
];

/// Parse a string into an ImportModifier.
pub fn parse(s: &str) -> Option<Self> {
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,
}
}

pub fn name(&self) -> &str {
match self {
ImportModifier::SideEffect => "side-effect",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@ pub struct ImportMetadata<'a> {
pub struct GroupMatcher {
// Custom groups that are used in `options.groups`
custom_groups: Vec<(CustomGroupDefinition, usize)>,

// Predefined groups sorted by priority,
// so that we don't need to enumerate all possible group names of a given import.
predefined_groups: Vec<(GroupName, usize)>,

// The index of "unknown" in groups or `groups.len()` if absent
unknown_group_index: usize,
}
Expand Down Expand Up @@ -62,10 +60,21 @@ impl GroupMatcher {

pub fn compute_group_index(&self, import_metadata: &ImportMetadata) -> usize {
for (custom_group, index) in &self.custom_groups {
let is_match = custom_group
.element_name_pattern
.iter()
.any(|pattern| fast_glob::glob_match(pattern, import_metadata.source));
let is_match = {
let name_matches = custom_group.element_name_pattern.is_empty()
|| custom_group
.element_name_pattern
.iter()
.any(|pattern| fast_glob::glob_match(pattern, import_metadata.source));
let selector_matches =
custom_group.selector.is_none_or(|s| import_metadata.selectors.contains(&s));
let modifiers_match =
custom_group.modifiers.iter().all(|m| import_metadata.modifiers.contains(m));

// These are AND logic
name_matches && selector_matches && modifiers_match
};

if is_match {
return *index;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use std::fmt;
use std::str::FromStr;

pub use super::group_config::{ImportModifier, ImportSelector};

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SortImportsOptions {
/// Partition imports by newlines.
Expand Down Expand Up @@ -101,6 +103,10 @@ pub struct CustomGroupDefinition {
pub group_name: String,
/// List of glob patterns to match import sources for this group.
pub element_name_pattern: Vec<String>,
/// When specified, the import's selectors must contain this selector.
pub selector: Option<ImportSelector>,
/// When specified, **all** modifiers must be present in the import's modifiers (AND logic).
pub modifiers: Vec<ImportModifier>,
}

/// Returns default prefixes for identifying internal imports: `["~/", "@/"]`.
Expand Down
13 changes: 12 additions & 1 deletion crates/oxc_formatter/tests/ir_transform/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
mod sort_imports;

use oxc_formatter::{
CustomGroupDefinition, FormatOptions, QuoteStyle, Semicolons, SortImportsOptions, SortOrder,
CustomGroupDefinition, FormatOptions, ImportModifier, ImportSelector, QuoteStyle, Semicolons,
SortImportsOptions, SortOrder,
};
use serde::Deserialize;

Expand Down Expand Up @@ -60,7 +61,10 @@ struct TestConfig {
#[serde(rename_all = "camelCase")]
struct TestCustomGroupDefinition {
group_name: String,
#[serde(default)]
element_name_pattern: Vec<String>,
selector: Option<String>,
modifiers: Option<Vec<String>>,
}

#[derive(Debug, Default, Deserialize)]
Expand Down Expand Up @@ -158,6 +162,13 @@ fn parse_test_config(json: &str) -> FormatOptions {
.map(|value| CustomGroupDefinition {
group_name: value.group_name,
element_name_pattern: value.element_name_pattern,
selector: value.selector.as_deref().and_then(ImportSelector::parse),
modifiers: value
.modifiers
.unwrap_or_default()
.iter()
.filter_map(|s| ImportModifier::parse(s))
.collect(),
})
.collect();
}
Expand Down
Loading
Loading