diff --git a/crates/biome_js_analyze/src/assist/source/use_sorted_keys.rs b/crates/biome_js_analyze/src/assist/source/use_sorted_keys.rs index c89819919a5a..c7c44b94c300 100644 --- a/crates/biome_js_analyze/src/assist/source/use_sorted_keys.rs +++ b/crates/biome_js_analyze/src/assist/source/use_sorted_keys.rs @@ -10,7 +10,9 @@ 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_js_syntax::{ + AnyJsExpression, AnyJsObjectMember, JsObjectExpression, JsObjectMemberList, T, +}; use biome_rowan::{AstNode, BatchMutationExt, TriviaPieceKind}; use biome_rule_options::use_sorted_keys::{SortOrder, UseSortedKeysOptions}; use biome_string_case::comparable_token::ComparableToken; @@ -129,6 +131,26 @@ declare_source_rule! { /// }; /// ``` /// + /// ### `groupByNesting` + /// When enabled, groups object keys by their value's nesting depth before sorting alphabetically. + /// + /// ```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", @@ -139,12 +161,78 @@ declare_source_rule! { } } +/// Compute a simple nesting depth indicator for a JavaScript expression used when grouping object members. +/// +/// The function returns `1` for object expressions and for array expressions that span multiple lines; +/// it returns `0` for single-line arrays and all other expression kinds. +/// +/// # Examples +/// +/// ```rust,ignore +/// // object literal -> depth 1 +/// let obj_expr: AnyJsExpression = parse_expression("({ a: 1 })"); +/// assert_eq!(get_nesting_depth_js(&obj_expr), 1); +/// +/// // single-line array -> depth 0 +/// let arr_expr: AnyJsExpression = parse_expression("[1, 2, 3]"); +/// assert_eq!(get_nesting_depth_js(&arr_expr), 0); +/// +/// // multi-line array -> depth 1 +/// let multi_arr_expr: AnyJsExpression = parse_expression("[\n 1,\n 2\n]"); +/// assert_eq!(get_nesting_depth_js(&multi_arr_expr), 1); +/// ``` +/// +/// # Returns +/// +/// `1` for object expressions and for arrays that contain a newline (span multiple lines), `0` otherwise. +fn get_nesting_depth_js(value: &AnyJsExpression) -> u8 { + match value { + AnyJsExpression::JsObjectExpression(_) => 1, + AnyJsExpression::JsArrayExpression(array) => { + // Check if array spans multiple lines by looking for newlines + if array.syntax().text_trimmed().contains_char('\n') { + 1 + } else { + 0 + } + } + _ => 0, + } +} + +/// Extracts the value expression from an object member +fn get_member_value(node: &AnyJsObjectMember) -> Option { + match node { + AnyJsObjectMember::JsPropertyObjectMember(prop) => prop.value().ok(), + _ => None, // Getters, setters, methods, etc. treated as non-nested + } +} + impl Rule for UseSortedKeys { type Query = Ast; type State = (); type Signals = Option; type Options = UseSortedKeysOptions; + /// Determines whether the object member list in the query is sorted according to the rule options. + /// + /// When `group_by_nesting` is enabled, members are compared by a pair (nesting depth, property name), + /// where nesting depth is computed from the member's value and property name is the member key token; + /// otherwise members are compared by property name alone. The comparator is chosen from `sort_order` + /// (natural or lexicographic). + /// + /// # Returns + /// + /// `Some(())` if the members are not sorted according to the configured options, `None` otherwise. + /// + /// # Examples + /// + /// ```no_run + /// # use crate::{RuleContext, UseSortedKeys}; + /// # fn example(ctx: &RuleContext) { + /// let signal = UseSortedKeys::run(ctx); + /// # } + /// ``` fn run(ctx: &RuleContext) -> Self::Signals { let options = ctx.options(); let sort_order = options.sort_order; @@ -153,16 +241,50 @@ 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 { + is_separated_list_sorted_by( + ctx.query(), + |node| { + let value = get_member_value(node)?; + let depth = get_nesting_depth_js(&value); + 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(()) + } } + /// Produces a diagnostic that marks an object whose properties are not sorted by key. + /// + /// The diagnostic uses the category `assist/source/useSortedKeys` and targets the query range + /// with the message "The object properties are not sorted by key." + /// + /// # Examples + /// + /// ``` + /// // In a rule implementation, `diagnostic(ctx, &state)` is used to create the diagnostic + /// // shown below. `ctx` is provided by the rule runner. + /// use biome_diagnostics::RuleDiagnostic; + /// + /// let expected = RuleDiagnostic::new( + /// category!("assist/source/useSortedKeys"), + /// /* range */ Default::default(), + /// "The object properties are not sorted by key.", + /// ); + /// ``` fn diagnostic(ctx: &RuleContext, _state: &Self::State) -> Option { Some(RuleDiagnostic::new( category!("assist/source/useSortedKeys"), @@ -181,6 +303,23 @@ impl Rule for UseSortedKeys { .map(|object| object.range()) } + /// Builds a rule action that replaces the object property list with a sorted version according to the rule options. + /// + /// When `group_by_nesting` is enabled the list is sorted by a tuple of (nesting depth of the member's value, property key); + /// otherwise the list is sorted by property key only. The sorting uses either natural or lexicographic ordering depending + /// on the configured `sort_order`. If sorting or list construction fails, the function returns `None`. + /// + /// # Returns + /// + /// `Some(JsRuleAction)` containing a mutation that replaces the original object member list with the sorted list if sorting + /// succeeded, `None` otherwise. + /// + /// # Examples + /// + /// ``` + /// // Given a rule context `ctx` and state `state`, produce the optional fix action: + /// let action = action(&ctx, &state); + /// ``` fn action(ctx: &RuleContext, _: &Self::State) -> Option { let list = ctx.query(); let options = ctx.options(); @@ -190,13 +329,28 @@ 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 { + sorted_separated_list_by( + list, + |node| { + let value = get_member_value(node)?; + let depth = get_nesting_depth_js(&value); + 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); @@ -208,4 +362,4 @@ impl Rule for UseSortedKeys { mutation, )) } -} +} \ No newline at end of file diff --git a/crates/biome_json_analyze/src/assist/source/use_sorted_keys.rs b/crates/biome_json_analyze/src/assist/source/use_sorted_keys.rs index 054ce0a6dcd4..1808f384e00d 100644 --- a/crates/biome_json_analyze/src/assist/source/use_sorted_keys.rs +++ b/crates/biome_json_analyze/src/assist/source/use_sorted_keys.rs @@ -6,7 +6,7 @@ use biome_analyze::{ use biome_console::markup; use biome_diagnostics::category; use biome_json_factory::make; -use biome_json_syntax::{JsonMemberList, JsonObjectValue, T, TextRange}; +use biome_json_syntax::{AnyJsonValue, JsonMemberList, JsonObjectValue, T, TextRange}; use biome_rowan::{AstNode, BatchMutationExt}; use biome_rule_options::use_sorted_keys::{SortOrder, UseSortedKeysOptions}; use biome_string_case::comparable_token::ComparableToken; @@ -75,6 +75,28 @@ declare_source_rule! { /// } /// ``` /// + /// ### `groupByNesting` + /// When enabled, groups object keys by their value's nesting depth before sorting alphabetically. + /// Simple values (primitives and single-line arrays) are sorted first, followed by nested values + /// (objects and multi-line arrays). + /// + /// ```json,options + /// { + /// "options": { + /// "groupByNesting": true + /// } + /// } + /// ``` + /// ```json,use_options,expect_diagnostic + /// { + /// "name": "Sample", + /// "details": { + /// "description": "nested" + /// }, + /// "id": "123" + /// } + /// ``` + /// pub UseSortedKeys { version: "1.9.0", name: "useSortedKeys", @@ -83,12 +105,47 @@ declare_source_rule! { } } +/// Determines the nesting depth of a JSON value for grouping purposes. +/// Objects and multi-line arrays are considered nested (depth 1). +/// Primitives and single-line arrays are considered simple (depth 0). +fn get_nesting_depth(value: &AnyJsonValue) -> u8 { + match value { + AnyJsonValue::JsonObjectValue(_) => 1, + AnyJsonValue::JsonArrayValue(array) => { + // Check if array spans multiple lines by looking for newlines + if array.to_string().contains('\n') { + 1 + } else { + 0 + } + } + _ => 0, // primitives: string, number, boolean, null + } +} + impl Rule for UseSortedKeys { type Query = Ast; type State = (); type Signals = Option; type Options = UseSortedKeysOptions; + /// Determines whether the queried JSON object members are sorted according to the rule options and signals when they are not. + /// + /// When `group_by_nesting` is enabled in the rule options, members are ordered first by their nesting depth (objects and multi-line arrays count as deeper) and then by key using the configured sort order (natural or lexicographic). When `group_by_nesting` is disabled, members are ordered by key only using the configured sort order. + /// + /// # Returns + /// + /// `Some(())` if the member list in the query is not sorted according to the configured options, `None` if it is sorted. + /// + /// # Examples + /// + /// ``` + /// // The function returns `Some(())` to indicate unsorted lists and `None` for sorted lists. + /// let unsorted: Option<()> = Some(()); + /// let sorted: Option<()> = None; + /// assert_eq!(unsorted, Some(())); + /// assert_eq!(sorted, None); + /// ``` fn run(ctx: &RuleContext) -> Option { let options = ctx.options(); let sort_order = options.sort_order; @@ -97,22 +154,55 @@ impl Rule for UseSortedKeys { SortOrder::Lexicographic => ComparableToken::lexicographic_cmp, }; - is_separated_list_sorted_by( - ctx.query(), - |node| { - node.name() - .ok()? - .inner_string_text() - .ok() - .map(ComparableToken::new) - }, - comparator, - ) - .ok()? - .not() - .then_some(()) + if options.group_by_nesting { + is_separated_list_sorted_by( + ctx.query(), + |node| { + let value = node.value().ok()?; + let depth = get_nesting_depth(&value); + let name = node + .name() + .ok()? + .inner_string_text() + .ok() + .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() + .ok()? + .inner_string_text() + .ok() + .map(ComparableToken::new) + }, + comparator, + ) + .ok()? + .not() + .then_some(()) + } } + /// Create a diagnostic indicating that the members of the containing JSON object are not sorted by key. + /// + /// The diagnostic uses category "assist/source/useSortedKeys", the text range of the enclosing object, and the message "The members are not sorted by key." + /// + /// # Examples + /// + /// ``` + /// // Given a rule context `ctx` and state `state` produced by `run`, this returns a diagnostic + /// // that can be reported to the user. + /// let diag = UseSortedKeys::diagnostic(&ctx, &state); + /// assert!(diag.is_some()); + /// ``` fn diagnostic(ctx: &RuleContext, state: &Self::State) -> Option { Some(RuleDiagnostic::new( category!("assist/source/useSortedKeys"), @@ -131,6 +221,26 @@ impl Rule for UseSortedKeys { .map(|node| node.range()) } + /// Produces an automatic fix that replaces the JSON object member list with a version sorted by key. + /// + /// When applied, the returned action mutates the source to sort members according to the rule's + /// options: keys are ordered either by natural or lexicographic comparison, and when + /// `group_by_nesting` is enabled members are grouped by their nesting depth before sorting by name. + /// + /// # Returns + /// + /// `Some(JsonRuleAction)` containing a mutation that replaces the original member list with the + /// sorted list; `None` if no action can be constructed. + /// + /// # Examples + /// + /// ```rust,no_run + /// // Given a rule context `ctx` for a JsonMemberList, produce the fix action: + /// let action = action(&ctx, &()); + /// if let Some(rule_action) = action { + /// // apply or inspect `rule_action` + /// } + /// ``` fn action(ctx: &RuleContext, _state: &Self::State) -> Option { let list = ctx.query(); let options = ctx.options(); @@ -140,19 +250,39 @@ impl Rule for UseSortedKeys { SortOrder::Lexicographic => ComparableToken::lexicographic_cmp, }; - let new_list = sorted_separated_list_by( - list, - |node| { - node.name() - .ok()? - .inner_string_text() - .ok() - .map(ComparableToken::new) - }, - || make::token(T![,]), - comparator, - ) - .ok()?; + let new_list = if options.group_by_nesting { + sorted_separated_list_by( + list, + |node| { + let value = node.value().ok()?; + let depth = get_nesting_depth(&value); + let name = node + .name() + .ok()? + .inner_string_text() + .ok() + .map(ComparableToken::new)?; + Some((depth, name)) + }, + || make::token(T![,]), + |(d1, n1), (d2, n2)| d1.cmp(d2).then_with(|| comparator(n1, n2)), + ) + .ok()? + } else { + sorted_separated_list_by( + list, + |node| { + node.name() + .ok()? + .inner_string_text() + .ok() + .map(ComparableToken::new) + }, + || make::token(T![,]), + comparator, + ) + .ok()? + }; let mut mutation = ctx.root().begin(); mutation.replace_node_discard_trivia(list.clone(), new_list); @@ -166,4 +296,4 @@ impl Rule for UseSortedKeys { mutation, )) } -} +} \ No newline at end of file