'use strict'; const path = require('node:path'); const {camelCase, kebabCase, snakeCase, upperFirst} = require('lodash'); const cartesianProductSamples = require('./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 pascalCase = string => upperFirst(camelCase(string)); const numberRegex = /\d+/; const PLACEHOLDER = '\uFFFF\uFFFF\uFFFF'; const PLACEHOLDER_REGEX = new RegExp(PLACEHOLDER, 'i'); 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(); function ignoreNumbers(caseFunction) { return string => { const stack = []; let execResult = numberRegex.exec(string); while (execResult) { stack.push(execResult[0]); string = string.replace(execResult[0], PLACEHOLDER); execResult = numberRegex.exec(string); } let withCase = caseFunction(string); while (stack.length > 0) { withCase = withCase.replace(PLACEHOLDER_REGEX, stack.shift()); } return withCase; }; } const cases = { camelCase: { fn: camelCase, name: 'camel case', }, kebabCase: { fn: kebabCase, name: 'kebab case', }, snakeCase: { fn: snakeCase, name: 'snake case', }, pascalCase: { fn: pascalCase, 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, extension}) { 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()}`))]; } const leadingUnderscoresRegex = /^(?_+)(?.*)$/; 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 (item instanceof RegExp) { return item; } return new RegExp(item, 'u'); }); const chosenCasesFunctions = chosenCases.map(case_ => ignoreNumbers(cases[case_].fn)); const filenameWithExtension = context.getPhysicalFilename(); if (filenameWithExtension === '' || filenameWithExtension === '') { return; } return { Program() { const extension = path.extname(filenameWithExtension); const filename = path.basename(filenameWithExtension, extension); const base = filename + extension; if (ignoredByDefault.has(base) || ignore.some(regexp => regexp.test(base))) { 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 + extension.toLowerCase(), extension}, }; } return; } const renamedFilenames = fixFilename(words, chosenCasesFunctions, { leading, extension, }); 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, }, }, additionalProperties: false, }, { properties: { cases: { properties: { camelCase: { type: 'boolean', }, snakeCase: { type: 'boolean', }, kebabCase: { type: 'boolean', }, pascalCase: { type: 'boolean', }, }, additionalProperties: false, }, ignore: { type: 'array', uniqueItems: true, }, }, additionalProperties: false, }, ], }, ]; /** @type {import('eslint').Rule.RuleModule} */ module.exports = { create, meta: { type: 'suggestion', docs: { description: 'Enforce a case style for filenames.', }, schema, messages, }, };