UNPKG

eslint-plugin-unicorn-x

Version:
312 lines (273 loc) 6.7 kB
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;