Repository: biomejs/biome (language: rust)

Issue:
💅 useFilenamingConvention errors on files starting with non-letter, a $ for example
### Environment information

```bash
CLI:
  Version:                      1.9.2
  Color support:                true

Platform:
  CPU Architecture:             aarch64
  OS:                           macos

Environment:
  BIOME_LOG_PATH:               unset
  BIOME_LOG_PREFIX_NAME:        unset
  BIOME_CONFIG_PATH:            unset
  NO_COLOR:                     unset
  TERM:                         "alacritty"
  JS_RUNTIME_VERSION:           "v20.17.0"
  JS_RUNTIME_NAME:              "node"
  NODE_PACKAGE_MANAGER:         "pnpm/9.11.0"

Biome Configuration:
  Status:                       Loaded successfully
  Formatter disabled:           false
  Linter disabled:              false
  Organize imports disabled:    false
  VCS disabled:                 false

Linter:
  JavaScript enabled:           true
  JSON enabled:                 true
  CSS enabled:                  true
  GraphQL enabled:              false
  Recommended:                  true
  All:                          false
  Enabled rules:
  style/useImportType
  suspicious/noCatchAssign
  suspicious/noUnsafeNegation
  complexity/useLiteralKeys
  complexity/noMultipleSpacesInRegularExpressionLiterals
  a11y/useValidLang
  complexity/noUselessEmptyExport
  suspicious/useNamespaceKeyword
  suspicious/useValidTypeof
  a11y/useValidAriaRole
  correctness/noConstantCondition
  a11y/useAriaActivedescendantWithTabindex
  suspicious/noAssignInExpressions
  style/useDefaultParameterLast
  complexity/noEmptyTypeParameters
  correctness/noConstructorReturn
  style/useSelfClosingElements
  suspicious/noDuplicateParameters
  suspicious/noDuplicateSelectorsKeyframeBlock
  suspicious/noMisplacedAssertion
  correctness/noUnknownProperty
  style/useTemplate
  correctness/noUnusedLabels
  complexity/noUselessTernary
  correctness/noUnreachableSuper
  suspicious/noCompareNegZero
  correctness/noSwitchDeclarations
  a11y/noAutofocus
  correctness/noUnsafeOptionalChaining
  correctness/noConstAssign
  suspicious/noControlCharactersInRegex
  complexity/noUselessTypeConstraint
  style/noVar
  suspicious/noDoubleEquals
  suspicious/noRedundantUseStrict
  style/useLiteralEnumMembers
  suspicious/noGlobalIsNan
  suspicious/noEmptyInterface
  suspicious/noConstEnum
  suspicious/noMisleadingCharacterClass
  correctness/noPrecisionLoss
  a11y/noLabelWithoutControl
  suspicious/noRedeclare
  correctness/noStringCaseMismatch
  correctness/noSetterReturn
  correctness/noInvalidConstructorSuper
  suspicious/noImplicitAnyLet
  suspicious/noFallthroughSwitchClause
  suspicious/noUnsafeDeclarationMerging
  correctness/noUnreachable
  a11y/useKeyWithClickEvents
  suspicious/noDuplicateObjectKeys
  complexity/noUselessThisAlias
  complexity/noThisInStatic
  complexity/useOptionalChain
  correctness/noInnerDeclarations
  suspicious/noDuplicateCase
  a11y/useValidAnchor
  complexity/useRegexLiterals
  correctness/noSelfAssign
  correctness/noInvalidBuiltinInstantiation
  style/noUselessElse
  style/useShorthandFunctionType
  suspicious/noShadowRestrictedNames
  correctness/noInvalidDirectionInLinearGradient
  style/useThrowNewError
  suspicious/noImportantInKeyframe
  a11y/useMediaCaption
  complexity/noUselessLabel
  complexity/noUselessCatch
  correctness/noUnsafeFinally
  a11y/useAriaPropsForRole
  correctness/noNonoctalDecimalEscape
  style/useEnumInitializers
  a11y/useHtmlLang
  suspicious/noDuplicateTestHooks
  complexity/noStaticOnlyClass
  suspicious/noEvolvingTypes
  style/useWhile
  complexity/useArrowFunction
  style/noInferrableTypes
  a11y/noNoninteractiveTabindex
  complexity/useSimpleNumberKeys
  correctness/useYield
  a11y/noInteractiveElementToNoninteractiveRole
  style/useNumericLiterals
  correctness/noUnnecessaryContinue
  suspicious/noApproximativeNumericConstant
  suspicious/noImportAssign
  suspicious/noLabelVar
  correctness/noGlobalObjectCalls
  suspicious/useDefaultSwitchClauseLast
  a11y/useAltText
  correctness/noEmptyCharacterClassInRegex
  correctness/noUnknownUnit
  suspicious/noSparseArray
  a11y/useIframeTitle
  complexity/noBannedTypes
  correctness/noVoidElementsWithChildren
  suspicious/noPrototypeBuiltins
  style/useAsConstAssertion
  correctness/useJsxKeyInIterable
  style/useExportType
  complexity/noUselessLoneBlockStatements
  suspicious/noDebugger
  style/noArguments
  suspicious/noMisleadingInstantiator
  a11y/useValidAriaValues
  a11y/useFocusableInteractive
  suspicious/noCommentText
  correctness/noUnmatchableAnbSelector
  suspicious/noDuplicateJsxProps
  suspicious/noGlobalAssign
  a11y/noPositiveTabindex
  correctness/noEmptyPattern
  complexity/noExcessiveNestedTestSuites
  security/noDangerouslySetInnerHtmlWithChildren
  a11y/useKeyWithMouseEvents
  suspicious/noExtraNonNullAssertion
  suspicious/noShorthandPropertyOverrides
  correctness/noRenderReturnValue
  correctness/useExhaustiveDependencies
  security/noGlobalEval
  style/useConst
  a11y/noRedundantRoles
  complexity/useFlatMap
  correctness/useIsNan
  correctness/useHookAtTopLevel
  correctness/noUnusedVariables
  suspicious/noGlobalIsFinite
  suspicious/noSelfCompare
  suspicious/noAsyncPromiseExecutor
  suspicious/noDuplicateFontNames
  suspicious/noThenProperty
  suspicious/useGetterReturn
  style/useNodejsImportProtocol
  a11y/noDistractingElements
  suspicious/noArrayIndexKey
  complexity/noWith
  suspicious/noDuplicateClassMembers
  complexity/noExtraBooleanCast
  suspicious/noSuspiciousSemicolonInJsx
  a11y/useValidAriaProps
  a11y/noRedundantAlt
  correctness/noChildrenProp
  correctness/noUnknownFunction
  correctness/noInvalidPositionAtImportRule
  suspicious/noConfusingLabels
  suspicious/noConfusingVoidType
  suspicious/noFocusedTests
  a11y/useButtonType
  style/noShoutyConstants
  a11y/noAriaUnsupportedElements
  correctness/noInvalidGridAreas
  style/useFilenamingConvention
  correctness/noFlatMapIdentity
  a11y/noBlankTarget
  a11y/useHeadingContent
  correctness/useValidForDirection
  correctness/noVoidTypeReturn
  correctness/noInvalidUseBeforeDeclaration
  a11y/noAriaHiddenOnFocusable
  a11y/useGenericFontNames
  correctness/noUnknownMediaFeatureName
  a11y/useAnchorContent
  complexity/noUselessRename
  style/useNumberNamespace
  complexity/noUselessConstructor
  a11y/noAccessKey
  nursery/useSortedClasses
  style/noUnusedTemplateLiteral
  complexity/noUselessSwitchCase
  style/useExponentiationOperator
  style/noNegationElse
  style/useSingleVarDeclarator
  suspicious/noExportsInTest
  a11y/noNoninteractiveElementToInteractiveRole
  style/noCommaOperator
  suspicious/noDuplicateAtImportRules
  suspicious/useIsArray
  a11y/noHeaderScope
  complexity/noUselessFragments
  suspicious/noMisrefactoredShorthandAssign
  complexity/noForEach
  suspicious/noClassAssign
  suspicious/noEmptyBlock
  suspicious/noFunctionAssign

Workspace:
  Open Documents:               0
```


### Rule name

useFilenamingConvention

### Playground link

https://github.com/hilja/biome-repro-1727426814198

### Expected result

Settings for `useFilenamingConvention`:

```json
{
  "linter": {
    "rules": {
      "style": {
        "useFilenamingConvention": {
          "level": "error",
          "options": {
            "strictCase": true,
            "requireAscii": true,
            "filenameCases": ["camelCase", "export"]
          }
        }
      }
    }
  }
}
```

Remix has route files like this `app/routes/$.tsx` and Biome errors on it:

```
The filename should be in camelCase or equal to the name of an export.
```

That `$.tsx` can’t really be camelCased no matter what. Should filenames like this be ignored by default?

Remix also has routes like this that are caught in the error: `app.$foo.$bar.tsx`.

For now I can ignore them in overrides of course.

### Code of Conduct

- [X] I agree to follow Biome's Code of Conduct


Discussion / hints:
Indeed, we could ignore them by default if it is widely used.
We have more and more exceptions to the rule. Soon the rule will become useless...
We should introduce a proper regex-like setting to allow users to customize the check.
I agree with you @Conaclos 

The initial intent of the rule was to enforce a convention, but if we keep adding exceptions, the rule doesn't have value anymore.

I'm not sure why we added exceptions for frameworks, but wouldn't it be better for users to ignore those files? Frameworks come and go, and they change their conventions, and it's hard to keep up with them.


> I'm not sure why we added exceptions for frameworks, but wouldn't it be better for users to ignore those files?

I think we initially thought that there are not so many exceptions, and it could be worth to support them to avoid users to customize. However, this is starting to defeat the purpose to have a rule.

We could remove the exceptions in Biome 2.0 and place them under a `legacy` flag.
> We have more and more exceptions to the rule. Soon the rule will become useless...

Great point!

This is not really a Remix thing, a `$` or `%` or any other singular non-letter character can’t be subjected to camelCasing or uppercasing in any universe. Am I seeing this correctly?
> This is not really a Remix thing, a $ or % or any other singular non-letter character can’t be subjected to camelCasing or uppercasing in any universe. Am I seeing this correctly?

Yeah, you are correct.
The rule is intended to reject cases like `$camelCase`. What I mean is if we continue adding exceptions like ignoring `$` prefixes, we will denature the rule.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b1a03c5c0bc4..e44ca70b9c5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
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<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()])
             }),
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..6f1e1821a492 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,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.
@@ -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),
@@ -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>"."
+                    },
+                ))
+            }
         }
     }
 }
@@ -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.
@@ -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,
@@ -375,6 +435,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..ceddb1dc0af4 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,
 };
@@ -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) {
@@ -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>"."
                     },
                 ))
             }
@@ -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),
 }
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 97%
rename from crates/biome_js_analyze/src/utils/regex.rs
rename to crates/biome_js_analyze/src/utils/restricted_regex.rs
index b5cc39a9c9bc..3cf83bbab552 100644
--- a/crates/biome_js_analyze/src/utils/regex.rs
+++ b/crates/biome_js_analyze/src/utils/restricted_regex.rs
@@ -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())
     }
 }
 
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"


Test patch:
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,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"]
+					}
+				}
+			}
+		}
+	}
+}