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
5 changes: 3 additions & 2 deletions apps/oxfmt/src-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,10 @@ export type SortImportsOptions = {
/**
* Groups configuration for organizing imports.
* Each array element represents a group, and multiple group names in the same array are treated as one.
* Accepts both `string` and `string[]` as group elements.
* Accepts `string`, `string[]`, or `{ newlinesBetween: boolean }` marker objects.
* Marker objects override the global `newlinesBetween` setting for the boundary between the adjacent groups.
*/
groups?: (string | string[])[];
groups?: (string | string[] | { newlinesBetween: boolean })[];
/** Define custom groups for matching specific imports. */
customGroups?: {
groupName: string;
Expand Down
144 changes: 143 additions & 1 deletion apps/oxfmt/src/core/oxfmtrc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,48 @@ impl FormatConfig {
sort_imports.internal_pattern = v;
}
if let Some(v) = config.groups {
sort_imports.groups = v.into_iter().map(SortGroupItemConfig::into_vec).collect();
let mut groups = Vec::new();
let mut newline_boundary_overrides: Vec<Option<bool>> = Vec::new();
let mut pending_override: Option<bool> = None;

for item in v {
match item {
SortGroupItemConfig::NewlinesBetween(marker) => {
if groups.is_empty() {
return Err("Invalid `sortImports` configuration: `{ \"newlinesBetween\" }` marker cannot appear at the start of `groups`".to_string());
}
if pending_override.is_some() {
return Err("Invalid `sortImports` configuration: consecutive `{ \"newlinesBetween\" }` markers are not allowed in `groups`".to_string());
}
pending_override = Some(marker.newlines_between);
}
other => {
if !groups.is_empty() {
// Record the boundary between the previous group and this one.
// `pending_override` is
// - `Some(bool)` if a marker preceded this group
// - or `None` (= use global `newlines_between`) otherwise
// For the very first group (`groups.is_empty()`),
// there is no preceding boundary, so we skip this entirely.
newline_boundary_overrides.push(pending_override.take());
}
groups.push(other.into_vec());
}
}
}

if pending_override.is_some() {
return Err("Invalid `sortImports` configuration: `{ \"newlinesBetween\" }` marker cannot appear at the end of `groups`".to_string());
}

sort_imports.groups = groups;
sort_imports.newline_boundary_overrides = newline_boundary_overrides;
}

if sort_imports.partition_by_newline
&& sort_imports.newline_boundary_overrides.iter().any(Option::is_some)
{
return Err("Invalid `sortImports` configuration: `partitionByNewline` and per-group `{ \"newlinesBetween\" }` markers cannot be used together".to_string());
}
if let Some(v) = config.custom_groups {
sort_imports.custom_groups = v
Expand Down Expand Up @@ -698,15 +739,30 @@ pub enum SortOrderConfig {
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum SortGroupItemConfig {
/// A `{ "newlinesBetween": bool }` marker object that overrides the global `newlinesBetween`
/// setting for the boundary between the previous and next groups.
NewlinesBetween(NewlinesBetweenMarker),
/// A single group name string (e.g. `"value-builtin"`).
Single(String),
/// Multiple group names treated as one group (e.g. `["value-builtin", "value-external"]`).
Multiple(Vec<String>),
}

/// A marker object for overriding `newlinesBetween` at a specific group boundary.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct NewlinesBetweenMarker {
pub newlines_between: bool,
}

impl SortGroupItemConfig {
fn into_vec(self) -> Vec<String> {
match self {
Self::Single(s) => vec![s],
Self::Multiple(v) => v,
Self::NewlinesBetween(_) => {
unreachable!("NewlinesBetween markers should be handled before calling into_vec")
}
}
}
}
Expand Down Expand Up @@ -1206,6 +1262,92 @@ mod tests {
assert_eq!(sort_imports.groups[0], vec!["builtin".to_string()]);
assert_eq!(sort_imports.groups[1], vec!["external".to_string(), "internal".to_string()]);
assert_eq!(sort_imports.groups[4], vec!["index".to_string()]);

// Test groups with newlinesBetween overrides
let config: FormatConfig = serde_json::from_str(
r#"{
"experimentalSortImports": {
"groups": [
"builtin",
{ "newlinesBetween": false },
"external",
"parent"
]
}
}"#,
)
.unwrap();
let oxfmt_options = config.into_oxfmt_options().unwrap();
let sort_imports = oxfmt_options.format_options.experimental_sort_imports.unwrap();
assert_eq!(sort_imports.groups.len(), 3);
assert_eq!(sort_imports.groups[0], vec!["builtin".to_string()]);
assert_eq!(sort_imports.groups[1], vec!["external".to_string()]);
assert_eq!(sort_imports.groups[2], vec!["parent".to_string()]);
assert_eq!(sort_imports.newline_boundary_overrides.len(), 2);
assert_eq!(sort_imports.newline_boundary_overrides[0], Some(false));
assert_eq!(sort_imports.newline_boundary_overrides[1], None);

// Test error: newlinesBetween at start of groups
let config: FormatConfig = serde_json::from_str(
r#"{
"experimentalSortImports": {
"groups": [
{ "newlinesBetween": false },
"builtin",
"external"
]
}
}"#,
)
.unwrap();
assert!(config.into_oxfmt_options().is_err_and(|e| e.contains("start")));

// Test error: newlinesBetween at end of groups
let config: FormatConfig = serde_json::from_str(
r#"{
"experimentalSortImports": {
"groups": [
"builtin",
"external",
{ "newlinesBetween": true }
]
}
}"#,
)
.unwrap();
assert!(config.into_oxfmt_options().is_err_and(|e| e.contains("end")));

// Test error: consecutive newlinesBetween markers
let config: FormatConfig = serde_json::from_str(
r#"{
"experimentalSortImports": {
"groups": [
"builtin",
{ "newlinesBetween": false },
{ "newlinesBetween": true },
"external"
]
}
}"#,
)
.unwrap();
assert!(config.into_oxfmt_options().is_err_and(|e| e.contains("consecutive")));

