Skip to content

Commit c0766df

Browse files
committed
docs(linter): Add config option docs for unicorn/filename-case rule. (#16280)
Part of #14743. - Refactors the FilenameCase rule code - Adds some additional tests for the rule. - Matches the original rule by ignoring `index.js`, `index.ts`, etc automatically, to avoid problems with the rule causing invalid filenames in various frameworks. - Adds schema-specific structs to match the actual config object an end-user will set up. I didn't want to refactor the entire rule to match the end-user config object for now, as that seemed like it'd be really difficult to get working. To review the changeset it'd be best to go through it commit-by-commit. Generated docs: ```md ## Configuration This rule accepts a configuration object with the following properties: ### case type: `"kebabCase" | "camelCase" | "snakeCase" | "pascalCase"` default: `"kebabCase"` The case style to enforce for filenames. You can set the `case` option like this: \```json "unicorn/filename-case": [ "error", { "case": "kebabCase" } ] \``` ### cases type: `object` default: `{"kebabCase":true, "camelCase":false, "snakeCase":false, "pascalCase":false}` The case style(s) to allow/enforce for filenames. `true` means the case style is allowed, `false` means it is banned. You can set the `cases` option like this: \```json "unicorn/filename-case": [ "error", { "cases": { "camelCase": true, "pascalCase": true } } ] \``` #### cases.camelCase type: `boolean` default: `false` Whether camel case is allowed, e.g. `someFileName.js`. #### cases.kebabCase type: `boolean` default: `true` Whether kebab case is allowed, e.g. `some-file-name.js`. #### cases.pascalCase type: `boolean` default: `false` Whether pascal case is allowed, e.g. `SomeFileName.js`. #### cases.snakeCase type: `boolean` default: `false` Whether snake case is allowed, e.g. `some_file_name.js`. ### ignore type: `[ string, null ]` A regular expression pattern for filenames to ignore. You can set the `ignore` option like this: \```json "unicorn/filename-case": [ "error", { "ignore": "^foo.*$" } ] \``` ### multipleFileExtensions type: `boolean` default: `true` Whether to treat additional, `.`-separated parts of a filename as parts of the extension rather than parts of the filename. ```
1 parent 8933c84 commit c0766df

File tree

4 files changed

+197
-106
lines changed

4 files changed

+197
-106
lines changed

crates/oxc_language_server/src/linter/snapshots/fixtures_linter_issue_14565@foo-bar.astro.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ file: fixtures/linter/issue_14565/foo-bar.astro
88

99
code: "eslint-plugin-unicorn(filename-case)"
1010
code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/unicorn/filename-case.html"
11-
message: "Filename should be in snake case, or pascal case\nhelp: Rename the file to 'foo_bar.astro', or 'FooBar.astro'"
11+
message: "Filename should be in snake_case, or PascalCase\nhelp: Rename the file to 'foo_bar.astro', or 'FooBar.astro'"
1212
range: Range { start: Position { line: 0, character: 3 }, end: Position { line: 0, character: 3 } }
1313
related_information[0].message: ""
1414
related_information[0].location.uri: "file://<variable>/fixtures/linter/issue_14565/foo-bar.astro"

crates/oxc_linter/src/rules/unicorn/filename_case.rs

Lines changed: 153 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use lazy_regex::{Regex, RegexBuilder};
44
use oxc_diagnostics::OxcDiagnostic;
55
use oxc_macros::declare_oxc_lint;
66
use oxc_span::Span;
7+
use schemars::JsonSchema;
8+
use serde::{Deserialize, Serialize};
79
use serde_json::Value;
810

911
use crate::{
@@ -28,16 +30,12 @@ impl std::ops::Deref for FilenameCase {
2830

2931
#[derive(Debug, Clone)]
3032
pub 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

4341
impl 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+
56144
declare_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

155192
impl 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

Comments
 (0)