Skip to content

Commit

Permalink
feat(useFilenamingConvention): add the match option
Browse files Browse the repository at this point in the history
  • Loading branch information
Conaclos committed Oct 19, 2024
1 parent 1c60340 commit 97d59ba
Show file tree
Hide file tree
Showing 18 changed files with 262 additions and 22 deletions.
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,46 @@ 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

### Parser

#### New features

- Add support for parsing the defer attribute in import statements ([#4215](https://github.com/biomejs/biome/issues/4215)).

```js
Expand Down
2 changes: 1 addition & 1 deletion crates/biome_cli/src/execute/migrate/eslint_typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions crates/biome_cli/src/execute/migrate/eslint_unicorn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ impl From<FilenameCaseOptions> 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()])
}),
Expand Down
85 changes: 73 additions & 12 deletions crates/biome_js_analyze/src/lint/style/use_filenaming_convention.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand All @@ -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
///
Expand Down Expand Up @@ -68,6 +71,7 @@ declare_lint_rule! {
/// "options": {
/// "strictCase": false,
/// "requireAscii": true,
/// "match": "%?(.+?)[.](.+)",
/// "filenameCases": ["camelCase", "export"]
/// }
/// }
Expand Down Expand Up @@ -96,6 +100,30 @@ 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 `(?:)`
/// - 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.
Expand Down Expand Up @@ -134,7 +162,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),
Expand Down Expand Up @@ -329,6 +373,16 @@ impl Rule for UseFilenamingConvention {
},
))
},
FileNamingConventionState::Match => {
let matching = options.matching.as_ref()?.as_str();
Some(RuleDiagnostic::new(
rule_category!(),
None as Option<TextRange>,
markup! {
"This filename should match the following regex "<Emphasis>"/"{matching}"/"</Emphasis>"."
},
))
}
}
}
}
Expand All @@ -341,6 +395,8 @@ pub enum FileNamingConventionState {
Filename,
/// An extension is not in lowercase
Extension,
/// The filename doesn't match the provided regex
Match,
}

/// Rule's options.
Expand All @@ -357,6 +413,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<RestrictedRegex>,

/// Allowed cases for file names.
#[serde(default, skip_serializing_if = "is_default")]
pub filename_cases: FilenameCases,
Expand All @@ -375,6 +435,7 @@ impl Default for FilenamingConventionOptions {
Self {
strict_case: true,
require_ascii: false,
matching: None,
filename_cases: FilenameCases::default(),
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -667,7 +667,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) {
Expand Down Expand Up @@ -756,7 +756,7 @@ impl Rule for UseNamingConvention {
rule_category!(),
name_token_range,
markup! {
"This "<Emphasis>{format_args!("{convention_selector}")}</Emphasis>" name"{trimmed_info}" should match the following regex "<Emphasis>"/"{regex}"/"</Emphasis>"."
"This "<Emphasis>{format_args!("{convention_selector}")}</Emphasis>" name"{trimmed_info}" should match the following regex "<Emphasis>"/"{regex.as_ref()}"/"</Emphasis>"."
},
))
}
Expand Down Expand Up @@ -897,7 +897,7 @@ pub enum Suggestion {
/// Use only ASCII characters
Ascii,
/// Use a name that matches this regex
Match(String),
Match(Box<str>),
/// Use a name that follows one of these formats
Formats(Formats),
}
Expand Down
2 changes: 1 addition & 1 deletion crates/biome_js_analyze/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,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())
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json",
"linter": {
"rules": {
"style": {
"useFilenamingConvention": {
"level": "error",
"options": {
"match": "%(.+?)[.](.+)",
"filenameCases": ["camelCase"]
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const C: number;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: "%validMatch.ts"
---
# Input
```ts
export const C: number;
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json",
"linter": {
"rules": {
"style": {
"useFilenamingConvention": {
"level": "error",
"options": {
"match": "%(.+)[.](.+)",
"filenameCases": ["camelCase"]
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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].*/.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json",
"linter": {
"rules": {
"style": {
"useFilenamingConvention": {
"level": "error",
"options": {
"match": "[^i].*",
"filenameCases": ["camelCase"]
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
```
Loading

0 comments on commit 97d59ba

Please sign in to comment.