Skip to content

Commit

Permalink
filename-case: Add option for multiple file extensions (#2186)
Browse files Browse the repository at this point in the history
Co-authored-by: fisker <lionkay@gmail.com>
  • Loading branch information
michaelowolf and fisker authored Feb 22, 2024
1 parent e6074fe commit 4594020
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 35 deletions.
46 changes: 46 additions & 0 deletions docs/rules/filename-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,49 @@ Don't forget that you must escape special characters that you don't want to be i
}
]
```

### 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.

Note that the parts of the filename treated as the extension will not have the filename case enforced.

For example:

```js
"unicorn/filename-case": [
"error",
{
"case": "pascalCase"
}
]

// Results
FooBar.Test.js
FooBar.TestUtils.js
FooBar.testUtils.js
FooBar.test.js
FooBar.test-utils.js
FooBar.test_utils.js
```

```js
"unicorn/filename-case": [
"error",
{
"case": "pascalCase",
"multipleFileExtensions": false
}
]

// Results
FooBar.Test.js
FooBar.TestUtils.js
FooBar.testUtils.js
FooBar.test.js
FooBar.test-utils.js
FooBar.test_utils.js
```
49 changes: 41 additions & 8 deletions rules/filename-case.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,38 @@ function validateFilename(words, caseFunctions) {
.every(({word}) => caseFunctions.some(caseFunction => caseFunction(word) === word));
}

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

const {
samples: combinations,
} = cartesianProductSamples(replacements);

return [...new Set(combinations.map(parts => `${leading}${parts.join('')}${extension.toLowerCase()}`))];
return [...new Set(combinations.map(parts => `${leading}${parts.join('')}${trailing}`))];
}

function getFilenameParts(filenameWithExtension, {multipleFileExtensions}) {
const extension = path.extname(filenameWithExtension);
const filename = path.basename(filenameWithExtension, extension);
const basename = filename + extension;

const parts = {
basename,
filename,
middle: '',
extension,
};

if (multipleFileExtensions) {
const [firstPart] = filename.split('.');
Object.assign(parts, {
filename: firstPart,
middle: filename.slice(firstPart.length),
});
}

return parts;
}

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

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

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

return {
Program() {
const extension = path.extname(filenameWithExtension);
const filename = path.basename(filenameWithExtension, extension);
const base = filename + extension;
const {
basename,
filename,
middle,
extension,
} = getFilenameParts(filenameWithExtension, {multipleFileExtensions});

if (ignoredByDefault.has(base) || ignore.some(regexp => regexp.test(base))) {
if (ignoredByDefault.has(basename) || ignore.some(regexp => regexp.test(basename))) {
return;
}

Expand All @@ -168,7 +195,7 @@ const create = context => {
return {
loc: {column: 0, line: 1},
messageId: MESSAGE_ID_EXTENSION,
data: {filename: filename + extension.toLowerCase(), extension},
data: {filename: filename + middle + extension.toLowerCase(), extension},
};
}

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

const renamedFilenames = fixFilename(words, chosenCasesFunctions, {
leading,
extension,
trailing: middle + extension.toLowerCase(),
});

return {
Expand Down Expand Up @@ -211,6 +238,9 @@ const schema = [
type: 'array',
uniqueItems: true,
},
multipleFileExtensions: {
type: 'boolean',
},
},
additionalProperties: false,
},
Expand All @@ -237,6 +267,9 @@ const schema = [
type: 'array',
uniqueItems: true,
},
multipleFileExtensions: {
type: 'boolean',
},
},
additionalProperties: false,
},
Expand Down
102 changes: 90 additions & 12 deletions test/filename-case.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function testManyCases(filename, chosenCases, errorMessage) {

function testCaseWithOptions(filename, errorMessage, options = []) {
return {
code: 'foo()',
code: `/* Filename ${filename} */`,
filename,
options,
errors: errorMessage && [
Expand All @@ -37,22 +37,30 @@ test({
testCase('src/foo/fooBar.js', 'camelCase'),
testCase('src/foo/bar.test.js', 'camelCase'),
testCase('src/foo/fooBar.test.js', 'camelCase'),
testCase('src/foo/fooBar.testUtils.js', 'camelCase'),
testCase('src/foo/fooBar.test-utils.js', 'camelCase'),
testCase('src/foo/fooBar.test_utils.js', 'camelCase'),
testCase('src/foo/.test_utils.js', 'camelCase'),
testCase('src/foo/foo.js', 'snakeCase'),
testCase('src/foo/foo_bar.js', 'snakeCase'),
testCase('src/foo/foo.test.js', 'snakeCase'),
testCase('src/foo/foo_bar.test.js', 'snakeCase'),
testCase('src/foo/foo_bar.test_utils.js', 'snakeCase'),
testCase('src/foo/foo_bar.test-utils.js', 'snakeCase'),
testCase('src/foo/.test-utils.js', 'snakeCase'),
testCase('src/foo/foo.js', 'kebabCase'),
testCase('src/foo/foo-bar.js', 'kebabCase'),
testCase('src/foo/foo.test.js', 'kebabCase'),
testCase('src/foo/foo-bar.test.js', 'kebabCase'),
testCase('src/foo/foo-bar.test-utils.js', 'kebabCase'),
testCase('src/foo/foo-bar.test_utils.js', 'kebabCase'),
testCase('src/foo/.test_utils.js', 'kebabCase'),
testCase('src/foo/Foo.js', 'pascalCase'),
testCase('src/foo/FooBar.js', 'pascalCase'),
testCase('src/foo/Foo.Test.js', 'pascalCase'),
testCase('src/foo/FooBar.Test.js', 'pascalCase'),
testCase('src/foo/FooBar.TestUtils.js', 'pascalCase'),
testCase('src/foo/Foo.test.js', 'pascalCase'),
testCase('src/foo/FooBar.test.js', 'pascalCase'),
testCase('src/foo/FooBar.test-utils.js', 'pascalCase'),
testCase('src/foo/FooBar.test_utils.js', 'pascalCase'),
testCase('src/foo/.test_utils.js', 'pascalCase'),
testCase('spec/iss47Spec.js', 'camelCase'),
testCase('spec/iss47Spec100.js', 'camelCase'),
testCase('spec/i18n.js', 'camelCase'),
Expand All @@ -65,7 +73,7 @@ test({
testCase('spec/iss47_100spec.js', 'snakeCase'),
testCase('spec/i18n.js', 'snakeCase'),
testCase('spec/Iss47Spec.js', 'pascalCase'),
testCase('spec/Iss47.100Spec.js', 'pascalCase'),
testCase('spec/Iss47.100spec.js', 'pascalCase'),
testCase('spec/I18n.js', 'pascalCase'),
testCase(undefined, 'camelCase'),
testCase(undefined, 'snakeCase'),
Expand Down Expand Up @@ -238,6 +246,31 @@ test({
...['index.js', 'index.mjs', 'index.cjs', 'index.ts', 'index.tsx', 'index.vue'].flatMap(
filename => ['camelCase', 'snakeCase', 'kebabCase', 'pascalCase'].map(chosenCase => testCase(filename, chosenCase)),
),
testCaseWithOptions('index.tsx', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
testCaseWithOptions('src/index.tsx', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
testCaseWithOptions('src/foo/fooBar.test.js', undefined, [{case: 'camelCase', multipleFileExtensions: false}]),
testCaseWithOptions('src/foo/fooBar.testUtils.js', undefined, [{case: 'camelCase', multipleFileExtensions: false}]),
testCaseWithOptions('src/foo/foo_bar.test_utils.js', undefined, [{case: 'snakeCase', multipleFileExtensions: false}]),
testCaseWithOptions('src/foo/foo.test.js', undefined, [{case: 'kebabCase', multipleFileExtensions: false}]),
testCaseWithOptions('src/foo/foo-bar.test.js', undefined, [{case: 'kebabCase', multipleFileExtensions: false}]),
testCaseWithOptions('src/foo/foo-bar.test-utils.js', undefined, [{case: 'kebabCase', multipleFileExtensions: false}]),
testCaseWithOptions('src/foo/Foo.Test.js', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
testCaseWithOptions('src/foo/FooBar.Test.js', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
testCaseWithOptions('src/foo/FooBar.TestUtils.js', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
testCaseWithOptions('spec/Iss47.100Spec.js', undefined, [{case: 'pascalCase', multipleFileExtensions: false}]),
// Multiple filename parts - multiple file extensions
testCaseWithOptions('src/foo/fooBar.Test.js', undefined, [{case: 'camelCase'}]),
testCaseWithOptions('test/foo/fooBar.testUtils.js', undefined, [{case: 'camelCase'}]),
testCaseWithOptions('test/foo/.testUtils.js', undefined, [{case: 'camelCase'}]),
testCaseWithOptions('test/foo/foo_bar.Test.js', undefined, [{case: 'snakeCase'}]),
testCaseWithOptions('test/foo/foo_bar.Test_Utils.js', undefined, [{case: 'snakeCase'}]),
testCaseWithOptions('test/foo/.Test_Utils.js', undefined, [{case: 'snakeCase'}]),
testCaseWithOptions('test/foo/foo-bar.Test.js', undefined, [{case: 'kebabCase'}]),
testCaseWithOptions('test/foo/foo-bar.Test-Utils.js', undefined, [{case: 'kebabCase'}]),
testCaseWithOptions('test/foo/.Test-Utils.js', undefined, [{case: 'kebabCase'}]),
testCaseWithOptions('test/foo/FooBar.Test.js', undefined, [{case: 'pascalCase'}]),
testCaseWithOptions('test/foo/FooBar.TestUtils.js', undefined, [{case: 'pascalCase'}]),
testCaseWithOptions('test/foo/.TestUtils.js', undefined, [{case: 'pascalCase'}]),
],
invalid: [
testCase(
Expand All @@ -258,7 +291,7 @@ test({
testCase(
'test/foo/foo_bar.test_utils.js',
'camelCase',
'Filename is not in camel case. Rename it to `fooBar.testUtils.js`.',
'Filename is not in camel case. Rename it to `fooBar.test_utils.js`.',
),
testCase(
'test/foo/fooBar.js',
Expand All @@ -273,7 +306,7 @@ test({
testCase(
'test/foo/fooBar.testUtils.js',
'snakeCase',
'Filename is not in snake case. Rename it to `foo_bar.test_utils.js`.',
'Filename is not in snake case. Rename it to `foo_bar.testUtils.js`.',
),
testCase(
'test/foo/fooBar.js',
Expand All @@ -288,7 +321,7 @@ test({
testCase(
'test/foo/fooBar.testUtils.js',
'kebabCase',
'Filename is not in kebab case. Rename it to `foo-bar.test-utils.js`.',
'Filename is not in kebab case. Rename it to `foo-bar.testUtils.js`.',
),
testCase(
'test/foo/fooBar.js',
Expand All @@ -298,12 +331,12 @@ test({
testCase(
'test/foo/foo_bar.test.js',
'pascalCase',
'Filename is not in pascal case. Rename it to `FooBar.Test.js`.',
'Filename is not in pascal case. Rename it to `FooBar.test.js`.',
),
testCase(
'test/foo/foo-bar.test-utils.js',
'pascalCase',
'Filename is not in pascal case. Rename it to `FooBar.TestUtils.js`.',
'Filename is not in pascal case. Rename it to `FooBar.test-utils.js`.',
),
testCase(
'src/foo/_FOO-BAR.js',
Expand Down Expand Up @@ -547,14 +580,59 @@ test({
},
'Filename is not in camel case, pascal case, or kebab case. Rename it to `1.js`.',
),
// Multiple filename parts - single file extension
testCaseWithOptions(
'src/foo/foo_bar.test.js',
'Filename is not in camel case. Rename it to `fooBar.test.js`.',
[{case: 'camelCase', multipleFileExtensions: false}],
),
testCaseWithOptions(
'test/foo/foo_bar.test_utils.js',
'Filename is not in camel case. Rename it to `fooBar.testUtils.js`.',
[{case: 'camelCase', multipleFileExtensions: false}],
),
testCaseWithOptions(
'test/foo/fooBar.test.js',
'Filename is not in snake case. Rename it to `foo_bar.test.js`.',
[{case: 'snakeCase', multipleFileExtensions: false}],
),
testCaseWithOptions(
'test/foo/fooBar.testUtils.js',
'Filename is not in snake case. Rename it to `foo_bar.test_utils.js`.',
[{case: 'snakeCase', multipleFileExtensions: false}],
),
testCaseWithOptions(
'test/foo/fooBar.test.js',
'Filename is not in kebab case. Rename it to `foo-bar.test.js`.',
[{case: 'kebabCase', multipleFileExtensions: false}],
),
testCaseWithOptions(
'test/foo/fooBar.testUtils.js',
'Filename is not in kebab case. Rename it to `foo-bar.test-utils.js`.',
[{case: 'kebabCase', multipleFileExtensions: false}],
),
testCaseWithOptions(
'test/foo/foo_bar.test.js',
'Filename is not in pascal case. Rename it to `FooBar.Test.js`.',
[{case: 'pascalCase', multipleFileExtensions: false}],
),
testCaseWithOptions(
'test/foo/foo-bar.test-utils.js',
'Filename is not in pascal case. Rename it to `FooBar.TestUtils.js`.',
[{case: 'pascalCase', multipleFileExtensions: false}],
),
],
});

test.snapshot({
valid: [
undefined,
'src/foo.JS/bar.js',
'src/foo.JS/bar.spec.js',
'src/foo.JS/.spec.js',
'src/foo.JS/bar',
'foo.SPEC.js',
'.SPEC.js',
].map(filename => ({code: `const filename = ${JSON.stringify(filename)};`, filename})),
invalid: [
{
Expand All @@ -575,6 +653,6 @@ test.snapshot({
'foo.jS',
'index.JS',
'foo..JS',
].map(filename => ({code: `const filename = ${JSON.stringify(filename)};`, filename})),
].map(filename => ({code: `/* Filename ${filename} */`, filename})),
],
});
Loading

0 comments on commit 4594020

Please sign in to comment.