// Test error: partitionByNewline with per-group newlinesBetween markers
let config: FormatConfig = serde_json::from_str(
r#"{
"experimentalSortImports": {
"partitionByNewline": true,
"groups": [
"builtin",
{ "newlinesBetween": false },
"external"
]
}
}"#,
)
.unwrap();
assert!(config.into_oxfmt_options().is_err_and(|e| e.contains("partitionByNewline")));
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/oxc_formatter/examples/sort_imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ fn main() -> Result<(), String> {
internal_pattern: default_internal_patterns(),
groups: default_groups(),
custom_groups: vec![],
newline_boundary_overrides: vec![],
};

// Read source file
Expand Down
52 changes: 42 additions & 10 deletions crates/oxc_formatter/src/ir_transform/sort_imports/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,17 +262,22 @@ impl SortImportsTransform {
// Insert newline when:
// 1. Group changes
// 2. Previous import was not ignored (don't insert after ignored)
if options.newlines_between {
let current_group_idx = sorted_import.group_idx;
if let Some(prev_idx) = prev_group_idx
&& prev_idx != current_group_idx
&& !prev_was_ignored
{
next_elements.push(FormatElement::Line(LineMode::Empty));
}
prev_group_idx = Some(current_group_idx);
prev_was_ignored = sorted_import.is_ignored;
// 3. The boundary override (or global `newlines_between`) says to insert
let current_group_idx = sorted_import.group_idx;
if let Some(prev_idx) = prev_group_idx
&& prev_idx != current_group_idx
&& !prev_was_ignored
&& should_insert_newline_between(
options.newlines_between,
&options.newline_boundary_overrides,
prev_idx,
current_group_idx,
)
{
next_elements.push(FormatElement::Line(LineMode::Empty));
}
prev_group_idx = Some(current_group_idx);
prev_was_ignored = sorted_import.is_ignored;

// Output leading lines and import line
for line in &sorted_import.leading_lines {
Expand Down Expand Up @@ -332,3 +337,30 @@ impl SortImportsTransform {
Some(next_elements)
}
}

/// Resolve whether a blank line should be inserted between two group indices.
/// Checks each boundary between `prev_group_idx` and `current_group_idx`,
/// using per-boundary overrides if available, otherwise the global `newlines_between`.
///
/// When groups are skipped (i.e. no imports match an intermediate group),
/// multiple boundaries are evaluated with OR semantics.
/// If any single boundary in the range resolves to `true`, a blank line is inserted.
fn should_insert_newline_between(
global_newlines_between: bool,
newline_boundary_overrides: &[Option<bool>],
prev_group_idx: usize,
current_group_idx: usize,
) -> bool {
if newline_boundary_overrides.is_empty() {
return global_newlines_between;
}

for idx in prev_group_idx..current_group_idx {
if newline_boundary_overrides.get(idx).copied().flatten().unwrap_or(global_newlines_between)
{
return true;
}
}

false
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ pub struct SortImportsOptions {
/// Define your own groups for matching very specific imports.
/// Default is `[]`.
pub custom_groups: Vec<CustomGroupDefinition>,
/// Per-boundary newline overrides.
/// `newline_boundary_overrides[i]` = override for boundary between `groups[i]` and `groups[i+1]`.
/// `None` means "use global `newlines_between`".
pub newline_boundary_overrides: Vec<Option<bool>>,
}

impl Default for SortImportsOptions {
Expand All @@ -50,6 +54,7 @@ impl Default for SortImportsOptions {
internal_pattern: default_internal_patterns(),
groups: default_groups(),
custom_groups: vec![],
newline_boundary_overrides: vec![],
}
}
}
Expand Down
61 changes: 34 additions & 27 deletions crates/oxc_formatter/tests/ir_transform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,43 +78,49 @@ struct TestSortImportsConfig {
newlines_between: Option<bool>,
internal_pattern: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_groups")]
groups: Option<Vec<Vec<String>>>,
groups: Option<ParsedGroups>,
custom_groups: Option<Vec<TestCustomGroupDefinition>>,
}

fn deserialize_groups<'de, D>(deserializer: D) -> Result<Option<Vec<Vec<String>>>, D::Error>
#[derive(Debug, Default)]
struct ParsedGroups {
groups: Vec<Vec<String>>,
newline_boundary_overrides: Vec<Option<bool>>,
}

fn deserialize_groups<'de, D>(deserializer: D) -> Result<Option<ParsedGroups>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
use serde_json::Value;

let value: Option<Value> = Option::deserialize(deserializer)?;
match value {
None => Ok(None),
Some(Value::Array(arr)) => {
let mut groups = Vec::new();
for item in arr {
match item {
Value::String(s) => groups.push(vec![s]),
Value::Array(group_arr) => {
let mut group = Vec::new();
for g in group_arr {
if let Value::String(s) = g {
group.push(s);
} else {
return Err(D::Error::custom("groups must contain strings"));
}
}
groups.push(group);
}
_ => return Err(D::Error::custom("groups must be strings or arrays")),
}
let Some(Value::Array(arr)) = Option::deserialize(deserializer)? else {
return Ok(None);
};

let mut groups = Vec::new();
let mut newline_boundary_overrides: Vec<Option<bool>> = Vec::new();
let mut pending_override: Option<bool> = None;

for item in arr {
if let Value::Object(obj) = item {
pending_override = obj.get("newlinesBetween").and_then(Value::as_bool);
} else {
if !groups.is_empty() {
newline_boundary_overrides.push(pending_override.take());
}
Ok(Some(groups))
let group = match item {
Value::String(s) => vec![s],
Value::Array(a) => {
a.into_iter().filter_map(|v| v.as_str().map(String::from)).collect()
}
_ => continue,
};
groups.push(group);
}
Some(_) => Err(D::Error::custom("groups must be an array")),
}

Ok(Some(ParsedGroups { groups, newline_boundary_overrides }))
}

fn parse_test_config(json: &str) -> FormatOptions {
Expand Down Expand Up @@ -154,7 +160,8 @@ fn parse_test_config(json: &str) -> FormatOptions {
sort_imports.internal_pattern = v;
}
if let Some(v) = sort_config.groups {
sort_imports.groups = v;
sort_imports.groups = v.groups;
sort_imports.newline_boundary_overrides = v.newline_boundary_overrides;
}
if let Some(v) = sort_config.custom_groups {
sort_imports.custom_groups = v
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod basic;
mod custom_groups;
mod groups;
mod newlines_between_override;
Loading
Loading