eslint-plugin-unicorn-x
Version:
More than 100 powerful ESLint rules
312 lines (273 loc) • 6.7 kB
JavaScript
import path from 'node:path';
import {isRegExp} from 'node:util/types';
import {pascalCase, snakeCase, kebabCase, camelCase} from 'scule';
import cartesianProductSamples from './utils/cartesian-product-samples.js';
const MESSAGE_ID = 'filename-case';
const MESSAGE_ID_EXTENSION = 'filename-extension';
const messages = {
[MESSAGE_ID]:
'Filename is not in {{chosenCases}}. Rename it to {{renamedFilenames}}.',
[MESSAGE_ID_EXTENSION]:
'File extension `{{extension}}` is not in lowercase. Rename it to `{{filename}}`.',
};
const isIgnoredChar = (char) => !/^[a-z\d-_]$/i.test(char);
const ignoredByDefault = new Set([
'index.js',
'index.mjs',
'index.cjs',
'index.ts',
'index.tsx',
'index.vue',
]);
const isLowerCase = (string) => string === string.toLowerCase();
const cases = {
camelCase: {
fn(string) {
return camelCase(string, {normalize: true});
},
name: 'camel case',
},
kebabCase: {
fn(string) {
const result = kebabCase(string);
if (result.endsWith('-')) {
return result.slice(0, -1);
}
return result;
},
name: 'kebab case',
},
snakeCase: {
fn(string) {
const result = snakeCase(string);
if (result.endsWith('_')) {
return result.slice(0, -1);
}
return result;
},
name: 'snake case',
},
pascalCase: {
fn(string) {
return pascalCase(string, {normalize: true});
},
name: 'pascal case',
},
};
/**
Get the cases specified by the option.
@param {object} options
@returns {string[]} The chosen cases.
*/
function getChosenCases(options) {
if (options.case) {
return [options.case];
}
if (options.cases) {
const cases = Object.keys(options.cases).filter(
(cases) => options.cases[cases],
);
return cases.length > 0 ? cases : ['kebabCase'];
}
return ['kebabCase'];
}
function validateFilename(words, caseFunctions) {
return words
.filter(({ignored}) => !ignored)
.every(({word}) =>
caseFunctions.some((caseFunction) => caseFunction(word) === word),
);
}
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('')}${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>.*)$/;
function splitFilename(filename) {
const result = leadingUnderscoresRegex.exec(filename) || {groups: {}};
const {leading = '', tailing = filename} = result.groups;
const words = [];
let lastWord;
for (const char of tailing) {
const isIgnored = isIgnoredChar(char);
if (lastWord?.ignored === isIgnored) {
lastWord.word += char;
} else {
lastWord = {
word: char,
ignored: isIgnored,
};
words.push(lastWord);
}
}
return {
leading,
words,
};
}
/**
Turns `[a, b, c]` into `a, b, or c`.
@param {string[]} words
@returns {string}
*/
const englishishJoinWords = (words) =>
new Intl.ListFormat('en-US', {type: 'disjunction'}).format(words);
/** @param {import('eslint').Rule.RuleContext} context */
const create = (context) => {
const options = context.options[0] || {};
const chosenCases = getChosenCases(options);
const ignore = (options.ignore || []).map((item) => {
if (isRegExp(item)) {
return item;
}
return new RegExp(item, 'u');
});
const multipleFileExtensions = options.multipleFileExtensions !== false;
const chosenCasesFunctions = chosenCases.map((case_) => cases[case_].fn);
const filenameWithExtension = context.physicalFilename;
if (
filenameWithExtension === '<input>' ||
filenameWithExtension === '<text>'
) {
return;
}
return {
Program() {
const {basename, filename, middle, extension} = getFilenameParts(
filenameWithExtension,
{multipleFileExtensions},
);
if (
ignoredByDefault.has(basename) ||
ignore.some((regexp) => regexp.test(basename))
) {
return;
}
const {leading, words} = splitFilename(filename);
const isValid = validateFilename(words, chosenCasesFunctions);
if (isValid) {
if (!isLowerCase(extension)) {
return {
loc: {column: 0, line: 1},
messageId: MESSAGE_ID_EXTENSION,
data: {
filename: filename + middle + extension.toLowerCase(),
extension,
},
};
}
return;
}
const renamedFilenames = fixFilename(words, chosenCasesFunctions, {
leading,
trailing: middle + extension.toLowerCase(),
});
return {
// Report on first character like `unicode-bom` rule
// https://github.com/eslint/eslint/blob/8a77b661bc921c3408bae01b3aa41579edfc6e58/lib/rules/unicode-bom.js#L46
loc: {column: 0, line: 1},
messageId: MESSAGE_ID,
data: {
chosenCases: englishishJoinWords(
chosenCases.map((x) => cases[x].name),
),
renamedFilenames: englishishJoinWords(
renamedFilenames.map((x) => `\`${x}\``),
),
},
};
},
};
};
const schema = [
{
oneOf: [
{
properties: {
case: {
enum: ['camelCase', 'snakeCase', 'kebabCase', 'pascalCase'],
},
ignore: {
type: 'array',
uniqueItems: true,
},
multipleFileExtensions: {
type: 'boolean',
},
},
additionalProperties: false,
},
{
properties: {
cases: {
properties: {
camelCase: {
type: 'boolean',
},
snakeCase: {
type: 'boolean',
},
kebabCase: {
type: 'boolean',
},
pascalCase: {
type: 'boolean',
},
},
additionalProperties: false,
},
ignore: {
type: 'array',
uniqueItems: true,
},
multipleFileExtensions: {
type: 'boolean',
},
},
additionalProperties: false,
},
],
},
];
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce a case style for filenames.',
recommended: true,
},
schema,
// eslint-disable-next-line eslint-plugin/require-meta-default-options
defaultOptions: [],
messages,
},
};
export default config;