Skip to content
Open
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: 7 additions & 0 deletions .changeset/afraid-humans-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@biomejs/biome": minor
---

Added the assist [`useSortedTypeFields`](https://biomejs.dev/assist/actions/use-sorted-type-fields/).

Biome now sorts the fields of GraphQL object types, interface types, and input object types alphabetically, e.g. `name, age, id` becomes `age, id, name`.
4 changes: 4 additions & 0 deletions crates/biome_configuration/src/analyzer/assist/actions.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/biome_graphql_analyze/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use std::io;
use std::path::PathBuf;
use std::time::SystemTime;
fn main() -> io::Result<()> {
watch_group("assist", "source")?;
watch_group("lint", "correctness")?;
watch_group("lint", "nursery")?;
watch_group("lint", "style")?;
Expand Down
4 changes: 4 additions & 0 deletions crates/biome_graphql_analyze/src/assist.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
//! Generated file, do not edit by hand, see `xtask/codegen`

pub mod source;
::biome_analyze::declare_category! { pub Assist { kind : Action , groups : [self :: source :: Source ,] } }
9 changes: 9 additions & 0 deletions crates/biome_graphql_analyze/src/assist/source.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! Generated file, do not edit by hand, see `xtask/codegen`

#![doc = r" Group description generated by proc macro at compile time."]
#![doc = r""]
#![doc = r" To add a new rule, create a `.rs` file in the group subdirectory"]
#![doc = r" and run `cargo check`. The build system will automatically discover"]
#![doc = r" and register your rule."]
use biome_analyze_macros::declare_group_from_fs;
declare_group_from_fs! { category : "assist" , group : "source" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
use crate::GraphqlRuleAction;
use biome_analyze::{
Ast, FixKind, Rule, RuleAction, RuleDiagnostic, RuleSource, context::RuleContext,
declare_source_rule,
};
use biome_console::markup;
use biome_diagnostics::category;
use biome_graphql_syntax::{
GraphqlFieldDefinition, GraphqlFieldDefinitionList, GraphqlFieldsDefinition,
GraphqlInputFieldList, GraphqlInputFieldsDefinition, GraphqlInputObjectTypeDefinition,
GraphqlInputObjectTypeExtension, GraphqlInputValueDefinition, GraphqlInterfaceTypeDefinition,
GraphqlInterfaceTypeExtension, GraphqlObjectTypeDefinition, GraphqlObjectTypeExtension,
};
use biome_rowan::{AstNode, AstNodeList, BatchMutationExt, NodeOrToken, SyntaxNode, TokenText, declare_node_union};
use std::cmp::Ordering;

declare_source_rule! {
/// Sort fields in GraphQL type definitions alphabetically.
///
/// This rule ensures that fields within `type`, `interface`, and `input`
/// definitions are sorted alphabetically. For GraphQL identifiers (`[A-Za-z0-9_]`),
/// the sort order matches JavaScript's `localeCompare()`, including case handling.
///
/// ## Examples
///
/// ```graphql,expect_diff
/// type User {
/// name: String
/// age: Int
/// id: ID
/// }
/// ```
///
/// ```graphql,expect_diff
/// interface Node {
/// name: String
/// id: ID
/// }
/// ```
///
/// ```graphql,expect_diff
/// input CreateUserInput {
/// name: String
/// age: Int
/// }
/// ```
///
pub UseSortedTypeFields {
version: "next",
name: "useSortedTypeFields",
language: "graphql",
recommended: false,
fix_kind: FixKind::Safe,
sources: &[RuleSource::EslintGraphql("alphabetize").inspired()],
}
}

declare_node_union! {
pub UseSortedTypeFieldsQuery =
GraphqlObjectTypeDefinition
| GraphqlObjectTypeExtension
| GraphqlInterfaceTypeDefinition
| GraphqlInterfaceTypeExtension
| GraphqlInputObjectTypeDefinition
| GraphqlInputObjectTypeExtension
}

pub enum UseSortedTypeFieldsState {
TypeFields(GraphqlFieldsDefinition),
InputFields(GraphqlInputFieldsDefinition),
}

impl Rule for UseSortedTypeFields {
type Query = Ast<UseSortedTypeFieldsQuery>;
type State = UseSortedTypeFieldsState;
type Signals = Option<Self::State>;
type Options = ();

fn run(ctx: &RuleContext<Self>) -> Option<Self::State> {
let node = ctx.query();
match node {
UseSortedTypeFieldsQuery::GraphqlObjectTypeDefinition(obj) => {
let fields = obj.fields()?;
if is_field_definition_list_sorted(&fields.fields()) {
None
} else {
Some(UseSortedTypeFieldsState::TypeFields(fields))
}
}
UseSortedTypeFieldsQuery::GraphqlObjectTypeExtension(obj) => {
let fields = obj.fields()?;
if is_field_definition_list_sorted(&fields.fields()) {
None
} else {
Some(UseSortedTypeFieldsState::TypeFields(fields))
}
}
UseSortedTypeFieldsQuery::GraphqlInterfaceTypeDefinition(interface) => {
let fields = interface.fields()?;
if is_field_definition_list_sorted(&fields.fields()) {
None
} else {
Some(UseSortedTypeFieldsState::TypeFields(fields))
}
}
UseSortedTypeFieldsQuery::GraphqlInterfaceTypeExtension(interface) => {
let fields = interface.fields()?;
if is_field_definition_list_sorted(&fields.fields()) {
None
} else {
Some(UseSortedTypeFieldsState::TypeFields(fields))
}
}
UseSortedTypeFieldsQuery::GraphqlInputObjectTypeDefinition(input) => {
let fields = input.input_fields()?;
if is_input_field_list_sorted(&fields.fields()) {
None
} else {
Some(UseSortedTypeFieldsState::InputFields(fields))
}
}
UseSortedTypeFieldsQuery::GraphqlInputObjectTypeExtension(input) => {
let fields = input.input_fields()?;
if is_input_field_list_sorted(&fields.fields()) {
None
} else {
Some(UseSortedTypeFieldsState::InputFields(fields))
}
}
}
}

fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
let range = match state {
UseSortedTypeFieldsState::TypeFields(fields) => fields.syntax().text_trimmed_range(),
UseSortedTypeFieldsState::InputFields(fields) => fields.syntax().text_trimmed_range(),
};
Some(RuleDiagnostic::new(
category!("assist/source/useSortedTypeFields"),
range,
markup! {
"These fields are not sorted."
},
))
}

fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<GraphqlRuleAction> {
let mut mutation = ctx.root().begin();
match state {
UseSortedTypeFieldsState::TypeFields(fields_def) => {
let sorted = make_sorted_field_definition_list(&fields_def.fields())?;
mutation.replace_node(fields_def.fields(), sorted);
}
UseSortedTypeFieldsState::InputFields(input_def) => {
let sorted = make_sorted_input_field_list(&input_def.fields())?;
mutation.replace_node(input_def.fields(), sorted);
}
}
Some(RuleAction::new(
ctx.metadata().action_category(ctx.category(), ctx.group()),
ctx.metadata().applicability(),
markup! { "Sort these fields." },
mutation,
))
}
}

fn get_field_definition_key(node: &GraphqlFieldDefinition) -> Option<TokenText> {
node.name().ok()?.value_token().ok().map(|t| t.token_text_trimmed())
}

fn get_input_value_definition_key(node: &GraphqlInputValueDefinition) -> Option<TokenText> {
node.name().ok()?.value_token().ok().map(|t| t.token_text_trimmed())
}

fn is_field_definition_list_sorted(list: &GraphqlFieldDefinitionList) -> bool {
let mut prev: Option<TokenText> = None;
for item in list {
if let Some(key) = get_field_definition_key(&item) {
if prev
.as_ref()
.is_some_and(|p| locale_compare(p, &key) == Ordering::Greater)
{
return false;
}
prev = Some(key);
}
}
true
}

fn is_input_field_list_sorted(list: &GraphqlInputFieldList) -> bool {
let mut prev: Option<TokenText> = None;
for item in list {
if let Some(key) = get_input_value_definition_key(&item) {
if prev
.as_ref()
.is_some_and(|p| locale_compare(p, &key) == Ordering::Greater)
{
return false;
}
prev = Some(key);
}
}
true
}

fn make_sorted_field_definition_list(list: &GraphqlFieldDefinitionList) -> Option<GraphqlFieldDefinitionList> {
let mut items: Vec<(Option<TokenText>, biome_graphql_syntax::GraphqlFieldDefinition)> =
list.iter().map(|n| (get_field_definition_key(&n), n)).collect();
items.sort_by(|(k1, _), (k2, _)| compare_keys(k1, k2));
GraphqlFieldDefinitionList::cast(SyntaxNode::new_detached(
list.syntax().kind(),
items.into_iter().map(|(_, n)| Some(NodeOrToken::Node(n.into_syntax()))),
))
}

fn make_sorted_input_field_list(list: &GraphqlInputFieldList) -> Option<GraphqlInputFieldList> {
let mut items: Vec<(Option<TokenText>, biome_graphql_syntax::GraphqlInputValueDefinition)> =
list.iter().map(|n| (get_input_value_definition_key(&n), n)).collect();
items.sort_by(|(k1, _), (k2, _)| compare_keys(k1, k2));
GraphqlInputFieldList::cast(SyntaxNode::new_detached(
list.syntax().kind(),
items.into_iter().map(|(_, n)| Some(NodeOrToken::Node(n.into_syntax()))),
))
}

fn compare_keys(k1: &Option<TokenText>, k2: &Option<TokenText>) -> Ordering {
match (k1, k2) {
(Some(a), Some(b)) => locale_compare(a, b),
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(None, None) => Ordering::Equal,
}
}

// Matches JavaScript's localeCompare() for GraphQL identifiers [_A-Za-z0-9]:
// primary: _ < digits < letters (case-insensitive); tiebreaker: lowercase before uppercase.
fn locale_compare(a: &str, b: &str) -> Ordering {
let primary_cmp = compare_primary(a, b);
if primary_cmp != Ordering::Equal {
return primary_cmp;
}

compare_tertiary(a, b)
}

fn compare_primary(a: &str, b: &str) -> Ordering {
for (a_byte, b_byte) in a.bytes().zip(b.bytes()) {
let a_key = primary_char_key(a_byte);
let b_key = primary_char_key(b_byte);
if a_key != b_key {
return a_key.cmp(&b_key);
}
}

a.len().cmp(&b.len())
}

fn primary_char_key(byte: u8) -> (u8, u8) {
if byte == b'_' {
return (0, 0);
}
if byte.is_ascii_digit() {
return (1, byte);
}
if byte.is_ascii_alphabetic() {
return (2, byte.to_ascii_lowercase());
}

(3, byte)
}

fn compare_tertiary(a: &str, b: &str) -> Ordering {
for (a_byte, b_byte) in a.bytes().zip(b.bytes()) {
if a_byte == b_byte {
continue;
}

if a_byte.eq_ignore_ascii_case(&b_byte)
&& a_byte.is_ascii_alphabetic()
&& b_byte.is_ascii_alphabetic()
{
return match (a_byte.is_ascii_lowercase(), b_byte.is_ascii_lowercase()) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => Ordering::Equal,
};
}

return a_byte.cmp(&b_byte);
}

a.len().cmp(&b.len())
}
1 change: 1 addition & 0 deletions crates/biome_graphql_analyze/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#![deny(clippy::use_self)]

mod assist;
mod lint;
mod registry;
mod suppression_action;
Expand Down
1 change: 1 addition & 0 deletions crates/biome_graphql_analyze/src/registry.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading