@@ -4,6 +4,8 @@ use lazy_regex::{Regex, RegexBuilder};
44use oxc_diagnostics:: OxcDiagnostic ;
55use oxc_macros:: declare_oxc_lint;
66use oxc_span:: Span ;
7+ use schemars:: JsonSchema ;
8+ use serde:: { Deserialize , Serialize } ;
79use serde_json:: Value ;
810
911use crate :: {
@@ -28,16 +30,12 @@ impl std::ops::Deref for FilenameCase {
2830
2931#[ derive( Debug , Clone ) ]
3032pub struct FilenameCaseConfig {
31- /// Whether kebab case is allowed.
3233 kebab_case : bool ,
33- /// Whether camel case is allowed.
3434 camel_case : bool ,
35- /// Whether snake case is allowed.
3635 snake_case : bool ,
37- /// Whether pascal case is allowed.
3836 pascal_case : bool ,
3937 ignore : Option < Regex > ,
40- multi_extensions : bool ,
38+ multiple_file_extensions : bool ,
4139}
4240
4341impl Default for FilenameCaseConfig {
@@ -48,17 +46,110 @@ impl Default for FilenameCaseConfig {
4846 snake_case : false ,
4947 pascal_case : false ,
5048 ignore : None ,
51- multi_extensions : true ,
49+ multiple_file_extensions : true ,
5250 }
5351 }
5452}
5553
54+ // Use a separate struct for configuration docs, as the main config struct is
55+ // too different from the format of the end-user configuration options' shape.
56+ #[ derive( Debug , Clone , JsonSchema ) ]
57+ #[ serde( rename_all = "camelCase" , default ) ]
58+ pub struct FilenameCaseConfigJson {
59+ /// The case style(s) to allow/enforce for filenames. `true` means the case style is allowed, `false` means it is banned.
60+ ///
61+ /// You can set the `cases` option like this:
62+ /// ```json
63+ /// "unicorn/filename-case": [
64+ /// "error",
65+ /// {
66+ /// "cases": {
67+ /// "camelCase": true,
68+ /// "pascalCase": true
69+ /// }
70+ /// }
71+ /// ]
72+ /// ```
73+ cases : FilenameCaseConfigJsonCases ,
74+ /// The case style to enforce for filenames.
75+ ///
76+ /// You can set the `case` option like this:
77+ /// ```json
78+ /// "unicorn/filename-case": [
79+ /// "error",
80+ /// {
81+ /// "case": "kebabCase"
82+ /// }
83+ /// ]
84+ /// ```
85+ case : FilenameCaseJsonOptions ,
86+ /// A regular expression pattern for filenames to ignore.
87+ ///
88+ /// You can set the `ignore` option like this:
89+ /// ```json
90+ /// "unicorn/filename-case": [
91+ /// "error",
92+ /// {
93+ /// "ignore": "^foo.*$"
94+ /// }
95+ /// ]
96+ /// ```
97+ ignore : Option < Regex > ,
98+ /// Whether to treat additional, `.`-separated parts of a filename as
99+ /// parts of the extension rather than parts of the filename.
100+ multiple_file_extensions : bool ,
101+ }
102+
103+ impl Default for FilenameCaseConfigJson {
104+ fn default ( ) -> Self {
105+ Self {
106+ cases : FilenameCaseConfigJsonCases :: default ( ) ,
107+ case : FilenameCaseJsonOptions :: KebabCase ,
108+ ignore : None ,
109+ multiple_file_extensions : true ,
110+ }
111+ }
112+ }
113+
114+ #[ derive( Debug , Clone , JsonSchema , Serialize , Deserialize ) ]
115+ #[ serde( rename_all = "camelCase" , default ) ]
116+ struct FilenameCaseConfigJsonCases {
117+ /// Whether kebab case is allowed, e.g. `some-file-name.js`.
118+ kebab_case : bool ,
119+ /// Whether camel case is allowed, e.g. `someFileName.js`.
120+ camel_case : bool ,
121+ /// Whether snake case is allowed, e.g. `some_file_name.js`.
122+ snake_case : bool ,
123+ /// Whether pascal case is allowed, e.g. `SomeFileName.js`.
124+ pascal_case : bool ,
125+ }
126+
127+ impl Default for FilenameCaseConfigJsonCases {
128+ fn default ( ) -> Self {
129+ Self { kebab_case : true , camel_case : false , snake_case : false , pascal_case : false }
130+ }
131+ }
132+
133+ #[ derive( Debug , Default , Clone , JsonSchema , Serialize , Deserialize ) ]
134+ #[ serde( rename_all = "camelCase" ) ]
135+ #[ expect( clippy:: enum_variant_names) ]
136+ enum FilenameCaseJsonOptions {
137+ #[ default]
138+ KebabCase ,
139+ CamelCase ,
140+ SnakeCase ,
141+ PascalCase ,
142+ }
143+
56144declare_oxc_lint ! (
57145 /// ### What it does
58146 ///
59147 /// Enforces a consistent case style for filenames to improve project organization and maintainability.
60148 /// By default, `kebab-case` is enforced, but other styles can be configured.
61149 ///
150+ /// Files named `index.js`, `index.ts`, etc. are exempt from this rule as they cannot reliably be
151+ /// renamed to other casings (mainly just a problem with PascalCase).
152+ ///
62153 /// ### Why is this bad?
63154 ///
64155 /// Inconsistent file naming conventions make it harder to locate files, navigate projects, and enforce
@@ -92,69 +183,16 @@ declare_oxc_lint!(
92183 /// - `SomeFileName.js`
93184 /// - `SomeFileName.Test.js`
94185 /// - `SomeFileName.TestUtils.js`
95- ///
96- /// ### Options
97- ///
98- /// #### case
99- ///
100- /// `{ type: 'kebabCase' | 'camelCase' | 'snakeCase' | 'pascalCase' }`
101- ///
102- /// You can set the `case` option like this:
103- /// ```json
104- /// "unicorn/filename-case": [
105- /// "error",
106- /// {
107- /// "case": "kebabCase"
108- /// }
109- /// ]
110- /// ```
111- ///
112- /// #### cases
113- ///
114- /// `{ type: { [key in 'kebabCase' | 'camelCase' | 'snakeCase' | 'pascalCase']?: boolean } }`
115- ///
116- /// You can set the `cases` option like this:
117- /// ```json
118- /// "unicorn/filename-case": [
119- /// "error",
120- /// {
121- /// "cases": {
122- /// "camelCase": true,
123- /// "pascalCase": true
124- /// }
125- /// }
126- /// ]
127- /// ```
128- ///
129- /// #### ignore
130- ///
131- /// `{ type: string }`
132- ///
133- /// Specifies a regular expression pattern for filenames that should be ignored by this rule.
134- ///
135- /// You can set the `ignore` option like this:
136- /// ```json
137- /// "unicorn/filename-case": [
138- /// "error",
139- /// {
140- /// "ignore": "^foo.*$"
141- /// }
142- /// ]
143- /// ```
144- ///
145- /// #### multipleFileExtensions
146- ///
147- /// `{ type: boolean, default: true }`
148- ///
149- /// Whether to treat additional, `.`-separated parts of a filename as parts of the extension rather than parts of the filename.
150186 FilenameCase ,
151187 unicorn,
152- style
188+ style,
189+ config = FilenameCaseConfigJson ,
153190) ;
154191
155192impl Rule for FilenameCase {
156193 fn from_configuration ( value : serde_json:: Value ) -> Self {
157- let mut config = FilenameCaseConfig { multi_extensions : true , ..Default :: default ( ) } ;
194+ let mut config =
195+ FilenameCaseConfig { multiple_file_extensions : true , ..Default :: default ( ) } ;
158196
159197 if let Some ( value) = value. get ( 0 ) {
160198 config. kebab_case = false ;
@@ -163,7 +201,7 @@ impl Rule for FilenameCase {
163201 }
164202
165203 if let Some ( Value :: Bool ( val) ) = value. get ( "multipleFileExtensions" ) {
166- config. multi_extensions = * val;
204+ config. multiple_file_extensions = * val;
167205 }
168206
169207 if let Some ( Value :: String ( s) ) = value. get ( "case" ) {
@@ -205,20 +243,28 @@ impl Rule for FilenameCase {
205243 return ;
206244 }
207245
208- let filename = if self . multi_extensions {
246+ let filename = if self . multiple_file_extensions {
209247 raw_filename. split ( '.' ) . next ( )
210248 } else {
211249 raw_filename. rsplit_once ( '.' ) . map ( |( before, _) | before)
212250 } ;
213251
214252 let filename = filename. unwrap_or ( raw_filename) ;
253+
254+ // Ignore files named "index" — they are often used as module entry points and
255+ // cannot reliably be renamed to other casings (e.g. "Index.js"), so allow them
256+ // regardless of the configured filename case.
257+ if filename. eq_ignore_ascii_case ( "index" ) {
258+ return ;
259+ }
260+
215261 let trimmed_filename = filename. trim_matches ( '_' ) ;
216262
217263 let cases = [
218- ( self . camel_case , Case :: Camel , "camel case " ) ,
219- ( self . kebab_case , Case :: Kebab , "kebab case" ) ,
220- ( self . snake_case , Case :: Snake , "snake case " ) ,
221- ( self . pascal_case , Case :: Pascal , "pascal case " ) ,
264+ ( self . camel_case , Case :: Camel , "camelCase " ) ,
265+ ( self . kebab_case , Case :: Kebab , "kebab- case" ) ,
266+ ( self . snake_case , Case :: Snake , "snake_case " ) ,
267+ ( self . pascal_case , Case :: Pascal , "PascalCase " ) ,
222268 ] ;
223269
224270 let mut valid_cases = Vec :: new ( ) ;
@@ -320,6 +366,12 @@ fn test() {
320366 }
321367
322368 let pass = vec ! [
369+ // Default is to allow kebab-case
370+ ( "" , None , None , Some ( PathBuf :: from( "foo-bar.tsx" ) ) ) ,
371+ ( "" , None , None , Some ( PathBuf :: from( "src/foo-bar.tsx" ) ) ) ,
372+ ( "" , None , None , Some ( PathBuf :: from( "src/bar/foo-bar.js" ) ) ) ,
373+ ( "" , None , None , Some ( PathBuf :: from( "src/bar/foo.js" ) ) ) ,
374+ // Specific cases
323375 test_case( "src/foo/bar.js" , "camelCase" ) ,
324376 test_case( "src/foo/fooBar.js" , "camelCase" ) ,
325377 test_case( "src/foo/bar.test.js" , "camelCase" ) ,
@@ -402,17 +454,48 @@ fn test() {
402454 serde_json:: json!( [ { "case" : "snakeCase" , "multipleFileExtensions" : false } ] ) ,
403455 ) ,
404456 ( "" , None , None , Some ( PathBuf :: from( "foo-bar.tsx" ) ) ) ,
457+ // Ensure all `index` files are allowed, despite being in non-conforming case.
458+ test_case( "index.js" , "camelCase" ) ,
459+ test_case( "index.js" , "snakeCase" ) ,
460+ test_case( "index.js" , "kebabCase" ) ,
461+ test_case( "index.js" , "pascalCase" ) ,
462+ test_case( "index.mjs" , "camelCase" ) ,
463+ test_case( "index.mjs" , "snakeCase" ) ,
464+ test_case( "index.mjs" , "kebabCase" ) ,
465+ test_case( "index.mjs" , "pascalCase" ) ,
466+ test_case( "index.cjs" , "camelCase" ) ,
467+ test_case( "index.cjs" , "snakeCase" ) ,
468+ test_case( "index.cjs" , "kebabCase" ) ,
469+ test_case( "index.cjs" , "pascalCase" ) ,
470+ test_case( "index.ts" , "camelCase" ) ,
471+ test_case( "index.ts" , "snakeCase" ) ,
472+ test_case( "index.ts" , "kebabCase" ) ,
473+ test_case( "index.ts" , "pascalCase" ) ,
474+ test_case( "index.tsx" , "camelCase" ) ,
475+ test_case( "index.tsx" , "snakeCase" ) ,
476+ test_case( "index.tsx" , "kebabCase" ) ,
477+ test_case( "index.tsx" , "pascalCase" ) ,
478+ test_case( "index.vue" , "camelCase" ) ,
479+ test_case( "index.vue" , "snakeCase" ) ,
480+ test_case( "index.vue" , "kebabCase" ) ,
481+ test_case( "index.vue" , "pascalCase" ) ,
482+ test_case( "foo/bar/index.vue" , "pascalCase" ) ,
405483 ] ;
406484
407485 let fail = vec ! [
408486 test_case( "src/foo/foo_bar.js" , "" ) ,
409- // todo: linter does not support uppercase JS files
487+ // todo: linter does not support uppercase . JS files
410488 // test_case("src/foo/foo_bar.JS", "camelCase"),
411489 test_case( "src/foo/foo_bar.test.js" , "camelCase" ) ,
412490 test_case( "test/foo/foo_bar.test_utils.js" , "camelCase" ) ,
413491 test_case( "test/foo/fooBar.js" , "snakeCase" ) ,
414492 test_case( "test/foo/fooBar.test.js" , "snakeCase" ) ,
415493 test_case( "test/foo/fooBar.testUtils.js" , "snakeCase" ) ,
494+ test_case( "test/foo/fooBar.test_utils.js" , "snakeCase" ) ,
495+ test_case_with_options(
496+ "test/foo/foo_bar.testUtils.js" ,
497+ serde_json:: json!( [ { "case" : "snakeCase" , "multipleFileExtensions" : false } ] ) ,
498+ ) ,
416499 test_case( "test/foo/fooBar.js" , "kebabCase" ) ,
417500 test_case( "test/foo/fooBar.test.js" , "kebabCase" ) ,
418501 test_case( "test/foo/fooBar.testUtils.js" , "kebabCase" ) ,
0 commit comments