Skip to content

Commit 9c34f72

Browse files
committed
feat(formatter/sort_imports)!: Report invalid group name with renaming side-effect > side_effect (#19416)
- Breaking: Rename ambiguous modifier/selector names - `side-effect` → `side_effect` - `side-effect-style` → `side_effect_style` - Now `-` is used only for joiner (`modifier-modifier-selector`) - before: `value-side-effect-style` 🆚 after: `value-side_effect_style` - Breaking: Add config-time validation for invalid group names - previously silently ignored... - or wrongly applied (and user didn't notice)
1 parent 8837fb5 commit 9c34f72

File tree

11 files changed

+230
-138
lines changed

11 files changed

+230
-138
lines changed

apps/oxfmt/src/core/oxfmtrc.rs

Lines changed: 91 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
use std::path::Path;
22

3+
use rustc_hash::FxHashSet;
34
use schemars::JsonSchema;
45
use serde::{Deserialize, Serialize};
56
use serde_json::Value;
67

78
use oxc_formatter::{
89
ArrowParentheses, AttributePosition, BracketSameLine, BracketSpacing, CustomGroupDefinition,
9-
EmbeddedLanguageFormatting, Expand, FormatOptions, ImportModifier, ImportSelector, IndentStyle,
10-
IndentWidth, LineEnding, LineWidth, QuoteProperties, QuoteStyle, Semicolons,
11-
SortImportsOptions, SortOrder, TailwindcssOptions, TrailingCommas,
10+
EmbeddedLanguageFormatting, Expand, FormatOptions, GroupEntry, GroupName, ImportModifier,
11+
ImportSelector, IndentStyle, IndentWidth, LineEnding, LineWidth, QuoteProperties, QuoteStyle,
12+
Semicolons, SortImportsOptions, SortOrder, TailwindcssOptions, TrailingCommas,
1213
};
1314
use oxc_toml::Options as TomlFormatterOptions;
1415

@@ -419,7 +420,46 @@ impl FormatConfig {
419420
if let Some(v) = config.internal_pattern {
420421
sort_imports.internal_pattern = v;
421422
}
423+
// Validate and parse `customGroups` first, since `groups` may refer to custom group names.
424+
if let Some(v) = config.custom_groups {
425+
let mut custom_groups = Vec::with_capacity(v.len());
426+
for cg in v {
427+
let CustomGroupItemConfig { group_name, element_name_pattern, .. } = cg;
428+
let selector = match cg.selector.as_deref() {
429+
Some(s) => match ImportSelector::parse(s) {
430+
Some(parsed) => Some(parsed),
431+
None => {
432+
return Err(format!(
433+
"Invalid `sortImports` configuration: unknown selector: `{s}` in customGroups: `{group_name}`"
434+
));
435+
}
436+
},
437+
None => None,
438+
};
439+
let raw_modifiers = cg.modifiers.unwrap_or_default();
440+
let mut modifiers = Vec::with_capacity(raw_modifiers.len());
441+
for m in &raw_modifiers {
442+
match ImportModifier::parse(m) {
443+
Some(parsed) => modifiers.push(parsed),
444+
None => {
445+
return Err(format!(
446+
"Invalid `sortImports` configuration: unknown modifier: `{m}` in customGroups: `{group_name}`"
447+
));
448+
}
449+
}
450+
}
451+
custom_groups.push(CustomGroupDefinition {
452+
group_name,
453+
element_name_pattern,
454+
selector,
455+
modifiers,
456+
});
457+
}
458+
sort_imports.custom_groups = custom_groups;
459+
}
422460
if let Some(v) = config.groups {
461+
let custom_group_names: FxHashSet<&str> =
462+
sort_imports.custom_groups.iter().map(|g| g.group_name.as_str()).collect();
423463
let mut groups = Vec::new();
424464
let mut newline_boundary_overrides: Vec<Option<bool>> = Vec::new();
425465
let mut pending_override: Option<bool> = None;
@@ -437,15 +477,24 @@ impl FormatConfig {
437477
}
438478
other => {
439479
if !groups.is_empty() {
440-
// Record the boundary between the previous group and this one.
441-
// `pending_override` is
442-
// - `Some(bool)` if a marker preceded this group
443-
// - or `None` (= use global `newlines_between`) otherwise
444-
// For the very first group (`groups.is_empty()`),
445-
// there is no preceding boundary, so we skip this entirely.
446480
newline_boundary_overrides.push(pending_override.take());
447481
}
448-
groups.push(other.into_vec());
482+
let mut entries = Vec::new();
483+
for name in other.into_vec() {
484+
let entry = if name == "unknown" {
485+
GroupEntry::Unknown
486+
} else if custom_group_names.contains(name.as_str()) {
487+
GroupEntry::Custom(name)
488+
} else if let Some(group_name) = GroupName::parse(&name) {
489+
GroupEntry::Predefined(group_name)
490+
} else {
491+
return Err(format!(
492+
"Invalid `sortImports` configuration: unknown group name `{name}` in `groups`"
493+
));
494+
};
495+
entries.push(entry);
496+
}
497+
groups.push(entries);
449498
}
450499
}
451500
}
@@ -463,22 +512,6 @@ impl FormatConfig {
463512
{
464513
return Err("Invalid `sortImports` configuration: `partitionByNewline` and per-group `{ \"newlinesBetween\" }` markers cannot be used together".to_string());
465514
}
466-
if let Some(v) = config.custom_groups {
467-
sort_imports.custom_groups = v
468-
.into_iter()
469-
.map(|c| CustomGroupDefinition {
470-
group_name: c.group_name,
471-
element_name_pattern: c.element_name_pattern,
472-
selector: c.selector.as_deref().and_then(ImportSelector::parse),
473-
modifiers: c
474-
.modifiers
475-
.unwrap_or_default()
476-
.iter()
477-
.filter_map(|s| ImportModifier::parse(s))
478-
.collect(),
479-
})
480-
.collect();
481-
}
482515

483516
// `partition_by_newline: true` and `newlines_between: true` cannot be used together
484517
if sort_imports.partition_by_newline && sort_imports.newlines_between {
@@ -679,8 +712,8 @@ pub struct SortImportsConfig {
679712
///
680713
/// The list of selectors is sorted from most to least important:
681714
/// - `type` — TypeScript type imports.
682-
/// - `side-effect-style` — Side effect style imports.
683-
/// - `side-effect` — Side effect imports.
715+
/// - `side_effect_style` — Side effect style imports.
716+
/// - `side_effect` — Side effect imports.
684717
/// - `style` — Style imports.
685718
/// - `index` — Main file from the current directory.
686719
/// - `sibling` — Modules from the same directory.
@@ -692,7 +725,7 @@ pub struct SortImportsConfig {
692725
/// - `import` — Any import.
693726
///
694727
/// The list of modifiers is sorted from most to least important:
695-
/// - `side-effect` — Side effect imports.
728+
/// - `side_effect` — Side effect imports.
696729
/// - `type` — TypeScript type imports.
697730
/// - `value` — Value imports.
698731
/// - `default` — Imports containing the default specifier.
@@ -776,14 +809,14 @@ pub struct CustomGroupItemConfig {
776809
pub element_name_pattern: Vec<String>,
777810
/// Selector to match the import kind.
778811
///
779-
/// Possible values: `"type"`, `"side-effect-style"`, `"side-effect"`, `"style"`, `"index"`,
812+
/// Possible values: `"type"`, `"side_effect_style"`, `"side_effect"`, `"style"`, `"index"`,
780813
/// `"sibling"`, `"parent"`, `"subpath"`, `"internal"`, `"builtin"`, `"external"`, `"import"`
781814
#[serde(skip_serializing_if = "Option::is_none")]
782815
pub selector: Option<String>,
783816
/// Modifiers to match the import characteristics.
784817
/// All specified modifiers must be present (AND logic).
785818
///
786-
/// Possible values: `"side-effect"`, `"type"`, `"value"`, `"default"`, `"wildcard"`, `"named"`
819+
/// Possible values: `"side_effect"`, `"type"`, `"value"`, `"default"`, `"wildcard"`, `"named"`
787820
#[serde(skip_serializing_if = "Option::is_none")]
788821
pub modifiers: Option<Vec<String>>,
789822
}
@@ -1259,9 +1292,21 @@ mod tests {
12591292
let oxfmt_options = config.into_oxfmt_options().unwrap();
12601293
let sort_imports = oxfmt_options.format_options.experimental_sort_imports.unwrap();
12611294
assert_eq!(sort_imports.groups.len(), 5);
1262-
assert_eq!(sort_imports.groups[0], vec!["builtin".to_string()]);
1263-
assert_eq!(sort_imports.groups[1], vec!["external".to_string(), "internal".to_string()]);
1264-
assert_eq!(sort_imports.groups[4], vec!["index".to_string()]);
1295+
assert_eq!(
1296+
sort_imports.groups[0],
1297+
vec![GroupEntry::Predefined(GroupName::parse("builtin").unwrap())]
1298+
);
1299+
assert_eq!(
1300+
sort_imports.groups[1],
1301+
vec![
1302+
GroupEntry::Predefined(GroupName::parse("external").unwrap()),
1303+
GroupEntry::Predefined(GroupName::parse("internal").unwrap())
1304+
]
1305+
);
1306+
assert_eq!(
1307+
sort_imports.groups[4],
1308+
vec![GroupEntry::Predefined(GroupName::parse("index").unwrap())]
1309+
);
12651310

12661311
// Test groups with newlinesBetween overrides
12671312
let config: FormatConfig = serde_json::from_str(
@@ -1280,9 +1325,18 @@ mod tests {
12801325
let oxfmt_options = config.into_oxfmt_options().unwrap();
12811326
let sort_imports = oxfmt_options.format_options.experimental_sort_imports.unwrap();
12821327
assert_eq!(sort_imports.groups.len(), 3);
1283-
assert_eq!(sort_imports.groups[0], vec!["builtin".to_string()]);
1284-
assert_eq!(sort_imports.groups[1], vec!["external".to_string()]);
1285-
assert_eq!(sort_imports.groups[2], vec!["parent".to_string()]);
1328+
assert_eq!(
1329+
sort_imports.groups[0],
1330+
vec![GroupEntry::Predefined(GroupName::parse("builtin").unwrap())]
1331+
);
1332+
assert_eq!(
1333+
sort_imports.groups[1],
1334+
vec![GroupEntry::Predefined(GroupName::parse("external").unwrap())]
1335+
);
1336+
assert_eq!(
1337+
sort_imports.groups[2],
1338+
vec![GroupEntry::Predefined(GroupName::parse("parent").unwrap())]
1339+
);
12861340
assert_eq!(sort_imports.newline_boundary_overrides.len(), 2);
12871341
assert_eq!(sort_imports.newline_boundary_overrides[0], Some(false));
12881342
assert_eq!(sort_imports.newline_boundary_overrides[1], None);

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

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
use std::cmp::Ordering;
22

3+
/// A parsed entry in a group configuration.
4+
#[derive(Debug, Clone, PartialEq, Eq)]
5+
pub enum GroupEntry {
6+
/// A predefined group name (e.g. "type-external", "value-builtin").
7+
Predefined(GroupName),
8+
/// The special "unknown" catch-all group.
9+
Unknown,
10+
/// A reference to a user-defined custom group by name.
11+
Custom(String),
12+
}
13+
314
/// Represents a group name pattern for matching imports.
415
/// A group name consists of 1 selector and N modifiers.
516
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -17,38 +28,34 @@ impl GroupName {
1728
/// Parse a group name string into a GroupName.
1829
///
1930
/// Format: `(modifier-)*selector`
31+
///
32+
/// Since no selector or modifier name contains `-`,
33+
/// we can simply split by `-`: the last element is the selector,
34+
/// and all preceding elements are modifiers.
35+
///
2036
/// Examples:
2137
/// - "external" -> modifiers: (empty), selector: External
2238
/// - "type-external" -> modifiers: Type, selector: External
2339
/// - "value-builtin" -> modifiers: Value, selector: Builtin
24-
/// - "side-effect-import" -> modifiers: SideEffect, selector: Import
25-
/// - "side-effect-type-external" -> modifiers: SideEffect, Type, selector: External
26-
/// - "named-side-effect-type-builtin" -> modifiers: SideEffect, Type, Named, selector: Builtin
40+
/// - "side_effect-import" -> modifiers: SideEffect, selector: Import
41+
/// - "side_effect-type-external" -> modifiers: SideEffect, Type, selector: External
2742
pub fn parse(s: &str) -> Option<Self> {
28-
// Try to parse as a selector without modifiers first
29-
if let Some(selector) = ImportSelector::parse(s) {
43+
let parts: Vec<&str> = s.split('-').collect();
44+
let selector = ImportSelector::parse(parts.last()?)?;
45+
46+
if parts.len() == 1 {
3047
return Some(Self { modifiers: vec![], selector });
3148
}
3249

33-
// Last part should be the selector
34-
let selector =
35-
*ImportSelector::ALL_SELECTORS.iter().find(|selector| s.ends_with(selector.name()))?;
36-
37-
// The remaining part represents a sequence of modifiers joined by "-".
38-
// Since modifiers themselves may contain "-", splitting by "-" would be ambiguous.
39-
// Instead, we iterate over modifiers in a predefined order and check
40-
// whether they appear in the remaining string.
41-
// This guarantees the extracted modifiers are already ordered
42-
// and no additional sorting is required.
43-
//
44-
// The trade-off is that this approach may tolerate invalid input,
45-
// as unmatched or malformed segments are not strictly rejected.
46-
let mut modifiers = Vec::with_capacity(ImportModifier::ALL_MODIFIERS.len());
47-
for m in ImportModifier::ALL_MODIFIERS {
48-
if s.contains(m.name()) {
49-
modifiers.push(*m);
50-
}
50+
let mut modifiers = Vec::with_capacity(parts.len() - 1);
51+
for part in &parts[..parts.len() - 1] {
52+
modifiers.push(ImportModifier::parse(part)?);
5153
}
54+
// Normalize modifier order so that
55+
// "type-value-external" and "value-type-external" are treated as the same.
56+
// Also deduplicate in case the user wrote "type-type-external".
57+
modifiers.sort_unstable();
58+
modifiers.dedup();
5259

5360
Some(Self { selector, modifiers })
5461
}
@@ -121,8 +128,8 @@ impl ImportSelector {
121128
pub fn parse(s: &str) -> Option<Self> {
122129
match s {
123130
"type" => Some(Self::Type),
124-
"side-effect-style" => Some(Self::SideEffectStyle),
125-
"side-effect" => Some(Self::SideEffect),
131+
"side_effect_style" => Some(Self::SideEffectStyle),
132+
"side_effect" => Some(Self::SideEffect),
126133
"style" => Some(Self::Style),
127134
"index" => Some(Self::Index),
128135
"sibling" => Some(Self::Sibling),
@@ -154,8 +161,8 @@ impl ImportSelector {
154161
pub fn name(&self) -> &str {
155162
match self {
156163
ImportSelector::Type => "type",
157-
ImportSelector::SideEffectStyle => "side-effect-style",
158-
ImportSelector::SideEffect => "side-effect",
164+
ImportSelector::SideEffectStyle => "side_effect_style",
165+
ImportSelector::SideEffect => "side_effect",
159166
ImportSelector::Style => "style",
160167
ImportSelector::Index => "index",
161168
ImportSelector::Sibling => "sibling",
@@ -200,7 +207,7 @@ impl ImportModifier {
200207
/// Parse a string into an ImportModifier.
201208
pub fn parse(s: &str) -> Option<Self> {
202209
match s {
203-
"side-effect" => Some(Self::SideEffect),
210+
"side_effect" => Some(Self::SideEffect),
204211
"type" => Some(Self::Type),
205212
"value" => Some(Self::Value),
206213
"default" => Some(Self::Default),
@@ -212,7 +219,7 @@ impl ImportModifier {
212219

213220
pub fn name(&self) -> &str {
214221
match self {
215-
ImportModifier::SideEffect => "side-effect",
222+
ImportModifier::SideEffect => "side_effect",
216223
ImportModifier::Type => "type",
217224
ImportModifier::Value => "value",
218225
ImportModifier::Default => "default",

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

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
use rustc_hash::{FxHashMap, FxHashSet};
1+
use rustc_hash::FxHashMap;
22

3-
use super::group_config::{GroupName, ImportModifier, ImportSelector};
3+
use super::group_config::{GroupEntry, GroupName, ImportModifier, ImportSelector};
44
use super::options::CustomGroupDefinition;
55

66
// Intermediate import metadata that is used for group matching
@@ -21,30 +21,31 @@ pub struct GroupMatcher {
2121
}
2222

2323
impl GroupMatcher {
24-
pub fn new(groups: &[Vec<String>], custom_groups: &[CustomGroupDefinition]) -> Self {
25-
let custom_group_name_set =
26-
custom_groups.iter().map(|g| g.group_name.clone()).collect::<FxHashSet<_>>();
27-
24+
pub fn new(groups: &[Vec<GroupEntry>], custom_groups: &[CustomGroupDefinition]) -> Self {
2825
let mut unknown_group_index: Option<usize> = None;
2926

3027
let mut used_custom_group_index_map = FxHashMap::default();
3128
let mut predefined_groups = Vec::new();
3229
for (index, group_union) in groups.iter().enumerate() {
33-
for group in group_union {
34-
if group == "unknown" {
35-
unknown_group_index = Some(index);
36-
} else if custom_group_name_set.contains(group) {
37-
used_custom_group_index_map.insert(group.to_owned(), index);
38-
} else if let Some(group_name) = GroupName::parse(group) {
39-
predefined_groups.push((group_name, index));
30+
for entry in group_union {
31+
match entry {
32+
GroupEntry::Unknown => {
33+
unknown_group_index = Some(index);
34+
}
35+
GroupEntry::Custom(name) => {
36+
used_custom_group_index_map.insert(name.as_str(), index);
37+
}
38+
GroupEntry::Predefined(group_name) => {
39+
predefined_groups.push((group_name.clone(), index));
40+
}
4041
}
4142
}
4243
}
4344

4445
let mut used_custom_groups: Vec<(CustomGroupDefinition, usize)> =
4546
Vec::with_capacity(used_custom_group_index_map.len());
4647
for custom_group in custom_groups {
47-
if let Some(index) = used_custom_group_index_map.get(&custom_group.group_name) {
48+
if let Some(index) = used_custom_group_index_map.get(custom_group.group_name.as_str()) {
4849
used_custom_groups.push((custom_group.clone(), *index));
4950
}
5051
}

0 commit comments

Comments
 (0)