1 | 'use strict';
|
2 | const path = require('path');
|
3 | const {camelCase, kebabCase, snakeCase, upperFirst} = require('lodash');
|
4 | const getDocumentationUrl = require('./utils/get-documentation-url');
|
5 | const cartesianProductSamples = require('./utils/cartesian-product-samples');
|
6 |
|
7 | const pascalCase = string => upperFirst(camelCase(string));
|
8 | const numberRegex = /\d+/;
|
9 | const PLACEHOLDER = '\uFFFF\uFFFF\uFFFF';
|
10 | const PLACEHOLDER_REGEX = new RegExp(PLACEHOLDER, 'i');
|
11 | const isIgnoredChar = char => !/^[a-z\d-_$]$/i.test(char);
|
12 | const ignoredByDefault = new Set(['index.js', 'index.mjs', 'index.cjs', 'index.ts', 'index.tsx', 'index.vue']);
|
13 |
|
14 | function 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 |
|
35 | const 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 |
|
55 |
|
56 |
|
57 |
|
58 |
|
59 |
|
60 | function 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 |
|
75 | function validateFilename(words, caseFunctions) {
|
76 | return words
|
77 | .filter(({ignored}) => !ignored)
|
78 | .every(({word}) => caseFunctions.some(fn => fn(word) === word));
|
79 | }
|
80 |
|
81 | function 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 |
|
92 | const leadingUnderscoresRegex = /^(?<leading>_+)(?<tailing>.*)$/;
|
93 | function 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 |
|
121 |
|
122 |
|
123 |
|
124 |
|
125 |
|
126 | function 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 |
|
140 | const 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 |
|
191 | const 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 |
|
241 | module.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 | };
|