diff --git a/Cargo.lock b/Cargo.lock index b6883f546322..a2e5b99ae991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -507,6 +507,7 @@ dependencies = [ "biome_deserialize", "biome_deserialize_macros", "biome_diagnostics", + "biome_graphql_factory", "biome_graphql_parser", "biome_graphql_syntax", "biome_rowan", diff --git a/crates/biome_configuration/src/analyzer/linter/rules.rs b/crates/biome_configuration/src/analyzer/linter/rules.rs index 8f6f8d302ff0..97f5efe38f7c 100644 --- a/crates/biome_configuration/src/analyzer/linter/rules.rs +++ b/crates/biome_configuration/src/analyzer/linter/rules.rs @@ -3432,6 +3432,10 @@ pub struct Nursery { #[serde(skip_serializing_if = "Option::is_none")] pub use_import_restrictions: Option>, + #[doc = "Enforce specifying the name of GraphQL operations."] + #[serde(skip_serializing_if = "Option::is_none")] + pub use_named_operation: + Option>, #[doc = "Enforce the sorting of CSS utility classes."] #[serde(skip_serializing_if = "Option::is_none")] pub use_sorted_classes: @@ -3509,6 +3513,7 @@ impl Nursery { "useGoogleFontDisplay", "useGuardForIn", "useImportRestrictions", + "useNamedOperation", "useSortedClasses", "useStrictMode", "useTrimStartEnd", @@ -3528,6 +3533,7 @@ impl Nursery { "useAriaPropsSupportedByRole", "useConsistentMemberAccessibility", "useDeprecatedReason", + "useNamedOperation", "useStrictMode", ]; const RECOMMENDED_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ @@ -3544,7 +3550,8 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[38]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[39]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[44]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46]), ]; const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), @@ -3595,6 +3602,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended_true(&self) -> bool { @@ -3831,26 +3839,31 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[43])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_named_operation.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[44])); } } - if let Some(rule) = self.use_strict_mode.as_ref() { + if let Some(rule) = self.use_sorted_classes.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45])); } } - if let Some(rule) = self.use_trim_start_end.as_ref() { + if let Some(rule) = self.use_strict_mode.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46])); } } - if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if let Some(rule) = self.use_trim_start_end.as_ref() { if rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47])); } } + if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> FxHashSet> { @@ -4075,26 +4088,31 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[43])); } } - if let Some(rule) = self.use_sorted_classes.as_ref() { + if let Some(rule) = self.use_named_operation.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[44])); } } - if let Some(rule) = self.use_strict_mode.as_ref() { + if let Some(rule) = self.use_sorted_classes.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45])); } } - if let Some(rule) = self.use_trim_start_end.as_ref() { + if let Some(rule) = self.use_strict_mode.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46])); } } - if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if let Some(rule) = self.use_trim_start_end.as_ref() { if rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47])); } } + if let Some(rule) = self.use_valid_autocomplete.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -4307,6 +4325,10 @@ impl Nursery { .use_import_restrictions .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "useNamedOperation" => self + .use_named_operation + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "useSortedClasses" => self .use_sorted_classes .as_ref() diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index c5b9fa49e2d5..da63e0de6c8f 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -200,6 +200,7 @@ define_categories! { "lint/nursery/useGuardForIn": "https://biomejs.dev/linter/rules/use-guard-for-in", "lint/nursery/useImportRestrictions": "https://biomejs.dev/linter/rules/use-import-restrictions", "lint/nursery/useJsxCurlyBraceConvention": "https://biomejs.dev/linter/rules/use-jsx-curly-brace-convention", + "lint/nursery/useNamedOperation": "https://biomejs.dev/linter/rules/use-named-operation", "lint/nursery/useSortedClasses": "https://biomejs.dev/linter/rules/use-sorted-classes", "lint/nursery/useStrictMode": "https://biomejs.dev/linter/rules/use-strict-mode", "lint/nursery/useTrimStartEnd": "https://biomejs.dev/linter/rules/use-trim-start-end", diff --git a/crates/biome_graphql_analyze/Cargo.toml b/crates/biome_graphql_analyze/Cargo.toml index 4e8bef66a6bb..80e1466c9237 100644 --- a/crates/biome_graphql_analyze/Cargo.toml +++ b/crates/biome_graphql_analyze/Cargo.toml @@ -16,6 +16,7 @@ biome_console = { workspace = true } biome_deserialize = { workspace = true } biome_deserialize_macros = { workspace = true } biome_diagnostics = { workspace = true } +biome_graphql_factory = { workspace = true } biome_graphql_syntax = { workspace = true } biome_rowan = { workspace = true } biome_string_case = { workspace = true } diff --git a/crates/biome_graphql_analyze/src/lib.rs b/crates/biome_graphql_analyze/src/lib.rs index 84cfb0b91404..920b47a7a60b 100644 --- a/crates/biome_graphql_analyze/src/lib.rs +++ b/crates/biome_graphql_analyze/src/lib.rs @@ -7,7 +7,7 @@ pub use crate::registry::visit_registry; use crate::suppression_action::GraphqlSuppressionAction; use biome_analyze::{ AnalysisFilter, AnalyzerOptions, AnalyzerSignal, ControlFlow, LanguageRoot, MatchQueryParams, - MetadataRegistry, RuleRegistry, SuppressionKind, + MetadataRegistry, RuleAction, RuleRegistry, SuppressionKind, }; use biome_diagnostics::{category, Error}; use biome_graphql_syntax::GraphqlLanguage; @@ -15,6 +15,8 @@ use biome_suppression::{parse_suppression_comment, SuppressionDiagnostic}; use std::ops::Deref; use std::sync::LazyLock; +pub(crate) type GraphqlRuleAction = RuleAction; + pub static METADATA: LazyLock = LazyLock::new(|| { let mut metadata = MetadataRegistry::default(); visit_registry(&mut metadata); diff --git a/crates/biome_graphql_analyze/src/lint/nursery.rs b/crates/biome_graphql_analyze/src/lint/nursery.rs index d0853ddfff3a..72e554e73022 100644 --- a/crates/biome_graphql_analyze/src/lint/nursery.rs +++ b/crates/biome_graphql_analyze/src/lint/nursery.rs @@ -4,6 +4,7 @@ use biome_analyze::declare_lint_group; pub mod no_duplicated_fields; pub mod use_deprecated_reason; +pub mod use_named_operation; declare_lint_group! { pub Nursery { @@ -11,6 +12,7 @@ declare_lint_group! { rules : [ self :: no_duplicated_fields :: NoDuplicatedFields , self :: use_deprecated_reason :: UseDeprecatedReason , + self :: use_named_operation :: UseNamedOperation , ] } } diff --git a/crates/biome_graphql_analyze/src/lint/nursery/use_named_operation.rs b/crates/biome_graphql_analyze/src/lint/nursery/use_named_operation.rs new file mode 100644 index 000000000000..e2ff1658d219 --- /dev/null +++ b/crates/biome_graphql_analyze/src/lint/nursery/use_named_operation.rs @@ -0,0 +1,117 @@ +use biome_analyze::{ + context::RuleContext, declare_lint_rule, ActionCategory, Ast, FixKind, Rule, RuleDiagnostic, + RuleSource, RuleSourceKind, +}; +use biome_console::markup; +use biome_graphql_factory::make; +use biome_graphql_syntax::{GraphqlOperationDefinition, GraphqlOperationType}; +use biome_rowan::{AstNode, BatchMutationExt}; +use biome_string_case::Case; + +use crate::GraphqlRuleAction; + +declare_lint_rule! { + /// Enforce specifying the name of GraphQL operations. + /// + /// This is useful because most GraphQL client libraries use the operation name for caching purposes. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```graphql,expect_diagnostic + /// query {} + /// ``` + /// + /// ### Valid + /// + /// ```graphql + /// query Human { + /// name + /// } + /// ``` + /// + pub UseNamedOperation { + version: "next", + name: "useNamedOperation", + language: "graphql", + sources: &[RuleSource::EslintGraphql("no-anonymous-operations")], + source_kind: RuleSourceKind::SameLogic, + recommended: true, + fix_kind: FixKind::Unsafe, + } +} + +impl Rule for UseNamedOperation { + type Query = Ast; + type State = GraphqlOperationType; + type Signals = Option; + type Options = (); + + fn run(ctx: &RuleContext) -> Option { + let node = ctx.query(); + let operation_type = node.ty().ok()?; + if node.name().is_some() { + None + } else { + Some(operation_type) + } + } + + fn diagnostic( + _ctx: &RuleContext, + operation_type: &Self::State, + ) -> Option { + Some( + RuleDiagnostic::new( + rule_category!(), + operation_type.range(), + markup! { + "Anonymous GraphQL operations are forbidden. Make sure to name your " {operation_type.text()}"." + }, + ) + .note(markup! { + "Most GraphQL client libraries use the operation name for caching purposes." + }), + ) + } + + fn action(ctx: &RuleContext, operation_type: &Self::State) -> Option { + let mut mutation = ctx.root().begin(); + let node = ctx.query().clone(); + let operation_type = operation_type.text(); + let suggested_name = get_suggested_name(&node, operation_type.clone()); + let new_name = make::graphql_name_binding(make::ident(&suggested_name)); + let new_node = node.clone().with_name(Some(new_name)); + mutation.replace_node(node, new_node); + + Some(GraphqlRuleAction::new( + ActionCategory::QuickFix, + ctx.metadata().applicability(), + markup! { + "Rename this "{operation_type}" to "{suggested_name}"." + }, + mutation, + )) + } +} + +fn get_suggested_name(operation: &GraphqlOperationDefinition, operation_type: String) -> String { + let suggested_name = operation + .selection_set() + .ok() + .and_then(|selection_set| { + selection_set + .selections() + .into_iter() + .find_map(|selection| selection.as_graphql_field().cloned()) + }) + .and_then(|first_field| { + first_field + .alias() + .map(|alias| alias.text()) + .or(first_field.name().ok().map(|name| name.text())) + }) + .unwrap_or(operation_type); + Case::Pascal.convert(&suggested_name) +} diff --git a/crates/biome_graphql_analyze/src/options.rs b/crates/biome_graphql_analyze/src/options.rs index 3d0990e8666e..536e144ed307 100644 --- a/crates/biome_graphql_analyze/src/options.rs +++ b/crates/biome_graphql_analyze/src/options.rs @@ -6,3 +6,5 @@ pub type NoDuplicatedFields = ::Options; pub type UseDeprecatedReason = ::Options; +pub type UseNamedOperation = + ::Options; diff --git a/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/invalid.graphql b/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/invalid.graphql new file mode 100644 index 000000000000..beef89efc396 --- /dev/null +++ b/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/invalid.graphql @@ -0,0 +1,3 @@ +query { human } +mutation { ...Type } +subscription {} diff --git a/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/invalid.graphql.snap b/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/invalid.graphql.snap new file mode 100644 index 000000000000..87e26fd85473 --- /dev/null +++ b/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/invalid.graphql.snap @@ -0,0 +1,71 @@ +--- +source: crates/biome_graphql_analyze/tests/spec_tests.rs +expression: invalid.graphql +--- +# Input +```graphql +query { human } +mutation { ...Type } +subscription {} + +``` + +# Diagnostics +``` +invalid.graphql:1:1 lint/nursery/useNamedOperation FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Anonymous GraphQL operations are forbidden. Make sure to name your query. + + > 1 │ query { human } + │ ^^^^^ + 2 │ mutation { ...Type } + 3 │ subscription {} + + i Most GraphQL client libraries use the operation name for caching purposes. + + i Unsafe fix: Rename this query to Human. + + 1 │ query·Human{·human·} + │ +++++ + +``` + +``` +invalid.graphql:2:1 lint/nursery/useNamedOperation FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Anonymous GraphQL operations are forbidden. Make sure to name your mutation. + + 1 │ query { human } + > 2 │ mutation { ...Type } + │ ^^^^^^^^ + 3 │ subscription {} + 4 │ + + i Most GraphQL client libraries use the operation name for caching purposes. + + i Unsafe fix: Rename this mutation to Mutation. + + 2 │ mutation·Mutation{·...Type·} + │ ++++++++ + +``` + +``` +invalid.graphql:3:1 lint/nursery/useNamedOperation FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! Anonymous GraphQL operations are forbidden. Make sure to name your subscription. + + 1 │ query { human } + 2 │ mutation { ...Type } + > 3 │ subscription {} + │ ^^^^^^^^^^^^ + 4 │ + + i Most GraphQL client libraries use the operation name for caching purposes. + + i Unsafe fix: Rename this subscription to Subscription. + + 3 │ subscription·Subscription{} + │ ++++++++++++ + +``` diff --git a/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/valid.graphql b/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/valid.graphql new file mode 100644 index 000000000000..f299f876959a --- /dev/null +++ b/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/valid.graphql @@ -0,0 +1,2 @@ +/* should not generate diagnostics */ +// var a = 1; \ No newline at end of file diff --git a/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/valid.graphql.snap b/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/valid.graphql.snap new file mode 100644 index 000000000000..8ac3e989281e --- /dev/null +++ b/crates/biome_graphql_analyze/tests/specs/nursery/useNamedOperation/valid.graphql.snap @@ -0,0 +1,9 @@ +--- +source: crates/biome_graphql_analyze/tests/spec_tests.rs +expression: valid.graphql +--- +# Input +```graphql +/* should not generate diagnostics */ +// var a = 1; +``` diff --git a/crates/biome_graphql_factory/src/lib.rs b/crates/biome_graphql_factory/src/lib.rs index c2c17c042706..1168a670c3ac 100644 --- a/crates/biome_graphql_factory/src/lib.rs +++ b/crates/biome_graphql_factory/src/lib.rs @@ -2,6 +2,7 @@ use biome_graphql_syntax::GraphqlLanguage; use biome_rowan::TreeBuilder; mod generated; +pub mod make; pub use crate::generated::GraphqlSyntaxFactory; // Re-exported for tests diff --git a/crates/biome_graphql_factory/src/make.rs b/crates/biome_graphql_factory/src/make.rs new file mode 100644 index 000000000000..b3b9c1b9d867 --- /dev/null +++ b/crates/biome_graphql_factory/src/make.rs @@ -0,0 +1,7 @@ +pub use crate::generated::node_factory::*; +use biome_graphql_syntax::{GraphqlSyntaxKind, GraphqlSyntaxToken}; + +/// Create a new literal name token with no attached trivia +pub fn ident(text: &str) -> GraphqlSyntaxToken { + GraphqlSyntaxToken::new_detached(GraphqlSyntaxKind::IDENT, text, [], []) +} diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 4bea6a98acd3..4260752a988f 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -1398,6 +1398,10 @@ export interface Nursery { * Disallows package private imports. */ useImportRestrictions?: RuleConfiguration_for_Null; + /** + * Enforce specifying the name of GraphQL operations. + */ + useNamedOperation?: RuleFixConfiguration_for_Null; /** * Enforce the sorting of CSS utility classes. */ @@ -2980,6 +2984,7 @@ export type Category = | "lint/nursery/useGuardForIn" | "lint/nursery/useImportRestrictions" | "lint/nursery/useJsxCurlyBraceConvention" + | "lint/nursery/useNamedOperation" | "lint/nursery/useSortedClasses" | "lint/nursery/useStrictMode" | "lint/nursery/useTrimStartEnd" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index a6ba2d58a8dd..f9a4810eb18b 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -2401,6 +2401,13 @@ { "type": "null" } ] }, + "useNamedOperation": { + "description": "Enforce specifying the name of GraphQL operations.", + "anyOf": [ + { "$ref": "#/definitions/RuleFixConfiguration" }, + { "type": "null" } + ] + }, "useSortedClasses": { "description": "Enforce the sorting of CSS utility classes.", "anyOf": [