Skip to content

Commit 4594020

Browse files
michaelowolffisker
andauthoredFeb 22, 2024··
filename-case: Add option for multiple file extensions (#2186)
Co-authored-by: fisker <lionkay@gmail.com>
1 parent e6074fe commit 4594020

File tree

5 files changed

+192
-35
lines changed

5 files changed

+192
-35
lines changed
 

‎docs/rules/filename-case.md

+46
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,49 @@ Don't forget that you must escape special characters that you don't want to be i
105105
}
106106
]
107107
```
108+
109+
### multipleFileExtensions
110+
111+
Type: `boolean`\
112+
Default: `true`
113+
114+
Whether to treat additional, `.`-separated parts of a filename as parts of the extension rather than parts of the filename.
115+
116+
Note that the parts of the filename treated as the extension will not have the filename case enforced.
117+
118+
For example:
119+
120+
```js
121+
"unicorn/filename-case": [
122+
"error",
123+
{
124+
"case": "pascalCase"
125+
}
126+
]
127+
128+
// Results
129+
FooBar.Test.js
130+
FooBar.TestUtils.js
131+
FooBar.testUtils.js
132+
FooBar.test.js
133+
FooBar.test-utils.js
134+
FooBar.test_utils.js
135+
```
136+
137+
```js
138+
"unicorn/filename-case": [
139+
"error",
140+
{
141+
"case": "pascalCase",
142+
"multipleFileExtensions": false
143+
}
144+
]
145+
146+
// Results
147+
FooBar.Test.js
148+
FooBar.TestUtils.js
149+
FooBar.testUtils.js
150+
FooBar.test.js
151+
FooBar.test-utils.js
152+
FooBar.test_utils.js
153+
```

‎rules/filename-case.js

+41-8
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,38 @@ function validateFilename(words, caseFunctions) {
8585
.every(({word}) => caseFunctions.some(caseFunction => caseFunction(word) === word));
8686
}
8787

88-
function fixFilename(words, caseFunctions, {leading, extension}) {
88+
function fixFilename(words, caseFunctions, {leading, trailing}) {
8989
const replacements = words
9090
.map(({word, ignored}) => ignored ? [word] : caseFunctions.map(caseFunction => caseFunction(word)));
9191

9292
const {
9393
samples: combinations,
9494
} = cartesianProductSamples(replacements);
9595

96-
return [...new Set(combinations.map(parts => `${leading}${parts.join('')}${extension.toLowerCase()}`))];
96+
return [...new Set(combinations.map(parts => `${leading}${parts.join('')}${trailing}`))];
97+
}
98+
99+
function getFilenameParts(filenameWithExtension, {multipleFileExtensions}) {
100+
const extension = path.extname(filenameWithExtension);
101+
const filename = path.basename(filenameWithExtension, extension);
102+
const basename = filename + extension;
103+
104+
const parts = {
105+
basename,
106+
filename,
107+
middle: '',
108+
extension,
109+
};
110+
111+
if (multipleFileExtensions) {
112+
const [firstPart] = filename.split('.');
113+
Object.assign(parts, {
114+
filename: firstPart,
115+
middle: filename.slice(firstPart.length),
116+
});
117+
}
118+
119+
return parts;
97120
}
98121

99122
const leadingUnderscoresRegex = /^(?<leading>_+)(?<tailing>.*)$/;
@@ -143,6 +166,7 @@ const create = context => {
143166

144167
return new RegExp(item, 'u');
145168
});
169+
const multipleFileExtensions = options.multipleFileExtensions !== false;
146170
const chosenCasesFunctions = chosenCases.map(case_ => ignoreNumbers(cases[case_].fn));
147171
const filenameWithExtension = context.physicalFilename;
148172

@@ -152,11 +176,14 @@ const create = context => {
152176

153177
return {
154178
Program() {
155-
const extension = path.extname(filenameWithExtension);
156-
const filename = path.basename(filenameWithExtension, extension);
157-
const base = filename + extension;
179+
const {
180+
basename,
181+
filename,
182+
middle,
183+
extension,
184+
} = getFilenameParts(filenameWithExtension, {multipleFileExtensions});
158185

159-
if (ignoredByDefault.has(base) || ignore.some(regexp => regexp.test(base))) {
186+
if (ignoredByDefault.has(basename) || ignore.some(regexp => regexp.test(basename))) {
160187
return;
161188
}
162189

@@ -168,7 +195,7 @@ const create = context => {
168195
return {
169196
loc: {column: 0, line: 1},
170197
messageId: MESSAGE_ID_EXTENSION,
171-
data: {filename: filename + extension.toLowerCase(), extension},
198+
data: {filename: filename + middle + extension.toLowerCase(), extension},
172199
};
173200
}
174201

@@ -177,7 +204,7 @@ const create = context => {
177204

178205
const renamedFilenames = fixFilename(words, chosenCasesFunctions, {
179206
leading,
180-
extension,
207+
trailing: middle + extension.toLowerCase(),
181208
});
182209

183210
return {
@@ -211,6 +238,9 @@ const schema = [
211238
type: 'array',
212239
uniqueItems: true,
213240
},
241+
multipleFileExtensions: {
242+
type: 'boolean',
243+
},
214244
},
215245
additionalProperties: false,
216246
},
@@ -237,6 +267,9 @@ const schema = [
237267
type: 'array',
238268
uniqueItems: true,
239269
},
270+
multipleFileExtensions: {
271+
type: 'boolean',
272+
},
240273
},
241274
additionalProperties: false,
242275
},

‎test/filename-case.mjs

+90-12
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function testManyCases(filename, chosenCases, errorMessage) {
2020

2121
function testCaseWithOptions(filename, errorMessage, options = []) {
2222
return {
23-
code: 'foo()',
23+
code: `/* Filename ${filename} */`,
2424
filename,
2525
options,
2626
errors: errorMessage && [
@@ -37,22 +37,30 @@ test({
3737
testCase('src/foo/fooBar.js', 'camelCase'),
3838
testCase('src/foo/bar.test.js', 'camelCase'),
3939
testCase('src/foo/fooBar.test.js', 'camelCase'),
40-
testCase('src/foo/fooBar.testUtils.js', 'camelCase'),
40+
testCase('src/foo/fooBar.test-utils.js', 'camelCase'),
41+
testCase('src/foo/fooBar.test_utils.js', 'camelCase'),
42+
testCase('src/foo/.test_utils.js', 'camelCase'),
4143
testCase('src/foo/foo.js', 'snakeCase'),
4244
testCase('src/foo/foo_bar.js', 'snakeCase'),
4345
testCase('src/foo/foo.test.js', 'snakeCase'),
4446
testCase('src/foo/foo_bar.test.js', 'snakeCase'),
4547
testCase('src/foo/foo_bar.test_utils.js', 'snakeCase'),
48+
testCase('src/foo/foo_bar.test-utils.js', 'snakeCase'),
49+
testCase('src/foo/.test-utils.js', 'snakeCase'),
4650
testCase('src/foo/foo.js', 'kebabCase'),
4751
testCase('src/foo/foo-bar.js', 'kebabCase'),
4852
testCase('src/foo/foo.test.js', 'kebabCase'),
4953
testCase('src/foo/foo-bar.test.js', 'kebabCase'),
5054
testCase('src/foo/foo-bar.test-utils.js', 'kebabCase'),
55+
testCase('src/foo/foo-bar.test_utils.js', 'kebabCase'),
56+
testCase('src/foo/.test_utils.js', 'kebabCase'),
5157
testCase('src/foo/Foo.js', 'pascalCase'),
5258
testCase('src/foo/FooBar.js', 'pascalCase'),
53-
testCase('src/foo/Foo.Test.js', 'pascalCase'),
54-
testCase('src/foo/FooBar.Test.js', 'pascalCase'),
55-
testCase('src/foo/FooBar.TestUtils.js', 'pascalCase'),
59+
testCase('src/foo/Foo.test.js', 'pascalCase'),
60+
testCase('src/foo/FooBar.test.js', 'pascalCase'),
61+
testCase('src/foo/FooBar.test-utils.js', 'pascalCase'),
62+
testCase('src/foo/FooBar.test_utils.js', 'pascalCase'),
63+
testCase('src/foo/.test_utils.js', 'pascalCase'),
5664
testCase('spec/iss47Spec.js', 'camelCase'),
5765
testCase('spec/iss47Spec100.js', 'camelCase'),
5866
testCase('spec/i18n.js', 'camelCase'),
@@ -65,7 +73,7 @@ test({
6573
testCase('spec/iss47_100spec.js', 'snakeCase'),
6674
testCase('spec/i18n.js', 'snakeCase'),
6775
testCase('spec/Iss47Spec.js', 'pascalCase'),
68-
testCase('spec/Iss47.100Spec.js', 'pascalCase'),
76+
testCase('spec/Iss47.100spec.js', 'pascalCase'),
6977
testCase('spec/I18n.js', 'pascalCase'),
7078
testCase(undefined, 'camelCase'),
7179
testCase(undefined, 'snakeCase'),
@@ -238,6 +246,31 @@ test({
238246
...['index.js', 'index.mjs', 'index.cjs', 'index.ts', 'index.tsx', 'index.vue'].flatMap(
239247
filename => ['camelCase', 'snakeCase', 'kebabCase', 'pascalCase'].map(chosenCase => testCase(filename, chosenCase)),
240248
),
249+
testCaseWithOptions('index.tsx', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
250+
testCaseWithOptions('src/index.tsx', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
251+
testCaseWithOptions('src/foo/fooBar.test.js', undefined, [{case: 'camelCase', multipleFileExtensions: false}]),
252+
testCaseWithOptions('src/foo/fooBar.testUtils.js', undefined, [{case: 'camelCase', multipleFileExtensions: false}]),
253+
testCaseWithOptions('src/foo/foo_bar.test_utils.js', undefined, [{case: 'snakeCase', multipleFileExtensions: false}]),
254+
testCaseWithOptions('src/foo/foo.test.js', undefined, [{case: 'kebabCase', multipleFileExtensions: false}]),
255+
testCaseWithOptions('src/foo/foo-bar.test.js', undefined, [{case: 'kebabCase', multipleFileExtensions: false}]),
256+
testCaseWithOptions('src/foo/foo-bar.test-utils.js', undefined, [{case: 'kebabCase', multipleFileExtensions: false}]),
257+
testCaseWithOptions('src/foo/Foo.Test.js', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
258+
testCaseWithOptions('src/foo/FooBar.Test.js', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
259+
testCaseWithOptions('src/foo/FooBar.TestUtils.js', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
260+
testCaseWithOptions('spec/Iss47.100Spec.js', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
261+
// Multiple filename parts - multiple file extensions
262+
testCaseWithOptions('src/foo/fooBar.Test.js', undefined, [{case: 'camelCase'}]),
263+
testCaseWithOptions('test/foo/fooBar.testUtils.js', undefined, [{case: 'camelCase'}]),
264+
testCaseWithOptions('test/foo/.testUtils.js', undefined, [{case: 'camelCase'}]),
265+
testCaseWithOptions('test/foo/foo_bar.Test.js', undefined, [{case: 'snakeCase'}]),
266+
testCaseWithOptions('test/foo/foo_bar.Test_Utils.js', undefined, [{case: 'snakeCase'}]),
267+
testCaseWithOptions('test/foo/.Test_Utils.js', undefined, [{case: 'snakeCase'}]),
268+
testCaseWithOptions('test/foo/foo-bar.Test.js', undefined, [{case: 'kebabCase'}]),
269+
testCaseWithOptions('test/foo/foo-bar.Test-Utils.js', undefined, [{case: 'kebabCase'}]),
270+
testCaseWithOptions('test/foo/.Test-Utils.js', undefined, [{case: 'kebabCase'}]),
271+
testCaseWithOptions('test/foo/FooBar.Test.js', undefined, [{case: 'pascalCase'}]),
272+
testCaseWithOptions('test/foo/FooBar.TestUtils.js', undefined, [{case: 'pascalCase'}]),
273+
testCaseWithOptions('test/foo/.TestUtils.js', undefined, [{case: 'pascalCase'}]),
241274
],
242275
invalid: [
243276
testCase(
@@ -258,7 +291,7 @@ test({
258291
testCase(
259292
'test/foo/foo_bar.test_utils.js',
260293
'camelCase',
261-
'Filename is not in camel case. Rename it to `fooBar.testUtils.js`.',
294+
'Filename is not in camel case. Rename it to `fooBar.test_utils.js`.',
262295
),
263296
testCase(
264297
'test/foo/fooBar.js',
@@ -273,7 +306,7 @@ test({
273306
testCase(
274307
'test/foo/fooBar.testUtils.js',
275308
'snakeCase',
276-
'Filename is not in snake case. Rename it to `foo_bar.test_utils.js`.',
309+
'Filename is not in snake case. Rename it to `foo_bar.testUtils.js`.',
277310
),
278311
testCase(
279312
'test/foo/fooBar.js',
@@ -288,7 +321,7 @@ test({
288321
testCase(
289322
'test/foo/fooBar.testUtils.js',
290323
'kebabCase',
291-
'Filename is not in kebab case. Rename it to `foo-bar.test-utils.js`.',
324+
'Filename is not in kebab case. Rename it to `foo-bar.testUtils.js`.',
292325
),
293326
testCase(
294327
'test/foo/fooBar.js',
@@ -298,12 +331,12 @@ test({
298331
testCase(
299332
'test/foo/foo_bar.test.js',
300333
'pascalCase',
301-
'Filename is not in pascal case. Rename it to `FooBar.Test.js`.',
334+
'Filename is not in pascal case. Rename it to `FooBar.test.js`.',
302335
),
303336
testCase(
304337
'test/foo/foo-bar.test-utils.js',
305338
'pascalCase',
306-
'Filename is not in pascal case. Rename it to `FooBar.TestUtils.js`.',
339+
'Filename is not in pascal case. Rename it to `FooBar.test-utils.js`.',
307340
),
308341
testCase(
309342
'src/foo/_FOO-BAR.js',
@@ -547,14 +580,59 @@ test({
547580
},
548581
'Filename is not in camel case, pascal case, or kebab case. Rename it to `1.js`.',
549582
),
583+
// Multiple filename parts - single file extension
584+
testCaseWithOptions(
585+
'src/foo/foo_bar.test.js',
586+
'Filename is not in camel case. Rename it to `fooBar.test.js`.',
587+
[{case: 'camelCase', multipleFileExtensions: false}],
588+
),
589+
testCaseWithOptions(
590+
'test/foo/foo_bar.test_utils.js',
591+
'Filename is not in camel case. Rename it to `fooBar.testUtils.js`.',
592+
[{case: 'camelCase', multipleFileExtensions: false}],
593+
),
594+
testCaseWithOptions(
595+
'test/foo/fooBar.test.js',
596+
'Filename is not in snake case. Rename it to `foo_bar.test.js`.',
597+
[{case: 'snakeCase', multipleFileExtensions: false}],
598+
),
599+
testCaseWithOptions(
600+
'test/foo/fooBar.testUtils.js',
601+
'Filename is not in snake case. Rename it to `foo_bar.test_utils.js`.',
602+
[{case: 'snakeCase', multipleFileExtensions: false}],
603+
),
604+
testCaseWithOptions(
605+
'test/foo/fooBar.test.js',
606+
'Filename is not in kebab case. Rename it to `foo-bar.test.js`.',
607+
[{case: 'kebabCase', multipleFileExtensions: false}],
608+
),
609+
testCaseWithOptions(
610+
'test/foo/fooBar.testUtils.js',
611+
'Filename is not in kebab case. Rename it to `foo-bar.test-utils.js`.',
612+
[{case: 'kebabCase', multipleFileExtensions: false}],
613+
),
614+
testCaseWithOptions(
615+
'test/foo/foo_bar.test.js',
616+
'Filename is not in pascal case. Rename it to `FooBar.Test.js`.',
617+
[{case: 'pascalCase', multipleFileExtensions: false}],
618+
),
619+
testCaseWithOptions(
620+
'test/foo/foo-bar.test-utils.js',
621+
'Filename is not in pascal case. Rename it to `FooBar.TestUtils.js`.',
622+
[{case: 'pascalCase', multipleFileExtensions: false}],
623+
),
550624
],
551625
});
552626

553627
test.snapshot({
554628
valid: [
555629
undefined,
556630
'src/foo.JS/bar.js',
631+
'src/foo.JS/bar.spec.js',
632+
'src/foo.JS/.spec.js',
557633
'src/foo.JS/bar',
634+
'foo.SPEC.js',
635+
'.SPEC.js',
558636
].map(filename => ({code: `const filename = ${JSON.stringify(filename)};`, filename})),
559637
invalid: [
560638
{
@@ -575,6 +653,6 @@ test.snapshot({
575653
'foo.jS',
576654
'index.JS',
577655
'foo..JS',
578-
].map(filename => ({code: `const filename = ${JSON.stringify(filename)};`, filename})),
656+
].map(filename => ({code: `/* Filename ${filename} */`, filename})),
579657
],
580658
});

‎test/snapshots/filename-case.mjs.md

+15-15
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,12 @@ Generated by [AVA](https://avajs.dev).
5858
11 |␊
5959
`
6060

