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
51 changes: 51 additions & 0 deletions .changeset/group-by-nesting-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
"@biomejs/biome": minor
---

Added `groupByNesting` option to the `useSortedKeys` assist. When enabled, object keys are grouped by their value's nesting depth before sorting alphabetically.

Simple values (primitives, single-line arrays, and single-line objects) are sorted first, followed by nested values (multi-line arrays and multi-line objects).

#### Example

To enable this option, configure it in your `biome.json`:

```json
{
"linter": {
"rules": {
"source": {
"useSortedKeys": {
"options": {
"groupByNesting": true
}
}
}
}
}
}
```

With this option, the following unsorted object:

```js
const object = {
"name": "Sample",
"details": {
"description": "nested"
},
"id": 123
}
```

Will be sorted as:

```js
const object ={
"id": 123,
"name": "Sample",
"details": {
"description": "nested"
}
}
```
167 changes: 146 additions & 21 deletions crates/biome_js_analyze/src/assist/source/use_sorted_keys.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::{borrow::Cow, ops::Not};
use std::{borrow::Cow, cmp::Ordering, ops::Not};

use biome_analyze::{
Ast, FixKind, Rule, RuleAction, RuleDiagnostic, RuleSource,
Expand All @@ -10,8 +10,10 @@ use biome_console::markup;
use biome_deserialize::TextRange;
use biome_diagnostics::{Applicability, category};
use biome_js_factory::make;
use biome_js_syntax::{JsObjectExpression, JsObjectMemberList, T};
use biome_rowan::{AstNode, BatchMutationExt, TriviaPieceKind};
use biome_js_syntax::{
AnyJsExpression, AnyJsObjectMember, JsLanguage, JsObjectExpression, JsObjectMemberList, T,
};
use biome_rowan::{AstNode, BatchMutationExt, SyntaxResult, SyntaxToken, TriviaPieceKind};
use biome_rule_options::use_sorted_keys::{SortOrder, UseSortedKeysOptions};
use biome_string_case::comparable_token::ComparableToken;

Expand Down Expand Up @@ -129,6 +131,31 @@ declare_source_rule! {
/// };
/// ```
///
/// ### `groupByNesting`
/// When enabled, groups object keys by their value's nesting depth before sorting alphabetically.
/// Simple values (primitives, single-line arrays, and single-line objects) are sorted first,
/// followed by nested values (multi-line arrays and multi-line objects).
///
/// > Default: `false`
///
///
/// ```json,options
/// {
/// "options": {
/// "groupByNesting": true
/// }
/// }
/// ```
/// ```js,use_options,expect_diagnostic
/// const obj = {
/// name: "Sample",
/// details: {
/// description: "nested"
/// },
/// id: 123
/// };
/// ```
///
pub UseSortedKeys {
version: "2.0.0",
name: "useSortedKeys",
Expand All @@ -139,6 +166,67 @@ declare_source_rule! {
}
}

/// Checks if an object/array spans multiple lines by examining CST trivia.
/// For non-empty containers, checks the first token of the members/elements.
/// For empty containers, checks the closing brace/bracket token.
fn has_multiline_content(
members_first_token: Option<SyntaxToken<JsLanguage>>,
closing_token: SyntaxResult<SyntaxToken<JsLanguage>>,
) -> bool {
members_first_token.map_or_else(
|| {
closing_token
.map(|token| token.has_leading_newline())
.unwrap_or(false)
},
|token| token.has_leading_newline(),
)
}

/// Determines the nesting depth of a JavaScript expression for grouping purposes.
fn get_nesting_depth(value: &AnyJsExpression) -> Ordering {
match value {
AnyJsExpression::JsObjectExpression(obj) => {
let members = obj.members();
if has_multiline_content(members.syntax().first_token(), obj.r_curly_token()) {
Ordering::Greater
} else {
Ordering::Equal
}
}
AnyJsExpression::JsArrayExpression(array) => {
let elements = array.elements();
if has_multiline_content(elements.syntax().first_token(), array.r_brack_token()) {
Ordering::Greater
} else {
Ordering::Equal
}
}
// Function and class expressions are treated as nested
AnyJsExpression::JsArrowFunctionExpression(_)
| AnyJsExpression::JsFunctionExpression(_)
| AnyJsExpression::JsClassExpression(_) => Ordering::Greater,
_ => Ordering::Equal,
}
}

/// Determines the nesting depth for an object member:
/// - properties: based on value expression;
/// - methods/getters/setters: treat as nested (1);
/// - spreads/computed or unnamed: non-sortable (None).
fn get_member_depth(node: &AnyJsObjectMember) -> Option<Ordering> {
match node {
AnyJsObjectMember::JsPropertyObjectMember(prop) => {
let value = prop.value().ok()?;
Some(get_nesting_depth(&value))
}
AnyJsObjectMember::JsMethodObjectMember(_)
| AnyJsObjectMember::JsGetterObjectMember(_)
| AnyJsObjectMember::JsSetterObjectMember(_) => Some(Ordering::Greater),
_ => None,
}
}

impl Rule for UseSortedKeys {
type Query = Ast<JsObjectMemberList>;
type State = ();
Expand All @@ -153,23 +241,46 @@ impl Rule for UseSortedKeys {
SortOrder::Lexicographic => ComparableToken::lexicographic_cmp,
};

is_separated_list_sorted_by(
ctx.query(),
|node| node.name().map(ComparableToken::new),
comparator,
)
.ok()?
.not()
.then_some(())
if options.group_by_nesting.unwrap_or(false) {
is_separated_list_sorted_by(
ctx.query(),
|node| {
let depth = get_member_depth(node)?;
let name = node.name().map(ComparableToken::new)?;
Some((depth, name))
},
|(d1, n1), (d2, n2)| d1.cmp(d2).then_with(|| comparator(n1, n2)),
)
.ok()?
.not()
.then_some(())
} else {
is_separated_list_sorted_by(
ctx.query(),
|node| node.name().map(ComparableToken::new),
comparator,
)
.ok()?
.not()
.then_some(())
}
}

fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
let options = ctx.options();
let message = if options.group_by_nesting.unwrap_or(false) {
markup! {
"The object properties are not sorted by nesting level and key."
}
} else {
markup! {
"The object properties are not sorted by key."
}
};
Some(RuleDiagnostic::new(
category!("assist/source/useSortedKeys"),
ctx.query().range(),
markup! {
"The object properties are not sorted by key."
},
message,
))
}

Expand All @@ -190,13 +301,27 @@ impl Rule for UseSortedKeys {
SortOrder::Lexicographic => ComparableToken::lexicographic_cmp,
};

let new_list = sorted_separated_list_by(
list,
|node| node.name().map(ComparableToken::new),
|| make::token(T![,]).with_trailing_trivia([(TriviaPieceKind::Whitespace, " ")]),
comparator,
)
.ok()?;
let new_list = if options.group_by_nesting.unwrap_or(false) {
sorted_separated_list_by(
list,
|node| {
let depth = get_member_depth(node)?;
let name = node.name().map(ComparableToken::new)?;
Some((depth, name))
},
|| make::token(T![,]).with_trailing_trivia([(TriviaPieceKind::Whitespace, " ")]),
|(d1, n1), (d2, n2)| d1.cmp(d2).then_with(|| comparator(n1, n2)),
)
.ok()?
} else {
sorted_separated_list_by(
list,
|node| node.name().map(ComparableToken::new),
|| make::token(T![,]).with_trailing_trivia([(TriviaPieceKind::Whitespace, " ")]),
comparator,
)
.ok()?
};

let mut mutation = ctx.root().begin();
mutation.replace_node_discard_trivia(list.clone(), new_list);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const obj = {
name: "Sample Item",
details: {
description: "This is a nested object",
status: "active"
},
id: "12345",
tags: ["short", "array"],
metadata: {
created: "2024-01-01",
updated: "2024-01-02"
},
count: 42,
multiLineArray: [
"item1",
"item2",
"item3"
]
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: group-by-nesting-lexicographic.js
---
# Input
```js
const obj = {
name: "Sample Item",
details: {
description: "This is a nested object",
status: "active"
},
id: "12345",
tags: ["short", "array"],
metadata: {
created: "2024-01-01",
updated: "2024-01-02"
},
count: 42,
multiLineArray: [
"item1",
"item2",
"item3"
]
};

```

# Diagnostics
```
group-by-nesting-lexicographic.js:2:3 assist/source/useSortedKeys FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━

i The object properties are not sorted by nesting level and key.

1 │ const obj = {
> 2 │ name: "Sample Item",
│ ^^^^^^^^^^^^^^^^^^^^
> 3 │ details: {
> 4 │ description: "This is a nested object",
...
> 17 │ "item3"
> 18 │ ]
│ ^
19 │ };
20 │

i Safe fix: Sort the object properties by key.

1 1 │ const obj = {
2 │ - ··name:·"Sample·Item",
3 │ - ··details:·{
2 │ + ··count:·42,
3 │ + ··id:·"12345",
4 │ + ··name:·"Sample·Item",
5 │ + ··tags:·["short",·"array"],
6 │ + ··details:·{
4 7 │ description: "This is a nested object",
5 8 │ status: "active"
6 9 │ },
7 │ - ··id:·"12345",
8 │ - ··tags:·["short",·"array"],
9 │ - ··metadata:·{
10 │ + ··metadata:·{
10 11 │ created: "2024-01-01",
11 12 │ updated: "2024-01-02"
12 │ - ··},
13 │ - ··count:·42,
13 │ + ··},
14 14 │ multiLineArray: [
15 15 │ "item1",


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json",
"assist": {
"actions": {
"source": {
"useSortedKeys": {
"level": "on",
"options": {
"sortOrder": "lexicographic",
"groupByNesting": true
}
}
}
}
}
}
Loading