UNPKG

17.3 kBJavaScriptView Raw
1'use strict';
2const path = require('path');
3const astUtils = require('eslint-ast-utils');
4const {defaultsDeep, upperFirst, lowerFirst} = require('lodash');
5
6const getDocumentationUrl = require('./utils/get-documentation-url');
7const avoidCapture = require('./utils/avoid-capture');
8const cartesianProductSamples = require('./utils/cartesian-product-samples');
9const isShorthandPropertyIdentifier = require('./utils/is-shorthand-property-identifier');
10const isShorthandImportIdentifier = require('./utils/is-shorthand-import-identifier');
11const getVariableIdentifiers = require('./utils/get-variable-identifiers');
12const renameIdentifier = require('./utils/rename-identifier');
13
14const isUpperCase = string => string === string.toUpperCase();
15const isUpperFirst = string => isUpperCase(string[0]);
16
17// Keep this alphabetically sorted for easier maintenance
18const defaultReplacements = {
19 acc: {
20 accumulator: true
21 },
22 arg: {
23 argument: true
24 },
25 args: {
26 arguments: true
27 },
28 arr: {
29 array: true
30 },
31 attr: {
32 attribute: true
33 },
34 attrs: {
35 attributes: true
36 },
37 btn: {
38 button: true
39 },
40 cb: {
41 callback: true
42 },
43 conf: {
44 config: true
45 },
46 ctx: {
47 context: true
48 },
49 cur: {
50 current: true
51 },
52 curr: {
53 current: true
54 },
55 db: {
56 database: true
57 },
58 dest: {
59 destination: true
60 },
61 dev: {
62 development: true
63 },
64 dir: {
65 direction: true,
66 directory: true
67 },
68 dirs: {
69 directories: true
70 },
71 doc: {
72 document: true
73 },
74 docs: {
75 documentation: true,
76 documents: true
77 },
78 e: {
79 error: true,
80 event: true
81 },
82 el: {
83 element: true
84 },
85 elem: {
86 element: true
87 },
88 env: {
89 environment: true
90 },
91 envs: {
92 environments: true
93 },
94 err: {
95 error: true
96 },
97 ev: {
98 event: true
99 },
100 evt: {
101 event: true
102 },
103 ext: {
104 extension: true
105 },
106 exts: {
107 extensions: true
108 },
109 len: {
110 length: true
111 },
112 lib: {
113 library: true
114 },
115 mod: {
116 module: true
117 },
118 msg: {
119 message: true
120 },
121 num: {
122 number: true
123 },
124 obj: {
125 object: true
126 },
127 opts: {
128 options: true
129 },
130 param: {
131 parameter: true
132 },
133 params: {
134 parameters: true
135 },
136 pkg: {
137 package: true
138 },
139 prev: {
140 previous: true
141 },
142 prod: {
143 production: true
144 },
145 prop: {
146 property: true
147 },
148 props: {
149 properties: true
150 },
151 ref: {
152 reference: true
153 },
154 refs: {
155 references: true
156 },
157 rel: {
158 related: true,
159 relationship: true,
160 relative: true
161 },
162 req: {
163 request: true
164 },
165 res: {
166 response: true,
167 result: true
168 },
169 ret: {
170 returnValue: true
171 },
172 retval: {
173 returnValue: true
174 },
175 sep: {
176 separator: true
177 },
178 src: {
179 source: true
180 },
181 stdDev: {
182 standardDeviation: true
183 },
184 str: {
185 string: true
186 },
187 tbl: {
188 table: true
189 },
190 temp: {
191 temporary: true
192 },
193 tit: {
194 title: true
195 },
196 tmp: {
197 temporary: true
198 },
199 val: {
200 value: true
201 }
202};
203
204const defaultWhitelist = {
205 // React PropTypes
206 // https://reactjs.org/docs/typechecking-with-proptypes.html
207 propTypes: true,
208 // React.Component Class property
209 // https://reactjs.org/docs/react-component.html#defaultprops
210 defaultProps: true,
211 // React.Component static method
212 // https://reactjs.org/docs/react-component.html#static-getderivedstatefromprops
213 getDerivedStateFromProps: true,
214 // Ember class name
215 // https://api.emberjs.com/ember/3.10/classes/Ember.EmberENV/properties
216 EmberENV: true,
217 // `package.json` field
218 // https://docs.npmjs.com/specifying-dependencies-and-devdependencies-in-a-package-json-file
219 devDependencies: true,
220 // Jest configuration
221 // https://jestjs.io/docs/en/configuration#setupfilesafterenv-array
222 setupFilesAfterEnv: true,
223 // Next.js function
224 // https://nextjs.org/learn/basics/fetching-data-for-pages
225 getInitialProps: true
226};
227
228const prepareOptions = ({
229 checkProperties = false,
230 checkVariables = true,
231
232 checkDefaultAndNamespaceImports = 'internal',
233 checkShorthandImports = 'internal',
234 checkShorthandProperties = false,
235
236 checkFilenames = true,
237
238 extendDefaultReplacements = true,
239 replacements = {},
240
241 extendDefaultWhitelist = true,
242 whitelist = {}
243} = {}) => {
244 const mergedReplacements = extendDefaultReplacements ?
245 defaultsDeep({}, replacements, defaultReplacements) :
246 replacements;
247
248 const mergedWhitelist = extendDefaultWhitelist ?
249 defaultsDeep({}, whitelist, defaultWhitelist) :
250 whitelist;
251
252 return {
253 checkProperties,
254 checkVariables,
255
256 checkDefaultAndNamespaceImports,
257 checkShorthandImports,
258 checkShorthandProperties,
259
260 checkFilenames,
261
262 replacements: new Map(
263 Object.entries(mergedReplacements).map(
264 ([discouragedName, replacements]) =>
265 [discouragedName, new Map(Object.entries(replacements))]
266 )
267 ),
268 whitelist: new Map(Object.entries(mergedWhitelist))
269 };
270};
271
272const getWordReplacements = (word, {replacements, whitelist}) => {
273 // Skip constants and whitelist
274 if (isUpperCase(word) || whitelist.get(word)) {
275 return [];
276 }
277
278 const replacement = replacements.get(lowerFirst(word)) ||
279 replacements.get(word) ||
280 replacements.get(upperFirst(word));
281
282 let wordReplacement = [];
283 if (replacement) {
284 const transform = isUpperFirst(word) ? upperFirst : lowerFirst;
285 wordReplacement = [...replacement.keys()]
286 .filter(name => replacement.get(name))
287 .map(name => transform(name));
288 }
289
290 return wordReplacement.length > 0 ? wordReplacement.sort() : [];
291};
292
293const getNameReplacements = (name, options, limit = 3) => {
294 const {whitelist} = options;
295
296 // Skip constants and whitelist
297 if (isUpperCase(name) || whitelist.get(name)) {
298 return {total: 0};
299 }
300
301 // Find exact replacements
302 const exactReplacements = getWordReplacements(name, options);
303
304 if (exactReplacements.length > 0) {
305 return {
306 total: exactReplacements.length,
307 samples: exactReplacements.slice(0, limit)
308 };
309 }
310
311 // Split words
312 const words = name.split(/(?=[^a-z])|(?<=[^A-Za-z])/).filter(Boolean);
313
314 let hasReplacements = false;
315 const combinations = words.map(word => {
316 const wordReplacements = getWordReplacements(word, options);
317
318 if (wordReplacements.length > 0) {
319 hasReplacements = true;
320 return wordReplacements;
321 }
322
323 return [word];
324 });
325
326 // No replacements for any word
327 if (!hasReplacements) {
328 return {total: 0};
329 }
330
331 const {
332 total,
333 samples
334 } = cartesianProductSamples(combinations, limit);
335
336 return {
337 total,
338 samples: samples.map(words => words.join(''))
339 };
340};
341
342const anotherNameMessage = 'A more descriptive name will do too.';
343
344const formatMessage = (discouragedName, replacements, nameTypeText) => {
345 const message = [];
346 const {total, samples = []} = replacements;
347
348 if (total === 1) {
349 message.push(`The ${nameTypeText} \`${discouragedName}\` should be named \`${samples[0]}\`.`);
350 } else {
351 let replacementsText = samples
352 .map(replacement => `\`${replacement}\``)
353 .join(', ');
354
355 const omittedReplacementsCount = total - samples.length;
356 if (omittedReplacementsCount > 0) {
357 replacementsText += `, ... (${omittedReplacementsCount > 99 ? '99+' : omittedReplacementsCount} more omitted)`;
358 }
359
360 message.push(`Please rename the ${nameTypeText} \`${discouragedName}\`.`);
361 message.push(`Suggested names are: ${replacementsText}.`);
362 }
363
364 message.push(anotherNameMessage);
365
366 return message.join(' ');
367};
368
369const isExportedIdentifier = identifier => {
370 if (
371 identifier.parent.type === 'VariableDeclarator' &&
372 identifier.parent.id === identifier
373 ) {
374 return (
375 identifier.parent.parent.type === 'VariableDeclaration' &&
376 identifier.parent.parent.parent.type === 'ExportNamedDeclaration'
377 );
378 }
379
380 if (
381 identifier.parent.type === 'FunctionDeclaration' &&
382 identifier.parent.id === identifier
383 ) {
384 return identifier.parent.parent.type === 'ExportNamedDeclaration';
385 }
386
387 if (
388 identifier.parent.type === 'ClassDeclaration' &&
389 identifier.parent.id === identifier
390 ) {
391 return identifier.parent.parent.type === 'ExportNamedDeclaration';
392 }
393
394 return false;
395};
396
397const shouldFix = variable => {
398 return !getVariableIdentifiers(variable).some(identifier => isExportedIdentifier(identifier));
399};
400
401const isDefaultOrNamespaceImportName = identifier => {
402 if (
403 identifier.parent.type === 'ImportDefaultSpecifier' &&
404 identifier.parent.local === identifier
405 ) {
406 return true;
407 }
408
409 if (
410 identifier.parent.type === 'ImportNamespaceSpecifier' &&
411 identifier.parent.local === identifier
412 ) {
413 return true;
414 }
415
416 if (
417 identifier.parent.type === 'ImportSpecifier' &&
418 identifier.parent.local === identifier &&
419 identifier.parent.imported.type === 'Identifier' &&
420 identifier.parent.imported.name === 'default'
421 ) {
422 return true;
423 }
424
425 if (
426 identifier.parent.type === 'VariableDeclarator' &&
427 identifier.parent.id === identifier &&
428 astUtils.isStaticRequire(identifier.parent.init)
429 ) {
430 return true;
431 }
432
433 return false;
434};
435
436const isClassVariable = variable => {
437 if (variable.defs.length !== 1) {
438 return false;
439 }
440
441 const [definition] = variable.defs;
442
443 return definition.type === 'ClassName';
444};
445
446const shouldReportIdentifierAsProperty = identifier => {
447 if (
448 identifier.parent.type === 'MemberExpression' &&
449 identifier.parent.property === identifier &&
450 !identifier.parent.computed &&
451 identifier.parent.parent.type === 'AssignmentExpression' &&
452 identifier.parent.parent.left === identifier.parent
453 ) {
454 return true;
455 }
456
457 if (
458 identifier.parent.type === 'Property' &&
459 identifier.parent.key === identifier &&
460 !identifier.parent.computed &&
461 !identifier.parent.shorthand && // Shorthand properties are reported and fixed as variables
462 identifier.parent.parent.type === 'ObjectExpression'
463 ) {
464 return true;
465 }
466
467 if (
468 identifier.parent.type === 'ExportSpecifier' &&
469 identifier.parent.exported === identifier &&
470 identifier.parent.local !== identifier // Same as shorthand properties above
471 ) {
472 return true;
473 }
474
475 if (
476 identifier.parent.type === 'MethodDefinition' &&
477 identifier.parent.key === identifier &&
478 !identifier.parent.computed
479 ) {
480 return true;
481 }
482
483 if (
484 identifier.parent.type === 'ClassProperty' &&
485 identifier.parent.key === identifier &&
486 !identifier.parent.computed
487 ) {
488 return true;
489 }
490
491 return false;
492};
493
494const isInternalImport = node => {
495 let source = '';
496
497 if (node.type === 'Variable') {
498 source = node.node.init.arguments[0].value;
499 } else if (node.type === 'ImportBinding') {
500 source = node.parent.source.value;
501 }
502
503 return (
504 !source.includes('node_modules') &&
505 (source.startsWith('.') || source.startsWith('/'))
506 );
507};
508
509const create = context => {
510 const {ecmaVersion} = context.parserOptions;
511 const options = prepareOptions(context.options[0]);
512 const filenameWithExtension = context.getFilename();
513 const sourceCode = context.getSourceCode();
514
515 // A `class` declaration produces two variables in two scopes:
516 // the inner class scope, and the outer one (whereever the class is declared).
517 // This map holds the outer ones to be later processed when the inner one is encountered.
518 // For why this is not a eslint issue see https://github.com/eslint/eslint-scope/issues/48#issuecomment-464358754
519 const identifierToOuterClassVariable = new WeakMap();
520
521 const checkPossiblyWeirdClassVariable = variable => {
522 if (isClassVariable(variable)) {
523 if (variable.scope.type === 'class') { // The inner class variable
524 const [definition] = variable.defs;
525 const outerClassVariable = identifierToOuterClassVariable.get(definition.name);
526
527 if (!outerClassVariable) {
528 return checkVariable(variable);
529 }
530
531 // Create a normal-looking variable (like a `var` or a `function`)
532 // For which a single `variable` holds all references, unline with `class`
533 const combinedReferencesVariable = {
534 name: variable.name,
535 scope: variable.scope,
536 defs: variable.defs,
537 identifiers: variable.identifiers,
538 references: variable.references.concat(outerClassVariable.references)
539 };
540
541 // Call the common checker with the newly forged normalized class variable
542 return checkVariable(combinedReferencesVariable);
543 }
544
545 // The outer class variable, we save it for later, when it's inner counterpart is encountered
546 const [definition] = variable.defs;
547 identifierToOuterClassVariable.set(definition.name, variable);
548
549 return;
550 }
551
552 return checkVariable(variable);
553 };
554
555 // Holds a map from a `Scope` to a `Set` of new variable names generated by our fixer.
556 // Used to avoid generating duplicate names, see for instance `let errCb, errorCb` test.
557 const scopeToNamesGeneratedByFixer = new WeakMap();
558 const isSafeName = (name, scopes) => scopes.every(scope => {
559 const generatedNames = scopeToNamesGeneratedByFixer.get(scope);
560 return !generatedNames || !generatedNames.has(name);
561 });
562
563 const checkVariable = variable => {
564 if (variable.defs.length === 0) {
565 return;
566 }
567
568 const [definition] = variable.defs;
569
570 if (isDefaultOrNamespaceImportName(definition.name)) {
571 if (!options.checkDefaultAndNamespaceImports) {
572 return;
573 }
574
575 if (
576 options.checkDefaultAndNamespaceImports === 'internal' &&
577 !isInternalImport(definition)
578 ) {
579 return;
580 }
581 }
582
583 if (isShorthandImportIdentifier(definition.name)) {
584 if (!options.checkShorthandImports) {
585 return;
586 }
587
588 if (
589 options.checkShorthandImports === 'internal' &&
590 !isInternalImport(definition)
591 ) {
592 return;
593 }
594 }
595
596 if (
597 !options.checkShorthandProperties &&
598 isShorthandPropertyIdentifier(definition.name)
599 ) {
600 return;
601 }
602
603 const variableReplacements = getNameReplacements(variable.name, options);
604
605 if (variableReplacements.total === 0) {
606 return;
607 }
608
609 const scopes = variable.references.map(reference => reference.from).concat(variable.scope);
610 variableReplacements.samples = variableReplacements.samples.map(
611 name => avoidCapture(name, scopes, ecmaVersion, isSafeName)
612 );
613
614 const problem = {
615 node: definition.name,
616 message: formatMessage(definition.name.name, variableReplacements, 'variable')
617 };
618
619 if (variableReplacements.total === 1 && shouldFix(variable)) {
620 const [replacement] = variableReplacements.samples;
621
622 for (const scope of scopes) {
623 if (!scopeToNamesGeneratedByFixer.has(scope)) {
624 scopeToNamesGeneratedByFixer.set(scope, new Set());
625 }
626
627 const generatedNames = scopeToNamesGeneratedByFixer.get(scope);
628 generatedNames.add(replacement);
629 }
630
631 problem.fix = function * (fixer) {
632 for (const identifier of getVariableIdentifiers(variable)) {
633 yield renameIdentifier(identifier, replacement, fixer, sourceCode);
634 }
635 };
636 }
637
638 context.report(problem);
639 };
640
641 const checkVariables = scope => {
642 scope.variables.forEach(variable => checkPossiblyWeirdClassVariable(variable));
643 };
644
645 const checkChildScopes = scope => {
646 scope.childScopes.forEach(scope => checkScope(scope));
647 };
648
649 const checkScope = scope => {
650 checkVariables(scope);
651
652 return checkChildScopes(scope);
653 };
654
655 return {
656 Identifier(node) {
657 if (!options.checkProperties) {
658 return;
659 }
660
661 if (node.name === '__proto__') {
662 return;
663 }
664
665 const identifierReplacements = getNameReplacements(node.name, options);
666
667 if (identifierReplacements.total === 0) {
668 return;
669 }
670
671 if (!shouldReportIdentifierAsProperty(node)) {
672 return;
673 }
674
675 const problem = {
676 node,
677 message: formatMessage(node.name, identifierReplacements, 'property')
678 };
679
680 context.report(problem);
681 },
682
683 Program(node) {
684 if (!options.checkFilenames) {
685 return;
686 }
687
688 if (
689 filenameWithExtension === '<input>' ||
690 filenameWithExtension === '<text>'
691 ) {
692 return;
693 }
694
695 const extension = path.extname(filenameWithExtension);
696 const filename = path.basename(filenameWithExtension, extension);
697
698 const filenameReplacements = getNameReplacements(filename, options);
699
700 if (filenameReplacements.total === 0) {
701 return;
702 }
703
704 filenameReplacements.samples = filenameReplacements.samples.map(replacement => `${replacement}${extension}`);
705
706 context.report({
707 node,
708 message: formatMessage(filenameWithExtension, filenameReplacements, 'filename')
709 });
710 },
711
712 'Program:exit'() {
713 if (!options.checkVariables) {
714 return;
715 }
716
717 checkScope(context.getScope());
718 }
719 };
720};
721
722const schema = [
723 {
724 type: 'object',
725 properties: {
726 checkProperties: {
727 type: 'boolean'
728 },
729 checkVariables: {
730 type: 'boolean'
731 },
732 checkDefaultAndNamespaceImports: {
733 type: [
734 'boolean',
735 'string'
736 ],
737 pattern: 'internal'
738 },
739 checkShorthandImports: {
740 type: [
741 'boolean',
742 'string'
743 ],
744 pattern: 'internal'
745 },
746 checkShorthandProperties: {
747 type: 'boolean'
748 },
749 checkFilenames: {
750 type: 'boolean'
751 },
752 extendDefaultReplacements: {
753 type: 'boolean'
754 },
755 replacements: {
756 $ref: '#/items/0/definitions/abbreviations'
757 },
758 extendDefaultWhitelist: {
759 type: 'boolean'
760 },
761 whitelist: {
762 $ref: '#/items/0/definitions/booleanObject'
763 }
764 },
765 additionalProperties: false,
766 definitions: {
767 abbreviations: {
768 type: 'object',
769 additionalProperties: {
770 $ref: '#/items/0/definitions/replacements'
771 }
772 },
773 replacements: {
774 anyOf: [
775 {
776 enum: [
777 false
778 ]
779 },
780 {
781 $ref: '#/items/0/definitions/booleanObject'
782 }
783 ]
784 },
785 booleanObject: {
786 type: 'object',
787 additionalProperties: {
788 type: 'boolean'
789 }
790 }
791 }
792 }
793];
794
795module.exports = {
796 create,
797 meta: {
798 type: 'suggestion',
799 docs: {
800 url: getDocumentationUrl(__filename)
801 },
802 fixable: 'code',
803 schema
804 }
805};