123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- '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 = /^(?<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 (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 === '<input>' || filenameWithExtension === '<text>') {
- 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,
- },
- };
|