diff --git a/CHANGELOG.md b/CHANGELOG.md index 633e13489d19..cf65ea892b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,54 @@ our [guidelines for writing a good changelog entry](https://github.com/biomejs/b #### New features - Add [noUselessUndefined](https://biomejs.dev/linter/rules/no-useless-undefined/). Contributed by @unvalley + +- [useFilenamingConvention](https://biomejs.dev/linter/rules/use-filenaming-convention) accepts a new option `match` ([#4105](https://github.com/biomejs/biome/issues/4105)). + + You can now validate filenames with a regular expression. + For instance, you can allow filenames to start with `%`: + + ```json + { + "linter": { + "rules": { + "style": { + "useFilenamingConvention": { + "level": "warn", + "options": { + "match": "%?(.+?)[.](.+)", + "filenameCases": ["camelCase"] + } + } + } + } + } + } + ``` + + If the regular expression captures strings, the first capture is considered to be the name of the file, and the second one to be the extensions (dot-separated values). + The name of the file and the extensions are checked against `filenameCases`. + Given the previous configuration, the filename `%index.d.ts` is valid because the first capture `index` is in `camelCase` and the second capture `d.ts` include dot-separated values in `lowercase`. + On the other hand, `%Index.d.ts` is not valid because the first capture `Index` is in `PascalCase`. + + Note that specifying `match` disallows any exceptions that are handled by the rule by default. + For example, the previous configuration doesn't allow filenames to be prefixed with underscores, + a period or a plus sign. + You need to include them in the regular expression if you still want to allow these exceptions. + + Contributed by @Conaclos + +- [useFilenamingConvention](https://biomejs.dev/linter/rules/use-filenaming-convention) and [useNamingConvention](https://biomejs.dev/linter/rules/use-naming-convention) `match` options now accept case-insensitive and case-sensitive groups. + + By default, the regular expression in `match` is case-sensitive. + You can now make it case-insensitive by using a case-insensitive group `(?i:)`. + For example, the regular expression `(?i:a)` matches `a` and `A`. + + Contributed by @Conaclos + +### Parser + +#### New features + - Add support for parsing the defer attribute in import statements ([#4215](https://github.com/biomejs/biome/issues/4215)). ```js diff --git a/crates/biome_cli/src/execute/migrate/eslint_typescript.rs b/crates/biome_cli/src/execute/migrate/eslint_typescript.rs index 8514934aab38..e79f177688ab 100644 --- a/crates/biome_cli/src/execute/migrate/eslint_typescript.rs +++ b/crates/biome_cli/src/execute/migrate/eslint_typescript.rs @@ -8,7 +8,7 @@ use biome_deserialize_macros::Deserializable; use biome_js_analyze::{ lint::nursery::use_consistent_member_accessibility, lint::style::{use_consistent_array_type, use_naming_convention}, - utils::regex::RestrictedRegex, + utils::restricted_regex::RestrictedRegex, }; use super::eslint_eslint; diff --git a/crates/biome_cli/src/execute/migrate/eslint_unicorn.rs b/crates/biome_cli/src/execute/migrate/eslint_unicorn.rs index d04a0424f693..fc965fe17ea2 100644 --- a/crates/biome_cli/src/execute/migrate/eslint_unicorn.rs +++ b/crates/biome_cli/src/execute/migrate/eslint_unicorn.rs @@ -19,6 +19,7 @@ impl From for use_filenaming_convention::FilenamingConventi use_filenaming_convention::FilenamingConventionOptions { strict_case: true, require_ascii: true, + matching: None, filename_cases: filename_cases.unwrap_or_else(|| { use_filenaming_convention::FilenameCases::from_iter([val.case.into()]) }), diff --git a/crates/biome_formatter/src/macros.rs b/crates/biome_formatter/src/macros.rs index 83af512c12c4..0c69d4af20ad 100644 --- a/crates/biome_formatter/src/macros.rs +++ b/crates/biome_formatter/src/macros.rs @@ -329,6 +329,7 @@ macro_rules! format { #[macro_export] macro_rules! best_fitting { ($least_expanded:expr, $($tail:expr),+ $(,)?) => {{ + #[allow(clippy::macro_metavars_in_unsafe)] unsafe { $crate::BestFitting::from_arguments_unchecked($crate::format_args!($least_expanded, $($tail),+)) } diff --git a/crates/biome_grit_formatter/src/grit/auxiliary/like.rs b/crates/biome_grit_formatter/src/grit/auxiliary/like.rs index 83625c5dccbb..bbc628b7cb8b 100644 --- a/crates/biome_grit_formatter/src/grit/auxiliary/like.rs +++ b/crates/biome_grit_formatter/src/grit/auxiliary/like.rs @@ -1,10 +1,27 @@ use crate::prelude::*; -use biome_grit_syntax::GritLike; -use biome_rowan::AstNode; +use biome_formatter::write; +use biome_grit_syntax::{GritLike, GritLikeFields}; #[derive(Debug, Clone, Default)] pub(crate) struct FormatGritLike; impl FormatNodeRule for FormatGritLike { fn fmt_fields(&self, node: &GritLike, f: &mut GritFormatter) -> FormatResult<()> { - format_verbatim_node(node.syntax()).fmt(f) + let GritLikeFields { + like_token, + l_curly_token, + example, + threshold, + r_curly_token, + } = node.as_fields(); + + write!( + f, + [ + like_token.format(), + l_curly_token.format(), + example.format(), + threshold.format(), + r_curly_token.format() + ] + ) } } diff --git a/crates/biome_grit_formatter/src/grit/lists/predicate_list.rs b/crates/biome_grit_formatter/src/grit/lists/predicate_list.rs index bc94f77bd127..6f2773bcc5dd 100644 --- a/crates/biome_grit_formatter/src/grit/lists/predicate_list.rs +++ b/crates/biome_grit_formatter/src/grit/lists/predicate_list.rs @@ -1,17 +1,18 @@ use crate::prelude::*; use biome_grit_syntax::GritPredicateList; + #[derive(Debug, Clone, Default)] pub(crate) struct FormatGritPredicateList; impl FormatRule for FormatGritPredicateList { type Context = GritFormatContext; fn fmt(&self, node: &GritPredicateList, f: &mut GritFormatter) -> FormatResult<()> { - let mut join = f.join_nodes_with_hardline(); + let separator = soft_line_break_or_space(); + let mut joiner = f.join_with(&separator); - for predicate in node { - let predicate = predicate?; - join.entry(predicate.syntax(), &format_or_verbatim(predicate.format())); + for formatted in node.format_separated(",") { + joiner.entry(&group(&indent(&formatted))); } - join.finish() + joiner.finish() } } diff --git a/crates/biome_grit_formatter/src/grit/predicates/predicate_accumulate.rs b/crates/biome_grit_formatter/src/grit/predicates/predicate_accumulate.rs index 3449decbdf50..b52983118618 100644 --- a/crates/biome_grit_formatter/src/grit/predicates/predicate_accumulate.rs +++ b/crates/biome_grit_formatter/src/grit/predicates/predicate_accumulate.rs @@ -1,6 +1,7 @@ use crate::prelude::*; -use biome_grit_syntax::GritPredicateAccumulate; -use biome_rowan::AstNode; +use biome_formatter::write; +use biome_grit_syntax::{GritPredicateAccumulate, GritPredicateAccumulateFields}; + #[derive(Debug, Clone, Default)] pub(crate) struct FormatGritPredicateAccumulate; impl FormatNodeRule for FormatGritPredicateAccumulate { @@ -9,6 +10,21 @@ impl FormatNodeRule for FormatGritPredicateAccumulate { node: &GritPredicateAccumulate, f: &mut GritFormatter, ) -> FormatResult<()> { - format_verbatim_node(node.syntax()).fmt(f) + let GritPredicateAccumulateFields { + left, + add_assign_token, + right, + } = node.as_fields(); + + write!( + f, + [ + left.format(), + space(), + add_assign_token.format(), + space(), + right.format() + ] + ) } } diff --git a/crates/biome_grit_formatter/src/grit/predicates/predicate_match.rs b/crates/biome_grit_formatter/src/grit/predicates/predicate_match.rs index d4fc199209de..f5d77c4cdcef 100644 --- a/crates/biome_grit_formatter/src/grit/predicates/predicate_match.rs +++ b/crates/biome_grit_formatter/src/grit/predicates/predicate_match.rs @@ -1,10 +1,25 @@ use crate::prelude::*; -use biome_grit_syntax::GritPredicateMatch; -use biome_rowan::AstNode; +use biome_formatter::write; +use biome_grit_syntax::{GritPredicateMatch, GritPredicateMatchFields}; #[derive(Debug, Clone, Default)] pub(crate) struct FormatGritPredicateMatch; impl FormatNodeRule for FormatGritPredicateMatch { fn fmt_fields(&self, node: &GritPredicateMatch, f: &mut GritFormatter) -> FormatResult<()> { - format_verbatim_node(node.syntax()).fmt(f) + let GritPredicateMatchFields { + left, + match_token, + right, + } = node.as_fields(); + + write!( + f, + [ + left.format(), + space(), + match_token.format(), + space(), + right.format() + ] + ) } } diff --git a/crates/biome_grit_formatter/src/grit/predicates/predicate_rewrite.rs b/crates/biome_grit_formatter/src/grit/predicates/predicate_rewrite.rs index b4c682600b99..6641ee7780b9 100644 --- a/crates/biome_grit_formatter/src/grit/predicates/predicate_rewrite.rs +++ b/crates/biome_grit_formatter/src/grit/predicates/predicate_rewrite.rs @@ -1,10 +1,29 @@ use crate::prelude::*; -use biome_grit_syntax::GritPredicateRewrite; -use biome_rowan::AstNode; +use biome_formatter::write; +use biome_grit_syntax::{GritPredicateRewrite, GritPredicateRewriteFields}; + #[derive(Debug, Clone, Default)] pub(crate) struct FormatGritPredicateRewrite; impl FormatNodeRule for FormatGritPredicateRewrite { fn fmt_fields(&self, node: &GritPredicateRewrite, f: &mut GritFormatter) -> FormatResult<()> { - format_verbatim_node(node.syntax()).fmt(f) + let GritPredicateRewriteFields { + annotation, + left, + fat_arrow_token, + right, + } = node.as_fields(); + + write!( + f, + [ + annotation.format(), + space(), + left.format(), + space(), + fat_arrow_token.format(), + space(), + right.format() + ] + ) } } diff --git a/crates/biome_grit_formatter/src/lib.rs b/crates/biome_grit_formatter/src/lib.rs index 4e04079ee536..74e5c55a8935 100644 --- a/crates/biome_grit_formatter/src/lib.rs +++ b/crates/biome_grit_formatter/src/lib.rs @@ -4,6 +4,7 @@ mod cst; mod generated; mod grit; mod prelude; +pub(crate) mod separated; use biome_formatter::{ comments::Comments, diff --git a/crates/biome_grit_formatter/src/prelude.rs b/crates/biome_grit_formatter/src/prelude.rs index 3dc661422125..0d15758a8fd8 100644 --- a/crates/biome_grit_formatter/src/prelude.rs +++ b/crates/biome_grit_formatter/src/prelude.rs @@ -3,10 +3,10 @@ #[allow(unused_imports)] pub(crate) use crate::{ - AsFormat, FormatNodeRule, FormattedIterExt as _, GritFormatContext, GritFormatter, IntoFormat, + AsFormat, FormatNodeRule, FormattedIterExt as _, FormattedIterExt, GritFormatContext, + GritFormatter, IntoFormat, }; pub(crate) use biome_formatter::prelude::*; -#[allow(unused_imports)] -pub(crate) use biome_rowan::{ - AstNode as _, AstNodeList as _, AstNodeSlotMap as _, AstSeparatedList as _, -}; +pub(crate) use biome_rowan::{AstNode as _, AstSeparatedList}; + +pub(crate) use crate::separated::FormatAstSeparatedListExtension; diff --git a/crates/biome_grit_formatter/src/separated.rs b/crates/biome_grit_formatter/src/separated.rs new file mode 100644 index 000000000000..82eebdf2fd14 --- /dev/null +++ b/crates/biome_grit_formatter/src/separated.rs @@ -0,0 +1,63 @@ +use biome_formatter::{ + separated::{FormatSeparatedElementRule, FormatSeparatedIter}, + FormatRefWithRule, +}; + +use crate::prelude::*; +use biome_grit_syntax::{GritLanguage, GritSyntaxToken}; +use biome_rowan::{AstNode, AstSeparatedListElementsIterator}; +use std::marker::PhantomData; + +use crate::{cst::FormatGritSyntaxToken, AsFormat, GritFormatContext}; + +#[derive(Clone)] +pub(crate) struct GritFormatSeparatedElementRule +where + N: AstNode, +{ + node: PhantomData, +} + +impl FormatSeparatedElementRule for GritFormatSeparatedElementRule +where + N: AstNode + AsFormat + 'static, +{ + type Context = GritFormatContext; + type FormatNode<'a> = N::Format<'a>; + type FormatSeparator<'a> = FormatRefWithRule<'a, GritSyntaxToken, FormatGritSyntaxToken>; + + fn format_node<'a>(&self, node: &'a N) -> Self::FormatNode<'a> { + node.format() + } + + fn format_separator<'a>(&self, separator: &'a GritSyntaxToken) -> Self::FormatSeparator<'a> { + separator.format() + } +} + +type GritFormatSeparatedIter = FormatSeparatedIter< + AstSeparatedListElementsIterator, + Node, + GritFormatSeparatedElementRule, +>; + +/// AST Separated list formatting extension methods +pub(crate) trait FormatAstSeparatedListExtension: + AstSeparatedList +{ + /// Prints a separated list of nodes + /// + /// Trailing separators will be reused from the original list or + /// created by calling the `separator_factory` function. + /// The last trailing separator in the list will only be printed + /// if the outer group breaks. + fn format_separated(&self, separator: &'static str) -> GritFormatSeparatedIter { + GritFormatSeparatedIter::new( + self.elements(), + separator, + GritFormatSeparatedElementRule { node: PhantomData }, + ) + } +} + +impl FormatAstSeparatedListExtension for T where T: AstSeparatedList {} diff --git a/crates/biome_grit_formatter/tests/specs/grit/patterns/create_new_files.grit b/crates/biome_grit_formatter/tests/specs/grit/patterns/create_new_files.grit new file mode 100644 index 000000000000..ecc38c6053f6 --- /dev/null +++ b/crates/biome_grit_formatter/tests/specs/grit/patterns/create_new_files.grit @@ -0,0 +1 @@ +`function $functionName($_) {$_}` as $f where{ $functionName<:r"test.*",$f=>.,$new_file_name=`$functionName.test.js`,$new_files+=file(name = $new_file_name, body = $f)} \ No newline at end of file diff --git a/crates/biome_grit_formatter/tests/specs/grit/patterns/create_new_files.grit.snap b/crates/biome_grit_formatter/tests/specs/grit/patterns/create_new_files.grit.snap new file mode 100644 index 000000000000..c77607c91516 --- /dev/null +++ b/crates/biome_grit_formatter/tests/specs/grit/patterns/create_new_files.grit.snap @@ -0,0 +1,47 @@ +--- +source: crates/biome_formatter_test/src/snapshot_builder.rs +info: grit/patterns/create_new_files.grit +--- +# Input + +```grit +`function $functionName($_) {$_}` as $f where{ $functionName<:r"test.*",$f=>.,$new_file_name=`$functionName.test.js`,$new_files+=file(name = $new_file_name, body = $f)} +``` + + +============================= + +# Outputs + +## Output 1 + +----- +Indent style: Tab +Indent width: 2 +Line ending: LF +Line width: 80 +Attribute Position: Auto +----- + +```grit +`function $functionName($_) {$_}` as $f where { + $functionName <: r"test.*", + $f => ., + $new_file_name = `$functionName.test.js`, + $new_files += file(name = $new_file_name, body = $f) +} +``` + + + +## Unimplemented nodes/tokens + +"`function $functionName($_) {$_}` as $f " => 0..40 +"\t$functionNam" => 48..61 +" r\"test.*" => 65..74 +"\t$" => 77..79 +" " => 83..84 +"\t$new_file_nam" => 87..101 +" `$functionName.test.js" => 104..127 +"\t$new_file" => 130..140 +" file(name = $new_file_name, body = $f" => 144..182 diff --git a/crates/biome_js_analyze/src/lint/correctness/no_flat_map_identity.rs b/crates/biome_js_analyze/src/lint/correctness/no_flat_map_identity.rs index f969760f9537..00e0b8c97d9c 100644 --- a/crates/biome_js_analyze/src/lint/correctness/no_flat_map_identity.rs +++ b/crates/biome_js_analyze/src/lint/correctness/no_flat_map_identity.rs @@ -73,7 +73,7 @@ impl Rule for NoFlatMapIdentity { AnyJsExpression::JsArrowFunctionExpression(arg) => { let parameter: String = match arg.parameters().ok()? { biome_js_syntax::AnyJsArrowFunctionParameters::AnyJsBinding(p) => { - p.text().trim_matches(&['(', ')']).to_owned() + p.text().trim_matches(['(', ')']).to_owned() } biome_js_syntax::AnyJsArrowFunctionParameters::JsParameters(p) => { if p.items().len() == 1 { @@ -110,8 +110,7 @@ impl Rule for NoFlatMapIdentity { } AnyJsExpression::JsFunctionExpression(arg) => { let function_parameter = arg.parameters().ok()?.text(); - let function_parameter = - function_parameter.trim_matches(&['(', ')']).to_owned(); + let function_parameter = function_parameter.trim_matches(['(', ')']).to_owned(); let mut statement = arg.body().ok()?.statements().into_iter(); if let Some(AnyJsStatement::JsReturnStatement(body)) = statement.next() { diff --git a/crates/biome_js_analyze/src/lint/style/use_filenaming_convention.rs b/crates/biome_js_analyze/src/lint/style/use_filenaming_convention.rs index 58dd1b3e2205..49cd4525bce1 100644 --- a/crates/biome_js_analyze/src/lint/style/use_filenaming_convention.rs +++ b/crates/biome_js_analyze/src/lint/style/use_filenaming_convention.rs @@ -1,4 +1,4 @@ -use crate::services::semantic::SemanticServices; +use crate::{services::semantic::SemanticServices, utils::restricted_regex::RestrictedRegex}; use biome_analyze::{ context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource, RuleSourceKind, }; @@ -18,21 +18,24 @@ declare_lint_rule! { /// /// Enforcing [naming conventions](https://en.wikipedia.org/wiki/Naming_convention_(programming)) helps to keep the codebase consistent. /// - /// A filename consists of two parts: a name and a set of consecutive extension. + /// A filename consists of two parts: a name and a set of consecutive extensions. /// For instance, `my-filename.test.js` has `my-filename` as name, and two consecutive extensions: `.test` and `.js`. /// - /// The filename can start with a dot or a plus sign, be prefixed and suffixed by underscores `_`. - /// For example, `.filename.js`, `+filename.js`, `__filename__.js`, or even `.__filename__.js`. + /// By default, the rule ensures that the name is either in [`camelCase`], [`kebab-case`], [`snake_case`], + /// or equal to the name of one export in the file. + /// By default, the rule ensures that the extensions are either in [`camelCase`], [`kebab-case`], or [`snake_case`]. /// - /// The convention of prefixing a filename with a plus sign is used by - /// [Sveltekit](https://kit.svelte.dev/docs/routing#page) and [Vike](https://vike.dev/route). + /// The rule supports the following exceptions: /// - /// Also, the rule supports dynamic route syntaxes of [Next.js](https://nextjs.org/docs/pages/building-your-application/routing/dynamic-routes#catch-all-segments), [SolidStart](https://docs.solidjs.com/solid-start/building-your-application/routing#renaming-index), [Nuxt](https://nuxt.com/docs/guide/directory-structure/server#catch-all-route), and [Astro](https://docs.astro.build/en/guides/routing/#rest-parameters). - /// For example `[...slug].js` and `[[...slug]].js` are valid filenames. + /// - The name of the file can start with a dot or a plus sign, be prefixed and suffixed by underscores `_`. + /// For example, `.filename.js`, `+filename.js`, `__filename__.js`, or even `.__filename__.js`. /// - /// By default, the rule ensures that the filename is either in [`camelCase`], [`kebab-case`], [`snake_case`], - /// or equal to the name of one export in the file. - /// By default, the rule ensures that the extensions are either in [`camelCase`], [`kebab-case`], or [`snake_case`]. + /// The convention of prefixing a filename with a plus sign is used by [Sveltekit](https://kit.svelte.dev/docs/routing#page) and [Vike](https://vike.dev/route). + /// + /// - Also, the rule supports dynamic route syntaxes of [Next.js](https://nextjs.org/docs/pages/building-your-application/routing/dynamic-routes#catch-all-segments), [SolidStart](https://docs.solidjs.com/solid-start/building-your-application/routing#renaming-index), [Nuxt](https://nuxt.com/docs/guide/directory-structure/server#catch-all-route), and [Astro](https://docs.astro.build/en/guides/routing/#rest-parameters). + /// For example `[...slug].js` and `[[...slug]].js` are valid filenames. + /// + /// Note that if you specify the `match' option, the previous exceptions will no longer be handled. /// /// ## Ignoring some files /// @@ -68,6 +71,7 @@ declare_lint_rule! { /// "options": { /// "strictCase": false, /// "requireAscii": true, + /// "match": "%?(.+?)[.](.+)", /// "filenameCases": ["camelCase", "export"] /// } /// } @@ -96,6 +100,31 @@ declare_lint_rule! { /// /// **This option will be turned on by default in Biome 2.0.** /// + /// ### match + /// + /// `match` defines a regular expression that the filename must match. + /// If the regex has capturing groups, then the first capture is considered as the filename + /// and the second one as file extensions separated by dots. + /// + /// For example, given the regular expression `%?(.+?)\.(.+)` and the filename `%index.d.ts`, + /// the filename matches the regular expression with two captures: `index` and `d.ts`. + /// The captures are checked against `filenameCases`. + /// Note that we use the non-greedy quantifier `+?` to stop capturing as soon as we met the next character (`.`). + /// If we use the greedy quantifier `+` instead, then the captures could be `index.d` and `ts`. + /// + /// The regular expression supports the following syntaxes: + /// + /// - Greedy quantifiers `*`, `?`, `+`, `{n}`, `{n,m}`, `{n,}`, `{m}` + /// - Non-greedy quantifiers `*?`, `??`, `+?`, `{n}?`, `{n,m}?`, `{n,}?`, `{m}?` + /// - Any character matcher `.` + /// - Character classes `[a-z]`, `[xyz]`, `[^a-z]` + /// - Alternations `|` + /// - Capturing groups `()` + /// - Non-capturing groups `(?:)` + /// - Case-insensitive groups `(?i:)` and case-sensitive groups `(?-i:)` + /// - A limited set of escaped characters including all special characters + /// and regular string escape characters `\f`, `\n`, `\r`, `\t`, `\v` + /// /// ### filenameCases /// /// By default, the rule enforces that the filename is either in [`camelCase`], [`kebab-case`], [`snake_case`], or equal to the name of one export in the file. @@ -134,7 +163,23 @@ impl Rule for UseFilenamingConvention { return Some(FileNamingConventionState::Ascii); } let first_char = file_name.bytes().next()?; - let (name, mut extensions) = if matches!(first_char, b'(' | b'[') { + let (name, mut extensions) = if let Some(matching) = &options.matching { + let Some(captures) = matching.captures(file_name) else { + return Some(FileNamingConventionState::Match); + }; + let mut captures = captures.iter().skip(1).flatten(); + let Some(first_capture) = captures.next() else { + // Match without any capture implies a valid case + return None; + }; + let name = first_capture.as_str(); + if name.is_empty() { + // Empty string are always valid. + return None; + } + let split = captures.next().map_or("", |x| x.as_str()).split('.'); + (name, split) + } else if matches!(first_char, b'(' | b'[') { // Support [Next.js](https://nextjs.org/docs/pages/building-your-application/routing/dynamic-routes#catch-all-segments), // [SolidStart](https://docs.solidjs.com/solid-start/building-your-application/routing#renaming-index), // [Nuxt](https://nuxt.com/docs/guide/directory-structure/server#catch-all-route), @@ -329,6 +374,16 @@ impl Rule for UseFilenamingConvention { }, )) }, + FileNamingConventionState::Match => { + let matching = options.matching.as_ref()?.as_str(); + Some(RuleDiagnostic::new( + rule_category!(), + None as Option, + markup! { + "This filename should match the following regex ""/"{matching}"/""." + }, + )) + } } } } @@ -341,6 +396,8 @@ pub enum FileNamingConventionState { Filename, /// An extension is not in lowercase Extension, + /// The filename doesn't match the provided regex + Match, } /// Rule's options. @@ -357,6 +414,10 @@ pub struct FilenamingConventionOptions { #[serde(default, skip_serializing_if = "is_default")] pub require_ascii: bool, + /// Regular expression to enforce + #[serde(default, rename = "match", skip_serializing_if = "Option::is_none")] + pub matching: Option, + /// Allowed cases for file names. #[serde(default, skip_serializing_if = "is_default")] pub filename_cases: FilenameCases, @@ -375,6 +436,7 @@ impl Default for FilenamingConventionOptions { Self { strict_case: true, require_ascii: false, + matching: None, filename_cases: FilenameCases::default(), } } diff --git a/crates/biome_js_analyze/src/lint/style/use_naming_convention.rs b/crates/biome_js_analyze/src/lint/style/use_naming_convention.rs index 821962d96cd3..d75fce7ce30c 100644 --- a/crates/biome_js_analyze/src/lint/style/use_naming_convention.rs +++ b/crates/biome_js_analyze/src/lint/style/use_naming_convention.rs @@ -3,8 +3,8 @@ use std::ops::{Deref, Range}; use crate::{ services::{control_flow::AnyJsControlFlowRoot, semantic::Semantic}, utils::{ - regex::RestrictedRegex, rename::{AnyJsRenamableDeclaration, RenameSymbolExtensions}, + restricted_regex::RestrictedRegex, }, JsRuleAction, }; @@ -603,6 +603,7 @@ declare_lint_rule! { /// - Alternations `|` /// - Capturing groups `()` /// - Non-capturing groups `(?:)` + /// - Case-insensitive groups `(?i:)` and case-sensitive groups `(?-i:)` /// - A limited set of escaped characters including all special characters /// and regular string escape characters `\f`, `\n`, `\r`, `\t`, `\v` /// @@ -667,7 +668,7 @@ impl Rule for UseNamingConvention { start: name_range_start as u16, end: (name_range_start + name.len()) as u16, }, - suggestion: Suggestion::Match(matching.to_string()), + suggestion: Suggestion::Match(matching.to_string().into_boxed_str()), }); }; if let Some(first_capture) = capture.iter().skip(1).find_map(|x| x) { @@ -756,7 +757,7 @@ impl Rule for UseNamingConvention { rule_category!(), name_token_range, markup! { - "This "{format_args!("{convention_selector}")}" name"{trimmed_info}" should match the following regex ""/"{regex}"/""." + "This "{format_args!("{convention_selector}")}" name"{trimmed_info}" should match the following regex ""/"{regex.as_ref()}"/""." }, )) } @@ -897,7 +898,7 @@ pub enum Suggestion { /// Use only ASCII characters Ascii, /// Use a name that matches this regex - Match(String), + Match(Box), /// Use a name that follows one of these formats Formats(Formats), } diff --git a/crates/biome_js_analyze/src/utils.rs b/crates/biome_js_analyze/src/utils.rs index ebd5d9779fd0..1bd8375a4df1 100644 --- a/crates/biome_js_analyze/src/utils.rs +++ b/crates/biome_js_analyze/src/utils.rs @@ -3,8 +3,8 @@ use biome_rowan::{AstNode, Direction, WalkEvent}; use std::iter; pub mod batch; -pub mod regex; pub mod rename; +pub mod restricted_regex; #[cfg(test)] pub mod tests; diff --git a/crates/biome_js_analyze/src/utils/regex.rs b/crates/biome_js_analyze/src/utils/restricted_regex.rs similarity index 79% rename from crates/biome_js_analyze/src/utils/regex.rs rename to crates/biome_js_analyze/src/utils/restricted_regex.rs index b5cc39a9c9bc..775c3805cc75 100644 --- a/crates/biome_js_analyze/src/utils/regex.rs +++ b/crates/biome_js_analyze/src/utils/restricted_regex.rs @@ -11,6 +11,11 @@ use biome_deserialize_macros::Deserializable; /// - Alternations `|` /// - Capturing groups `()` /// - Non-capturing groups `(?:)` +/// - Non-capturing groups with flags `(?flags:)` and negated flags `(?-flags:)` +/// Supported flags: +/// - `i`: ignore case +/// - `m`: multiline mode +/// - `s`: single line mode (`.` matches also `\n`) /// - A limited set of escaped characters including all regex special characters /// and regular string escape characters `\f`, `\n`, `\r`, `\t`, `\v` /// @@ -27,12 +32,19 @@ impl Deref for RestrictedRegex { } } -impl std::fmt::Display for RestrictedRegex { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl RestrictedRegex { + /// Returns the original string of this regex. + pub fn as_str(&self) -> &str { let repr = self.0.as_str(); debug_assert!(repr.starts_with("^(?:")); debug_assert!(repr.ends_with(")$")); - f.write_str(&repr[4..(repr.len() - 2)]) + &repr[4..(repr.len() - 2)] + } +} + +impl std::fmt::Display for RestrictedRegex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) } } @@ -166,10 +178,31 @@ fn is_restricted_regex(pattern: &str) -> Result<(), regex::Error> { }; } Some(b':') => {} - _ => { - return Err(regex::Error::Syntax( - "Group flags `(?flags:)` are not supported.".to_string(), - )); + c => { + let mut current = c; + while matches!(current, Some(b'i' | b'm' | b's' | b'-')) { + current = it.next() + } + match current { + Some(b':') => {} + Some(b')') => { + return Err(regex::Error::Syntax( + "Group modifiers `(?flags)` are not supported.".to_string(), + )); + } + Some(c) if c.is_ascii() => { + // SAFETY: `c` is ASCII according to the guard + let c = c as char; + return Err(regex::Error::Syntax(format!( + "Group flags `(?{c}:)` are not supported." + ))); + } + _ => { + return Err(regex::Error::Syntax( + "Unterminated non-capturing group.".to_string(), + )); + } + } } }, _ => {} @@ -190,7 +223,6 @@ mod tests { assert!(is_restricted_regex("a$").is_err()); assert!(is_restricted_regex(r"\").is_err()); assert!(is_restricted_regex(r"\p{L}").is_err()); - assert!(is_restricted_regex(r"(?i:)").is_err()); assert!(is_restricted_regex(r"(?=a)").is_err()); assert!(is_restricted_regex(r"(?!a)").is_err()); assert!(is_restricted_regex(r"(?:a)").is_err()); @@ -203,6 +235,9 @@ mod tests { assert!(is_restricted_regex("").is_ok()); assert!(is_restricted_regex("abc").is_ok()); assert!(is_restricted_regex("(?:a)(.+)z").is_ok()); + assert!(is_restricted_regex("(?ims:a)(.+)z").is_ok()); + assert!(is_restricted_regex("(?-ims:a)(.+)z").is_ok()); + assert!(is_restricted_regex("(?i-ms:a)(.+)z").is_ok()); assert!(is_restricted_regex("[A-Z][^a-z]").is_ok()); assert!(is_restricted_regex(r"\n\t\v\f").is_ok()); assert!(is_restricted_regex("([^_])").is_ok()); diff --git a/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/%validMatch.options.json b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/%validMatch.options.json new file mode 100644 index 000000000000..4ca3b76c2355 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/%validMatch.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "rules": { + "style": { + "useFilenamingConvention": { + "level": "error", + "options": { + "match": "%(.+?)[.](.+)", + "filenameCases": ["camelCase"] + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/%validMatch.ts b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/%validMatch.ts new file mode 100644 index 000000000000..9f6f21a9a7d6 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/%validMatch.ts @@ -0,0 +1 @@ +export const C: number; \ No newline at end of file diff --git a/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/%validMatch.ts.snap b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/%validMatch.ts.snap new file mode 100644 index 000000000000..c328df96e44d --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/%validMatch.ts.snap @@ -0,0 +1,8 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: "%validMatch.ts" +--- +# Input +```ts +export const C: number; +``` diff --git a/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatch.js b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatch.js new file mode 100644 index 000000000000..c00140d8b4f2 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatch.js @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "rules": { + "style": { + "useFilenamingConvention": { + "level": "error", + "options": { + "match": "%(.+)[.](.+)", + "filenameCases": ["camelCase"] + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatch.js.snap b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatch.js.snap new file mode 100644 index 000000000000..4df08937ace6 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatch.js.snap @@ -0,0 +1,33 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalidMatch.js +--- +# Input +```jsx +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "rules": { + "style": { + "useFilenamingConvention": { + "level": "error", + "options": { + "match": "%(.+)[.](.+)", + "filenameCases": ["camelCase"] + } + } + } + } + } +} + +``` + +# Diagnostics +``` +invalidMatch.js lint/style/useFilenamingConvention ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! This filename should match the following regex /[^i].*/. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatch.options.json b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatch.options.json new file mode 100644 index 000000000000..8a95cd250064 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatch.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "rules": { + "style": { + "useFilenamingConvention": { + "level": "error", + "options": { + "match": "[^i].*", + "filenameCases": ["camelCase"] + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatchExtension.INVALID.js b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatchExtension.INVALID.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatchExtension.INVALID.js.snap b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatchExtension.INVALID.js.snap new file mode 100644 index 000000000000..401ec90934bf --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatchExtension.INVALID.js.snap @@ -0,0 +1,17 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: invalidMatchExtension.INVALID.js +--- +# Input +```jsx + +``` + +# Diagnostics +``` +invalidMatchExtension.INVALID.js lint/style/useFilenamingConvention ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! The file extension should be in camelCase. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatchExtension.INVALID.options.json b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatchExtension.INVALID.options.json new file mode 100644 index 000000000000..0fea908f472a --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/style/useFilenamingConvention/invalidMatchExtension.INVALID.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "rules": { + "style": { + "useFilenamingConvention": { + "level": "error", + "options": { + "match": "(.+?)[.](.+)", + "filenameCases": ["camelCase"] + } + } + } + } + } +} diff --git a/crates/biome_suppression/src/lib.rs b/crates/biome_suppression/src/lib.rs index a61159991dc2..803d31a91ee4 100644 --- a/crates/biome_suppression/src/lib.rs +++ b/crates/biome_suppression/src/lib.rs @@ -43,7 +43,7 @@ pub fn parse_suppression_comment( "/*" => { comment = comment .strip_suffix("*/") - .or_else(|| comment.strip_suffix(&['*', '/'])) + .or_else(|| comment.strip_suffix(['*', '/'])) .unwrap_or(comment); true } diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 4bea6a98acd3..ff74fa585db1 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -2477,6 +2477,10 @@ export interface FilenamingConventionOptions { * Allowed cases for file names. */ filenameCases: FilenameCases; + /** + * Regular expression to enforce + */ + match?: Regex; /** * If `false`, then non-ASCII characters are allowed. */ @@ -2553,6 +2557,7 @@ For example, for React's `useRef()` hook the value would be `true`, while for `u export type Accessibility = "noPublic" | "explicit" | "none"; export type ConsistentArrayType = "shorthand" | "generic"; export type FilenameCases = FilenameCase[]; +export type Regex = string; export interface Convention { /** * String cases to enforce @@ -2586,7 +2591,6 @@ export type FilenameCase = | "PascalCase" | "snake_case"; export type Formats = Format[]; -export type Regex = string; export interface Selector { /** * Declaration kind diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index a6ba2d58a8dd..b9e701ff783a 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -1267,6 +1267,10 @@ "description": "Allowed cases for file names.", "allOf": [{ "$ref": "#/definitions/FilenameCases" }] }, + "match": { + "description": "Regular expression to enforce", + "anyOf": [{ "$ref": "#/definitions/Regex" }, { "type": "null" }] + }, "requireAscii": { "description": "If `false`, then non-ASCII characters are allowed.", "type": "boolean" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7d0bc1fd6683..446fcd475d92 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -2,4 +2,4 @@ # The default profile includes rustc, rust-std, cargo, rust-docs, rustfmt and clippy. # https://rust-lang.github.io/rustup/concepts/profiles.html profile = "default" -channel = "1.81.0" +channel = "1.82.0"