61-
## invalid(2): const filename = "foo.JS";
61+
## invalid(2): /* Filename foo.JS */
6262

6363
> Input
6464
6565
`␊
66-
1 | const filename = "foo.JS";
66+
1 | /* Filename foo.JS */
6767
`
6868

6969
> Filename
@@ -75,16 +75,16 @@ Generated by [AVA](https://avajs.dev).
7575
> Error 1/1
7676
7777
`␊
78-
> 1 | const filename = "foo.JS";
78+
> 1 | /* Filename foo.JS */
7979
| ^ File extension \`.JS\` is not in lowercase. Rename it to \`foo.js\`.␊
8080
`
8181

82-
## invalid(3): const filename = "foo.Js";
82+
## invalid(3): /* Filename foo.Js */
8383

8484
> Input
8585
8686
`␊
87-
1 | const filename = "foo.Js";
87+
1 | /* Filename foo.Js */
8888
`
8989

9090
> Filename
@@ -96,16 +96,16 @@ Generated by [AVA](https://avajs.dev).
9696
> Error 1/1
9797
9898
`␊
99-
> 1 | const filename = "foo.Js";
99+
> 1 | /* Filename foo.Js */
100100
| ^ File extension \`.Js\` is not in lowercase. Rename it to \`foo.js\`.␊
101101
`
102102

103-
## invalid(4): const filename = "foo.jS";
103+
## invalid(4): /* Filename foo.jS */
104104

105105
> Input
106106
107107
`␊
108-
1 | const filename = "foo.jS";
108+
1 | /* Filename foo.jS */
109109
`
110110

111111
> Filename
@@ -117,16 +117,16 @@ Generated by [AVA](https://avajs.dev).
117117
> Error 1/1
118118
119119
`␊
120-
> 1 | const filename = "foo.jS";
120+
> 1 | /* Filename foo.jS */
121121
| ^ File extension \`.jS\` is not in lowercase. Rename it to \`foo.js\`.␊
122122
`
123123

124-
## invalid(5): const filename = "index.JS";
124+
## invalid(5): /* Filename index.JS */
125125

126126
> Input
127127
128128
`␊
129-
1 | const filename = "index.JS";
129+
1 | /* Filename index.JS */
130130
`
131131

132132
> Filename
@@ -138,16 +138,16 @@ Generated by [AVA](https://avajs.dev).
138138
> Error 1/1
139139
140140
`␊
141-
> 1 | const filename = "index.JS";
141+
> 1 | /* Filename index.JS */
142142
| ^ File extension \`.JS\` is not in lowercase. Rename it to \`index.js\`.␊
143143
`
144144

145-
## invalid(6): const filename = "foo..JS";
145+
## invalid(6): /* Filename foo..JS */
146146

147147
> Input
148148
149149
`␊
150-
1 | const filename = "foo..JS";
150+
1 | /* Filename foo..JS */
151151
`
152152

153153
> Filename
@@ -159,6 +159,6 @@ Generated by [AVA](https://avajs.dev).
159159
> Error 1/1
160160
161161
`␊
162-
> 1 | const filename = "foo..JS";
162+
> 1 | /* Filename foo..JS */
163163
| ^ File extension \`.JS\` is not in lowercase. Rename it to \`foo..js\`.␊
164164
`

‎test/snapshots/filename-case.mjs.snap

-4 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)
Please sign in to comment.