diff --git a/CHANGELOG.md b/CHANGELOG.md index f4db3d90a267..3633c4cd4e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,23 @@ New entries must be placed in a section entitled `Unreleased`. Read our [guidelines for writing a good changelog entry](https://github.com/biomejs/biome/blob/main/CONTRIBUTING.md#changelog). -## Unreleased - -### Analyzer +## v1.9.2 (2024-09-19) ### CLI +#### New features + +- Added support for custom GritQL definitions, including: + - Pattern and predicate definitions: https://docs.grit.io/guides/patterns + - Function definitions: https://docs.grit.io/language/functions#function-definitions + + Contributed by @arendjr + #### Bug fixes - Fix [#3917](https://github.com/biomejs/biome/issues/3917), where the fixed files were incorrectly computed. Contributed by @ematipico - -### Configuration +- Fixed an issue that caused GritQL `contains` queries to report false positives when the matched + node appeared inside a sibling node. Contributed by @arendjr ### Editors @@ -36,10 +42,6 @@ our [guidelines for writing a good changelog entry](https://github.com/biomejs/b Contributed by @ematipico -### Formatter - -### JavaScript APIs - ### Linter #### New features @@ -49,6 +51,8 @@ our [guidelines for writing a good changelog entry](https://github.com/biomejs/b #### Bug fixes +- [noLabelWithoutControl](https://biomejs.dev/linter/rules/no-label-without-control/) now accept JSX expression as label value ([#3875](https://github.com/biomejs/biome/issues/3875)). Contributed by @Conaclos + - [useFilenamingConvention](https://biomejs.dev/linter/rules/use-filenaming-convention) no longer suggests names with a disallowed case ([#3952](https://github.com/biomejs/biome/issues/3952)). Contributed by @Conaclos - [useFilenamingConvention](https://biomejs.dev/linter/rules/use-filenaming-convention) now recognizes file names starting with ASCII digits as lowercase ([#3952](https://github.com/biomejs/biome/issues/3952)). diff --git a/Cargo.lock b/Cargo.lock index ff076684f598..69175e8fee25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -639,6 +639,7 @@ dependencies = [ "im", "insta", "path-absolutize", + "rand 0.8.5", "regex", "rustc-hash 1.1.0", "serde", diff --git a/crates/biome_css_analyze/src/lint/nursery/no_missing_var_function.rs b/crates/biome_css_analyze/src/lint/nursery/no_missing_var_function.rs index a1462c96126c..38675bd7e392 100644 --- a/crates/biome_css_analyze/src/lint/nursery/no_missing_var_function.rs +++ b/crates/biome_css_analyze/src/lint/nursery/no_missing_var_function.rs @@ -113,7 +113,7 @@ declare_lint_rule! { /// ``` /// pub NoMissingVarFunction { - version: "next", + version: "1.9.2", name: "noMissingVarFunction", language: "css", recommended: true, diff --git a/crates/biome_grit_patterns/Cargo.toml b/crates/biome_grit_patterns/Cargo.toml index b15b9d47d433..9938a2bb80ea 100644 --- a/crates/biome_grit_patterns/Cargo.toml +++ b/crates/biome_grit_patterns/Cargo.toml @@ -25,6 +25,7 @@ grit-pattern-matcher = { version = "0.3" } grit-util = { version = "0.3" } im = { version = "15.1.0" } path-absolutize = { version = "3.1.1", optional = false, features = ["use_unix_paths_on_wasm"] } +rand = { version = "0.8" } regex = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/biome_grit_patterns/src/errors.rs b/crates/biome_grit_patterns/src/errors.rs index 501152a7d52e..8ba3ee25283d 100644 --- a/crates/biome_grit_patterns/src/errors.rs +++ b/crates/biome_grit_patterns/src/errors.rs @@ -15,6 +15,9 @@ pub enum CompileError { /// Used for missing syntax nodes. MissingSyntaxNode, + /// A built-in function call was discovered in an unexpected context. + UnexpectedBuiltinCall(String), + /// A metavariables was discovered in an unexpected context. UnexpectedMetavariable, @@ -95,6 +98,9 @@ impl Diagnostic for CompileError { CompileError::MissingSyntaxNode => { fmt.write_markup(markup! { "A syntax node was missing" }) } + CompileError::UnexpectedBuiltinCall(name) => { + fmt.write_markup(markup! { "Unexpected call to built-in: "{{name}}"()" }) + } CompileError::UnexpectedMetavariable => { fmt.write_markup(markup! { "Unexpected metavariable" }) } @@ -176,6 +182,11 @@ impl Diagnostic for CompileError { fn advices(&self, visitor: &mut dyn biome_diagnostics::Visit) -> std::io::Result<()> { match self { + CompileError::UnexpectedBuiltinCall(name) => visitor.record_log( + LogCategory::Info, + &markup! { "Built-in "{{name}}" can only be used on the right-hand side of a rewrite" } + .to_owned(), + ), CompileError::ReservedMetavariable(_) => visitor.record_log( LogCategory::Info, &markup! { "Try using a different variable name" }.to_owned(), diff --git a/crates/biome_grit_patterns/src/grit_built_in_functions.rs b/crates/biome_grit_patterns/src/grit_built_in_functions.rs new file mode 100644 index 000000000000..108c356f5118 --- /dev/null +++ b/crates/biome_grit_patterns/src/grit_built_in_functions.rs @@ -0,0 +1,400 @@ +use crate::{ + grit_context::{GritExecContext, GritQueryContext}, + grit_resolved_pattern::GritResolvedPattern, +}; +use anyhow::{anyhow, bail, Result}; +use grit_pattern_matcher::{ + binding::Binding, + constant::Constant, + context::ExecContext, + pattern::{ + get_absolute_file_name, CallBuiltIn, JoinFn, LazyBuiltIn, Pattern, ResolvedPattern, + ResolvedSnippet, State, + }, +}; +use grit_util::AnalysisLogs; +use im::Vector; +use path_absolutize::Absolutize; +use rand::{seq::SliceRandom, Rng}; +use std::borrow::Cow; +use std::path::Path; + +pub type CallableFn = dyn for<'a> Fn( + &'a [Option>], + &'a GritExecContext<'a>, + &mut State<'a, GritQueryContext>, + &mut AnalysisLogs, + ) -> Result> + + Send + + Sync; + +pub struct BuiltInFunction { + pub name: &'static str, + pub params: Vec<&'static str>, + pub(crate) func: Box, +} + +impl BuiltInFunction { + fn call<'a>( + &self, + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, + ) -> Result> { + (self.func)(args, context, state, logs) + } + + pub fn new(name: &'static str, params: Vec<&'static str>, func: Box) -> Self { + Self { name, params, func } + } +} + +impl std::fmt::Debug for BuiltInFunction { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("BuiltInFunction") + .field("name", &self.name) + .field("params", &self.params) + .finish() + } +} + +#[derive(Debug)] +pub struct BuiltIns(Vec); + +impl Default for BuiltIns { + fn default() -> Self { + vec![ + BuiltInFunction::new("resolve", vec!["path"], Box::new(resolve_path_fn)), + BuiltInFunction::new("capitalize", vec!["string"], Box::new(capitalize_fn)), + BuiltInFunction::new("lowercase", vec!["string"], Box::new(lowercase_fn)), + BuiltInFunction::new("uppercase", vec!["string"], Box::new(uppercase_fn)), + BuiltInFunction::new("text", vec!["string"], Box::new(text_fn)), + BuiltInFunction::new("trim", vec!["string", "trim_chars"], Box::new(trim_fn)), + BuiltInFunction::new("join", vec!["list", "separator"], Box::new(join_fn)), + BuiltInFunction::new("distinct", vec!["list"], Box::new(distinct_fn)), + BuiltInFunction::new("length", vec!["target"], Box::new(length_fn)), + BuiltInFunction::new("shuffle", vec!["list"], Box::new(shuffle_fn)), + BuiltInFunction::new("random", vec!["floor", "ceiling"], Box::new(random_fn)), + BuiltInFunction::new("split", vec!["string", "separator"], Box::new(split_fn)), + ] + .into() + } +} + +impl BuiltIns { + pub(crate) fn call<'a>( + &self, + call: &'a CallBuiltIn, + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, + ) -> Result> { + self.0[call.index].call(&call.args, context, state, logs) + } + + pub(crate) fn get_built_ins(&self) -> &[BuiltInFunction] { + &self.0 + } +} + +impl From> for BuiltIns { + fn from(built_ins: Vec) -> Self { + Self(built_ins) + } +} + +fn capitalize_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + let Some(Some(arg1)) = args.into_iter().next() else { + bail!("capitalize() takes 1 argument: string"); + }; + + let string = arg1.text(&state.files, context.language())?; + Ok(ResolvedPattern::from_string(capitalize(&string))) +} + +fn distinct_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + let Some(Some(arg1)) = args.into_iter().next() else { + bail!("distinct() takes 1 argument: list"); + }; + + match arg1 { + GritResolvedPattern::List(list) => { + let mut unique_list = Vector::new(); + for item in list { + if !unique_list.contains(&item) { + unique_list.push_back(item); + } + } + Ok(GritResolvedPattern::List(unique_list)) + } + GritResolvedPattern::Binding(binding) => match binding.last() { + Some(binding) => { + let Some(list_items) = binding.list_items() else { + bail!("distinct() requires a list as the first argument"); + }; + + let mut unique_list = Vector::new(); + for item in list_items { + let resolved = ResolvedPattern::from_node_binding(item); + if !unique_list.contains(&resolved) { + unique_list.push_back(resolved); + } + } + Ok(GritResolvedPattern::List(unique_list)) + } + None => Ok(GritResolvedPattern::Binding(binding)), + }, + _ => bail!("distinct() requires a list as the first argument"), + } +} + +fn join_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + let mut args = args.into_iter(); + let (Some(Some(arg1)), Some(Some(arg2))) = (args.next(), args.next()) else { + bail!("join() takes 2 arguments: list and separator"); + }; + + let separator = arg2.text(&state.files, context.language())?; + let join = if let Some(items) = arg1.get_list_items() { + JoinFn::from_patterns(items.cloned(), separator.to_string()) + } else if let Some(items) = arg1.get_list_binding_items() { + JoinFn::from_patterns(items, separator.to_string()) + } else { + bail!("join() requires a list as the first argument"); + }; + + let snippet = ResolvedSnippet::LazyFn(Box::new(LazyBuiltIn::Join(join))); + Ok(ResolvedPattern::from_resolved_snippet(snippet)) +} + +fn length_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + let Some(Some(arg1)) = args.into_iter().next() else { + bail!("length() takes 1 argument: list or string"); + }; + + Ok(match arg1 { + GritResolvedPattern::List(list) => { + ResolvedPattern::from_constant(Constant::Integer(list.len().try_into()?)) + } + GritResolvedPattern::Binding(binding) => match binding.last() { + Some(resolved_pattern) => { + let length = if let Some(list_items) = resolved_pattern.list_items() { + list_items.count() + } else { + resolved_pattern.text(context.language())?.len() + }; + ResolvedPattern::from_constant(Constant::Integer(length.try_into()?)) + } + None => bail!("length() requires a list or string as the first argument"), + }, + resolved_pattern => { + let Ok(text) = resolved_pattern.text(&state.files, context.language()) else { + bail!("length() requires a list or string as the first argument"); + }; + + ResolvedPattern::from_constant(Constant::Integer(text.len().try_into()?)) + } + }) +} + +fn lowercase_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + let Some(Some(arg1)) = args.into_iter().next() else { + bail!("lowercase() takes 1 argument: string"); + }; + + let string = arg1.text(&state.files, context.language())?; + Ok(ResolvedPattern::from_string(string.to_lowercase())) +} + +fn random_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + + match args.as_slice() { + [Some(start), Some(end)] => { + let start = start.text(&state.files, context.language())?; + let end = end.text(&state.files, context.language())?; + let start = start.parse::()?; + let end = end.parse::()?; + // Inclusive range + let value = state.get_rng().gen_range(start..=end); + Ok(ResolvedPattern::from_constant(Constant::Integer(value))) + } + [None, None] => { + let value = state.get_rng().gen::(); + Ok(ResolvedPattern::from_constant(Constant::Float(value))) + } + _ => bail!("random() takes 0 or 2 arguments: an optional start and end"), + } +} + +/// Turns an arbitrary path into a resolved and normalized absolute path +fn resolve_path_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + let Some(Some(arg1)) = args.into_iter().next() else { + bail!("resolve() takes 1 argument: path"); + }; + + let current_file = get_absolute_file_name(state, context.language())?; + let target_path = arg1.text(&state.files, context.language())?; + + let resolved_path = resolve(target_path, current_file.into())?; + + Ok(ResolvedPattern::from_string(resolved_path)) +} + +fn shuffle_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + let Some(Some(arg1)) = args.into_iter().next() else { + bail!("shuffle() takes 1 argument: list"); + }; + + let mut list: Vec<_> = if let Some(items) = arg1.get_list_items() { + items.cloned().collect() + } else if let Some(items) = arg1.get_list_binding_items() { + items.collect() + } else { + bail!("shuffle() requires a list as the first argument"); + }; + + list.shuffle(state.get_rng()); + Ok(GritResolvedPattern::from_list_parts(list.into_iter())) +} + +fn split_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + let mut args = args.into_iter(); + let (Some(Some(arg1)), Some(Some(arg2))) = (args.next(), args.next()) else { + bail!("split() takes 2 arguments: string and separator"); + }; + + let separator = arg2.text(&state.files, context.language())?; + let separator = separator.as_ref(); + + let string = arg1.text(&state.files, context.language())?; + let parts = string.split(separator).map(|s| { + ResolvedPattern::from_resolved_snippet(ResolvedSnippet::Text(s.to_string().into())) + }); + Ok(ResolvedPattern::from_list_parts(parts)) +} + +fn text_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + let Some(Some(arg1)) = args.into_iter().next() else { + bail!("text() takes 1 argument: string"); + }; + + let string = arg1.text(&state.files, context.language())?; + Ok(ResolvedPattern::from_string(string.to_string())) +} + +fn trim_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + let mut args = args.into_iter(); + let (Some(Some(arg1)), Some(Some(arg2))) = (args.next(), args.next()) else { + bail!("trim() takes 2 arguments: string and trim_chars"); + }; + + let trim_chars = arg2.text(&state.files, context.language())?; + let trim_chars: Vec = trim_chars.chars().collect(); + let trim_chars = trim_chars.as_slice(); + + let string = arg1.text(&state.files, context.language())?; + let string = string.trim_matches(trim_chars).to_string(); + Ok(ResolvedPattern::from_string(string)) +} + +fn uppercase_fn<'a>( + args: &'a [Option>], + context: &'a GritExecContext<'a>, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, +) -> Result> { + let args = GritResolvedPattern::from_patterns(args, state, context, logs)?; + let Some(Some(arg1)) = args.into_iter().next() else { + bail!("uppercase() takes 1 argument: string"); + }; + + let string = arg1.text(&state.files, context.language())?; + Ok(ResolvedPattern::from_string(string.to_uppercase())) +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + } +} + +fn resolve<'a>(target_path: Cow<'a, str>, from_file: Cow<'a, str>) -> Result { + let Some(source_path) = Path::new(from_file.as_ref()).parent() else { + bail!("could not get parent directory of file name {}", &from_file); + }; + let our_path = Path::new(target_path.as_ref()); + let absolutized = our_path.absolutize_from(source_path)?; + Ok(absolutized + .to_str() + .ok_or_else(|| anyhow!("could not build absolute path from file name {target_path}"))? + .to_owned()) +} diff --git a/crates/biome_grit_patterns/src/grit_context.rs b/crates/biome_grit_patterns/src/grit_context.rs index 33883b7fed4c..5d29a8f1d226 100644 --- a/crates/biome_grit_patterns/src/grit_context.rs +++ b/crates/biome_grit_patterns/src/grit_context.rs @@ -1,4 +1,5 @@ use crate::grit_binding::GritBinding; +use crate::grit_built_in_functions::BuiltIns; use crate::grit_code_snippet::GritCodeSnippet; use crate::grit_file::GritFile; use crate::grit_node_patterns::{GritLeafNodePattern, GritNodePattern}; @@ -38,38 +39,17 @@ impl QueryContext for GritQueryContext { pub struct GritExecContext<'a> { /// The language to which the snippet should apply. - lang: GritTargetLanguage, + pub lang: GritTargetLanguage, /// The name of the snippet being executed. - name: Option<&'a str>, - - loadable_files: &'a [GritTargetFile], - files: &'a FileOwners, - functions: &'a [GritFunctionDefinition], - patterns: &'a [PatternDefinition], - predicates: &'a [PredicateDefinition], -} - -impl<'a> GritExecContext<'a> { - pub fn new( - lang: GritTargetLanguage, - name: Option<&'a str>, - loadable_files: &'a [GritTargetFile], - files: &'a FileOwners, - functions: &'a [GritFunctionDefinition], - patterns: &'a [PatternDefinition], - predicates: &'a [PredicateDefinition], - ) -> Self { - Self { - lang, - name, - loadable_files, - files, - functions, - patterns, - predicates, - } - } + pub name: Option<&'a str>, + + pub loadable_files: &'a [GritTargetFile], + pub files: &'a FileOwners, + pub built_ins: &'a BuiltIns, + pub functions: &'a [GritFunctionDefinition], + pub patterns: &'a [PatternDefinition], + pub predicates: &'a [PredicateDefinition], } impl<'a> ExecContext<'a, GritQueryContext> for GritExecContext<'a> { @@ -91,12 +71,12 @@ impl<'a> ExecContext<'a, GritQueryContext> for GritExecContext<'a> { fn call_built_in( &self, - _call: &'a CallBuiltIn, - _context: &'a Self, - _state: &mut State<'a, GritQueryContext>, - _logs: &mut AnalysisLogs, + call: &'a CallBuiltIn, + context: &'a Self, + state: &mut State<'a, GritQueryContext>, + logs: &mut AnalysisLogs, ) -> Result> { - unimplemented!("built-in functions are still TODO") + self.built_ins.call(call, context, state, logs) } fn files(&self) -> &FileOwners { diff --git a/crates/biome_grit_patterns/src/grit_query.rs b/crates/biome_grit_patterns/src/grit_query.rs index 11edc833bfdf..63e33cb33db3 100644 --- a/crates/biome_grit_patterns/src/grit_query.rs +++ b/crates/biome_grit_patterns/src/grit_query.rs @@ -1,4 +1,5 @@ use crate::diagnostics::CompilerDiagnostic; +use crate::grit_built_in_functions::BuiltIns; use crate::grit_context::{GritExecContext, GritQueryContext, GritTargetFile}; use crate::grit_definitions::{ compile_definitions, scan_definitions, Definitions, ScannedDefinitionInfo, @@ -27,6 +28,9 @@ use im::Vector; use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsStr; use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +static BUILT_INS: LazyLock = LazyLock::new(BuiltIns::default); // These need to remain ordered by index. const GLOBAL_VARS: [(&str, usize); 4] = [ @@ -64,15 +68,16 @@ impl GritQuery { let file_owners = FileOwners::new(); let files = vec![file]; let file_ptr = FilePtr::new(0, 0); - let context = GritExecContext::new( - self.language.clone(), - self.name.as_deref(), - &files, - &file_owners, - &self.definitions.functions, - &self.definitions.patterns, - &self.definitions.predicates, - ); + let context = GritExecContext { + lang: self.language.clone(), + name: self.name.as_deref(), + loadable_files: &files, + files: &file_owners, + built_ins: &BUILT_INS, + functions: &self.definitions.functions, + patterns: &self.definitions.patterns, + predicates: &self.definitions.predicates, + }; let var_registry = VarRegistry::from_locations(&self.variable_locations); @@ -112,6 +117,7 @@ impl GritQuery { let context = CompilationContext { source_path, lang, + built_ins: &BUILT_INS, pattern_definition_info, predicate_definition_info, function_definition_info, diff --git a/crates/biome_grit_patterns/src/grit_target_node.rs b/crates/biome_grit_patterns/src/grit_target_node.rs index eefdd9a28a24..93b8f247bde0 100644 --- a/crates/biome_grit_patterns/src/grit_target_node.rs +++ b/crates/biome_grit_patterns/src/grit_target_node.rs @@ -506,6 +506,9 @@ impl<'a> AstCursor for GritTargetNodeCursor<'a> { } fn goto_next_sibling(&mut self) -> bool { + if self.node == self.root { + return false; + } match self.node.next_sibling() { Some(sibling) => { self.node = sibling; diff --git a/crates/biome_grit_patterns/src/lib.rs b/crates/biome_grit_patterns/src/lib.rs index f2ad2ea075b2..4f0c93a05e79 100644 --- a/crates/biome_grit_patterns/src/lib.rs +++ b/crates/biome_grit_patterns/src/lib.rs @@ -2,6 +2,7 @@ mod diagnostics; mod errors; mod grit_analysis_ext; mod grit_binding; +mod grit_built_in_functions; mod grit_code_snippet; mod grit_context; mod grit_definitions; diff --git a/crates/biome_grit_patterns/src/pattern_compiler/call_compiler.rs b/crates/biome_grit_patterns/src/pattern_compiler/call_compiler.rs index 6dc7bdd6caa2..79a6b2d52fe2 100644 --- a/crates/biome_grit_patterns/src/pattern_compiler/call_compiler.rs +++ b/crates/biome_grit_patterns/src/pattern_compiler/call_compiler.rs @@ -4,7 +4,7 @@ use biome_grit_syntax::{ AnyGritMaybeNamedArg, AnyGritPattern, GritNamedArgList, GritNodeLike, GritSyntaxKind, }; use biome_rowan::AstNode; -use grit_pattern_matcher::pattern::{Call, CallFunction, FilePattern, Pattern}; +use grit_pattern_matcher::pattern::{Call, CallBuiltIn, CallFunction, FilePattern, Pattern}; use grit_util::{ByteRange, Language}; use std::collections::BTreeMap; @@ -16,6 +16,7 @@ pub(super) fn call_pattern_from_node_with_name( node: &GritNodeLike, name: String, context: &mut NodeCompilationContext, + is_rhs: bool, ) -> Result, CompileError> { let named_args = named_args_from_node(node, &name, context)?; let mut args = named_args_to_map(named_args, context)?; @@ -45,6 +46,22 @@ pub(super) fn call_pattern_from_node_with_name( .map_or(Pattern::Underscore, |p| p.1); let body = args.remove_entry("$body").map_or(Pattern::Top, |p| p.1); Ok(Pattern::File(Box::new(FilePattern::new(name, body)))) + } else if let Some((index, built_in)) = context + .compilation + .built_ins + .get_built_ins() + .iter() + .enumerate() + .find(|(_, built_in)| built_in.name == name) + { + if !is_rhs { + return Err(CompileError::UnexpectedBuiltinCall(name)); + } + + let params = &built_in.params; + Ok(Pattern::CallBuiltIn(Box::new(call_built_in_from_args( + args, params, index, lang, + )?))) } else if let Some(info) = context.compilation.function_definition_info.get(&name) { let args = match_args_to_params(&name, args, &collect_params(&info.parameters), lang)?; Ok(Pattern::CallFunction(Box::new(CallFunction::new( @@ -58,6 +75,22 @@ pub(super) fn call_pattern_from_node_with_name( } } +fn call_built_in_from_args( + mut args: BTreeMap>, + params: &[&str], + index: usize, + lang: &impl Language, +) -> Result, CompileError> { + let mut pattern_params = Vec::with_capacity(args.len()); + for param in params.iter() { + match args.remove(&(lang.metavariable_prefix().to_owned() + param)) { + Some(p) => pattern_params.push(Some(p)), + None => pattern_params.push(None), + } + } + Ok(CallBuiltIn::new(index, pattern_params)) +} + pub(super) fn collect_params(parameters: &[(String, ByteRange)]) -> Vec { parameters.iter().map(|p| p.0.clone()).collect() } @@ -90,8 +123,21 @@ pub(super) fn named_args_from_node( name: &str, context: &mut NodeCompilationContext, ) -> Result, CompileError> { - let expected_params = if let Some(info) = context.compilation.function_definition_info.get(name) + let expected_params = if let Some(built_in) = context + .compilation + .built_ins + .get_built_ins() + .iter() + .find(|built_in| built_in.name == name) { + Some( + built_in + .params + .iter() + .map(|param| (*param).to_string()) + .collect(), + ) + } else if let Some(info) = context.compilation.function_definition_info.get(name) { Some(collect_params(&info.parameters)) } else { context diff --git a/crates/biome_grit_patterns/src/pattern_compiler/compilation_context.rs b/crates/biome_grit_patterns/src/pattern_compiler/compilation_context.rs index f0c9d112bc23..694809e0514e 100644 --- a/crates/biome_grit_patterns/src/pattern_compiler/compilation_context.rs +++ b/crates/biome_grit_patterns/src/pattern_compiler/compilation_context.rs @@ -1,7 +1,10 @@ use grit_pattern_matcher::pattern::VariableSourceLocations; use grit_util::ByteRange; -use crate::{diagnostics::CompilerDiagnostic, grit_target_language::GritTargetLanguage}; +use crate::{ + diagnostics::CompilerDiagnostic, grit_built_in_functions::BuiltIns, + grit_target_language::GritTargetLanguage, +}; use std::{collections::BTreeMap, path::Path}; pub(crate) struct CompilationContext<'a> { @@ -11,6 +14,7 @@ pub(crate) struct CompilationContext<'a> { /// The target language being matched on. pub lang: GritTargetLanguage, + pub built_ins: &'a BuiltIns, pub pattern_definition_info: BTreeMap, pub predicate_definition_info: BTreeMap, pub function_definition_info: BTreeMap, @@ -18,10 +22,15 @@ pub(crate) struct CompilationContext<'a> { impl<'a> CompilationContext<'a> { #[cfg(test)] - pub(crate) fn new(source_path: Option<&'a Path>, lang: GritTargetLanguage) -> Self { + pub(crate) fn new( + source_path: Option<&'a Path>, + lang: GritTargetLanguage, + built_ins: &'a BuiltIns, + ) -> Self { Self { source_path, lang, + built_ins, pattern_definition_info: Default::default(), predicate_definition_info: Default::default(), function_definition_info: Default::default(), diff --git a/crates/biome_grit_patterns/src/pattern_compiler/node_like_compiler.rs b/crates/biome_grit_patterns/src/pattern_compiler/node_like_compiler.rs index 7e405cf59e2a..625207d97eba 100644 --- a/crates/biome_grit_patterns/src/pattern_compiler/node_like_compiler.rs +++ b/crates/biome_grit_patterns/src/pattern_compiler/node_like_compiler.rs @@ -24,7 +24,7 @@ impl NodeLikeCompiler { if let Some(kind) = lang.kind_by_name(&name) { node_pattern_from_node_with_name_and_kind(node, name, kind, context, is_rhs) } else { - call_pattern_from_node_with_name(node, name, context) + call_pattern_from_node_with_name(node, name, context, is_rhs) } } } diff --git a/crates/biome_grit_patterns/src/pattern_compiler/snippet_compiler.rs b/crates/biome_grit_patterns/src/pattern_compiler/snippet_compiler.rs index 08dd938bfea3..820c19fe56e8 100644 --- a/crates/biome_grit_patterns/src/pattern_compiler/snippet_compiler.rs +++ b/crates/biome_grit_patterns/src/pattern_compiler/snippet_compiler.rs @@ -489,8 +489,8 @@ fn unescape(raw_string: &str) -> String { mod tests { use super::*; use crate::{ - grit_js_parser::GritJsParser, pattern_compiler::compilation_context::CompilationContext, - JsTargetLanguage, + grit_built_in_functions::BuiltIns, grit_js_parser::GritJsParser, + pattern_compiler::compilation_context::CompilationContext, JsTargetLanguage, }; use grit_util::Parser; use regex::Regex; @@ -561,8 +561,12 @@ mod tests { #[test] fn test_pattern_from_node() { - let compilation_context = - CompilationContext::new(None, GritTargetLanguage::JsTargetLanguage(JsTargetLanguage)); + let built_ins = BuiltIns::default(); + let compilation_context = CompilationContext::new( + None, + GritTargetLanguage::JsTargetLanguage(JsTargetLanguage), + &built_ins, + ); let mut vars = BTreeMap::new(); let mut vars_array = Vec::new(); let mut global_vars = BTreeMap::new(); @@ -798,8 +802,12 @@ mod tests { #[test] fn test_pattern_with_metavariables_from_node() { - let compilation_context = - CompilationContext::new(None, GritTargetLanguage::JsTargetLanguage(JsTargetLanguage)); + let built_ins = BuiltIns::default(); + let compilation_context = CompilationContext::new( + None, + GritTargetLanguage::JsTargetLanguage(JsTargetLanguage), + &built_ins, + ); let mut vars = BTreeMap::new(); let mut vars_array = vec![Vec::new()]; let mut global_vars = BTreeMap::new(); diff --git a/crates/biome_grit_patterns/tests/quick_test.rs b/crates/biome_grit_patterns/tests/quick_test.rs index da32c54aeda7..0c2980137d92 100644 --- a/crates/biome_grit_patterns/tests/quick_test.rs +++ b/crates/biome_grit_patterns/tests/quick_test.rs @@ -7,7 +7,12 @@ use biome_js_syntax::JsFileSource; #[ignore] #[test] fn test_query() { - let parse_grit_result = parse_grit("`foo.$x && foo.$x()`"); + let parse_grit_result = parse_grit( + "`console.log($args)` where { + $args <: contains `world` +} +", + ); if !parse_grit_result.diagnostics().is_empty() { panic!("Cannot parse query:\n{:?}", parse_grit_result.diagnostics()); } @@ -23,7 +28,9 @@ fn test_query() { println!("Diagnostics from compiling query:\n{:?}", query.diagnostics); } - let body = r#"foo.bar && foo.bar(); + let body = r#"console.log("hello, world"); +console.log("hello", world); +console.log(`hello ${world}`); "#; let file = GritTargetFile { @@ -32,5 +39,5 @@ fn test_query() { }; let results = query.execute(file).expect("could not execute query"); - println!("Results: {results:?}"); + println!("Results: {results:#?}"); } diff --git a/crates/biome_grit_patterns/tests/specs/ts/capitalize.grit b/crates/biome_grit_patterns/tests/specs/ts/capitalize.grit new file mode 100644 index 000000000000..f245e6aba86b --- /dev/null +++ b/crates/biome_grit_patterns/tests/specs/ts/capitalize.grit @@ -0,0 +1,3 @@ +`console.log($arg)` where { + $arg => capitalize(string = $arg) +} diff --git a/crates/biome_grit_patterns/tests/specs/ts/capitalize.snap b/crates/biome_grit_patterns/tests/specs/ts/capitalize.snap new file mode 100644 index 000000000000..68c701a4e46e --- /dev/null +++ b/crates/biome_grit_patterns/tests/specs/ts/capitalize.snap @@ -0,0 +1,12 @@ +--- +source: crates/biome_grit_patterns/tests/spec_tests.rs +expression: capitalize +--- +SnapshotResult { + messages: [], + matched_ranges: [ + "1:1-1:29", + ], + rewritten_files: [], + created_files: [], +} diff --git a/crates/biome_grit_patterns/tests/specs/ts/capitalize.ts b/crates/biome_grit_patterns/tests/specs/ts/capitalize.ts new file mode 100644 index 000000000000..8764baf693fc --- /dev/null +++ b/crates/biome_grit_patterns/tests/specs/ts/capitalize.ts @@ -0,0 +1 @@ +console.log('hello, world!'); diff --git a/crates/biome_grit_patterns/tests/specs/ts/containsSnippet.grit b/crates/biome_grit_patterns/tests/specs/ts/containsSnippet.grit new file mode 100644 index 000000000000..4f6b7843b646 --- /dev/null +++ b/crates/biome_grit_patterns/tests/specs/ts/containsSnippet.grit @@ -0,0 +1,3 @@ +`console.log($args)` where { + $args <: contains `world` +} diff --git a/crates/biome_grit_patterns/tests/specs/ts/containsSnippet.snap b/crates/biome_grit_patterns/tests/specs/ts/containsSnippet.snap new file mode 100644 index 000000000000..12b50a315dab --- /dev/null +++ b/crates/biome_grit_patterns/tests/specs/ts/containsSnippet.snap @@ -0,0 +1,13 @@ +--- +source: crates/biome_grit_patterns/tests/spec_tests.rs +expression: containsSnippet +--- +SnapshotResult { + messages: [], + matched_ranges: [ + "2:1-2:28", + "3:1-3:30", + ], + rewritten_files: [], + created_files: [], +} diff --git a/crates/biome_grit_patterns/tests/specs/ts/containsSnippet.ts b/crates/biome_grit_patterns/tests/specs/ts/containsSnippet.ts new file mode 100644 index 000000000000..2be0c6975408 --- /dev/null +++ b/crates/biome_grit_patterns/tests/specs/ts/containsSnippet.ts @@ -0,0 +1,3 @@ +console.log("hello, world"); +console.log("hello", world); +console.log(`hello ${world}`); diff --git a/crates/biome_html_formatter/Cargo.toml b/crates/biome_html_formatter/Cargo.toml index 5288131cddf9..e54667e5ef74 100644 --- a/crates/biome_html_formatter/Cargo.toml +++ b/crates/biome_html_formatter/Cargo.toml @@ -21,7 +21,7 @@ biome_formatter_test = { workspace = true } biome_fs = { workspace = true } biome_html_parser = { workspace = true } biome_parser = { workspace = true } -biome_service = { workspace = true } +biome_service = { workspace = true, features = ["experimental-html"] } countme = { workspace = true, features = ["enable"] } tests_macros = { workspace = true } diff --git a/crates/biome_html_formatter/tests/prettier_tests.rs b/crates/biome_html_formatter/tests/prettier_tests.rs index 75b8cd3c13a5..f42abd1fbd0e 100644 --- a/crates/biome_html_formatter/tests/prettier_tests.rs +++ b/crates/biome_html_formatter/tests/prettier_tests.rs @@ -7,7 +7,7 @@ use biome_html_syntax::HtmlFileSource; mod language; -tests_macros::gen_tests! {"tests/specs/prettier/**/*.html", crate::test_snapshot, "script"} +tests_macros::gen_tests! {"tests/specs/prettier/**/*.html", crate::test_snapshot, ""} #[allow(dead_code)] fn test_snapshot(input: &'static str, _: &str, _: &str, _: &str) { diff --git a/crates/biome_html_formatter/tests/spec_test.rs b/crates/biome_html_formatter/tests/spec_test.rs index fea0ee2e56b9..ecfd4a4a7bf5 100644 --- a/crates/biome_html_formatter/tests/spec_test.rs +++ b/crates/biome_html_formatter/tests/spec_test.rs @@ -25,10 +25,10 @@ mod language { /// * `json/null` -> input: `tests/specs/json/null.json`, expected output: `tests/specs/json/null.json.snap` /// * `null` -> input: `tests/specs/null.json`, expected output: `tests/specs/null.json.snap` pub fn run(spec_input_file: &str, _expected_file: &str, test_directory: &str, _file_type: &str) { - let root_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/specs/")); + let root_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/specs/html")); let Some(test_file) = SpecTestFile::try_from_file(spec_input_file, root_path, None) else { - return; + panic!("Failed to set up snapshot test"); }; let source_type: HtmlFileSource = test_file.input_file().as_path().try_into().unwrap(); diff --git a/crates/biome_html_formatter/tests/spec_tests.rs b/crates/biome_html_formatter/tests/spec_tests.rs index 1023857cbe4d..1e2e624a4036 100644 --- a/crates/biome_html_formatter/tests/spec_tests.rs +++ b/crates/biome_html_formatter/tests/spec_tests.rs @@ -4,6 +4,6 @@ mod spec_test; mod formatter { mod html { - tests_macros::gen_tests! {"tests/specs/**/*.html", crate::spec_test::run, ""} + tests_macros::gen_tests! {"tests/specs/html/**/*.html", crate::spec_test::run, ""} } } diff --git a/crates/biome_html_formatter/tests/specs/attributes-break.html b/crates/biome_html_formatter/tests/specs/html/attributes/break.html similarity index 100% rename from crates/biome_html_formatter/tests/specs/attributes-break.html rename to crates/biome_html_formatter/tests/specs/html/attributes/break.html diff --git a/crates/biome_html_formatter/tests/specs/attributes-break.html.snap b/crates/biome_html_formatter/tests/specs/html/attributes/break.html.snap similarity index 93% rename from crates/biome_html_formatter/tests/specs/attributes-break.html.snap rename to crates/biome_html_formatter/tests/specs/html/attributes/break.html.snap index befbd8c3b1cb..e759ed258dbb 100644 --- a/crates/biome_html_formatter/tests/specs/attributes-break.html.snap +++ b/crates/biome_html_formatter/tests/specs/html/attributes/break.html.snap @@ -1,6 +1,6 @@ --- source: crates/biome_formatter_test/src/snapshot_builder.rs -info: attributes-break.html +info: attributes/break.html --- # Input diff --git a/crates/biome_html_formatter/tests/specs/attributes-no-break.html b/crates/biome_html_formatter/tests/specs/html/attributes/no-break.html similarity index 100% rename from crates/biome_html_formatter/tests/specs/attributes-no-break.html rename to crates/biome_html_formatter/tests/specs/html/attributes/no-break.html diff --git a/crates/biome_html_formatter/tests/specs/attributes-no-break.html.snap b/crates/biome_html_formatter/tests/specs/html/attributes/no-break.html.snap similarity index 91% rename from crates/biome_html_formatter/tests/specs/attributes-no-break.html.snap rename to crates/biome_html_formatter/tests/specs/html/attributes/no-break.html.snap index de5bb0ce15af..f54e1b647582 100644 --- a/crates/biome_html_formatter/tests/specs/attributes-no-break.html.snap +++ b/crates/biome_html_formatter/tests/specs/html/attributes/no-break.html.snap @@ -1,6 +1,6 @@ --- source: crates/biome_formatter_test/src/snapshot_builder.rs -info: attributes-no-break.html +info: attributes/no-break.html --- # Input diff --git a/crates/biome_html_formatter/tests/specs/attributes-self-closing.html b/crates/biome_html_formatter/tests/specs/html/attributes/self-closing.html similarity index 100% rename from crates/biome_html_formatter/tests/specs/attributes-self-closing.html rename to crates/biome_html_formatter/tests/specs/html/attributes/self-closing.html diff --git a/crates/biome_html_formatter/tests/specs/attributes-self-closing.html.snap b/crates/biome_html_formatter/tests/specs/html/attributes/self-closing.html.snap similarity index 89% rename from crates/biome_html_formatter/tests/specs/attributes-self-closing.html.snap rename to crates/biome_html_formatter/tests/specs/html/attributes/self-closing.html.snap index 8bd93c746836..4ee89251ba2d 100644 --- a/crates/biome_html_formatter/tests/specs/attributes-self-closing.html.snap +++ b/crates/biome_html_formatter/tests/specs/html/attributes/self-closing.html.snap @@ -1,6 +1,6 @@ --- source: crates/biome_formatter_test/src/snapshot_builder.rs -info: attributes-self-closing.html +info: attributes/self-closing.html --- # Input diff --git a/crates/biome_html_formatter/tests/specs/attributes-single-quotes.html b/crates/biome_html_formatter/tests/specs/html/attributes/single-quotes.html similarity index 100% rename from crates/biome_html_formatter/tests/specs/attributes-single-quotes.html rename to crates/biome_html_formatter/tests/specs/html/attributes/single-quotes.html diff --git a/crates/biome_html_formatter/tests/specs/attributes-single-quotes.html.snap b/crates/biome_html_formatter/tests/specs/html/attributes/single-quotes.html.snap similarity index 76% rename from crates/biome_html_formatter/tests/specs/attributes-single-quotes.html.snap rename to crates/biome_html_formatter/tests/specs/html/attributes/single-quotes.html.snap index 855e8962ba0e..2073db19c0cc 100644 --- a/crates/biome_html_formatter/tests/specs/attributes-single-quotes.html.snap +++ b/crates/biome_html_formatter/tests/specs/html/attributes/single-quotes.html.snap @@ -1,6 +1,6 @@ --- source: crates/biome_formatter_test/src/snapshot_builder.rs -info: attributes-single-quotes.html +info: attributes/single-quotes.html --- # Input @@ -25,4 +25,4 @@ Attribute Position: Auto ----- ```html -should keep "these" quotes``` +should keep "these" quotes``` diff --git a/crates/biome_html_formatter/tests/specs/example.html b/crates/biome_html_formatter/tests/specs/html/example.html similarity index 100% rename from crates/biome_html_formatter/tests/specs/example.html rename to crates/biome_html_formatter/tests/specs/html/example.html diff --git a/crates/biome_html_formatter/tests/specs/example.html.snap b/crates/biome_html_formatter/tests/specs/html/example.html.snap similarity index 100% rename from crates/biome_html_formatter/tests/specs/example.html.snap rename to crates/biome_html_formatter/tests/specs/html/example.html.snap diff --git a/crates/biome_html_formatter/tests/specs/long-content.html b/crates/biome_html_formatter/tests/specs/html/long-content.html similarity index 100% rename from crates/biome_html_formatter/tests/specs/long-content.html rename to crates/biome_html_formatter/tests/specs/html/long-content.html diff --git a/crates/biome_html_formatter/tests/specs/long-content.html.snap b/crates/biome_html_formatter/tests/specs/html/long-content.html.snap similarity index 100% rename from crates/biome_html_formatter/tests/specs/long-content.html.snap rename to crates/biome_html_formatter/tests/specs/html/long-content.html.snap diff --git a/crates/biome_html_formatter/tests/specs/many-children.html b/crates/biome_html_formatter/tests/specs/html/many-children.html similarity index 100% rename from crates/biome_html_formatter/tests/specs/many-children.html rename to crates/biome_html_formatter/tests/specs/html/many-children.html diff --git a/crates/biome_html_formatter/tests/specs/many-children.html.snap b/crates/biome_html_formatter/tests/specs/html/many-children.html.snap similarity index 100% rename from crates/biome_html_formatter/tests/specs/many-children.html.snap rename to crates/biome_html_formatter/tests/specs/html/many-children.html.snap diff --git a/crates/biome_html_formatter/tests/specs/self-closing.html b/crates/biome_html_formatter/tests/specs/html/self-closing.html similarity index 100% rename from crates/biome_html_formatter/tests/specs/self-closing.html rename to crates/biome_html_formatter/tests/specs/html/self-closing.html diff --git a/crates/biome_html_formatter/tests/specs/self-closing.html.snap b/crates/biome_html_formatter/tests/specs/html/self-closing.html.snap similarity index 100% rename from crates/biome_html_formatter/tests/specs/self-closing.html.snap rename to crates/biome_html_formatter/tests/specs/html/self-closing.html.snap diff --git a/crates/biome_js_analyze/src/lint/a11y/no_label_without_control.rs b/crates/biome_js_analyze/src/lint/a11y/no_label_without_control.rs index 0b49d791fd2f..8ba4b85881e9 100644 --- a/crates/biome_js_analyze/src/lint/a11y/no_label_without_control.rs +++ b/crates/biome_js_analyze/src/lint/a11y/no_label_without_control.rs @@ -4,10 +4,10 @@ use biome_analyze::{ use biome_console::markup; use biome_deserialize_macros::Deserializable; use biome_js_syntax::{ - AnyJsxAttribute, AnyJsxAttributeValue, AnyJsxTag, JsxAttribute, JsxAttributeList, JsxName, - JsxReferenceIdentifier, JsxText, + AnyJsxAttribute, AnyJsxAttributeName, AnyJsxAttributeValue, AnyJsxElementName, AnyJsxTag, + JsSyntaxKind, JsxAttribute, }; -use biome_rowan::AstNode; +use biome_rowan::{AstNode, WalkEvent}; use serde::{Deserialize, Serialize}; declare_lint_rule! { @@ -93,28 +93,6 @@ declare_lint_rule! { } } -#[derive(Clone, Debug, Default, Deserialize, Deserializable, Eq, PartialEq, Serialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct NoLabelWithoutControlOptions { - /// Array of component names that should be considered the same as an `input` element. - pub input_components: Vec, - /// Array of attributes that should be treated as the `label` accessible text content. - pub label_attributes: Vec, - /// Array of component names that should be considered the same as a `label` element. - pub label_components: Vec, -} - -pub struct NoLabelWithoutControlState { - pub has_text_content: bool, - pub has_control_association: bool, -} - -const DEFAULT_LABEL_ATTRIBUTES: &[&str; 2] = &["aria-label", "alt"]; -const DEFAULT_LABEL_COMPONENTS: &[&str; 1] = &["label"]; -const DEFAULT_INPUT_COMPONENTS: &[&str; 6] = - &["input", "meter", "output", "progress", "select", "textarea"]; - impl Rule for NoLabelWithoutControl { type Query = Ast; type State = NoLabelWithoutControlState; @@ -128,9 +106,12 @@ impl Rule for NoLabelWithoutControl { let label_components = &options.label_components; let input_components = &options.input_components; - let element_name = get_element_name(node)?; - let is_allowed_element = label_components.contains(&element_name) - || DEFAULT_LABEL_COMPONENTS.contains(&element_name.as_str()); + let element_name = node.name()?.name_value_token()?; + let element_name = element_name.text_trimmed(); + let is_allowed_element = label_components + .iter() + .any(|label_component_name| label_component_name == element_name) + || DEFAULT_LABEL_COMPONENTS.contains(&element_name); if !is_allowed_element { return None; @@ -138,7 +119,7 @@ impl Rule for NoLabelWithoutControl { let has_text_content = has_accessible_label(node, label_attributes); let has_control_association = - has_for_attribute(node)? || has_nested_control(node, input_components); + has_for_attribute(node) || has_nested_control(node, input_components); if has_text_content && has_control_association { return None; @@ -176,100 +157,145 @@ impl Rule for NoLabelWithoutControl { } } -/// Returns the `JsxAttributeList` of the passed `AnyJsxTag` -fn get_element_attributes(jsx_tag: &AnyJsxTag) -> Option { - match jsx_tag { - AnyJsxTag::JsxElement(element) => Some(element.opening_element().ok()?.attributes()), - AnyJsxTag::JsxSelfClosingElement(element) => Some(element.attributes()), - _ => None, - } +#[derive(Clone, Debug, Default, Deserialize, Deserializable, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NoLabelWithoutControlOptions { + /// Array of component names that should be considered the same as an `input` element. + pub input_components: Vec, + /// Array of attributes that should be treated as the `label` accessible text content. + pub label_attributes: Vec, + /// Array of component names that should be considered the same as a `label` element. + pub label_components: Vec, } -/// Returns the element name of a `AnyJsxTag` -fn get_element_name(jsx_tag: &AnyJsxTag) -> Option { - match jsx_tag { - AnyJsxTag::JsxElement(element) => Some(element.opening_element().ok()?.name().ok()?.text()), - AnyJsxTag::JsxSelfClosingElement(element) => Some(element.name().ok()?.text()), - _ => None, - } +pub struct NoLabelWithoutControlState { + pub has_text_content: bool, + pub has_control_association: bool, } -/// Returns whether the passed `AnyJsxTag` have a `for` or `htmlFor` attribute -fn has_for_attribute(jsx_tag: &AnyJsxTag) -> Option { - let for_attributes = &["for", "htmlFor"]; - let attributes = get_element_attributes(jsx_tag)?; +const DEFAULT_LABEL_ATTRIBUTES: [&str; 3] = ["aria-label", "aria-labelledby", "alt"]; +const DEFAULT_LABEL_COMPONENTS: [&str; 1] = ["label"]; +const DEFAULT_INPUT_COMPONENTS: [&str; 6] = + ["input", "meter", "output", "progress", "select", "textarea"]; - Some(attributes.into_iter().any(|attribute| { - match attribute { - AnyJsxAttribute::JsxAttribute(jsx_attribute) => jsx_attribute - .name() - .is_ok_and(|jsx_name| for_attributes.contains(&jsx_name.text().as_str())), - _ => false, - } - })) +/// Returns whether the passed `AnyJsxTag` have a `for` or `htmlFor` attribute +fn has_for_attribute(jsx_tag: &AnyJsxTag) -> bool { + let for_attributes = ["for", "htmlFor"]; + let Some(attributes) = jsx_tag.attributes() else { + return false; + }; + attributes.into_iter().any(|attribute| match attribute { + AnyJsxAttribute::JsxAttribute(jsx_attribute) => jsx_attribute + .name() + .ok() + .and_then(|jsx_name| { + if let AnyJsxAttributeName::JsxName(jsx_name) = jsx_name { + jsx_name.value_token().ok() + } else { + None + } + }) + .is_some_and(|jsx_name| for_attributes.contains(&jsx_name.text_trimmed())), + AnyJsxAttribute::JsxSpreadAttribute(_) => false, + }) } /// Returns whether the passed `AnyJsxTag` have a child that is considered an input component /// according to the passed `input_components` parameter fn has_nested_control(jsx_tag: &AnyJsxTag, input_components: &[String]) -> bool { - jsx_tag - .syntax() - .descendants() - .any(|descendant| match JsxName::try_cast(descendant) { - Ok(jsx_name) => { - let attribute_name = jsx_name.text(); - input_components.contains(&attribute_name) - || DEFAULT_INPUT_COMPONENTS.contains(&attribute_name.as_str()) - } - Err(descendant) => { - if let Some(jsx_reference_identifier) = JsxReferenceIdentifier::cast(descendant) { - let attribute_name = jsx_reference_identifier.text(); - input_components.contains(&attribute_name) - || DEFAULT_INPUT_COMPONENTS.contains(&attribute_name.as_str()) - } else { - false + let mut child_iter = jsx_tag.syntax().preorder(); + while let Some(event) = child_iter.next() { + match event { + WalkEvent::Enter(child) => match child.kind() { + JsSyntaxKind::JSX_ELEMENT + | JsSyntaxKind::JSX_OPENING_ELEMENT + | JsSyntaxKind::JSX_CHILD_LIST + | JsSyntaxKind::JSX_SELF_CLOSING_ELEMENT => {} + _ => { + let Some(element_name) = AnyJsxElementName::cast(child) else { + child_iter.skip_subtree(); + continue; + }; + let Some(element_name) = element_name.name_value_token() else { + continue; + }; + let element_name = element_name.text_trimmed(); + if DEFAULT_INPUT_COMPONENTS.contains(&element_name) + || input_components.iter().any(|name| name == element_name) + { + return true; + } } - } - }) + }, + WalkEvent::Leave(_) => {} + } + } + false } -/// Returns whether the passed `AnyJsxTag` meets one of the following conditions: +/// Returns `true` whether the passed `jsx_tag` meets one of the following conditions: /// - Has a label attribute that corresponds to the `label_attributes` parameter -/// - Has an `aria-labelledby` attribute -/// - Has a child `JsxText` node +/// - Has a label among `DEFAULT_LABEL_ATTRIBUTES` +/// - Has a child that acts as a label fn has_accessible_label(jsx_tag: &AnyJsxTag, label_attributes: &[String]) -> bool { - let mut has_text = false; - let mut has_accessible_attribute = false; - - for descendant in jsx_tag.syntax().descendants() { - has_text = has_text || JsxText::can_cast(descendant.kind()); - - if let Some(jsx_attribute) = JsxAttribute::cast(descendant) { - if let (Some(jsx_name), Some(jsx_attribute_value)) = ( - jsx_attribute.name().ok(), - jsx_attribute - .initializer() - .and_then(|initializer| initializer.value().ok()), - ) { - let attribute_name = jsx_name.text(); - let has_label_attribute = label_attributes.contains(&attribute_name) - || DEFAULT_LABEL_ATTRIBUTES.contains(&attribute_name.as_str()); - let is_aria_labelledby_attribute = jsx_name.text() == "aria-labelledby"; - let has_value = has_jsx_attribute_value(&jsx_attribute_value); - - if has_value && (is_aria_labelledby_attribute || has_label_attribute) { - has_accessible_attribute = true + let mut child_iter = jsx_tag.syntax().preorder(); + while let Some(event) = child_iter.next() { + match event { + WalkEvent::Enter(child) => match child.kind() { + JsSyntaxKind::JSX_EXPRESSION_CHILD + | JsSyntaxKind::JSX_SPREAD_CHILD + | JsSyntaxKind::JSX_TEXT => { + return true; + } + JsSyntaxKind::JSX_ELEMENT + | JsSyntaxKind::JSX_OPENING_ELEMENT + | JsSyntaxKind::JSX_CHILD_LIST + | JsSyntaxKind::JSX_SELF_CLOSING_ELEMENT + | JsSyntaxKind::JSX_ATTRIBUTE_LIST => {} + JsSyntaxKind::JSX_ATTRIBUTE => { + let attribute = JsxAttribute::unwrap_cast(child); + if has_label_attribute(&attribute, label_attributes) { + return true; + } + child_iter.skip_subtree(); } - } + _ => { + child_iter.skip_subtree(); + } + }, + WalkEvent::Leave(_) => {} } } + false +} - has_accessible_attribute || has_text +/// Returns `true` whether the passed `attribute` meets one of the following conditions: +/// - Has a label attribute that corresponds to the `label_attributes` parameter +/// - Has a label among `DEFAULT_LABEL_ATTRIBUTES` +fn has_label_attribute(attribute: &JsxAttribute, label_attributes: &[String]) -> bool { + let Ok(attribute_name) = attribute.name().and_then(|name| name.name_token()) else { + return false; + }; + let attribute_name = attribute_name.text_trimmed(); + if !DEFAULT_LABEL_ATTRIBUTES.contains(&attribute_name) + && !label_attributes.iter().any(|name| name == attribute_name) + { + return false; + } + attribute + .initializer() + .and_then(|init| init.value().ok()) + .is_some_and(|v| has_label_attribute_value(&v)) } /// Returns whether the passed `jsx_attribute_value` has a valid value inside it -fn has_jsx_attribute_value(jsx_attribute_value: &AnyJsxAttributeValue) -> bool { - jsx_attribute_value - .as_static_value() - .is_some_and(|static_value| !static_value.text().trim().is_empty()) +fn has_label_attribute_value(jsx_attribute_value: &AnyJsxAttributeValue) -> bool { + match jsx_attribute_value { + AnyJsxAttributeValue::AnyJsxTag(_) => false, + AnyJsxAttributeValue::JsxExpressionAttributeValue(_) => true, + AnyJsxAttributeValue::JsxString(jsx_string) => !jsx_string + .inner_string_text() + .is_ok_and(|text| text.text().trim().is_empty()), + } } diff --git a/crates/biome_js_analyze/src/lint/nursery/use_component_export_only_modules.rs b/crates/biome_js_analyze/src/lint/nursery/use_component_export_only_modules.rs index e94c1e7f841c..03b7914e01cd 100644 --- a/crates/biome_js_analyze/src/lint/nursery/use_component_export_only_modules.rs +++ b/crates/biome_js_analyze/src/lint/nursery/use_component_export_only_modules.rs @@ -102,7 +102,7 @@ declare_lint_rule! { /// [`camelCase`]: https://en.wikipedia.org/wiki/Camel_case /// [`PascalCase`]: https://en.wikipedia.org/wiki/Camel_case pub UseComponentExportOnlyModules { - version: "next", + version: "1.9.2", name: "useComponentExportOnlyModules", language: "jsx", sources: &[RuleSource::EslintReactRefresh("only-export-components")], diff --git a/crates/biome_js_analyze/tests/specs/a11y/noLabelWithoutControl/valid.jsx b/crates/biome_js_analyze/tests/specs/a11y/noLabelWithoutControl/valid.jsx index 8320f7cbe7f7..74e6c226d3fe 100644 --- a/crates/biome_js_analyze/tests/specs/a11y/noLabelWithoutControl/valid.jsx +++ b/crates/biome_js_analyze/tests/specs/a11y/noLabelWithoutControl/valid.jsx @@ -28,3 +28,5 @@ ; ;