From fdb9e187d458df322939bee59bbd04ba4fc7a6b1 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Tue, 29 Apr 2025 17:00:54 +0100 Subject: [PATCH 01/23] feat(html): analyzer --- Cargo.lock | 32 ++- Cargo.toml | 1 + crates/biome_cli/Cargo.toml | 1 + crates/biome_configuration/Cargo.toml | 5 +- crates/biome_html_analyze/Cargo.toml | 38 ++++ crates/biome_html_analyze/src/lib.rs | 183 ++++++++++++++++++ crates/biome_html_analyze/src/lint.rs | 6 + crates/biome_html_analyze/src/lint/nursery.rs | 7 + .../src/lint/nursery/no_header_scope.rs | 54 ++++++ crates/biome_html_analyze/src/options.rs | 7 + crates/biome_html_analyze/src/registry.rs | 7 + .../src/suppression_action.rs | 30 +++ crates/biome_service/Cargo.toml | 1 + crates/biome_service/src/documentation/mod.rs | 12 ++ crates/biome_service/src/file_handlers/mod.rs | 64 +++++- .../@biomejs/backend-jsonrpc/src/workspace.ts | 4 + .../@biomejs/biome/configuration_schema.json | 7 + xtask/codegen/Cargo.toml | 5 + xtask/codegen/src/generate_analyzer.rs | 28 +++ xtask/codegen/src/generate_configuration.rs | 41 ++++ xtask/rules_check/Cargo.toml | 3 + xtask/rules_check/src/lib.rs | 12 ++ 22 files changed, 544 insertions(+), 4 deletions(-) create mode 100644 crates/biome_html_analyze/Cargo.toml create mode 100644 crates/biome_html_analyze/src/lib.rs create mode 100644 crates/biome_html_analyze/src/lint.rs create mode 100644 crates/biome_html_analyze/src/lint/nursery.rs create mode 100644 crates/biome_html_analyze/src/lint/nursery/no_header_scope.rs create mode 100644 crates/biome_html_analyze/src/options.rs create mode 100644 crates/biome_html_analyze/src/registry.rs create mode 100644 crates/biome_html_analyze/src/suppression_action.rs diff --git a/Cargo.lock b/Cargo.lock index dccf1bd5a8c3..a0625f62927e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,6 +184,7 @@ dependencies = [ "biome_graphql_analyze", "biome_graphql_syntax", "biome_grit_patterns", + "biome_html_analyze", "biome_html_formatter", "biome_js_analyze", "biome_js_formatter", @@ -749,6 +750,29 @@ dependencies = [ "serde", ] +[[package]] +name = "biome_html_analyze" +version = "0.5.7" +dependencies = [ + "biome_analyze", + "biome_console", + "biome_deserialize", + "biome_deserialize_macros", + "biome_diagnostics", + "biome_html_factory", + "biome_html_parser", + "biome_html_syntax", + "biome_rowan", + "biome_string_case", + "biome_suppression", + "biome_test_utils", + "camino", + "insta", + "schemars", + "serde", + "tests_macros", +] + [[package]] name = "biome_html_factory" version = "0.5.7" @@ -1496,7 +1520,7 @@ dependencies = [ "biome_grit_parser", "biome_grit_patterns", "biome_grit_syntax", - "biome_html_factory", + "biome_html_analyze", "biome_html_formatter", "biome_html_parser", "biome_html_syntax", @@ -4047,6 +4071,9 @@ dependencies = [ "biome_graphql_analyze", "biome_graphql_parser", "biome_graphql_syntax", + "biome_html_analyze", + "biome_html_parser", + "biome_html_syntax", "biome_js_analyze", "biome_js_parser", "biome_js_syntax", @@ -5752,6 +5779,9 @@ dependencies = [ "biome_graphql_analyze", "biome_graphql_parser", "biome_graphql_syntax", + "biome_html_analyze", + "biome_html_parser", + "biome_html_syntax", "biome_js_analyze", "biome_js_factory", "biome_js_formatter", diff --git a/Cargo.toml b/Cargo.toml index 1e64773aee33..e8d8df4e265a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -128,6 +128,7 @@ biome_grit_formatter = { version = "0.0.0", path = "./crates/biome_grit_ biome_grit_parser = { version = "0.1.0", path = "./crates/biome_grit_parser" } biome_grit_patterns = { version = "0.0.1", path = "./crates/biome_grit_patterns" } biome_grit_syntax = { version = "0.5.7", path = "./crates/biome_grit_syntax" } +biome_html_analyze = { version = "0.5.7", path = "./crates/biome_html_analyze" } biome_html_factory = { version = "0.5.7", path = "./crates/biome_html_factory" } biome_html_formatter = { version = "0.0.0", path = "./crates/biome_html_formatter" } biome_html_parser = { version = "0.0.1", path = "./crates/biome_html_parser" } diff --git a/crates/biome_cli/Cargo.toml b/crates/biome_cli/Cargo.toml index dfa50d2aa018..32abc9ac33a1 100644 --- a/crates/biome_cli/Cargo.toml +++ b/crates/biome_cli/Cargo.toml @@ -34,6 +34,7 @@ biome_glob = { workspace = true } biome_graphql_analyze = { workspace = true } biome_graphql_syntax = { workspace = true } biome_grit_patterns = { workspace = true } +biome_html_analyze = { workspace = true } biome_html_formatter = { workspace = true } biome_js_analyze = { workspace = true } biome_js_formatter = { workspace = true } diff --git a/crates/biome_configuration/Cargo.toml b/crates/biome_configuration/Cargo.toml index c6eafb0b007d..98a3cf00e04a 100644 --- a/crates/biome_configuration/Cargo.toml +++ b/crates/biome_configuration/Cargo.toml @@ -13,8 +13,9 @@ version = "0.0.1" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -biome_analyze = { workspace = true, features = ["serde"] } -biome_console = { workspace = true } +biome_analyze = { workspace = true, features = ["serde"] } +biome_console = { workspace = true } + biome_deserialize = { workspace = true } biome_deserialize_macros = { workspace = true } biome_diagnostics = { workspace = true } diff --git a/crates/biome_html_analyze/Cargo.toml b/crates/biome_html_analyze/Cargo.toml new file mode 100644 index 000000000000..2fa2742b758c --- /dev/null +++ b/crates/biome_html_analyze/Cargo.toml @@ -0,0 +1,38 @@ +[package] +authors.workspace = true +categories.workspace = true +description = "Biome's HTML linter" +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +name = "biome_html_analyze" +repository.workspace = true +version = "0.5.7" + +[dependencies] +biome_analyze = { workspace = true } +biome_console = { workspace = true } +biome_deserialize = { workspace = true } +biome_deserialize_macros = { workspace = true } +biome_diagnostics = { workspace = true } +biome_html_factory = { workspace = true } +biome_html_syntax = { workspace = true } +biome_rowan = { workspace = true } +biome_string_case = { workspace = true } +biome_suppression = { workspace = true } +schemars = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +biome_html_parser = { path = "../biome_html_parser" } +biome_test_utils = { path = "../biome_test_utils" } +camino = { workspace = true } +insta = { workspace = true, features = ["glob"] } +tests_macros = { path = "../tests_macros" } + +[features] +schema = ["schemars"] + +[lints] +workspace = true diff --git a/crates/biome_html_analyze/src/lib.rs b/crates/biome_html_analyze/src/lib.rs new file mode 100644 index 000000000000..7fc0e5e06632 --- /dev/null +++ b/crates/biome_html_analyze/src/lib.rs @@ -0,0 +1,183 @@ +#![deny(clippy::use_self)] + +mod lint; +pub mod options; +mod registry; +mod suppression_action; + +pub use crate::registry::visit_registry; +use crate::suppression_action::HtmlSuppressionAction; +use biome_analyze::{ + AnalysisFilter, AnalyzerOptions, AnalyzerSignal, AnalyzerSuppression, ControlFlow, + LanguageRoot, MatchQueryParams, MetadataRegistry, RuleRegistry, to_analyzer_suppressions, +}; +use biome_deserialize::TextRange; +use biome_diagnostics::Error; +use biome_html_syntax::HtmlLanguage; +use biome_suppression::{SuppressionDiagnostic, parse_suppression_comment}; +use std::ops::Deref; +use std::sync::LazyLock; + +pub static METADATA: LazyLock = LazyLock::new(|| { + let mut metadata = MetadataRegistry::default(); + visit_registry(&mut metadata); + metadata +}); + +/// Run the analyzer on the provided `root`: this process will use the given `filter` +/// to selectively restrict analysis to specific rules / a specific source range, +/// then call `emit_signal` when an analysis rule emits a diagnostic or action +pub fn analyze<'a, F, B>( + root: &LanguageRoot, + filter: AnalysisFilter, + options: &'a AnalyzerOptions, + emit_signal: F, +) -> (Option, Vec) +where + F: FnMut(&dyn AnalyzerSignal) -> ControlFlow + 'a, + B: 'a, +{ + analyze_with_inspect_matcher(root, filter, |_| {}, options, emit_signal) +} + +/// Run the analyzer on the provided `root`: this process will use the given `filter` +/// to selectively restrict analysis to specific rules / a specific source range, +/// then call `emit_signal` when an analysis rule emits a diagnostic or action. +/// Additionally, this function takes a `inspect_matcher` function that can be +/// used to inspect the "query matches" emitted by the analyzer before they are +/// processed by the lint rules registry +pub fn analyze_with_inspect_matcher<'a, V, F, B>( + root: &LanguageRoot, + filter: AnalysisFilter, + inspect_matcher: V, + options: &'a AnalyzerOptions, + mut emit_signal: F, +) -> (Option, Vec) +where + V: FnMut(&MatchQueryParams) + 'a, + F: FnMut(&dyn AnalyzerSignal) -> ControlFlow + 'a, + B: 'a, +{ + fn parse_linter_suppression_comment( + text: &str, + piece_range: TextRange, + ) -> Vec, SuppressionDiagnostic>> { + let mut result = Vec::new(); + + for suppression in parse_suppression_comment(text) { + let suppression = match suppression { + Ok(suppression) => suppression, + Err(err) => { + result.push(Err(err)); + continue; + } + }; + + let analyzer_suppressions: Vec<_> = to_analyzer_suppressions(suppression, piece_range) + .into_iter() + .map(Ok) + .collect(); + + result.extend(analyzer_suppressions) + } + + result + } + + let mut registry = RuleRegistry::builder(&filter, root); + visit_registry(&mut registry); + + let (registry, services, diagnostics, visitors) = registry.build(); + + // Bail if we can't parse a rule option + if !diagnostics.is_empty() { + return (None, diagnostics); + } + + let mut analyzer = biome_analyze::Analyzer::new( + METADATA.deref(), + biome_analyze::InspectMatcher::new(registry, inspect_matcher), + parse_linter_suppression_comment, + // TODO: add suppression action + Box::new(HtmlSuppressionAction), + &mut emit_signal, + ); + + for ((phase, _), visitor) in visitors { + analyzer.add_visitor(phase, visitor); + } + + ( + analyzer.run(biome_analyze::AnalyzerContext { + root: root.clone(), + range: filter.range, + services, + options, + }), + diagnostics, + ) +} + +#[cfg(test)] +mod tests { + use crate::analyze; + use biome_analyze::{AnalysisFilter, AnalyzerOptions, ControlFlow, Never, RuleFilter}; + use biome_console::fmt::{Formatter, Termcolor}; + use biome_console::{Markup, markup}; + use biome_diagnostics::termcolor::NoColor; + use biome_diagnostics::{Diagnostic, DiagnosticExt, PrintDiagnostic, Severity}; + use biome_html_parser::parse_html; + use biome_rowan::TextRange; + use std::slice; + + #[ignore] + #[test] + fn quick_test() { + fn markup_to_string(markup: Markup) -> String { + let mut buffer = Vec::new(); + let mut write = Termcolor(NoColor::new(&mut buffer)); + let mut fmt = Formatter::new(&mut write); + fmt.write_markup(markup).unwrap(); + + String::from_utf8(buffer).unwrap() + } + + const SOURCE: &str = r#" "#; + + let parsed = parse_html(SOURCE); + + let mut error_ranges: Vec = Vec::new(); + let rule_filter = RuleFilter::Rule("nursery", "noUnknownPseudoClass"); + let options = AnalyzerOptions::default(); + analyze( + &parsed.tree(), + AnalysisFilter { + enabled_rules: Some(slice::from_ref(&rule_filter)), + ..AnalysisFilter::default() + }, + &options, + |signal| { + if let Some(diag) = signal.diagnostic() { + error_ranges.push(diag.location().span.unwrap()); + let error = diag + .with_severity(Severity::Warning) + .with_file_path("ahahah") + .with_file_source_code(SOURCE); + let text = markup_to_string(markup! { + {PrintDiagnostic::verbose(&error)} + }); + eprintln!("{text}"); + } + + for action in signal.actions() { + let new_code = action.mutation.commit(); + eprintln!("{new_code}"); + } + + ControlFlow::::Continue(()) + }, + ); + + assert_eq!(error_ranges.as_slice(), &[]); + } +} diff --git a/crates/biome_html_analyze/src/lint.rs b/crates/biome_html_analyze/src/lint.rs new file mode 100644 index 000000000000..9a25c84e0093 --- /dev/null +++ b/crates/biome_html_analyze/src/lint.rs @@ -0,0 +1,6 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +//! Generated file, do not edit by hand, see `xtask/codegen` + +pub mod nursery; +::biome_analyze::declare_category! { pub Lint { kind : Lint , groups : [self :: nursery :: Nursery ,] } } diff --git a/crates/biome_html_analyze/src/lint/nursery.rs b/crates/biome_html_analyze/src/lint/nursery.rs new file mode 100644 index 000000000000..e6a958ab03bf --- /dev/null +++ b/crates/biome_html_analyze/src/lint/nursery.rs @@ -0,0 +1,7 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +//! Generated file, do not edit by hand, see `xtask/codegen` + +use biome_analyze::declare_lint_group; +pub mod no_header_scope; +declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_header_scope :: NoHeaderScope ,] } } diff --git a/crates/biome_html_analyze/src/lint/nursery/no_header_scope.rs b/crates/biome_html_analyze/src/lint/nursery/no_header_scope.rs new file mode 100644 index 000000000000..61560b8e5d37 --- /dev/null +++ b/crates/biome_html_analyze/src/lint/nursery/no_header_scope.rs @@ -0,0 +1,54 @@ +use biome_analyze::context::RuleContext; +use biome_analyze::{Ast, FixKind, Rule, RuleDiagnostic, RuleSource, declare_lint_rule}; +use biome_diagnostics::Severity; +use biome_html_syntax::AnyHtmlElement; + +declare_lint_rule! { + /// The scope prop should be used only on `` elements. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```html,ignore + ///
+ /// ``` + /// + /// ### Valid + /// + /// ```html,ignore + /// + /// ``` + /// + /// ## Accessibility guidelines + /// + /// - [WCAG 1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) + /// - [WCAG 4.1.1](https://www.w3.org/WAI/WCAG21/Understanding/parsing) + /// + pub NoHeaderScope { + version: "next", + name: "noHeaderScope", + language: "html", + sources: &[RuleSource::EslintJsxA11y("scope").same()], + recommended: true, + severity: Severity::Error, + fix_kind: FixKind::Unsafe, + } +} + +impl Rule for NoHeaderScope { + type Query = Ast; + type State = (); + type Signals = Option; + type Options = (); + + fn run(ctx: &RuleContext) -> Self::Signals { + let _element = ctx.query(); + + None + } + + fn diagnostic(_ctx: &RuleContext, _: &Self::State) -> Option { + None + } +} diff --git a/crates/biome_html_analyze/src/options.rs b/crates/biome_html_analyze/src/options.rs new file mode 100644 index 000000000000..22e0475e7832 --- /dev/null +++ b/crates/biome_html_analyze/src/options.rs @@ -0,0 +1,7 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +//! Generated file, do not edit by hand, see `xtask/codegen` + +use crate::lint; +pub type NoHeaderScope = + ::Options; diff --git a/crates/biome_html_analyze/src/registry.rs b/crates/biome_html_analyze/src/registry.rs new file mode 100644 index 000000000000..97ee757b8e1d --- /dev/null +++ b/crates/biome_html_analyze/src/registry.rs @@ -0,0 +1,7 @@ +//! Generated file, do not edit by hand, see `xtask/codegen` + +use biome_analyze::RegistryVisitor; +use biome_html_syntax::HtmlLanguage; +pub fn visit_registry>(registry: &mut V) { + registry.record_category::(); +} diff --git a/crates/biome_html_analyze/src/suppression_action.rs b/crates/biome_html_analyze/src/suppression_action.rs new file mode 100644 index 000000000000..d435ac377807 --- /dev/null +++ b/crates/biome_html_analyze/src/suppression_action.rs @@ -0,0 +1,30 @@ +use biome_analyze::{ApplySuppression, SuppressionAction}; +use biome_html_syntax::HtmlLanguage; +use biome_rowan::{BatchMutation, SyntaxToken}; + +pub(crate) struct HtmlSuppressionAction; + +impl SuppressionAction for HtmlSuppressionAction { + type Language = HtmlLanguage; + + fn find_token_for_inline_suppression( + &self, + _original_token: SyntaxToken, + ) -> Option> { + todo!() + } + + fn apply_inline_suppression( + &self, + _mutation: &mut BatchMutation, + _apply_suppression: ApplySuppression, + _suppression_text: &str, + _suppression_reason: &str, + ) { + todo!() + } + + fn suppression_top_level_comment(&self, _suppression_text: &str) -> String { + todo!() + } +} diff --git a/crates/biome_service/Cargo.toml b/crates/biome_service/Cargo.toml index df458fdddf7f..cd43ba467d09 100644 --- a/crates/biome_service/Cargo.toml +++ b/crates/biome_service/Cargo.toml @@ -36,6 +36,7 @@ biome_grit_formatter = { workspace = true } biome_grit_parser = { workspace = true } biome_grit_patterns = { workspace = true, features = ["serde"] } biome_grit_syntax = { workspace = true } +biome_html_analyze = { workspace = true } biome_html_factory = { workspace = true } biome_html_formatter = { workspace = true, features = ["serde"] } biome_html_parser = { workspace = true } diff --git a/crates/biome_service/src/documentation/mod.rs b/crates/biome_service/src/documentation/mod.rs index 16aeb566b610..ba9a150712da 100644 --- a/crates/biome_service/src/documentation/mod.rs +++ b/crates/biome_service/src/documentation/mod.rs @@ -6,6 +6,7 @@ use biome_console::fmt::{Display, Formatter}; use biome_console::{Padding, markup}; use biome_css_syntax::CssLanguage; use biome_graphql_syntax::GraphqlLanguage; +use biome_html_syntax::HtmlLanguage; use biome_js_syntax::JsLanguage; use biome_json_syntax::JsonLanguage; use biome_rowan::Language; @@ -53,6 +54,7 @@ impl RulesVisitor { }; biome_graphql_analyze::visit_registry(&mut visitor); + biome_html_analyze::visit_registry(&mut visitor); biome_css_analyze::visit_registry(&mut visitor); biome_json_analyze::visit_registry(&mut visitor); biome_js_analyze::visit_registry(&mut visitor); @@ -121,6 +123,16 @@ impl RegistryVisitor for RulesVisitor { } } +impl RegistryVisitor for RulesVisitor { + fn record_rule(&mut self) + where + R: Rule> + + 'static, + { + self.store_rule::(); + } +} + impl biome_console::fmt::Display for ExplainRule { fn fmt(&self, fmt: &mut Formatter) -> std::io::Result<()> { let metadata = &self.metadata; diff --git a/crates/biome_service/src/file_handlers/mod.rs b/crates/biome_service/src/file_handlers/mod.rs index 67a18fd68007..e129c2c481eb 100644 --- a/crates/biome_service/src/file_handlers/mod.rs +++ b/crates/biome_service/src/file_handlers/mod.rs @@ -32,7 +32,7 @@ use biome_graphql_analyze::METADATA as graphql_metadata; use biome_graphql_syntax::{GraphqlFileSource, GraphqlLanguage}; use biome_grit_patterns::{GritQuery, GritQueryEffect, GritTargetFile}; use biome_grit_syntax::file_source::GritFileSource; -use biome_html_syntax::HtmlFileSource; +use biome_html_syntax::{HtmlFileSource, HtmlLanguage}; use biome_js_analyze::METADATA as js_metadata; use biome_js_parser::{JsParserOptions, parse}; use biome_js_syntax::{ @@ -1021,6 +1021,25 @@ impl RegistryVisitor for SyntaxVisitor<'_> { } } +impl RegistryVisitor for SyntaxVisitor<'_> { + fn record_category>(&mut self) { + if C::CATEGORY == RuleCategory::Syntax { + C::record_groups(self) + } + } + + fn record_rule(&mut self) + where + R: Rule> + + 'static, + { + self.enabled_rules.push(RuleFilter::Rule( + ::NAME, + R::METADATA.name, + )) + } +} + /// Type meant to register all the lint rules for each language supported by Biome /// #[derive(Debug)] @@ -1335,6 +1354,30 @@ impl RegistryVisitor for LintVisitor<'_, '_> { } } +impl RegistryVisitor for LintVisitor<'_, '_> { + fn record_category>(&mut self) { + if C::CATEGORY == RuleCategory::Lint { + C::record_groups(self) + } + } + + fn record_group>(&mut self) { + G::record_rules(self) + } + + fn record_rule(&mut self) + where + R: Rule> + + 'static, + { + self.push_rule::::Language>( + graphql_metadata + .find_rule(R::Group::NAME, R::METADATA.name) + .map(RuleFilter::from), + ) + } +} + struct AssistsVisitor<'a, 'b> { settings: &'b Settings, enabled_rules: Vec>, @@ -1487,6 +1530,22 @@ impl RegistryVisitor for AssistsVisitor<'_, '_> { } } +impl RegistryVisitor for AssistsVisitor<'_, '_> { + fn record_category>(&mut self) { + if C::CATEGORY == RuleCategory::Action { + C::record_groups(self) + } + } + + fn record_rule(&mut self) + where + R: Rule> + + 'static, + { + self.push_rule::::Language>(); + } +} + pub(crate) struct AnalyzerVisitorBuilder<'a> { settings: &'a Settings, only: Option<&'a [AnalyzerSelector]>, @@ -1564,6 +1623,7 @@ impl<'b> AnalyzerVisitorBuilder<'b> { biome_css_analyze::visit_registry(&mut syntax); biome_json_analyze::visit_registry(&mut syntax); biome_graphql_analyze::visit_registry(&mut syntax); + biome_html_analyze::visit_registry(&mut syntax); enabled_rules.extend(syntax.enabled_rules); let package_json = self @@ -1584,6 +1644,7 @@ impl<'b> AnalyzerVisitorBuilder<'b> { biome_css_analyze::visit_registry(&mut lint); biome_json_analyze::visit_registry(&mut lint); biome_graphql_analyze::visit_registry(&mut lint); + biome_html_analyze::visit_registry(&mut lint); let (linter_enabled_rules, linter_disabled_rules) = lint.finish(); enabled_rules.extend(linter_enabled_rules); disabled_rules.extend(linter_disabled_rules); @@ -1594,6 +1655,7 @@ impl<'b> AnalyzerVisitorBuilder<'b> { biome_css_analyze::visit_registry(&mut assist); biome_json_analyze::visit_registry(&mut assist); biome_graphql_analyze::visit_registry(&mut assist); + biome_html_analyze::visit_registry(&mut assist); let (assists_enabled_rules, assists_disabled_rules) = assist.finish(); enabled_rules.extend(assists_enabled_rules); disabled_rules.extend(assists_disabled_rules); diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index ab2fb3298b13..c73025f97114 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -1650,6 +1650,10 @@ export interface Nursery { * Require Promise-like statements to be handled appropriately. */ noFloatingPromises?: RuleFixConfiguration_for_NoFloatingPromisesOptions; + /** + * The scope prop should be used only on \ elements. + */ + noHeaderScope?: RuleFixConfiguration_for_NoHeaderScopeOptions; /** * Prevent import cycles. */ diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index d583f42e0e98..a0a62a6975e3 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -5055,6 +5055,13 @@ { "type": "null" } ] }, + "noHeaderScope": { + "description": "The scope prop should be used only on \\ elements.", + "anyOf": [ + { "$ref": "#/definitions/NoHeaderScopeConfiguration" }, + { "type": "null" } + ] + }, "noImportCycles": { "description": "Prevent import cycles.", "anyOf": [ diff --git a/xtask/codegen/Cargo.toml b/xtask/codegen/Cargo.toml index 3dac9cddb480..ee723c4fbfaf 100644 --- a/xtask/codegen/Cargo.toml +++ b/xtask/codegen/Cargo.toml @@ -24,6 +24,9 @@ biome_diagnostics = { workspace = true, optional = true } biome_graphql_analyze = { workspace = true, optional = true } biome_graphql_parser = { workspace = true, optional = true } biome_graphql_syntax = { workspace = true, optional = true } +biome_html_analyze = { workspace = true, optional = true } +biome_html_parser = { workspace = true, optional = true } +biome_html_syntax = { workspace = true, optional = true } biome_js_analyze = { workspace = true, optional = true } biome_js_factory = { workspace = true, optional = true } biome_js_formatter = { workspace = true, optional = true } @@ -51,6 +54,8 @@ configuration = [ "biome_css_syntax", "biome_graphql_analyze", "biome_graphql_syntax", + "biome_html_analyze", + "biome_html_syntax", "biome_rowan", "pulldown-cmark", "biome_diagnostics", diff --git a/xtask/codegen/src/generate_analyzer.rs b/xtask/codegen/src/generate_analyzer.rs index 5a7658a48ec8..007f1d6de21e 100644 --- a/xtask/codegen/src/generate_analyzer.rs +++ b/xtask/codegen/src/generate_analyzer.rs @@ -10,6 +10,7 @@ pub fn generate_analyzer() -> Result<()> { generate_json_analyzer()?; generate_css_analyzer()?; generate_graphql_analyzer()?; + generate_html_analyzer()?; Ok(()) } @@ -49,6 +50,14 @@ fn generate_graphql_analyzer() -> Result<()> { update_graphql_registry_builder(analyzers) } +fn generate_html_analyzer() -> Result<()> { + let base_path = project_root().join("crates/biome_html_analyze/src"); + let mut analyzers = BTreeMap::new(); + generate_category("lint", &mut analyzers, &base_path)?; + + update_html_registry_builder(analyzers) +} + fn generate_category( name: &'static str, entries: &mut BTreeMap<&'static str, TokenStream>, @@ -283,3 +292,22 @@ fn update_graphql_registry_builder(analyzers: BTreeMap<&'static str, TokenStream Ok(()) } + +fn update_html_registry_builder(analyzers: BTreeMap<&'static str, TokenStream>) -> Result<()> { + let path = project_root().join("crates/biome_html_analyze/src/registry.rs"); + + let categories = analyzers.into_values(); + + let tokens = xtask::reformat(quote! { + use biome_analyze::RegistryVisitor; + use biome_html_syntax::HtmlLanguage; + + pub fn visit_registry>(registry: &mut V) { + #( #categories )* + } + })?; + + fs2::write(path, tokens)?; + + Ok(()) +} diff --git a/xtask/codegen/src/generate_configuration.rs b/xtask/codegen/src/generate_configuration.rs index 6808eaa65794..74ad004422e8 100644 --- a/xtask/codegen/src/generate_configuration.rs +++ b/xtask/codegen/src/generate_configuration.rs @@ -3,6 +3,7 @@ use biome_analyze::{ }; use biome_css_syntax::CssLanguage; use biome_graphql_syntax::GraphqlLanguage; +use biome_html_syntax::HtmlLanguage; use biome_js_syntax::JsLanguage; use biome_json_syntax::JsonLanguage; use biome_string_case::Case; @@ -106,6 +107,25 @@ impl RegistryVisitor for LintRulesVisitor { } } +impl RegistryVisitor for LintRulesVisitor { + fn record_category>(&mut self) { + if matches!(C::CATEGORY, RuleCategory::Lint) { + C::record_groups(self); + } + } + + fn record_rule(&mut self) + where + R: Rule> + + 'static, + { + self.groups + .entry(::NAME) + .or_default() + .insert(R::METADATA.name, R::METADATA); + } +} + // ======= ASSIST ====== #[derive(Default)] struct AssistActionsVisitor { @@ -187,6 +207,25 @@ impl RegistryVisitor for AssistActionsVisitor { } } +impl RegistryVisitor for AssistActionsVisitor { + fn record_category>(&mut self) { + if matches!(C::CATEGORY, RuleCategory::Action) { + C::record_groups(self); + } + } + + fn record_rule(&mut self) + where + R: Rule> + + 'static, + { + self.groups + .entry(::NAME) + .or_default() + .insert(R::METADATA.name, R::METADATA); + } +} + pub(crate) fn generate_rule_options(mode: Mode) -> Result<()> { let rule_options_root = get_analyzer_rule_options_path(); let lib_root = rule_options_root.join("lib.rs"); @@ -257,6 +296,8 @@ pub(crate) fn generate_rules_configuration(mode: Mode) -> Result<()> { biome_css_analyze::visit_registry(&mut assist_visitor); biome_graphql_analyze::visit_registry(&mut lint_visitor); biome_graphql_analyze::visit_registry(&mut assist_visitor); + biome_html_analyze::visit_registry(&mut lint_visitor); + biome_html_analyze::visit_registry(&mut assist_visitor); // let LintRulesVisitor { groups } = lint_visitor; diff --git a/xtask/rules_check/Cargo.toml b/xtask/rules_check/Cargo.toml index 6c063980f645..dcfa4a09407b 100644 --- a/xtask/rules_check/Cargo.toml +++ b/xtask/rules_check/Cargo.toml @@ -19,6 +19,9 @@ biome_fs = { workspace = true } biome_graphql_analyze = { workspace = true } biome_graphql_parser = { workspace = true } biome_graphql_syntax = { workspace = true } +biome_html_analyze = { workspace = true } +biome_html_parser = { workspace = true } +biome_html_syntax = { workspace = true } biome_js_analyze = { workspace = true } biome_js_parser = { workspace = true } biome_js_syntax = { workspace = true } diff --git a/xtask/rules_check/src/lib.rs b/xtask/rules_check/src/lib.rs index f2bcc080b11a..91f6d5d932a1 100644 --- a/xtask/rules_check/src/lib.rs +++ b/xtask/rules_check/src/lib.rs @@ -18,6 +18,7 @@ use biome_css_syntax::CssLanguage; use biome_deserialize::json::deserialize_from_json_ast; use biome_diagnostics::{DiagnosticExt, PrintDiagnostic, Severity}; use biome_graphql_syntax::GraphqlLanguage; +use biome_html_syntax::HtmlLanguage; use biome_js_parser::JsParserOptions; use biome_js_syntax::{EmbeddingKind, JsFileSource, JsLanguage, TextSize}; use biome_json_factory::make; @@ -125,11 +126,22 @@ pub fn check_rules() -> anyhow::Result<()> { } } + impl RegistryVisitor for LintRulesVisitor { + fn record_rule(&mut self) + where + R: Rule> + + 'static, + { + self.push_rule::::Language>() + } + } + let mut visitor = LintRulesVisitor::default(); biome_js_analyze::visit_registry(&mut visitor); biome_json_analyze::visit_registry(&mut visitor); biome_css_analyze::visit_registry(&mut visitor); biome_graphql_analyze::visit_registry(&mut visitor); + biome_html_analyze::visit_registry(&mut visitor); let LintRulesVisitor { groups, errors } = visitor; if !errors.is_empty() { From 92fd7725a806394c1dfce07164a0b12d7f48846e Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Fri, 3 Oct 2025 17:15:19 +0100 Subject: [PATCH 02/23] feat(html): analyzer --- crates/biome_html_analyze/src/lib.rs | 7 +- crates/biome_html_analyze/src/lint.rs | 4 +- .../src/lint/{nursery.rs => a11y.rs} | 2 +- .../src/lint/a11y/no_header_scope.rs | 155 +++++++++++++++ .../src/lint/nursery/no_header_scope.rs | 54 ----- crates/biome_html_analyze/src/options.rs | 2 +- .../src/suppression_action.rs | 94 +++++++-- crates/biome_html_analyze/tests/spec_tests.rs | 184 ++++++++++++++++++ .../specs/a11y/noHeaderScope/invalid.html | 6 + .../a11y/noHeaderScope/invalid.html.snap | 143 ++++++++++++++ .../tests/specs/a11y/noHeaderScope/valid.html | 6 + .../specs/a11y/noHeaderScope/valid.html.snap | 14 ++ 12 files changed, 600 insertions(+), 71 deletions(-) rename crates/biome_html_analyze/src/lint/{nursery.rs => a11y.rs} (62%) create mode 100644 crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs delete mode 100644 crates/biome_html_analyze/src/lint/nursery/no_header_scope.rs create mode 100644 crates/biome_html_analyze/tests/spec_tests.rs create mode 100644 crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/invalid.html create mode 100644 crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/invalid.html.snap create mode 100644 crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/valid.html create mode 100644 crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/valid.html.snap diff --git a/crates/biome_html_analyze/src/lib.rs b/crates/biome_html_analyze/src/lib.rs index 7fc0e5e06632..c5c72aa0cbb6 100644 --- a/crates/biome_html_analyze/src/lib.rs +++ b/crates/biome_html_analyze/src/lib.rs @@ -9,7 +9,8 @@ pub use crate::registry::visit_registry; use crate::suppression_action::HtmlSuppressionAction; use biome_analyze::{ AnalysisFilter, AnalyzerOptions, AnalyzerSignal, AnalyzerSuppression, ControlFlow, - LanguageRoot, MatchQueryParams, MetadataRegistry, RuleRegistry, to_analyzer_suppressions, + LanguageRoot, MatchQueryParams, MetadataRegistry, RuleAction, RuleRegistry, + to_analyzer_suppressions, }; use biome_deserialize::TextRange; use biome_diagnostics::Error; @@ -18,6 +19,8 @@ use biome_suppression::{SuppressionDiagnostic, parse_suppression_comment}; use std::ops::Deref; use std::sync::LazyLock; +pub(crate) type HtmlRuleAction = RuleAction; + pub static METADATA: LazyLock = LazyLock::new(|| { let mut metadata = MetadataRegistry::default(); visit_registry(&mut metadata); @@ -144,7 +147,7 @@ mod tests { const SOURCE: &str = r#" "#; - let parsed = parse_html(SOURCE); + let parsed = parse_html(SOURCE, Default::default()); let mut error_ranges: Vec = Vec::new(); let rule_filter = RuleFilter::Rule("nursery", "noUnknownPseudoClass"); diff --git a/crates/biome_html_analyze/src/lint.rs b/crates/biome_html_analyze/src/lint.rs index 9a25c84e0093..b2f2e59b40b4 100644 --- a/crates/biome_html_analyze/src/lint.rs +++ b/crates/biome_html_analyze/src/lint.rs @@ -2,5 +2,5 @@ //! Generated file, do not edit by hand, see `xtask/codegen` -pub mod nursery; -::biome_analyze::declare_category! { pub Lint { kind : Lint , groups : [self :: nursery :: Nursery ,] } } +pub mod a11y; +::biome_analyze::declare_category! { pub Lint { kind : Lint , groups : [self :: a11y :: A11y ,] } } diff --git a/crates/biome_html_analyze/src/lint/nursery.rs b/crates/biome_html_analyze/src/lint/a11y.rs similarity index 62% rename from crates/biome_html_analyze/src/lint/nursery.rs rename to crates/biome_html_analyze/src/lint/a11y.rs index e6a958ab03bf..51c45be57db9 100644 --- a/crates/biome_html_analyze/src/lint/nursery.rs +++ b/crates/biome_html_analyze/src/lint/a11y.rs @@ -4,4 +4,4 @@ use biome_analyze::declare_lint_group; pub mod no_header_scope; -declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_header_scope :: NoHeaderScope ,] } } +declare_lint_group! { pub A11y { name : "a11y" , rules : [self :: no_header_scope :: NoHeaderScope ,] } } diff --git a/crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs b/crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs new file mode 100644 index 000000000000..61222599a68d --- /dev/null +++ b/crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs @@ -0,0 +1,155 @@ +use biome_analyze::context::RuleContext; +use biome_analyze::{declare_lint_rule, Ast, FixKind, Rule, RuleDiagnostic, RuleSource}; +use biome_console::markup; +use biome_diagnostics::Severity; +use biome_html_syntax::{AnyHtmlAttribute, AnyHtmlElement, HtmlAttribute, HtmlAttributeList}; +use biome_rowan::{AstNode, AstNodeList, BatchMutationExt}; + +use crate::HtmlRuleAction; + +declare_lint_rule! { + /// The scope prop should be used only on `` elements. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```html,expect_diagnostic + ///
+ /// ``` + /// + /// ```html,expect_diagnostic + ///
+ /// ``` + /// + /// ### Valid + /// + /// ```html + /// + /// ``` + /// + /// ```html + /// + /// ``` + /// + /// ## Accessibility guidelines + /// + /// - [WCAG 1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) + /// - [WCAG 4.1.1](https://www.w3.org/WAI/WCAG21/Understanding/parsing) + /// + pub NoHeaderScope { + version: "next", + name: "noHeaderScope", + language: "html", + sources: &[RuleSource::EslintJsxA11y("scope").same()], + recommended: true, + severity: Severity::Error, + fix_kind: FixKind::Unsafe, + } +} + +impl Rule for NoHeaderScope { + type Query = Ast; + type State = HtmlAttribute; + type Signals = Option; + type Options = (); + + fn run(ctx: &RuleContext) -> Self::Signals { + let element = ctx.query(); + + // Check if element is NOT a th element and has a scope attribute + if is_th_element(element) { + return None; + } + + // Check if element has a scope attribute + let attributes = get_element_attributes(element)?; + let scope_attribute = find_attribute_by_name(&attributes, "scope")?; + + Some(scope_attribute) + } + + fn diagnostic(_ctx: &RuleContext, state: &Self::State) -> Option { + let diagnostic = RuleDiagnostic::new( + rule_category!(), + state.range(), + markup! {"Avoid using the ""scope"" attribute on elements other than ""th"" elements."} + .to_owned(), + ).note(markup!{ + "The ""scope"" attribute is used to associate a data cell with its corresponding header cell in a data table, + so it should be placed on ""th"" elements to provide accessibility to screen readers." + }).note(markup!{ + "Follow the links for more information, + ""WCAG 1.3.1"" + ""WCAG 4.1.1""" + }); + + Some(diagnostic) + } + + fn action(ctx: &RuleContext, state: &Self::State) -> Option { + let mut mutation = ctx.root().begin(); + mutation.remove_node(state.clone()); + + Some(HtmlRuleAction::new( + ctx.metadata().action_category(ctx.category(), ctx.group()), + ctx.metadata().applicability(), + markup! { "Remove the ""scope"" attribute." }.to_owned(), + mutation, + )) + } +} + +// Helper function to check if element is a th element +fn is_th_element(element: &AnyHtmlElement) -> bool { + match element { + AnyHtmlElement::HtmlElement(el) => { + if let Ok(opening_element) = el.opening_element() { + if let Ok(name) = opening_element.name() { + if let Ok(name_token) = name.value_token() { + return name_token.text_trimmed() == "th"; + } + } + } + false + } + AnyHtmlElement::HtmlSelfClosingElement(el) => { + if let Ok(name) = el.name() { + if let Ok(name_token) = name.value_token() { + return name_token.text_trimmed() == "th"; + } + } + false + } + _ => false, + } +} + +// Helper function to get element attributes +fn get_element_attributes(element: &AnyHtmlElement) -> Option { + match element { + AnyHtmlElement::HtmlElement(el) => { + let opening_element = el.opening_element().ok()?; + Some(opening_element.attributes()) + } + AnyHtmlElement::HtmlSelfClosingElement(el) => Some(el.attributes()), + _ => None, + } +} + +// Helper function to find attribute by name +fn find_attribute_by_name( + attributes: &HtmlAttributeList, + name_to_lookup: &str, +) -> Option { + attributes.iter().find_map(|attribute| { + if let AnyHtmlAttribute::HtmlAttribute(attribute) = attribute { + let name = attribute.name().ok()?; + let name_token = name.value_token().ok()?; + if name_token.text_trimmed() == name_to_lookup { + return Some(attribute); + } + } + None + }) +} diff --git a/crates/biome_html_analyze/src/lint/nursery/no_header_scope.rs b/crates/biome_html_analyze/src/lint/nursery/no_header_scope.rs deleted file mode 100644 index 61560b8e5d37..000000000000 --- a/crates/biome_html_analyze/src/lint/nursery/no_header_scope.rs +++ /dev/null @@ -1,54 +0,0 @@ -use biome_analyze::context::RuleContext; -use biome_analyze::{Ast, FixKind, Rule, RuleDiagnostic, RuleSource, declare_lint_rule}; -use biome_diagnostics::Severity; -use biome_html_syntax::AnyHtmlElement; - -declare_lint_rule! { - /// The scope prop should be used only on `` elements. - /// - /// ## Examples - /// - /// ### Invalid - /// - /// ```html,ignore - ///
- /// ``` - /// - /// ### Valid - /// - /// ```html,ignore - /// - /// ``` - /// - /// ## Accessibility guidelines - /// - /// - [WCAG 1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) - /// - [WCAG 4.1.1](https://www.w3.org/WAI/WCAG21/Understanding/parsing) - /// - pub NoHeaderScope { - version: "next", - name: "noHeaderScope", - language: "html", - sources: &[RuleSource::EslintJsxA11y("scope").same()], - recommended: true, - severity: Severity::Error, - fix_kind: FixKind::Unsafe, - } -} - -impl Rule for NoHeaderScope { - type Query = Ast; - type State = (); - type Signals = Option; - type Options = (); - - fn run(ctx: &RuleContext) -> Self::Signals { - let _element = ctx.query(); - - None - } - - fn diagnostic(_ctx: &RuleContext, _: &Self::State) -> Option { - None - } -} diff --git a/crates/biome_html_analyze/src/options.rs b/crates/biome_html_analyze/src/options.rs index 22e0475e7832..d4e16dd07977 100644 --- a/crates/biome_html_analyze/src/options.rs +++ b/crates/biome_html_analyze/src/options.rs @@ -4,4 +4,4 @@ use crate::lint; pub type NoHeaderScope = - ::Options; + ::Options; diff --git a/crates/biome_html_analyze/src/suppression_action.rs b/crates/biome_html_analyze/src/suppression_action.rs index d435ac377807..1f28cd5eb6c9 100644 --- a/crates/biome_html_analyze/src/suppression_action.rs +++ b/crates/biome_html_analyze/src/suppression_action.rs @@ -1,6 +1,6 @@ use biome_analyze::{ApplySuppression, SuppressionAction}; -use biome_html_syntax::HtmlLanguage; -use biome_rowan::{BatchMutation, SyntaxToken}; +use biome_html_syntax::{HtmlLanguage, HtmlSyntaxToken}; +use biome_rowan::{BatchMutation, TriviaPieceKind}; pub(crate) struct HtmlSuppressionAction; @@ -9,22 +9,94 @@ impl SuppressionAction for HtmlSuppressionAction { fn find_token_for_inline_suppression( &self, - _original_token: SyntaxToken, + token: HtmlSyntaxToken, ) -> Option> { - todo!() + let mut apply_suppression = ApplySuppression { + token_has_trailing_comments: false, + token_to_apply_suppression: token.clone(), + should_insert_leading_newline: false, + }; + + // Find the token at the start of suppressed token's line + let mut current_token = token; + loop { + let trivia = current_token.leading_trivia(); + if trivia.pieces().any(|trivia| trivia.kind().is_newline()) { + break; + } else if let Some(prev_token) = current_token.prev_token() { + current_token = prev_token + } else { + break; + } + } + + apply_suppression.token_has_trailing_comments = current_token + .trailing_trivia() + .pieces() + .any(|trivia| trivia.kind().is_comment()); + apply_suppression.token_to_apply_suppression = current_token; + Some(apply_suppression) } fn apply_inline_suppression( &self, - _mutation: &mut BatchMutation, - _apply_suppression: ApplySuppression, - _suppression_text: &str, - _suppression_reason: &str, + mutation: &mut BatchMutation, + apply_suppression: ApplySuppression, + suppression_text: &str, + suppression_reason: &str, ) { - todo!() + let ApplySuppression { + token_to_apply_suppression, + token_has_trailing_comments, + should_insert_leading_newline: _, + } = apply_suppression; + + let mut new_token = token_to_apply_suppression.clone(); + let has_leading_whitespace = new_token + .leading_trivia() + .pieces() + .any(|trivia| trivia.is_whitespace()); + + if token_has_trailing_comments { + new_token = new_token.with_trailing_trivia([ + ( + TriviaPieceKind::MultiLineComment, + format!("").as_str(), + ), + (TriviaPieceKind::Newline, "\n"), + ]); + } else if has_leading_whitespace { + let suppression_comment = format!(""); + let mut trivia = vec![ + ( + TriviaPieceKind::MultiLineComment, + suppression_comment.as_str(), + ), + (TriviaPieceKind::Newline, "\n"), + ]; + let leading_whitespace: Vec<_> = new_token + .leading_trivia() + .pieces() + .filter(|p| p.is_whitespace()) + .collect(); + + for w in leading_whitespace.iter() { + trivia.push((TriviaPieceKind::Whitespace, w.text())); + } + new_token = new_token.with_leading_trivia(trivia); + } else { + new_token = new_token.with_leading_trivia([ + ( + TriviaPieceKind::MultiLineComment, + format!("").as_str(), + ), + (TriviaPieceKind::Newline, "\n"), + ]); + } + mutation.replace_token_transfer_trivia(token_to_apply_suppression, new_token); } - fn suppression_top_level_comment(&self, _suppression_text: &str) -> String { - todo!() + fn suppression_top_level_comment(&self, suppression_text: &str) -> String { + format!("") } } diff --git a/crates/biome_html_analyze/tests/spec_tests.rs b/crates/biome_html_analyze/tests/spec_tests.rs new file mode 100644 index 000000000000..57acaab8416e --- /dev/null +++ b/crates/biome_html_analyze/tests/spec_tests.rs @@ -0,0 +1,184 @@ +use biome_analyze::{AnalysisFilter, AnalyzerAction, ControlFlow, Never, RuleFilter}; +use biome_diagnostics::advice::CodeSuggestionAdvice; +use biome_html_parser::{HtmlParseOptions, parse_html}; +use biome_html_syntax::{HtmlFileSource, HtmlLanguage}; +use biome_rowan::AstNode; +use biome_test_utils::{ + CheckActionType, assert_diagnostics_expectation_comment, assert_errors_are_absent, + code_fix_to_string, create_analyzer_options, diagnostic_to_string, + has_bogus_nodes_or_empty_slots, parse_test_path, register_leak_checker, scripts_from_json, + write_analyzer_snapshot, +}; +use camino::Utf8Path; +use std::ops::Deref; +use std::{fs::read_to_string, slice}; + +tests_macros::gen_tests! {"tests/specs/**/*.{html,json,jsonc}", crate::run_test, "module"} + +fn run_test(input: &'static str, _: &str, _: &str, _: &str) { + register_leak_checker(); + + let input_file = Utf8Path::new(input); + let file_name = input_file.file_name().unwrap(); + + let (group, rule) = parse_test_path(input_file); + if rule == "specs" { + panic!("the test file must be placed in the {rule}/// directory"); + } + if group == "specs" { + panic!("the test file must be placed in the {group}/{rule}// directory"); + } + if biome_html_analyze::METADATA + .deref() + .find_rule(group, rule) + .is_none() + { + panic!("could not find rule {group}/{rule}"); + } + + let rule_filter = RuleFilter::Rule(group, rule); + let filter = AnalysisFilter { + enabled_rules: Some(slice::from_ref(&rule_filter)), + ..AnalysisFilter::default() + }; + + let mut snapshot = String::new(); + let extension = input_file.extension().unwrap_or_default(); + + let input_code = read_to_string(input_file) + .unwrap_or_else(|err| panic!("failed to read {input_file:?}: {err:?}")); + + if let Some(scripts) = scripts_from_json(extension, &input_code) { + for script in scripts { + analyze_and_snap( + &mut snapshot, + &script, + HtmlFileSource::html(), + filter, + file_name, + input_file, + CheckActionType::Lint, + ); + } + } else { + let Ok(source_type) = input_file.try_into() else { + return; + }; + analyze_and_snap( + &mut snapshot, + &input_code, + source_type, + filter, + file_name, + input_file, + CheckActionType::Lint, + ); + }; + + insta::with_settings!({ + prepend_module_to_snapshot => false, + snapshot_path => input_file.parent().unwrap(), + }, { + insta::assert_snapshot!(file_name, snapshot, file_name); + }); +} + +#[expect(clippy::too_many_arguments)] +pub(crate) fn analyze_and_snap( + snapshot: &mut String, + input_code: &str, + source_type: HtmlFileSource, + filter: AnalysisFilter, + file_name: &str, + input_file: &Utf8Path, + check_action_type: CheckActionType, +) { + let parsed = parse_html(input_code, HtmlParseOptions::default()); + let root = parsed.tree(); + + let mut diagnostics = Vec::new(); + let mut code_fixes = Vec::new(); + let options = create_analyzer_options::(input_file, &mut diagnostics); + + let (_, errors) = biome_html_analyze::analyze(&root, filter, &options, |event| { + if let Some(mut diag) = event.diagnostic() { + for action in event.actions() { + if check_action_type.is_suppression() { + if action.is_suppression() { + check_code_action(input_file, input_code, source_type, &action); + diag = diag.add_code_suggestion(CodeSuggestionAdvice::from(action)); + } + } else if !action.is_suppression() { + check_code_action(input_file, input_code, source_type, &action); + diag = diag.add_code_suggestion(CodeSuggestionAdvice::from(action)); + } + } + + diagnostics.push(diagnostic_to_string(file_name, input_code, diag.into())); + return ControlFlow::Continue(()); + } + + for action in event.actions() { + if check_action_type.is_suppression() { + if action.category.matches("quickfix.suppressRule") { + check_code_action(input_file, input_code, source_type, &action); + code_fixes.push(code_fix_to_string(input_code, action)); + } + } else if !action.category.matches("quickfix.suppressRule") { + check_code_action(input_file, input_code, source_type, &action); + code_fixes.push(code_fix_to_string(input_code, action)); + } + } + + ControlFlow::::Continue(()) + }); + + for error in errors { + diagnostics.push(diagnostic_to_string(file_name, input_code, error)); + } + + write_analyzer_snapshot( + snapshot, + input_code, + diagnostics.as_slice(), + code_fixes.as_slice(), + "html", + ); + + assert_diagnostics_expectation_comment(input_file, root.syntax(), diagnostics); +} + +fn check_code_action( + path: &Utf8Path, + source: &str, + _source_type: HtmlFileSource, + action: &AnalyzerAction, +) { + let (new_tree, text_edit) = match action + .mutation + .clone() + .commit_with_text_range_and_edit(true) + { + (new_tree, Some((_, text_edit))) => (new_tree, text_edit), + (new_tree, None) => (new_tree, Default::default()), + }; + + let output = text_edit.new_string(source); + + // Checks that applying the text edits returned by the BatchMutation + // returns the same code as printing the modified syntax tree + assert_eq!(new_tree.to_string(), output); + + if has_bogus_nodes_or_empty_slots(&new_tree) { + panic!("modified tree has bogus nodes or empty slots:\n{new_tree:#?} \n\n {new_tree}") + } + + // Checks the returned tree contains no missing children node + if format!("{new_tree:?}").contains("missing (required)") { + panic!("modified tree has missing children:\n{new_tree:#?}") + } + + // Re-parse the modified code and panic if the resulting tree has syntax errors + let re_parse = parse_html(&output, HtmlParseOptions::default()); + assert_errors_are_absent(re_parse.tree().syntax(), re_parse.diagnostics(), path); +} diff --git a/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/invalid.html b/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/invalid.html new file mode 100644 index 000000000000..c87f15f58f32 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/invalid.html @@ -0,0 +1,6 @@ + +
+
+ + +

diff --git a/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/invalid.html.snap b/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/invalid.html.snap new file mode 100644 index 000000000000..a3d6e5c179cc --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/invalid.html.snap @@ -0,0 +1,143 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: invalid.html +--- +# Input +```html + +
+
+ + +

+ +``` + +# Diagnostics +``` +invalid.html:2:6 lint/a11y/noHeaderScope FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Avoid using the scope attribute on elements other than th elements. + + 1 │ + > 2 │
+ │ ^^^^^^^^^^^ + 3 │
+ 4 │ + + i The scope attribute is used to associate a data cell with its corresponding header cell in a data table, + so it should be placed on th elements to provide accessibility to screen readers. + + i Follow the links for more information, + WCAG 1.3.1 + WCAG 4.1.1 + + i Unsafe fix: Remove the scope attribute. + + 2 │
+ │ ----------- + +``` + +``` +invalid.html:3:6 lint/a11y/noHeaderScope FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Avoid using the scope attribute on elements other than th elements. + + 1 │ + 2 │
+ > 3 │
+ │ ^^^^^ + 4 │ + 5 │ + + i The scope attribute is used to associate a data cell with its corresponding header cell in a data table, + so it should be placed on th elements to provide accessibility to screen readers. + + i Follow the links for more information, + WCAG 1.3.1 + WCAG 4.1.1 + + i Unsafe fix: Remove the scope attribute. + + 3 │
+ │ ----- + +``` + +``` +invalid.html:4:5 lint/a11y/noHeaderScope FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Avoid using the scope attribute on elements other than th elements. + + 2 │
+ 3 │
+ > 4 │ + │ ^^^^^^^^^^^ + 5 │ + 6 │

+ + i The scope attribute is used to associate a data cell with its corresponding header cell in a data table, + so it should be placed on th elements to provide accessibility to screen readers. + + i Follow the links for more information, + WCAG 1.3.1 + WCAG 4.1.1 + + i Unsafe fix: Remove the scope attribute. + + 4 │ + │ ----------- + +``` + +``` +invalid.html:5:7 lint/a11y/noHeaderScope FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Avoid using the scope attribute on elements other than th elements. + + 3 │
+ 4 │ + > 5 │ + │ ^^^^^^^^^^^ + 6 │

+ 7 │ + + i The scope attribute is used to associate a data cell with its corresponding header cell in a data table, + so it should be placed on th elements to provide accessibility to screen readers. + + i Follow the links for more information, + WCAG 1.3.1 + WCAG 4.1.1 + + i Unsafe fix: Remove the scope attribute. + + 5 │ + │ ----------- + +``` + +``` +invalid.html:6:4 lint/a11y/noHeaderScope FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Avoid using the scope attribute on elements other than th elements. + + 4 │ + 5 │ + > 6 │

+ │ ^^^^^ + 7 │ + + i The scope attribute is used to associate a data cell with its corresponding header cell in a data table, + so it should be placed on th elements to provide accessibility to screen readers. + + i Follow the links for more information, + WCAG 1.3.1 + WCAG 4.1.1 + + i Unsafe fix: Remove the scope attribute. + + 6 │

+ │ ----- + +``` diff --git a/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/valid.html b/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/valid.html new file mode 100644 index 000000000000..9e58269eec6d --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/valid.html @@ -0,0 +1,6 @@ + + + + +
+ diff --git a/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/valid.html.snap b/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/valid.html.snap new file mode 100644 index 000000000000..95e276a4dcb1 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/a11y/noHeaderScope/valid.html.snap @@ -0,0 +1,14 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: valid.html +--- +# Input +```html + + + + +
+ + +``` From 99a12d8f9627d06d58f10583ccbe4b41d5406bef Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:22:42 +0000 Subject: [PATCH 03/23] [autofix.ci] apply automated fixes --- crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs | 2 +- packages/@biomejs/backend-jsonrpc/src/workspace.ts | 4 ---- packages/@biomejs/biome/configuration_schema.json | 7 ------- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs b/crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs index 61222599a68d..40cee86a294f 100644 --- a/crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs +++ b/crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs @@ -1,5 +1,5 @@ use biome_analyze::context::RuleContext; -use biome_analyze::{declare_lint_rule, Ast, FixKind, Rule, RuleDiagnostic, RuleSource}; +use biome_analyze::{Ast, FixKind, Rule, RuleDiagnostic, RuleSource, declare_lint_rule}; use biome_console::markup; use biome_diagnostics::Severity; use biome_html_syntax::{AnyHtmlAttribute, AnyHtmlElement, HtmlAttribute, HtmlAttributeList}; diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index c73025f97114..ab2fb3298b13 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -1650,10 +1650,6 @@ export interface Nursery { * Require Promise-like statements to be handled appropriately. */ noFloatingPromises?: RuleFixConfiguration_for_NoFloatingPromisesOptions; - /** - * The scope prop should be used only on \ elements. - */ - noHeaderScope?: RuleFixConfiguration_for_NoHeaderScopeOptions; /** * Prevent import cycles. */ diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index a0a62a6975e3..d583f42e0e98 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -5055,13 +5055,6 @@ { "type": "null" } ] }, - "noHeaderScope": { - "description": "The scope prop should be used only on \\ elements.", - "anyOf": [ - { "$ref": "#/definitions/NoHeaderScopeConfiguration" }, - { "type": "null" } - ] - }, "noImportCycles": { "description": "Prevent import cycles.", "anyOf": [ From 4d6edfe7dcbd1d40e358b696e7c6db73b8d96c70 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Fri, 3 Oct 2025 17:35:00 +0100 Subject: [PATCH 04/23] chore: optimise code --- .../src/analyzer/linter/rules.rs | 56 ++-------------- .../src/generated/domain_selector.rs | 2 - .../src/lint/a11y/no_header_scope.rs | 65 ++++--------------- 3 files changed, 20 insertions(+), 103 deletions(-) diff --git a/crates/biome_configuration/src/analyzer/linter/rules.rs b/crates/biome_configuration/src/analyzer/linter/rules.rs index 82a605305458..2e46dff193c9 100644 --- a/crates/biome_configuration/src/analyzer/linter/rules.rs +++ b/crates/biome_configuration/src/analyzer/linter/rules.rs @@ -413,8 +413,6 @@ pub enum RuleName { UseOptionalChain, UseParseIntRadix, UseQwikClasslist, - UseQwikMethodUsage, - UseQwikValidLexicalScope, UseReactFunctionComponents, UseReadonlyClassProperties, UseRegexLiterals, @@ -781,8 +779,6 @@ impl RuleName { Self::UseOptionalChain => "useOptionalChain", Self::UseParseIntRadix => "useParseIntRadix", Self::UseQwikClasslist => "useQwikClasslist", - Self::UseQwikMethodUsage => "useQwikMethodUsage", - Self::UseQwikValidLexicalScope => "useQwikValidLexicalScope", Self::UseReactFunctionComponents => "useReactFunctionComponents", Self::UseReadonlyClassProperties => "useReadonlyClassProperties", Self::UseRegexLiterals => "useRegexLiterals", @@ -1145,8 +1141,6 @@ impl RuleName { Self::UseOptionalChain => RuleGroup::Complexity, Self::UseParseIntRadix => RuleGroup::Correctness, Self::UseQwikClasslist => RuleGroup::Nursery, - Self::UseQwikMethodUsage => RuleGroup::Nursery, - Self::UseQwikValidLexicalScope => RuleGroup::Nursery, Self::UseReactFunctionComponents => RuleGroup::Nursery, Self::UseReadonlyClassProperties => RuleGroup::Style, Self::UseRegexLiterals => RuleGroup::Complexity, @@ -1518,8 +1512,6 @@ impl std::str::FromStr for RuleName { "useOptionalChain" => Ok(Self::UseOptionalChain), "useParseIntRadix" => Ok(Self::UseParseIntRadix), "useQwikClasslist" => Ok(Self::UseQwikClasslist), - "useQwikMethodUsage" => Ok(Self::UseQwikMethodUsage), - "useQwikValidLexicalScope" => Ok(Self::UseQwikValidLexicalScope), "useReactFunctionComponents" => Ok(Self::UseReactFunctionComponents), "useReadonlyClassProperties" => Ok(Self::UseReadonlyClassProperties), "useRegexLiterals" => Ok(Self::UseRegexLiterals), @@ -4637,7 +4629,7 @@ impl From for Correctness { #[cfg_attr(feature = "schema", derive(JsonSchema))] #[serde(rename_all = "camelCase", default, deny_unknown_fields)] #[doc = r" A list of rules that belong to this group"] -pub struct Nursery { # [doc = r" Enables the recommended rules for this group"] # [serde (skip_serializing_if = "Option::is_none")] pub recommended : Option < bool > , # [doc = "Restrict imports of deprecated exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_deprecated_imports : Option < RuleConfiguration < biome_rule_options :: no_deprecated_imports :: NoDeprecatedImportsOptions >> , # [doc = "Prevent the listing of duplicate dependencies. The rule supports the following dependency groups: \"bundledDependencies\", \"bundleDependencies\", \"dependencies\", \"devDependencies\", \"overrides\", \"optionalDependencies\", and \"peerDependencies\"."] # [serde (skip_serializing_if = "Option::is_none")] pub no_duplicate_dependencies : Option < RuleConfiguration < biome_rule_options :: no_duplicate_dependencies :: NoDuplicateDependenciesOptions >> , # [doc = "Require Promise-like statements to be handled appropriately."] # [serde (skip_serializing_if = "Option::is_none")] pub no_floating_promises : Option < RuleFixConfiguration < biome_rule_options :: no_floating_promises :: NoFloatingPromisesOptions >> , # [doc = "Prevent import cycles."] # [serde (skip_serializing_if = "Option::is_none")] pub no_import_cycles : Option < RuleConfiguration < biome_rule_options :: no_import_cycles :: NoImportCyclesOptions >> , # [doc = "Disallow string literals inside JSX elements."] # [serde (skip_serializing_if = "Option::is_none")] pub no_jsx_literals : Option < RuleConfiguration < biome_rule_options :: no_jsx_literals :: NoJsxLiteralsOptions >> , # [doc = "Disallow Promises to be used in places where they are almost certainly a mistake."] # [serde (skip_serializing_if = "Option::is_none")] pub no_misused_promises : Option < RuleFixConfiguration < biome_rule_options :: no_misused_promises :: NoMisusedPromisesOptions >> , # [doc = "Prevent client components from being async functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_next_async_client_component : Option < RuleConfiguration < biome_rule_options :: no_next_async_client_component :: NoNextAsyncClientComponentOptions >> , # [doc = "Disallow non-null assertions after optional chaining expressions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_non_null_asserted_optional_chain : Option < RuleConfiguration < biome_rule_options :: no_non_null_asserted_optional_chain :: NoNonNullAssertedOptionalChainOptions >> , # [doc = "Disallow useVisibleTask$() functions in Qwik components."] # [serde (skip_serializing_if = "Option::is_none")] pub no_qwik_use_visible_task : Option < RuleConfiguration < biome_rule_options :: no_qwik_use_visible_task :: NoQwikUseVisibleTaskOptions >> , # [doc = "Replaces usages of forwardRef with passing ref as a prop."] # [serde (skip_serializing_if = "Option::is_none")] pub no_react_forward_ref : Option < RuleFixConfiguration < biome_rule_options :: no_react_forward_ref :: NoReactForwardRefOptions >> , # [doc = "Disallow usage of sensitive data such as API keys and tokens."] # [serde (skip_serializing_if = "Option::is_none")] pub no_secrets : Option < RuleConfiguration < biome_rule_options :: no_secrets :: NoSecretsOptions >> , # [doc = "Disallow variable declarations from shadowing variables declared in the outer scope."] # [serde (skip_serializing_if = "Option::is_none")] pub no_shadow : Option < RuleConfiguration < biome_rule_options :: no_shadow :: NoShadowOptions >> , # [doc = "Disallow unnecessary type-based conditions that can be statically determined as redundant."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unnecessary_conditions : Option < RuleConfiguration < biome_rule_options :: no_unnecessary_conditions :: NoUnnecessaryConditionsOptions >> , # [doc = "Warn when importing non-existing exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unresolved_imports : Option < RuleConfiguration < biome_rule_options :: no_unresolved_imports :: NoUnresolvedImportsOptions >> , # [doc = "Disallow expression statements that are neither a function call nor an assignment."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unused_expressions : Option < RuleConfiguration < biome_rule_options :: no_unused_expressions :: NoUnusedExpressionsOptions >> , # [doc = "Disallow unused catch bindings."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_catch_binding : Option < RuleFixConfiguration < biome_rule_options :: no_useless_catch_binding :: NoUselessCatchBindingOptions >> , # [doc = "Disallow the use of useless undefined."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_undefined : Option < RuleFixConfiguration < biome_rule_options :: no_useless_undefined :: NoUselessUndefinedOptions >> , # [doc = "Enforce that Vue component data options are declared as functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_data_object_declaration : Option < RuleFixConfiguration < biome_rule_options :: no_vue_data_object_declaration :: NoVueDataObjectDeclarationOptions >> , # [doc = "Disallow duplicate keys in Vue component data, methods, computed properties, and other options."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_duplicate_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_duplicate_keys :: NoVueDuplicateKeysOptions >> , # [doc = "Disallow reserved keys in Vue component data and computed properties."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_keys :: NoVueReservedKeysOptions >> , # [doc = "Disallow reserved names to be used as props."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_props : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_props :: NoVueReservedPropsOptions >> , # [doc = "Enforces href attribute for \\ elements."] # [serde (skip_serializing_if = "Option::is_none")] pub use_anchor_href : Option < RuleConfiguration < biome_rule_options :: use_anchor_href :: UseAnchorHrefOptions >> , # [doc = "Enforce consistent arrow function bodies."] # [serde (skip_serializing_if = "Option::is_none")] pub use_consistent_arrow_return : Option < RuleFixConfiguration < biome_rule_options :: use_consistent_arrow_return :: UseConsistentArrowReturnOptions >> , # [doc = "Enforce type definitions to consistently use either interface or type."] # [serde (skip_serializing_if = "Option::is_none")] pub use_consistent_type_definitions : Option < RuleFixConfiguration < biome_rule_options :: use_consistent_type_definitions :: UseConsistentTypeDefinitionsOptions >> , # [doc = "Require switch-case statements to be exhaustive."] # [serde (skip_serializing_if = "Option::is_none")] pub use_exhaustive_switch_cases : Option < RuleFixConfiguration < biome_rule_options :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCasesOptions >> , # [doc = "Enforce types in functions, methods, variables, and parameters."] # [serde (skip_serializing_if = "Option::is_none")] pub use_explicit_type : Option < RuleConfiguration < biome_rule_options :: use_explicit_type :: UseExplicitTypeOptions >> , # [doc = "Enforces that \\ elements have both width and height attributes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_image_size : Option < RuleConfiguration < biome_rule_options :: use_image_size :: UseImageSizeOptions >> , # [doc = "Enforce a maximum number of parameters in function definitions."] # [serde (skip_serializing_if = "Option::is_none")] pub use_max_params : Option < RuleConfiguration < biome_rule_options :: use_max_params :: UseMaxParamsOptions >> , # [doc = "Prefer using the class prop as a classlist over the classnames helper."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_classlist : Option < RuleConfiguration < biome_rule_options :: use_qwik_classlist :: UseQwikClasslistOptions >> , # [doc = "Disallow use* hooks outside of component$ or other use* hooks in Qwik applications."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_method_usage : Option < RuleConfiguration < biome_rule_options :: use_qwik_method_usage :: UseQwikMethodUsageOptions >> , # [doc = "Disallow unserializable expressions in Qwik dollar ($) scopes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_valid_lexical_scope : Option < RuleConfiguration < biome_rule_options :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScopeOptions >> , # [doc = "Enforce that components are defined as functions and never as classes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_react_function_components : Option < RuleConfiguration < biome_rule_options :: use_react_function_components :: UseReactFunctionComponentsOptions >> , # [doc = "Enforce the sorting of CSS utility classes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_sorted_classes : Option < RuleFixConfiguration < biome_rule_options :: use_sorted_classes :: UseSortedClassesOptions >> , # [doc = "Enforce multi-word component names in Vue components."] # [serde (skip_serializing_if = "Option::is_none")] pub use_vue_multi_word_component_names : Option < RuleConfiguration < biome_rule_options :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNamesOptions >> } +pub struct Nursery { # [doc = r" Enables the recommended rules for this group"] # [serde (skip_serializing_if = "Option::is_none")] pub recommended : Option < bool > , # [doc = "Restrict imports of deprecated exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_deprecated_imports : Option < RuleConfiguration < biome_rule_options :: no_deprecated_imports :: NoDeprecatedImportsOptions >> , # [doc = "Prevent the listing of duplicate dependencies. The rule supports the following dependency groups: \"bundledDependencies\", \"bundleDependencies\", \"dependencies\", \"devDependencies\", \"overrides\", \"optionalDependencies\", and \"peerDependencies\"."] # [serde (skip_serializing_if = "Option::is_none")] pub no_duplicate_dependencies : Option < RuleConfiguration < biome_rule_options :: no_duplicate_dependencies :: NoDuplicateDependenciesOptions >> , # [doc = "Require Promise-like statements to be handled appropriately."] # [serde (skip_serializing_if = "Option::is_none")] pub no_floating_promises : Option < RuleFixConfiguration < biome_rule_options :: no_floating_promises :: NoFloatingPromisesOptions >> , # [doc = "Prevent import cycles."] # [serde (skip_serializing_if = "Option::is_none")] pub no_import_cycles : Option < RuleConfiguration < biome_rule_options :: no_import_cycles :: NoImportCyclesOptions >> , # [doc = "Disallow string literals inside JSX elements."] # [serde (skip_serializing_if = "Option::is_none")] pub no_jsx_literals : Option < RuleConfiguration < biome_rule_options :: no_jsx_literals :: NoJsxLiteralsOptions >> , # [doc = "Disallow Promises to be used in places where they are almost certainly a mistake."] # [serde (skip_serializing_if = "Option::is_none")] pub no_misused_promises : Option < RuleFixConfiguration < biome_rule_options :: no_misused_promises :: NoMisusedPromisesOptions >> , # [doc = "Prevent client components from being async functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_next_async_client_component : Option < RuleConfiguration < biome_rule_options :: no_next_async_client_component :: NoNextAsyncClientComponentOptions >> , # [doc = "Disallow non-null assertions after optional chaining expressions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_non_null_asserted_optional_chain : Option < RuleConfiguration < biome_rule_options :: no_non_null_asserted_optional_chain :: NoNonNullAssertedOptionalChainOptions >> , # [doc = "Disallow useVisibleTask$() functions in Qwik components."] # [serde (skip_serializing_if = "Option::is_none")] pub no_qwik_use_visible_task : Option < RuleConfiguration < biome_rule_options :: no_qwik_use_visible_task :: NoQwikUseVisibleTaskOptions >> , # [doc = "Replaces usages of forwardRef with passing ref as a prop."] # [serde (skip_serializing_if = "Option::is_none")] pub no_react_forward_ref : Option < RuleFixConfiguration < biome_rule_options :: no_react_forward_ref :: NoReactForwardRefOptions >> , # [doc = "Disallow usage of sensitive data such as API keys and tokens."] # [serde (skip_serializing_if = "Option::is_none")] pub no_secrets : Option < RuleConfiguration < biome_rule_options :: no_secrets :: NoSecretsOptions >> , # [doc = "Disallow variable declarations from shadowing variables declared in the outer scope."] # [serde (skip_serializing_if = "Option::is_none")] pub no_shadow : Option < RuleConfiguration < biome_rule_options :: no_shadow :: NoShadowOptions >> , # [doc = "Disallow unnecessary type-based conditions that can be statically determined as redundant."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unnecessary_conditions : Option < RuleConfiguration < biome_rule_options :: no_unnecessary_conditions :: NoUnnecessaryConditionsOptions >> , # [doc = "Warn when importing non-existing exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unresolved_imports : Option < RuleConfiguration < biome_rule_options :: no_unresolved_imports :: NoUnresolvedImportsOptions >> , # [doc = "Disallow expression statements that are neither a function call nor an assignment."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unused_expressions : Option < RuleConfiguration < biome_rule_options :: no_unused_expressions :: NoUnusedExpressionsOptions >> , # [doc = "Disallow unused catch bindings."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_catch_binding : Option < RuleFixConfiguration < biome_rule_options :: no_useless_catch_binding :: NoUselessCatchBindingOptions >> , # [doc = "Disallow the use of useless undefined."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_undefined : Option < RuleFixConfiguration < biome_rule_options :: no_useless_undefined :: NoUselessUndefinedOptions >> , # [doc = "Enforce that Vue component data options are declared as functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_data_object_declaration : Option < RuleFixConfiguration < biome_rule_options :: no_vue_data_object_declaration :: NoVueDataObjectDeclarationOptions >> , # [doc = "Disallow duplicate keys in Vue component data, methods, computed properties, and other options."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_duplicate_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_duplicate_keys :: NoVueDuplicateKeysOptions >> , # [doc = "Disallow reserved keys in Vue component data and computed properties."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_keys :: NoVueReservedKeysOptions >> , # [doc = "Disallow reserved names to be used as props."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_props : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_props :: NoVueReservedPropsOptions >> , # [doc = "Enforces href attribute for \\ elements."] # [serde (skip_serializing_if = "Option::is_none")] pub use_anchor_href : Option < RuleConfiguration < biome_rule_options :: use_anchor_href :: UseAnchorHrefOptions >> , # [doc = "Enforce consistent arrow function bodies."] # [serde (skip_serializing_if = "Option::is_none")] pub use_consistent_arrow_return : Option < RuleFixConfiguration < biome_rule_options :: use_consistent_arrow_return :: UseConsistentArrowReturnOptions >> , # [doc = "Enforce type definitions to consistently use either interface or type."] # [serde (skip_serializing_if = "Option::is_none")] pub use_consistent_type_definitions : Option < RuleFixConfiguration < biome_rule_options :: use_consistent_type_definitions :: UseConsistentTypeDefinitionsOptions >> , # [doc = "Require switch-case statements to be exhaustive."] # [serde (skip_serializing_if = "Option::is_none")] pub use_exhaustive_switch_cases : Option < RuleFixConfiguration < biome_rule_options :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCasesOptions >> , # [doc = "Enforce types in functions, methods, variables, and parameters."] # [serde (skip_serializing_if = "Option::is_none")] pub use_explicit_type : Option < RuleConfiguration < biome_rule_options :: use_explicit_type :: UseExplicitTypeOptions >> , # [doc = "Enforces that \\ elements have both width and height attributes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_image_size : Option < RuleConfiguration < biome_rule_options :: use_image_size :: UseImageSizeOptions >> , # [doc = "Enforce a maximum number of parameters in function definitions."] # [serde (skip_serializing_if = "Option::is_none")] pub use_max_params : Option < RuleConfiguration < biome_rule_options :: use_max_params :: UseMaxParamsOptions >> , # [doc = "Prefer using the class prop as a classlist over the classnames helper."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_classlist : Option < RuleConfiguration < biome_rule_options :: use_qwik_classlist :: UseQwikClasslistOptions >> , # [doc = "Enforce that components are defined as functions and never as classes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_react_function_components : Option < RuleConfiguration < biome_rule_options :: use_react_function_components :: UseReactFunctionComponentsOptions >> , # [doc = "Enforce the sorting of CSS utility classes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_sorted_classes : Option < RuleFixConfiguration < biome_rule_options :: use_sorted_classes :: UseSortedClassesOptions >> , # [doc = "Enforce multi-word component names in Vue components."] # [serde (skip_serializing_if = "Option::is_none")] pub use_vue_multi_word_component_names : Option < RuleConfiguration < biome_rule_options :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNamesOptions >> } impl Nursery { const GROUP_NAME: &'static str = "nursery"; pub(crate) const GROUP_RULES: &'static [&'static str] = &[ @@ -4670,8 +4662,6 @@ impl Nursery { "useImageSize", "useMaxParams", "useQwikClasslist", - "useQwikMethodUsage", - "useQwikValidLexicalScope", "useReactFunctionComponents", "useSortedClasses", "useVueMultiWordComponentNames", @@ -4711,8 +4701,6 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32]), - RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33]), ]; } impl RuleGroupExt for Nursery { @@ -4869,30 +4857,20 @@ impl RuleGroupExt for Nursery { { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } - if let Some(rule) = self.use_qwik_method_usage.as_ref() - && rule.is_enabled() - { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); - } - if let Some(rule) = self.use_qwik_valid_lexical_scope.as_ref() - && rule.is_enabled() - { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); - } if let Some(rule) = self.use_react_function_components.as_ref() && rule.is_enabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } if let Some(rule) = self.use_sorted_classes.as_ref() && rule.is_enabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); } if let Some(rule) = self.use_vue_multi_word_component_names.as_ref() && rule.is_enabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); } index_set } @@ -5043,30 +5021,20 @@ impl RuleGroupExt for Nursery { { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } - if let Some(rule) = self.use_qwik_method_usage.as_ref() - && rule.is_disabled() - { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); - } - if let Some(rule) = self.use_qwik_valid_lexical_scope.as_ref() - && rule.is_disabled() - { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); - } if let Some(rule) = self.use_react_function_components.as_ref() && rule.is_disabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } if let Some(rule) = self.use_sorted_classes.as_ref() && rule.is_disabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); } if let Some(rule) = self.use_vue_multi_word_component_names.as_ref() && rule.is_disabled() { - index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33])); + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); } index_set } @@ -5214,14 +5182,6 @@ impl RuleGroupExt for Nursery { .use_qwik_classlist .as_ref() .map(|conf| (conf.level(), conf.get_options())), - "useQwikMethodUsage" => self - .use_qwik_method_usage - .as_ref() - .map(|conf| (conf.level(), conf.get_options())), - "useQwikValidLexicalScope" => self - .use_qwik_valid_lexical_scope - .as_ref() - .map(|conf| (conf.level(), conf.get_options())), "useReactFunctionComponents" => self .use_react_function_components .as_ref() @@ -5271,8 +5231,6 @@ impl From for Nursery { use_image_size: Some(value.into()), use_max_params: Some(value.into()), use_qwik_classlist: Some(value.into()), - use_qwik_method_usage: Some(value.into()), - use_qwik_valid_lexical_scope: Some(value.into()), use_react_function_components: Some(value.into()), use_sorted_classes: Some(value.into()), use_vue_multi_word_component_names: Some(value.into()), diff --git a/crates/biome_configuration/src/generated/domain_selector.rs b/crates/biome_configuration/src/generated/domain_selector.rs index c82fe05cf96d..af8a467d6a83 100644 --- a/crates/biome_configuration/src/generated/domain_selector.rs +++ b/crates/biome_configuration/src/generated/domain_selector.rs @@ -38,8 +38,6 @@ static QWIK_FILTERS: LazyLock>> = LazyLock::new(|| { RuleFilter::Rule("nursery", "useAnchorHref"), RuleFilter::Rule("nursery", "useImageSize"), RuleFilter::Rule("nursery", "useQwikClasslist"), - RuleFilter::Rule("nursery", "useQwikMethodUsage"), - RuleFilter::Rule("nursery", "useQwikValidLexicalScope"), RuleFilter::Rule("suspicious", "noReactSpecificProps"), ] }); diff --git a/crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs b/crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs index 40cee86a294f..bdacfbec47b7 100644 --- a/crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs +++ b/crates/biome_html_analyze/src/lint/a11y/no_header_scope.rs @@ -2,8 +2,8 @@ use biome_analyze::context::RuleContext; use biome_analyze::{Ast, FixKind, Rule, RuleDiagnostic, RuleSource, declare_lint_rule}; use biome_console::markup; use biome_diagnostics::Severity; -use biome_html_syntax::{AnyHtmlAttribute, AnyHtmlElement, HtmlAttribute, HtmlAttributeList}; -use biome_rowan::{AstNode, AstNodeList, BatchMutationExt}; +use biome_html_syntax::{AnyHtmlElement, HtmlAttribute}; +use biome_rowan::{AstNode, BatchMutationExt}; use crate::HtmlRuleAction; @@ -58,15 +58,12 @@ impl Rule for NoHeaderScope { let element = ctx.query(); // Check if element is NOT a th element and has a scope attribute - if is_th_element(element) { + if is_th_element(element)? { return None; } // Check if element has a scope attribute - let attributes = get_element_attributes(element)?; - let scope_attribute = find_attribute_by_name(&attributes, "scope")?; - - Some(scope_attribute) + element.find_attribute_by_name("scope") } fn diagnostic(_ctx: &RuleContext, state: &Self::State) -> Option { @@ -101,55 +98,19 @@ impl Rule for NoHeaderScope { } // Helper function to check if element is a th element -fn is_th_element(element: &AnyHtmlElement) -> bool { - match element { - AnyHtmlElement::HtmlElement(el) => { - if let Ok(opening_element) = el.opening_element() { - if let Ok(name) = opening_element.name() { - if let Ok(name_token) = name.value_token() { - return name_token.text_trimmed() == "th"; - } - } - } - false - } - AnyHtmlElement::HtmlSelfClosingElement(el) => { - if let Ok(name) = el.name() { - if let Ok(name_token) = name.value_token() { - return name_token.text_trimmed() == "th"; - } - } - false - } - _ => false, - } -} - -// Helper function to get element attributes -fn get_element_attributes(element: &AnyHtmlElement) -> Option { +fn is_th_element(element: &AnyHtmlElement) -> Option { match element { AnyHtmlElement::HtmlElement(el) => { let opening_element = el.opening_element().ok()?; - Some(opening_element.attributes()) + let name = opening_element.name().ok()?; + let name_token = name.value_token().ok()?; + Some(name_token.text_trimmed().eq_ignore_ascii_case("th")) } - AnyHtmlElement::HtmlSelfClosingElement(el) => Some(el.attributes()), - _ => None, - } -} - -// Helper function to find attribute by name -fn find_attribute_by_name( - attributes: &HtmlAttributeList, - name_to_lookup: &str, -) -> Option { - attributes.iter().find_map(|attribute| { - if let AnyHtmlAttribute::HtmlAttribute(attribute) = attribute { - let name = attribute.name().ok()?; + AnyHtmlElement::HtmlSelfClosingElement(el) => { + let name = el.name().ok()?; let name_token = name.value_token().ok()?; - if name_token.text_trimmed() == name_to_lookup { - return Some(attribute); - } + Some(name_token.text_trimmed().eq_ignore_ascii_case("th")) } - None - }) + _ => Some(false), + } } From e1098957f64baff6b5b709e3ec055fc0a1abe42d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:41:30 +0000 Subject: [PATCH 05/23] [autofix.ci] apply automated fixes --- crates/biome_configuration/src/generated/domain_selector.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/biome_configuration/src/generated/domain_selector.rs b/crates/biome_configuration/src/generated/domain_selector.rs index af8a467d6a83..c82fe05cf96d 100644 --- a/crates/biome_configuration/src/generated/domain_selector.rs +++ b/crates/biome_configuration/src/generated/domain_selector.rs @@ -38,6 +38,8 @@ static QWIK_FILTERS: LazyLock>> = LazyLock::new(|| { RuleFilter::Rule("nursery", "useAnchorHref"), RuleFilter::Rule("nursery", "useImageSize"), RuleFilter::Rule("nursery", "useQwikClasslist"), + RuleFilter::Rule("nursery", "useQwikMethodUsage"), + RuleFilter::Rule("nursery", "useQwikValidLexicalScope"), RuleFilter::Rule("suspicious", "noReactSpecificProps"), ] }); From 78fa0f4fecdcc732bb7e8cc26e943410446cca17 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Fri, 3 Oct 2025 17:44:20 +0100 Subject: [PATCH 06/23] chore: fix suppression tests --- crates/biome_html_analyze/tests/spec_tests.rs | 36 ++++++++++++++ .../a11y/noHeaderScope/noHeaderScope.html | 2 + .../noHeaderScope/noHeaderScope.html.snap | 47 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 crates/biome_html_analyze/tests/suppression/a11y/noHeaderScope/noHeaderScope.html create mode 100644 crates/biome_html_analyze/tests/suppression/a11y/noHeaderScope/noHeaderScope.html.snap diff --git a/crates/biome_html_analyze/tests/spec_tests.rs b/crates/biome_html_analyze/tests/spec_tests.rs index 57acaab8416e..97ffdad35365 100644 --- a/crates/biome_html_analyze/tests/spec_tests.rs +++ b/crates/biome_html_analyze/tests/spec_tests.rs @@ -14,6 +14,7 @@ use std::ops::Deref; use std::{fs::read_to_string, slice}; tests_macros::gen_tests! {"tests/specs/**/*.{html,json,jsonc}", crate::run_test, "module"} +tests_macros::gen_tests! {"tests/suppression/**/*.{html,json,jsonc}", crate::run_suppression_test, "module"} fn run_test(input: &'static str, _: &str, _: &str, _: &str) { register_leak_checker(); @@ -182,3 +183,38 @@ fn check_code_action( let re_parse = parse_html(&output, HtmlParseOptions::default()); assert_errors_are_absent(re_parse.tree().syntax(), re_parse.diagnostics(), path); } + +pub(crate) fn run_suppression_test(input: &'static str, _: &str, _: &str, _: &str) { + register_leak_checker(); + + let input_file = Utf8Path::new(input); + let file_name = input_file.file_name().unwrap(); + let input_code = read_to_string(input_file) + .unwrap_or_else(|err| panic!("failed to read {input_file:?}: {err:?}")); + + let (group, rule) = parse_test_path(input_file); + + let rule_filter = RuleFilter::Rule(group, rule); + let filter = AnalysisFilter { + enabled_rules: Some(slice::from_ref(&rule_filter)), + ..AnalysisFilter::default() + }; + + let mut snapshot = String::new(); + analyze_and_snap( + &mut snapshot, + &input_code, + HtmlFileSource::html(), + filter, + file_name, + input_file, + CheckActionType::Suppression, + ); + + insta::with_settings!({ + prepend_module_to_snapshot => false, + snapshot_path => input_file.parent().unwrap(), + }, { + insta::assert_snapshot!(file_name, snapshot, file_name); + }); +} diff --git a/crates/biome_html_analyze/tests/suppression/a11y/noHeaderScope/noHeaderScope.html b/crates/biome_html_analyze/tests/suppression/a11y/noHeaderScope/noHeaderScope.html new file mode 100644 index 000000000000..e663d968175c --- /dev/null +++ b/crates/biome_html_analyze/tests/suppression/a11y/noHeaderScope/noHeaderScope.html @@ -0,0 +1,2 @@ + +
diff --git a/crates/biome_html_analyze/tests/suppression/a11y/noHeaderScope/noHeaderScope.html.snap b/crates/biome_html_analyze/tests/suppression/a11y/noHeaderScope/noHeaderScope.html.snap new file mode 100644 index 000000000000..4d2a6c336e74 --- /dev/null +++ b/crates/biome_html_analyze/tests/suppression/a11y/noHeaderScope/noHeaderScope.html.snap @@ -0,0 +1,47 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: noHeaderScope.html +--- +# Input +```html + +
+ +``` + +# Diagnostics +``` +noHeaderScope.html:2:6 lint/a11y/noHeaderScope FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Avoid using the scope attribute on elements other than th elements. + + 1 │ + > 2 │
+ │ ^^^^^^^^^^^ + 3 │ + + i The scope attribute is used to associate a data cell with its corresponding header cell in a data table, + so it should be placed on th elements to provide accessibility to screen readers. + + i Follow the links for more information, + WCAG 1.3.1 + WCAG 4.1.1 + + i Safe fix: Suppress rule lint/a11y/noHeaderScope for this line. + + 1 1 │ + 2 │ - + 2 │ + + 3 │ + + 3 4 │ + + i Safe fix: Suppress rule lint/a11y/noHeaderScope for the whole file. + + 1 1 │ + 2 │ - + 2 │ + + 3 │ + + 3 4 │ + + +``` From 9d01533208b0403718fcf1853fb6e1a9b159531c Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Sun, 5 Oct 2025 08:56:49 +0100 Subject: [PATCH 07/23] chore: rebase and update rules check --- Cargo.lock | 1 + .../src/analyzer/linter/rules.rs | 56 ++++++++++++++++--- xtask/rules_check/src/lib.rs | 41 +++++++++++++- 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a0625f62927e..9d8e77e134b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1521,6 +1521,7 @@ dependencies = [ "biome_grit_patterns", "biome_grit_syntax", "biome_html_analyze", + "biome_html_factory", "biome_html_formatter", "biome_html_parser", "biome_html_syntax", diff --git a/crates/biome_configuration/src/analyzer/linter/rules.rs b/crates/biome_configuration/src/analyzer/linter/rules.rs index 2e46dff193c9..82a605305458 100644 --- a/crates/biome_configuration/src/analyzer/linter/rules.rs +++ b/crates/biome_configuration/src/analyzer/linter/rules.rs @@ -413,6 +413,8 @@ pub enum RuleName { UseOptionalChain, UseParseIntRadix, UseQwikClasslist, + UseQwikMethodUsage, + UseQwikValidLexicalScope, UseReactFunctionComponents, UseReadonlyClassProperties, UseRegexLiterals, @@ -779,6 +781,8 @@ impl RuleName { Self::UseOptionalChain => "useOptionalChain", Self::UseParseIntRadix => "useParseIntRadix", Self::UseQwikClasslist => "useQwikClasslist", + Self::UseQwikMethodUsage => "useQwikMethodUsage", + Self::UseQwikValidLexicalScope => "useQwikValidLexicalScope", Self::UseReactFunctionComponents => "useReactFunctionComponents", Self::UseReadonlyClassProperties => "useReadonlyClassProperties", Self::UseRegexLiterals => "useRegexLiterals", @@ -1141,6 +1145,8 @@ impl RuleName { Self::UseOptionalChain => RuleGroup::Complexity, Self::UseParseIntRadix => RuleGroup::Correctness, Self::UseQwikClasslist => RuleGroup::Nursery, + Self::UseQwikMethodUsage => RuleGroup::Nursery, + Self::UseQwikValidLexicalScope => RuleGroup::Nursery, Self::UseReactFunctionComponents => RuleGroup::Nursery, Self::UseReadonlyClassProperties => RuleGroup::Style, Self::UseRegexLiterals => RuleGroup::Complexity, @@ -1512,6 +1518,8 @@ impl std::str::FromStr for RuleName { "useOptionalChain" => Ok(Self::UseOptionalChain), "useParseIntRadix" => Ok(Self::UseParseIntRadix), "useQwikClasslist" => Ok(Self::UseQwikClasslist), + "useQwikMethodUsage" => Ok(Self::UseQwikMethodUsage), + "useQwikValidLexicalScope" => Ok(Self::UseQwikValidLexicalScope), "useReactFunctionComponents" => Ok(Self::UseReactFunctionComponents), "useReadonlyClassProperties" => Ok(Self::UseReadonlyClassProperties), "useRegexLiterals" => Ok(Self::UseRegexLiterals), @@ -4629,7 +4637,7 @@ impl From for Correctness { #[cfg_attr(feature = "schema", derive(JsonSchema))] #[serde(rename_all = "camelCase", default, deny_unknown_fields)] #[doc = r" A list of rules that belong to this group"] -pub struct Nursery { # [doc = r" Enables the recommended rules for this group"] # [serde (skip_serializing_if = "Option::is_none")] pub recommended : Option < bool > , # [doc = "Restrict imports of deprecated exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_deprecated_imports : Option < RuleConfiguration < biome_rule_options :: no_deprecated_imports :: NoDeprecatedImportsOptions >> , # [doc = "Prevent the listing of duplicate dependencies. The rule supports the following dependency groups: \"bundledDependencies\", \"bundleDependencies\", \"dependencies\", \"devDependencies\", \"overrides\", \"optionalDependencies\", and \"peerDependencies\"."] # [serde (skip_serializing_if = "Option::is_none")] pub no_duplicate_dependencies : Option < RuleConfiguration < biome_rule_options :: no_duplicate_dependencies :: NoDuplicateDependenciesOptions >> , # [doc = "Require Promise-like statements to be handled appropriately."] # [serde (skip_serializing_if = "Option::is_none")] pub no_floating_promises : Option < RuleFixConfiguration < biome_rule_options :: no_floating_promises :: NoFloatingPromisesOptions >> , # [doc = "Prevent import cycles."] # [serde (skip_serializing_if = "Option::is_none")] pub no_import_cycles : Option < RuleConfiguration < biome_rule_options :: no_import_cycles :: NoImportCyclesOptions >> , # [doc = "Disallow string literals inside JSX elements."] # [serde (skip_serializing_if = "Option::is_none")] pub no_jsx_literals : Option < RuleConfiguration < biome_rule_options :: no_jsx_literals :: NoJsxLiteralsOptions >> , # [doc = "Disallow Promises to be used in places where they are almost certainly a mistake."] # [serde (skip_serializing_if = "Option::is_none")] pub no_misused_promises : Option < RuleFixConfiguration < biome_rule_options :: no_misused_promises :: NoMisusedPromisesOptions >> , # [doc = "Prevent client components from being async functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_next_async_client_component : Option < RuleConfiguration < biome_rule_options :: no_next_async_client_component :: NoNextAsyncClientComponentOptions >> , # [doc = "Disallow non-null assertions after optional chaining expressions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_non_null_asserted_optional_chain : Option < RuleConfiguration < biome_rule_options :: no_non_null_asserted_optional_chain :: NoNonNullAssertedOptionalChainOptions >> , # [doc = "Disallow useVisibleTask$() functions in Qwik components."] # [serde (skip_serializing_if = "Option::is_none")] pub no_qwik_use_visible_task : Option < RuleConfiguration < biome_rule_options :: no_qwik_use_visible_task :: NoQwikUseVisibleTaskOptions >> , # [doc = "Replaces usages of forwardRef with passing ref as a prop."] # [serde (skip_serializing_if = "Option::is_none")] pub no_react_forward_ref : Option < RuleFixConfiguration < biome_rule_options :: no_react_forward_ref :: NoReactForwardRefOptions >> , # [doc = "Disallow usage of sensitive data such as API keys and tokens."] # [serde (skip_serializing_if = "Option::is_none")] pub no_secrets : Option < RuleConfiguration < biome_rule_options :: no_secrets :: NoSecretsOptions >> , # [doc = "Disallow variable declarations from shadowing variables declared in the outer scope."] # [serde (skip_serializing_if = "Option::is_none")] pub no_shadow : Option < RuleConfiguration < biome_rule_options :: no_shadow :: NoShadowOptions >> , # [doc = "Disallow unnecessary type-based conditions that can be statically determined as redundant."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unnecessary_conditions : Option < RuleConfiguration < biome_rule_options :: no_unnecessary_conditions :: NoUnnecessaryConditionsOptions >> , # [doc = "Warn when importing non-existing exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unresolved_imports : Option < RuleConfiguration < biome_rule_options :: no_unresolved_imports :: NoUnresolvedImportsOptions >> , # [doc = "Disallow expression statements that are neither a function call nor an assignment."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unused_expressions : Option < RuleConfiguration < biome_rule_options :: no_unused_expressions :: NoUnusedExpressionsOptions >> , # [doc = "Disallow unused catch bindings."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_catch_binding : Option < RuleFixConfiguration < biome_rule_options :: no_useless_catch_binding :: NoUselessCatchBindingOptions >> , # [doc = "Disallow the use of useless undefined."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_undefined : Option < RuleFixConfiguration < biome_rule_options :: no_useless_undefined :: NoUselessUndefinedOptions >> , # [doc = "Enforce that Vue component data options are declared as functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_data_object_declaration : Option < RuleFixConfiguration < biome_rule_options :: no_vue_data_object_declaration :: NoVueDataObjectDeclarationOptions >> , # [doc = "Disallow duplicate keys in Vue component data, methods, computed properties, and other options."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_duplicate_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_duplicate_keys :: NoVueDuplicateKeysOptions >> , # [doc = "Disallow reserved keys in Vue component data and computed properties."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_keys :: NoVueReservedKeysOptions >> , # [doc = "Disallow reserved names to be used as props."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_props : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_props :: NoVueReservedPropsOptions >> , # [doc = "Enforces href attribute for \\
elements."] # [serde (skip_serializing_if = "Option::is_none")] pub use_anchor_href : Option < RuleConfiguration < biome_rule_options :: use_anchor_href :: UseAnchorHrefOptions >> , # [doc = "Enforce consistent arrow function bodies."] # [serde (skip_serializing_if = "Option::is_none")] pub use_consistent_arrow_return : Option < RuleFixConfiguration < biome_rule_options :: use_consistent_arrow_return :: UseConsistentArrowReturnOptions >> , # [doc = "Enforce type definitions to consistently use either interface or type."] # [serde (skip_serializing_if = "Option::is_none")] pub use_consistent_type_definitions : Option < RuleFixConfiguration < biome_rule_options :: use_consistent_type_definitions :: UseConsistentTypeDefinitionsOptions >> , # [doc = "Require switch-case statements to be exhaustive."] # [serde (skip_serializing_if = "Option::is_none")] pub use_exhaustive_switch_cases : Option < RuleFixConfiguration < biome_rule_options :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCasesOptions >> , # [doc = "Enforce types in functions, methods, variables, and parameters."] # [serde (skip_serializing_if = "Option::is_none")] pub use_explicit_type : Option < RuleConfiguration < biome_rule_options :: use_explicit_type :: UseExplicitTypeOptions >> , # [doc = "Enforces that \\ elements have both width and height attributes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_image_size : Option < RuleConfiguration < biome_rule_options :: use_image_size :: UseImageSizeOptions >> , # [doc = "Enforce a maximum number of parameters in function definitions."] # [serde (skip_serializing_if = "Option::is_none")] pub use_max_params : Option < RuleConfiguration < biome_rule_options :: use_max_params :: UseMaxParamsOptions >> , # [doc = "Prefer using the class prop as a classlist over the classnames helper."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_classlist : Option < RuleConfiguration < biome_rule_options :: use_qwik_classlist :: UseQwikClasslistOptions >> , # [doc = "Enforce that components are defined as functions and never as classes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_react_function_components : Option < RuleConfiguration < biome_rule_options :: use_react_function_components :: UseReactFunctionComponentsOptions >> , # [doc = "Enforce the sorting of CSS utility classes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_sorted_classes : Option < RuleFixConfiguration < biome_rule_options :: use_sorted_classes :: UseSortedClassesOptions >> , # [doc = "Enforce multi-word component names in Vue components."] # [serde (skip_serializing_if = "Option::is_none")] pub use_vue_multi_word_component_names : Option < RuleConfiguration < biome_rule_options :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNamesOptions >> } +pub struct Nursery { # [doc = r" Enables the recommended rules for this group"] # [serde (skip_serializing_if = "Option::is_none")] pub recommended : Option < bool > , # [doc = "Restrict imports of deprecated exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_deprecated_imports : Option < RuleConfiguration < biome_rule_options :: no_deprecated_imports :: NoDeprecatedImportsOptions >> , # [doc = "Prevent the listing of duplicate dependencies. The rule supports the following dependency groups: \"bundledDependencies\", \"bundleDependencies\", \"dependencies\", \"devDependencies\", \"overrides\", \"optionalDependencies\", and \"peerDependencies\"."] # [serde (skip_serializing_if = "Option::is_none")] pub no_duplicate_dependencies : Option < RuleConfiguration < biome_rule_options :: no_duplicate_dependencies :: NoDuplicateDependenciesOptions >> , # [doc = "Require Promise-like statements to be handled appropriately."] # [serde (skip_serializing_if = "Option::is_none")] pub no_floating_promises : Option < RuleFixConfiguration < biome_rule_options :: no_floating_promises :: NoFloatingPromisesOptions >> , # [doc = "Prevent import cycles."] # [serde (skip_serializing_if = "Option::is_none")] pub no_import_cycles : Option < RuleConfiguration < biome_rule_options :: no_import_cycles :: NoImportCyclesOptions >> , # [doc = "Disallow string literals inside JSX elements."] # [serde (skip_serializing_if = "Option::is_none")] pub no_jsx_literals : Option < RuleConfiguration < biome_rule_options :: no_jsx_literals :: NoJsxLiteralsOptions >> , # [doc = "Disallow Promises to be used in places where they are almost certainly a mistake."] # [serde (skip_serializing_if = "Option::is_none")] pub no_misused_promises : Option < RuleFixConfiguration < biome_rule_options :: no_misused_promises :: NoMisusedPromisesOptions >> , # [doc = "Prevent client components from being async functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_next_async_client_component : Option < RuleConfiguration < biome_rule_options :: no_next_async_client_component :: NoNextAsyncClientComponentOptions >> , # [doc = "Disallow non-null assertions after optional chaining expressions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_non_null_asserted_optional_chain : Option < RuleConfiguration < biome_rule_options :: no_non_null_asserted_optional_chain :: NoNonNullAssertedOptionalChainOptions >> , # [doc = "Disallow useVisibleTask$() functions in Qwik components."] # [serde (skip_serializing_if = "Option::is_none")] pub no_qwik_use_visible_task : Option < RuleConfiguration < biome_rule_options :: no_qwik_use_visible_task :: NoQwikUseVisibleTaskOptions >> , # [doc = "Replaces usages of forwardRef with passing ref as a prop."] # [serde (skip_serializing_if = "Option::is_none")] pub no_react_forward_ref : Option < RuleFixConfiguration < biome_rule_options :: no_react_forward_ref :: NoReactForwardRefOptions >> , # [doc = "Disallow usage of sensitive data such as API keys and tokens."] # [serde (skip_serializing_if = "Option::is_none")] pub no_secrets : Option < RuleConfiguration < biome_rule_options :: no_secrets :: NoSecretsOptions >> , # [doc = "Disallow variable declarations from shadowing variables declared in the outer scope."] # [serde (skip_serializing_if = "Option::is_none")] pub no_shadow : Option < RuleConfiguration < biome_rule_options :: no_shadow :: NoShadowOptions >> , # [doc = "Disallow unnecessary type-based conditions that can be statically determined as redundant."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unnecessary_conditions : Option < RuleConfiguration < biome_rule_options :: no_unnecessary_conditions :: NoUnnecessaryConditionsOptions >> , # [doc = "Warn when importing non-existing exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unresolved_imports : Option < RuleConfiguration < biome_rule_options :: no_unresolved_imports :: NoUnresolvedImportsOptions >> , # [doc = "Disallow expression statements that are neither a function call nor an assignment."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unused_expressions : Option < RuleConfiguration < biome_rule_options :: no_unused_expressions :: NoUnusedExpressionsOptions >> , # [doc = "Disallow unused catch bindings."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_catch_binding : Option < RuleFixConfiguration < biome_rule_options :: no_useless_catch_binding :: NoUselessCatchBindingOptions >> , # [doc = "Disallow the use of useless undefined."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_undefined : Option < RuleFixConfiguration < biome_rule_options :: no_useless_undefined :: NoUselessUndefinedOptions >> , # [doc = "Enforce that Vue component data options are declared as functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_data_object_declaration : Option < RuleFixConfiguration < biome_rule_options :: no_vue_data_object_declaration :: NoVueDataObjectDeclarationOptions >> , # [doc = "Disallow duplicate keys in Vue component data, methods, computed properties, and other options."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_duplicate_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_duplicate_keys :: NoVueDuplicateKeysOptions >> , # [doc = "Disallow reserved keys in Vue component data and computed properties."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_keys :: NoVueReservedKeysOptions >> , # [doc = "Disallow reserved names to be used as props."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_props : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_props :: NoVueReservedPropsOptions >> , # [doc = "Enforces href attribute for \\ elements."] # [serde (skip_serializing_if = "Option::is_none")] pub use_anchor_href : Option < RuleConfiguration < biome_rule_options :: use_anchor_href :: UseAnchorHrefOptions >> , # [doc = "Enforce consistent arrow function bodies."] # [serde (skip_serializing_if = "Option::is_none")] pub use_consistent_arrow_return : Option < RuleFixConfiguration < biome_rule_options :: use_consistent_arrow_return :: UseConsistentArrowReturnOptions >> , # [doc = "Enforce type definitions to consistently use either interface or type."] # [serde (skip_serializing_if = "Option::is_none")] pub use_consistent_type_definitions : Option < RuleFixConfiguration < biome_rule_options :: use_consistent_type_definitions :: UseConsistentTypeDefinitionsOptions >> , # [doc = "Require switch-case statements to be exhaustive."] # [serde (skip_serializing_if = "Option::is_none")] pub use_exhaustive_switch_cases : Option < RuleFixConfiguration < biome_rule_options :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCasesOptions >> , # [doc = "Enforce types in functions, methods, variables, and parameters."] # [serde (skip_serializing_if = "Option::is_none")] pub use_explicit_type : Option < RuleConfiguration < biome_rule_options :: use_explicit_type :: UseExplicitTypeOptions >> , # [doc = "Enforces that \\ elements have both width and height attributes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_image_size : Option < RuleConfiguration < biome_rule_options :: use_image_size :: UseImageSizeOptions >> , # [doc = "Enforce a maximum number of parameters in function definitions."] # [serde (skip_serializing_if = "Option::is_none")] pub use_max_params : Option < RuleConfiguration < biome_rule_options :: use_max_params :: UseMaxParamsOptions >> , # [doc = "Prefer using the class prop as a classlist over the classnames helper."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_classlist : Option < RuleConfiguration < biome_rule_options :: use_qwik_classlist :: UseQwikClasslistOptions >> , # [doc = "Disallow use* hooks outside of component$ or other use* hooks in Qwik applications."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_method_usage : Option < RuleConfiguration < biome_rule_options :: use_qwik_method_usage :: UseQwikMethodUsageOptions >> , # [doc = "Disallow unserializable expressions in Qwik dollar ($) scopes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_valid_lexical_scope : Option < RuleConfiguration < biome_rule_options :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScopeOptions >> , # [doc = "Enforce that components are defined as functions and never as classes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_react_function_components : Option < RuleConfiguration < biome_rule_options :: use_react_function_components :: UseReactFunctionComponentsOptions >> , # [doc = "Enforce the sorting of CSS utility classes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_sorted_classes : Option < RuleFixConfiguration < biome_rule_options :: use_sorted_classes :: UseSortedClassesOptions >> , # [doc = "Enforce multi-word component names in Vue components."] # [serde (skip_serializing_if = "Option::is_none")] pub use_vue_multi_word_component_names : Option < RuleConfiguration < biome_rule_options :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNamesOptions >> } impl Nursery { const GROUP_NAME: &'static str = "nursery"; pub(crate) const GROUP_RULES: &'static [&'static str] = &[ @@ -4662,6 +4670,8 @@ impl Nursery { "useImageSize", "useMaxParams", "useQwikClasslist", + "useQwikMethodUsage", + "useQwikValidLexicalScope", "useReactFunctionComponents", "useSortedClasses", "useVueMultiWordComponentNames", @@ -4701,6 +4711,8 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33]), ]; } impl RuleGroupExt for Nursery { @@ -4857,21 +4869,31 @@ impl RuleGroupExt for Nursery { { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } - if let Some(rule) = self.use_react_function_components.as_ref() + if let Some(rule) = self.use_qwik_method_usage.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } - if let Some(rule) = self.use_sorted_classes.as_ref() + if let Some(rule) = self.use_qwik_valid_lexical_scope.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); } - if let Some(rule) = self.use_vue_multi_word_component_names.as_ref() + if let Some(rule) = self.use_react_function_components.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); } + if let Some(rule) = self.use_sorted_classes.as_ref() + && rule.is_enabled() + { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32])); + } + if let Some(rule) = self.use_vue_multi_word_component_names.as_ref() + && rule.is_enabled() + { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33])); + } index_set } fn get_disabled_rules(&self) -> FxHashSet> { @@ -5021,21 +5043,31 @@ impl RuleGroupExt for Nursery { { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } - if let Some(rule) = self.use_react_function_components.as_ref() + if let Some(rule) = self.use_qwik_method_usage.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } - if let Some(rule) = self.use_sorted_classes.as_ref() + if let Some(rule) = self.use_qwik_valid_lexical_scope.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); } - if let Some(rule) = self.use_vue_multi_word_component_names.as_ref() + if let Some(rule) = self.use_react_function_components.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); } + if let Some(rule) = self.use_sorted_classes.as_ref() + && rule.is_disabled() + { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[32])); + } + if let Some(rule) = self.use_vue_multi_word_component_names.as_ref() + && rule.is_disabled() + { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33])); + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -5182,6 +5214,14 @@ impl RuleGroupExt for Nursery { .use_qwik_classlist .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "useQwikMethodUsage" => self + .use_qwik_method_usage + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), + "useQwikValidLexicalScope" => self + .use_qwik_valid_lexical_scope + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "useReactFunctionComponents" => self .use_react_function_components .as_ref() @@ -5231,6 +5271,8 @@ impl From for Nursery { use_image_size: Some(value.into()), use_max_params: Some(value.into()), use_qwik_classlist: Some(value.into()), + use_qwik_method_usage: Some(value.into()), + use_qwik_valid_lexical_scope: Some(value.into()), use_react_function_components: Some(value.into()), use_sorted_classes: Some(value.into()), use_vue_multi_word_component_names: Some(value.into()), diff --git a/xtask/rules_check/src/lib.rs b/xtask/rules_check/src/lib.rs index 91f6d5d932a1..d4a48da6f66a 100644 --- a/xtask/rules_check/src/lib.rs +++ b/xtask/rules_check/src/lib.rs @@ -18,6 +18,7 @@ use biome_css_syntax::CssLanguage; use biome_deserialize::json::deserialize_from_json_ast; use biome_diagnostics::{DiagnosticExt, PrintDiagnostic, Severity}; use biome_graphql_syntax::GraphqlLanguage; +use biome_html_parser::HtmlParseOptions; use biome_html_syntax::HtmlLanguage; use biome_js_parser::JsParserOptions; use biome_js_syntax::{EmbeddingKind, JsFileSource, JsLanguage, TextSize}; @@ -404,7 +405,45 @@ fn assert_lint( }); } } - DocumentFileSource::Html(..) => todo!("HTML analysis is not yet supported"), + DocumentFileSource::Html(..) => { + let parse = biome_html_parser::parse_html(code, HtmlParseOptions::default()); + + if parse.has_errors() { + for diag in parse.into_diagnostics() { + let error = diag + .with_file_path(test.file_path()) + .with_file_source_code(code); + diagnostics.write_parse_error(error); + } + } else { + let root = parse.tree(); + + let rule_filter = RuleFilter::Rule(group, rule); + let filter = AnalysisFilter { + enabled_rules: Some(slice::from_ref(&rule_filter)), + ..AnalysisFilter::default() + }; + + let options = test.create_analyzer_options::(config)?; + + biome_html_analyze::analyze(&root, filter, &options, |signal| { + if let Some(mut diag) = signal.diagnostic() { + for action in signal.actions() { + if !action.is_suppression() { + diag = diag.add_code_suggestion(action.into()); + } + } + + let error = diag + .with_file_path(test.file_path()) + .with_file_source_code(code); + diagnostics.write_diagnostic(error); + } + + ControlFlow::<()>::Continue(()) + }); + } + } DocumentFileSource::Grit(..) => todo!("Grit analysis is not yet supported"), // Unknown code blocks should be ignored by tests From 8889d3dde7f1756e174117a5a1afdcf2210960ab Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Sun, 5 Oct 2025 08:59:12 +0100 Subject: [PATCH 08/23] chore: remove todo --- crates/biome_html_analyze/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/biome_html_analyze/src/lib.rs b/crates/biome_html_analyze/src/lib.rs index c5c72aa0cbb6..375a264c2807 100644 --- a/crates/biome_html_analyze/src/lib.rs +++ b/crates/biome_html_analyze/src/lib.rs @@ -101,7 +101,6 @@ where METADATA.deref(), biome_analyze::InspectMatcher::new(registry, inspect_matcher), parse_linter_suppression_comment, - // TODO: add suppression action Box::new(HtmlSuppressionAction), &mut emit_signal, ); From 8b50c54ae5dbdd8ff37bc49c60df1ed6ae5c8d9c Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Sun, 5 Oct 2025 09:05:49 +0100 Subject: [PATCH 09/23] chore: update workspace logic --- crates/biome_html_analyze/tests/spec_tests.rs | 1 - .../biome_service/src/file_handlers/html.rs | 272 +++++++++++++++--- 2 files changed, 227 insertions(+), 46 deletions(-) diff --git a/crates/biome_html_analyze/tests/spec_tests.rs b/crates/biome_html_analyze/tests/spec_tests.rs index 97ffdad35365..588fd0ff0f4a 100644 --- a/crates/biome_html_analyze/tests/spec_tests.rs +++ b/crates/biome_html_analyze/tests/spec_tests.rs @@ -84,7 +84,6 @@ fn run_test(input: &'static str, _: &str, _: &str, _: &str) { }); } -#[expect(clippy::too_many_arguments)] pub(crate) fn analyze_and_snap( snapshot: &mut String, input_code: &str, diff --git a/crates/biome_service/src/file_handlers/html.rs b/crates/biome_service/src/file_handlers/html.rs index 8b7dd6ebf578..85466e7af398 100644 --- a/crates/biome_service/src/file_handlers/html.rs +++ b/crates/biome_service/src/file_handlers/html.rs @@ -1,25 +1,25 @@ use super::{ - AnalyzerCapabilities, Capabilities, CodeActionsParams, DebugCapabilities, DocumentFileSource, - EnabledForPath, ExtensionHandler, FixAllParams, FormatEmbedNode, FormatterCapabilities, - LintParams, LintResults, ParseEmbedResult, ParseResult, ParserCapabilities, SearchCapabilities, - UpdateSnippetsNodes, + AnalyzerCapabilities, AnalyzerVisitorBuilder, Capabilities, CodeActionsParams, + DebugCapabilities, DocumentFileSource, EnabledForPath, ExtensionHandler, FixAllParams, + FormatEmbedNode, FormatterCapabilities, LintParams, LintResults, ParseEmbedResult, ParseResult, + ParserCapabilities, ProcessLint, SearchCapabilities, UpdateSnippetsNodes, is_diagnostic_error, }; use crate::settings::{OverrideSettings, check_feature_activity, check_override_feature_activity}; -use crate::workspace::EmbeddedSnippet; +use crate::workspace::{CodeAction, EmbeddedSnippet, FixAction, FixFileMode}; use crate::workspace::{FixFileResult, PullActionsResult}; use crate::{ WorkspaceError, settings::{ServiceLanguage, Settings}, workspace::GetSyntaxTreeResult, }; -use biome_analyze::AnalyzerOptions; +use biome_analyze::{AnalysisFilter, AnalyzerOptions, ControlFlow, Never, RuleError}; use biome_configuration::html::{ HtmlAssistConfiguration, HtmlAssistEnabled, HtmlFormatterConfiguration, HtmlFormatterEnabled, HtmlLinterConfiguration, HtmlLinterEnabled, HtmlParseInterpolation, HtmlParserConfiguration, }; use biome_css_parser::parse_css_with_offset_and_cache; use biome_css_syntax::{CssFileSource, CssLanguage}; -use biome_diagnostics::{Diagnostic, Severity}; +use biome_diagnostics::Applicability; use biome_formatter::format_element::{Interned, LineMode}; use biome_formatter::prelude::{Document, Tag}; use biome_formatter::{ @@ -27,6 +27,7 @@ use biome_formatter::{ LineWidth, Printed, }; use biome_fs::BiomePath; +use biome_html_analyze::analyze; use biome_html_factory::make::ident; use biome_html_formatter::context::SelfCloseVoidElements; use biome_html_formatter::{ @@ -43,8 +44,9 @@ use biome_json_syntax::{JsonFileSource, JsonLanguage}; use biome_parser::AnyParse; use biome_rowan::{AstNode, AstNodeList, BatchMutation, NodeCache, SendNode}; use camino::Utf8Path; +use std::borrow::Cow; use std::fmt::Debug; -use tracing::instrument; +use tracing::{debug_span, error, instrument, trace_span}; #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -665,51 +667,231 @@ fn format_embedded( #[tracing::instrument(level = "debug", skip(params))] fn lint(params: LintParams) -> LintResults { - let diagnostics = params - .parse - .into_serde_diagnostics(params.diagnostic_offset); - - let diagnostic_count = diagnostics.len() as u32; - let skipped_diagnostics = diagnostic_count.saturating_sub(diagnostics.len() as u32); - let errors = diagnostics - .iter() - .filter(|diag| diag.severity() <= Severity::Error) - .count(); - - LintResults { - diagnostics, - errors, - skipped_diagnostics, - } + let workspace_settings = ¶ms.settings; + let analyzer_options = workspace_settings.analyzer_options::( + params.path, + ¶ms.language, + params.suppression_reason.as_deref(), + ); + let tree = params.parse.tree(); + + let (enabled_rules, disabled_rules, analyzer_options) = + AnalyzerVisitorBuilder::new(params.settings, analyzer_options) + .with_only(params.only) + .with_skip(params.skip) + .with_path(params.path.as_path()) + .with_enabled_selectors(params.enabled_selectors) + .with_project_layout(params.project_layout.clone()) + .finish(); + + let filter = AnalysisFilter { + categories: params.categories, + enabled_rules: Some(enabled_rules.as_slice()), + disabled_rules: &disabled_rules, + range: None, + }; + + let mut process_lint = ProcessLint::new(¶ms); + + let (_, analyze_diagnostics) = analyze(&tree, filter, &analyzer_options, |signal| { + process_lint.process_signal(signal) + }); + + process_lint.into_result( + params + .parse + .into_serde_diagnostics(params.diagnostic_offset), + analyze_diagnostics, + ) } -pub(crate) fn code_actions(_params: CodeActionsParams) -> PullActionsResult { - PullActionsResult { actions: vec![] } +pub(crate) fn code_actions(params: CodeActionsParams) -> PullActionsResult { + let CodeActionsParams { + parse, + range, + settings, + path, + module_graph: _, + project_layout, + language, + only, + skip, + suppression_reason, + enabled_rules: rules, + plugins: _, + categories, + } = params; + let _ = debug_span!("Code actions GraphQL", range =? range, path =? path).entered(); + let tree = parse.tree(); + let _ = trace_span!("Parsed file", tree =? tree).entered(); + let Some(_) = language.to_graphql_file_source() else { + error!("Could not determine the file source of the file"); + return PullActionsResult { + actions: Vec::new(), + }; + }; + + let analyzer_options = + settings.analyzer_options::(path, &language, suppression_reason.as_deref()); + let mut actions = Vec::new(); + let (enabled_rules, disabled_rules, analyzer_options) = + AnalyzerVisitorBuilder::new(settings, analyzer_options) + .with_only(only) + .with_skip(skip) + .with_path(path.as_path()) + .with_enabled_selectors(rules) + .with_project_layout(project_layout) + .finish(); + + let filter = AnalysisFilter { + categories, + enabled_rules: Some(enabled_rules.as_slice()), + disabled_rules: &disabled_rules, + range, + }; + + analyze(&tree, filter, &analyzer_options, |signal| { + actions.extend(signal.actions().into_code_action_iter().map(|item| { + CodeAction { + category: item.category.clone(), + rule_name: item + .rule_name + .map(|(group, name)| (Cow::Borrowed(group), Cow::Borrowed(name))), + suggestion: item.suggestion, + } + })); + + ControlFlow::::Continue(()) + }); + + PullActionsResult { actions } } #[tracing::instrument(level = "debug", skip(params))] pub(crate) fn fix_all(params: FixAllParams) -> Result { // We don't have analyzer rules yet - let tree: HtmlRoot = params.parse.tree(); - let code = if params.should_format { - format_node( - params - .settings - .format_options::(params.biome_path, ¶ms.document_file_source), - tree.syntax(), - false, - )? - .print()? - .into_code() - } else { - tree.syntax().to_string() + let mut tree: HtmlRoot = params.parse.tree(); + + // Compute final rules (taking `overrides` into account) + let rules = params.settings.as_linter_rules(params.biome_path.as_path()); + let analyzer_options = params.settings.analyzer_options::( + params.biome_path, + ¶ms.document_file_source, + params.suppression_reason.as_deref(), + ); + let (enabled_rules, disabled_rules, analyzer_options) = + AnalyzerVisitorBuilder::new(params.settings, analyzer_options) + .with_only(params.only) + .with_skip(params.skip) + .with_path(params.biome_path.as_path()) + .with_enabled_selectors(params.enabled_rules) + .with_project_layout(params.project_layout) + .finish(); + + let filter = AnalysisFilter { + categories: params.rule_categories, + enabled_rules: Some(enabled_rules.as_slice()), + disabled_rules: &disabled_rules, + range: None, }; - Ok(FixFileResult { - code, - skipped_suggested_fixes: 0, - actions: vec![], - errors: 0, - }) + + let mut actions = Vec::new(); + let mut skipped_suggested_fixes = 0; + let mut errors: u16 = 0; + + loop { + let (action, _) = analyze(&tree, filter, &analyzer_options, |signal| { + let current_diagnostic = signal.diagnostic(); + + if let Some(diagnostic) = current_diagnostic.as_ref() + && is_diagnostic_error(diagnostic, rules.as_deref()) + { + errors += 1; + } + + for action in signal.actions() { + // suppression actions should not be part of the fixes (safe or suggested) + if action.is_suppression() { + continue; + } + + match params.fix_file_mode { + FixFileMode::SafeFixes => { + if action.applicability == Applicability::MaybeIncorrect { + skipped_suggested_fixes += 1; + } + if action.applicability == Applicability::Always { + errors = errors.saturating_sub(1); + return ControlFlow::Break(action); + } + } + FixFileMode::SafeAndUnsafeFixes => { + if matches!( + action.applicability, + Applicability::Always | Applicability::MaybeIncorrect + ) { + errors = errors.saturating_sub(1); + return ControlFlow::Break(action); + } + } + FixFileMode::ApplySuppressions => { + // TODO: implement once a GraphQL suppression action is available + } + } + } + + ControlFlow::Continue(()) + }); + + match action { + Some(action) => { + if let (root, Some((range, _))) = + action.mutation.commit_with_text_range_and_edit(true) + { + tree = match HtmlRoot::cast(root) { + Some(tree) => tree, + None => { + return Err(WorkspaceError::RuleError( + RuleError::ReplacedRootWithNonRootError { + rule_name: action.rule_name.map(|(group, rule)| { + (Cow::Borrowed(group), Cow::Borrowed(rule)) + }), + }, + )); + } + }; + actions.push(FixAction { + rule_name: action + .rule_name + .map(|(group, rule)| (Cow::Borrowed(group), Cow::Borrowed(rule))), + range, + }); + } + } + None => { + let code = if params.should_format { + format_node( + params.settings.format_options::( + params.biome_path, + ¶ms.document_file_source, + ), + tree.syntax(), + true, + )? + .print()? + .into_code() + } else { + tree.syntax().to_string() + }; + return Ok(FixFileResult { + code, + skipped_suggested_fixes, + actions, + errors: errors.into(), + }); + } + } + } } #[instrument(level = "debug", skip_all)] From 4a90fd596fcfd6b6eeb5bd805bc5e6ac251eff7b Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Sun, 5 Oct 2025 09:09:14 +0100 Subject: [PATCH 10/23] chore: add tests --- crates/biome_cli/tests/cases/html.rs | 42 +++++++++++++ .../should_lint_a_html_file.snap | 63 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 crates/biome_cli/tests/snapshots/main_cases_html/should_lint_a_html_file.snap diff --git a/crates/biome_cli/tests/cases/html.rs b/crates/biome_cli/tests/cases/html.rs index 7b1d98b38f6f..2dd7cddb5ddc 100644 --- a/crates/biome_cli/tests/cases/html.rs +++ b/crates/biome_cli/tests/cases/html.rs @@ -331,3 +331,45 @@ fn should_apply_fixes_to_embedded_languages() { result, )); } + +#[test] +fn should_lint_a_html_file() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + let html_file = Utf8Path::new("file.html"); + fs.insert( + html_file.into(), + r#"
+"# + .as_bytes(), + ); + + fs.insert( + Utf8Path::new("biome.json").into(), + r#"{ + "html": { + "linter": { + "enabled": true + } + } +}"# + .as_bytes(), + ); + + let (fs, result) = run_cli( + fs, + &mut console, + Args::from(["lint", html_file.as_str()].as_slice()), + ); + + assert!(result.is_err(), "run_cli returned {result:?}"); + + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "should_lint_a_html_file", + fs, + console, + result, + )); +} diff --git a/crates/biome_cli/tests/snapshots/main_cases_html/should_lint_a_html_file.snap b/crates/biome_cli/tests/snapshots/main_cases_html/should_lint_a_html_file.snap new file mode 100644 index 000000000000..5506bda346f4 --- /dev/null +++ b/crates/biome_cli/tests/snapshots/main_cases_html/should_lint_a_html_file.snap @@ -0,0 +1,63 @@ +--- +source: crates/biome_cli/tests/snap_test.rs +expression: redactor(content) +--- +## `biome.json` + +```json +{ + "html": { + "linter": { + "enabled": true + } + } +} +``` + +## `file.html` + +```html +
+ +``` + +# Termination Message + +```block +lint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Some errors were emitted while running checks. + + + +``` + +# Emitted Messages + +```block +file.html:1:6 lint/a11y/noHeaderScope FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Avoid using the scope attribute on elements other than th elements. + + > 1 │
+ │ ^^^^^^^^^^^ + 2 │ + + i The scope attribute is used to associate a data cell with its corresponding header cell in a data table, + so it should be placed on th elements to provide accessibility to screen readers. + + i Follow the links for more information, + WCAG 1.3.1 + WCAG 4.1.1 + + i Unsafe fix: Remove the scope attribute. + + 1 │ + │ ----------- + +``` + +```block +Checked 1 file in