UNPKG

5.51 kBJavaScriptView Raw
1'use strict';
2const path = require('path');
3const {camelCase, kebabCase, snakeCase, upperFirst} = require('lodash');
4const getDocumentationUrl = require('./utils/get-documentation-url');
5const cartesianProductSamples = require('./utils/cartesian-product-samples');
6
7const pascalCase = string => upperFirst(camelCase(string));
8const numberRegex = /\d+/;
9const PLACEHOLDER = '\uFFFF\uFFFF\uFFFF';
10const PLACEHOLDER_REGEX = new RegExp(PLACEHOLDER, 'i');
11const isIgnoredChar = char => !/^[a-z\d-_$]$/i.test(char);
12const ignoredByDefault = new Set(['index.js', 'index.mjs', 'index.cjs', 'index.ts', 'index.tsx', 'index.vue']);
13
14function ignoreNumbers(fn) {
15 return string => {
16 const stack = [];
17 let execResult = numberRegex.exec(string);
18
19 while (execResult) {
20 stack.push(execResult[0]);
21 string = string.replace(execResult[0], PLACEHOLDER);
22 execResult = numberRegex.exec(string);
23 }
24
25 let withCase = fn(string);
26
27 while (stack.length > 0) {
28 withCase = withCase.replace(PLACEHOLDER_REGEX, stack.shift());
29 }
30
31 return withCase;
32 };
33}
34
35const cases = {
36 camelCase: {
37 fn: camelCase,
38 name: 'camel case'
39 },
40 kebabCase: {
41 fn: kebabCase,
42 name: 'kebab case'
43 },
44 snakeCase: {
45 fn: snakeCase,
46 name: 'snake case'
47 },
48 pascalCase: {
49 fn: pascalCase,
50 name: 'pascal case'
51 }
52};
53
54/**
55Get the cases specified by the option.
56
57@param {object} options
58@returns {string[]} The chosen cases.
59*/
60function getChosenCases(options) {
61 if (options.case) {
62 return [options.case];
63 }
64
65 if (options.cases) {
66 const cases = Object.keys(options.cases)
67 .filter(cases => options.cases[cases]);
68
69 return cases.length > 0 ? cases : ['kebabCase'];
70 }
71
72 return ['kebabCase'];
73}
74
75function validateFilename(words, caseFunctions) {
76 return words
77 .filter(({ignored}) => !ignored)
78 .every(({word}) => caseFunctions.some(fn => fn(word) === word));
79}
80
81function fixFilename(words, caseFunctions, {leading, extension}) {
82 const replacements = words
83 .map(({word, ignored}) => ignored ? [word] : caseFunctions.map(fn => fn(word)));
84
85 const {
86 samples: combinations
87 } = cartesianProductSamples(replacements);
88
89 return combinations.map(parts => `${leading}${parts.join('')}${extension}`);
90}
91
92const leadingUnderscoresRegex = /^(?<leading>_+)(?<tailing>.*)$/;
93function splitFilename(filename) {
94 const result = leadingUnderscoresRegex.exec(filename) || {groups: {}};
95 const {leading = '', tailing = filename} = result.groups;
96
97 const words = [];
98
99 let lastWord;
100 for (const char of tailing) {
101 const isIgnored = isIgnoredChar(char);
102
103 if (lastWord && lastWord.ignored === isIgnored) {
104 lastWord.word += char;
105 } else {
106 lastWord = {
107 word: char,
108 ignored: isIgnored
109 };
110 words.push(lastWord);
111 }
112 }
113
114 return {
115 leading,
116 words
117 };
118}
119
120/**
121Turns `[a, b, c]` into `a, b, or c`.
122
123@param {string[]} words
124@returns {string}
125*/
126function englishishJoinWords(words) {
127 if (words.length === 1) {
128 return words[0];
129 }
130
131 if (words.length === 2) {
132 return `${words[0]} or ${words[1]}`;
133 }
134
135 words = words.slice();
136 const last = words.pop();
137 return `${words.join(', ')}, or ${last}`;
138}
139
140const create = context => {
141 const options = context.options[0] || {};
142 const chosenCases = getChosenCases(options);
143 const ignore = (options.ignore || []).map(item => {
144 if (item instanceof RegExp) {
145 return item;
146 }
147
148 return new RegExp(item, 'u');
149 });
150 const chosenCasesFunctions = chosenCases.map(case_ => ignoreNumbers(cases[case_].fn));
151 const filenameWithExtension = context.getFilename();
152
153 if (filenameWithExtension === '<input>' || filenameWithExtension === '<text>') {
154 return {};
155 }
156
157 return {
158 Program: node => {
159 const extension = path.extname(filenameWithExtension);
160 const filename = path.basename(filenameWithExtension, extension);
161 const base = filename + extension;
162
163 if (ignoredByDefault.has(base) || ignore.some(regexp => regexp.test(base))) {
164 return;
165 }
166
167 const {leading, words} = splitFilename(filename);
168 const isValid = validateFilename(words, chosenCasesFunctions);
169
170 if (isValid) {
171 return;
172 }
173
174 const renamedFilenames = fixFilename(words, chosenCasesFunctions, {
175 leading,
176 extension
177 });
178
179 context.report({
180 node,
181 messageId: chosenCases.length > 1 ? 'renameToCases' : 'renameToCase',
182 data: {
183 chosenCases: englishishJoinWords(chosenCases.map(x => cases[x].name)),
184 renamedFilenames: englishishJoinWords(renamedFilenames.map(x => `\`${x}\``))
185 }
186 });
187 }
188 };
189};
190
191const schema = [
192 {
193 oneOf: [
194 {
195 properties: {
196 case: {
197 enum: [
198 'camelCase',
199 'snakeCase',
200 'kebabCase',
201 'pascalCase'
202 ]
203 },
204 ignore: {
205 type: 'array',
206 uniqueItems: true
207 }
208 },
209 additionalProperties: false
210 },
211 {
212 properties: {
213 cases: {
214 properties: {
215 camelCase: {
216 type: 'boolean'
217 },
218 snakeCase: {
219 type: 'boolean'
220 },
221 kebabCase: {
222 type: 'boolean'
223 },
224 pascalCase: {
225 type: 'boolean'
226 }
227 },
228 additionalProperties: false
229 },
230 ignore: {
231 type: 'array',
232 uniqueItems: true
233 }
234 },
235 additionalProperties: false
236 }
237 ]
238 }
239];
240
241module.exports = {
242 create,
243 meta: {
244 type: 'suggestion',
245 docs: {
246 url: getDocumentationUrl(__filename)
247 },
248 schema,
249 messages: {
250 renameToCase: 'Filename is not in {{chosenCases}}. Rename it to {{renamedFilenames}}.',
251 renameToCases: 'Filename is not in {{chosenCases}}. Rename it to {{renamedFilenames}}.'
252 }
253 }
254};