prevent-abbreviations.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  1. 'use strict';
  2. const path = require('path');
  3. const {defaultsDeep, upperFirst, lowerFirst} = require('lodash');
  4. const avoidCapture = require('./utils/avoid-capture.js');
  5. const cartesianProductSamples = require('./utils/cartesian-product-samples.js');
  6. const isShorthandPropertyValue = require('./utils/is-shorthand-property-value.js');
  7. const isShorthandImportLocal = require('./utils/is-shorthand-import-local.js');
  8. const getVariableIdentifiers = require('./utils/get-variable-identifiers.js');
  9. const isStaticRequire = require('./utils/is-static-require.js');
  10. const {defaultReplacements, defaultAllowList, defaultIgnore} = require('./shared/abbreviations.js');
  11. const {renameVariable} = require('./fix/index.js');
  12. const getScopes = require('./utils/get-scopes.js');
  13. const MESSAGE_ID_REPLACE = 'replace';
  14. const MESSAGE_ID_SUGGESTION = 'suggestion';
  15. const anotherNameMessage = 'A more descriptive name will do too.';
  16. const messages = {
  17. [MESSAGE_ID_REPLACE]: `The {{nameTypeText}} \`{{discouragedName}}\` should be named \`{{replacement}}\`. ${anotherNameMessage}`,
  18. [MESSAGE_ID_SUGGESTION]: `Please rename the {{nameTypeText}} \`{{discouragedName}}\`. Suggested names are: {{replacementsText}}. ${anotherNameMessage}`,
  19. };
  20. const isUpperCase = string => string === string.toUpperCase();
  21. const isUpperFirst = string => isUpperCase(string[0]);
  22. const prepareOptions = ({
  23. checkProperties = false,
  24. checkVariables = true,
  25. checkDefaultAndNamespaceImports = 'internal',
  26. checkShorthandImports = 'internal',
  27. checkShorthandProperties = false,
  28. checkFilenames = true,
  29. extendDefaultReplacements = true,
  30. replacements = {},
  31. extendDefaultAllowList = true,
  32. allowList = {},
  33. ignore = [],
  34. } = {}) => {
  35. const mergedReplacements = extendDefaultReplacements
  36. ? defaultsDeep({}, replacements, defaultReplacements)
  37. : replacements;
  38. const mergedAllowList = extendDefaultAllowList
  39. ? defaultsDeep({}, allowList, defaultAllowList)
  40. : allowList;
  41. ignore = [...defaultIgnore, ...ignore];
  42. ignore = ignore.map(
  43. pattern => pattern instanceof RegExp ? pattern : new RegExp(pattern, 'u'),
  44. );
  45. return {
  46. checkProperties,
  47. checkVariables,
  48. checkDefaultAndNamespaceImports,
  49. checkShorthandImports,
  50. checkShorthandProperties,
  51. checkFilenames,
  52. replacements: new Map(
  53. Object.entries(mergedReplacements).map(
  54. ([discouragedName, replacements]) =>
  55. [discouragedName, new Map(Object.entries(replacements))],
  56. ),
  57. ),
  58. allowList: new Map(Object.entries(mergedAllowList)),
  59. ignore,
  60. };
  61. };
  62. const getWordReplacements = (word, {replacements, allowList}) => {
  63. // Skip constants and allowList
  64. if (isUpperCase(word) || allowList.get(word)) {
  65. return [];
  66. }
  67. const replacement = replacements.get(lowerFirst(word))
  68. || replacements.get(word)
  69. || replacements.get(upperFirst(word));
  70. let wordReplacement = [];
  71. if (replacement) {
  72. const transform = isUpperFirst(word) ? upperFirst : lowerFirst;
  73. wordReplacement = [...replacement.keys()]
  74. .filter(name => replacement.get(name))
  75. .map(name => transform(name));
  76. }
  77. return wordReplacement.length > 0 ? wordReplacement.sort() : [];
  78. };
  79. const getNameReplacements = (name, options, limit = 3) => {
  80. const {allowList, ignore} = options;
  81. // Skip constants and allowList
  82. if (isUpperCase(name) || allowList.get(name) || ignore.some(regexp => regexp.test(name))) {
  83. return {total: 0};
  84. }
  85. // Find exact replacements
  86. const exactReplacements = getWordReplacements(name, options);
  87. if (exactReplacements.length > 0) {
  88. return {
  89. total: exactReplacements.length,
  90. samples: exactReplacements.slice(0, limit),
  91. };
  92. }
  93. // Split words
  94. const words = name.split(/(?=[^a-z])|(?<=[^A-Za-z])/).filter(Boolean);
  95. let hasReplacements = false;
  96. const combinations = words.map(word => {
  97. const wordReplacements = getWordReplacements(word, options);
  98. if (wordReplacements.length > 0) {
  99. hasReplacements = true;
  100. return wordReplacements;
  101. }
  102. return [word];
  103. });
  104. // No replacements for any word
  105. if (!hasReplacements) {
  106. return {total: 0};
  107. }
  108. const {
  109. total,
  110. samples,
  111. } = cartesianProductSamples(combinations, limit);
  112. return {
  113. total,
  114. samples: samples.map(words => words.join('')),
  115. };
  116. };
  117. const getMessage = (discouragedName, replacements, nameTypeText) => {
  118. const {total, samples = []} = replacements;
  119. if (total === 1) {
  120. return {
  121. messageId: MESSAGE_ID_REPLACE,
  122. data: {
  123. nameTypeText,
  124. discouragedName,
  125. replacement: samples[0],
  126. },
  127. };
  128. }
  129. let replacementsText = samples
  130. .map(replacement => `\`${replacement}\``)
  131. .join(', ');
  132. const omittedReplacementsCount = total - samples.length;
  133. if (omittedReplacementsCount > 0) {
  134. replacementsText += `, ... (${omittedReplacementsCount > 99 ? '99+' : omittedReplacementsCount} more omitted)`;
  135. }
  136. return {
  137. messageId: MESSAGE_ID_SUGGESTION,
  138. data: {
  139. nameTypeText,
  140. discouragedName,
  141. replacementsText,
  142. },
  143. };
  144. };
  145. const isExportedIdentifier = identifier => {
  146. if (
  147. identifier.parent.type === 'VariableDeclarator'
  148. && identifier.parent.id === identifier
  149. ) {
  150. return (
  151. identifier.parent.parent.type === 'VariableDeclaration'
  152. && identifier.parent.parent.parent.type === 'ExportNamedDeclaration'
  153. );
  154. }
  155. if (
  156. identifier.parent.type === 'FunctionDeclaration'
  157. && identifier.parent.id === identifier
  158. ) {
  159. return identifier.parent.parent.type === 'ExportNamedDeclaration';
  160. }
  161. if (
  162. identifier.parent.type === 'ClassDeclaration'
  163. && identifier.parent.id === identifier
  164. ) {
  165. return identifier.parent.parent.type === 'ExportNamedDeclaration';
  166. }
  167. if (
  168. identifier.parent.type === 'TSTypeAliasDeclaration'
  169. && identifier.parent.id === identifier
  170. ) {
  171. return identifier.parent.parent.type === 'ExportNamedDeclaration';
  172. }
  173. return false;
  174. };
  175. const shouldFix = variable => !getVariableIdentifiers(variable).some(identifier => isExportedIdentifier(identifier));
  176. const isDefaultOrNamespaceImportName = identifier => {
  177. if (
  178. identifier.parent.type === 'ImportDefaultSpecifier'
  179. && identifier.parent.local === identifier
  180. ) {
  181. return true;
  182. }
  183. if (
  184. identifier.parent.type === 'ImportNamespaceSpecifier'
  185. && identifier.parent.local === identifier
  186. ) {
  187. return true;
  188. }
  189. if (
  190. identifier.parent.type === 'ImportSpecifier'
  191. && identifier.parent.local === identifier
  192. && identifier.parent.imported.type === 'Identifier'
  193. && identifier.parent.imported.name === 'default'
  194. ) {
  195. return true;
  196. }
  197. if (
  198. identifier.parent.type === 'VariableDeclarator'
  199. && identifier.parent.id === identifier
  200. && isStaticRequire(identifier.parent.init)
  201. ) {
  202. return true;
  203. }
  204. return false;
  205. };
  206. const isClassVariable = variable => {
  207. if (variable.defs.length !== 1) {
  208. return false;
  209. }
  210. const [definition] = variable.defs;
  211. return definition.type === 'ClassName';
  212. };
  213. const shouldReportIdentifierAsProperty = identifier => {
  214. if (
  215. identifier.parent.type === 'MemberExpression'
  216. && identifier.parent.property === identifier
  217. && !identifier.parent.computed
  218. && identifier.parent.parent.type === 'AssignmentExpression'
  219. && identifier.parent.parent.left === identifier.parent
  220. ) {
  221. return true;
  222. }
  223. if (
  224. identifier.parent.type === 'Property'
  225. && identifier.parent.key === identifier
  226. && !identifier.parent.computed
  227. && !identifier.parent.shorthand // Shorthand properties are reported and fixed as variables
  228. && identifier.parent.parent.type === 'ObjectExpression'
  229. ) {
  230. return true;
  231. }
  232. if (
  233. identifier.parent.type === 'ExportSpecifier'
  234. && identifier.parent.exported === identifier
  235. && identifier.parent.local !== identifier // Same as shorthand properties above
  236. ) {
  237. return true;
  238. }
  239. if (
  240. (
  241. identifier.parent.type === 'MethodDefinition'
  242. || identifier.parent.type === 'PropertyDefinition'
  243. )
  244. && identifier.parent.key === identifier
  245. && !identifier.parent.computed
  246. ) {
  247. return true;
  248. }
  249. return false;
  250. };
  251. const isInternalImport = node => {
  252. let source = '';
  253. if (node.type === 'Variable') {
  254. source = node.node.init.arguments[0].value;
  255. } else if (node.type === 'ImportBinding') {
  256. source = node.parent.source.value;
  257. }
  258. return (
  259. !source.includes('node_modules')
  260. && (source.startsWith('.') || source.startsWith('/'))
  261. );
  262. };
  263. /** @param {import('eslint').Rule.RuleContext} context */
  264. const create = context => {
  265. const options = prepareOptions(context.options[0]);
  266. const filenameWithExtension = context.getPhysicalFilename();
  267. // A `class` declaration produces two variables in two scopes:
  268. // the inner class scope, and the outer one (wherever the class is declared).
  269. // This map holds the outer ones to be later processed when the inner one is encountered.
  270. // For why this is not a eslint issue see https://github.com/eslint/eslint-scope/issues/48#issuecomment-464358754
  271. const identifierToOuterClassVariable = new WeakMap();
  272. const checkPossiblyWeirdClassVariable = variable => {
  273. if (isClassVariable(variable)) {
  274. if (variable.scope.type === 'class') { // The inner class variable
  275. const [definition] = variable.defs;
  276. const outerClassVariable = identifierToOuterClassVariable.get(definition.name);
  277. if (!outerClassVariable) {
  278. return checkVariable(variable);
  279. }
  280. // Create a normal-looking variable (like a `var` or a `function`)
  281. // For which a single `variable` holds all references, unlike with a `class`
  282. const combinedReferencesVariable = {
  283. name: variable.name,
  284. scope: variable.scope,
  285. defs: variable.defs,
  286. identifiers: variable.identifiers,
  287. references: [...variable.references, ...outerClassVariable.references],
  288. };
  289. // Call the common checker with the newly forged normalized class variable
  290. return checkVariable(combinedReferencesVariable);
  291. }
  292. // The outer class variable, we save it for later, when it's inner counterpart is encountered
  293. const [definition] = variable.defs;
  294. identifierToOuterClassVariable.set(definition.name, variable);
  295. return;
  296. }
  297. return checkVariable(variable);
  298. };
  299. // Holds a map from a `Scope` to a `Set` of new variable names generated by our fixer.
  300. // Used to avoid generating duplicate names, see for instance `let errCb, errorCb` test.
  301. const scopeToNamesGeneratedByFixer = new WeakMap();
  302. const isSafeName = (name, scopes) => scopes.every(scope => {
  303. const generatedNames = scopeToNamesGeneratedByFixer.get(scope);
  304. return !generatedNames || !generatedNames.has(name);
  305. });
  306. const checkVariable = variable => {
  307. if (variable.defs.length === 0) {
  308. return;
  309. }
  310. const [definition] = variable.defs;
  311. if (isDefaultOrNamespaceImportName(definition.name)) {
  312. if (!options.checkDefaultAndNamespaceImports) {
  313. return;
  314. }
  315. if (
  316. options.checkDefaultAndNamespaceImports === 'internal'
  317. && !isInternalImport(definition)
  318. ) {
  319. return;
  320. }
  321. }
  322. if (isShorthandImportLocal(definition.name)) {
  323. if (!options.checkShorthandImports) {
  324. return;
  325. }
  326. if (
  327. options.checkShorthandImports === 'internal'
  328. && !isInternalImport(definition)
  329. ) {
  330. return;
  331. }
  332. }
  333. if (
  334. !options.checkShorthandProperties
  335. && isShorthandPropertyValue(definition.name)
  336. ) {
  337. return;
  338. }
  339. const variableReplacements = getNameReplacements(variable.name, options);
  340. if (variableReplacements.total === 0) {
  341. return;
  342. }
  343. const scopes = [
  344. ...variable.references.map(reference => reference.from),
  345. variable.scope,
  346. ];
  347. variableReplacements.samples = variableReplacements.samples.map(
  348. name => avoidCapture(name, scopes, isSafeName),
  349. );
  350. const problem = {
  351. ...getMessage(definition.name.name, variableReplacements, 'variable'),
  352. node: definition.name,
  353. };
  354. if (variableReplacements.total === 1 && shouldFix(variable) && variableReplacements.samples[0]) {
  355. const [replacement] = variableReplacements.samples;
  356. for (const scope of scopes) {
  357. if (!scopeToNamesGeneratedByFixer.has(scope)) {
  358. scopeToNamesGeneratedByFixer.set(scope, new Set());
  359. }
  360. const generatedNames = scopeToNamesGeneratedByFixer.get(scope);
  361. generatedNames.add(replacement);
  362. }
  363. problem.fix = fixer => renameVariable(variable, replacement, fixer);
  364. }
  365. context.report(problem);
  366. };
  367. const checkVariables = scope => {
  368. for (const variable of scope.variables) {
  369. checkPossiblyWeirdClassVariable(variable);
  370. }
  371. };
  372. const checkScope = scope => {
  373. const scopes = getScopes(scope);
  374. for (const scope of scopes) {
  375. checkVariables(scope);
  376. }
  377. };
  378. return {
  379. Identifier(node) {
  380. if (!options.checkProperties) {
  381. return;
  382. }
  383. if (node.name === '__proto__') {
  384. return;
  385. }
  386. const identifierReplacements = getNameReplacements(node.name, options);
  387. if (identifierReplacements.total === 0) {
  388. return;
  389. }
  390. if (!shouldReportIdentifierAsProperty(node)) {
  391. return;
  392. }
  393. const problem = {
  394. ...getMessage(node.name, identifierReplacements, 'property'),
  395. node,
  396. };
  397. context.report(problem);
  398. },
  399. Program(node) {
  400. if (!options.checkFilenames) {
  401. return;
  402. }
  403. if (
  404. filenameWithExtension === '<input>'
  405. || filenameWithExtension === '<text>'
  406. ) {
  407. return;
  408. }
  409. const filename = path.basename(filenameWithExtension);
  410. const extension = path.extname(filename);
  411. const filenameReplacements = getNameReplacements(path.basename(filename, extension), options);
  412. if (filenameReplacements.total === 0) {
  413. return;
  414. }
  415. filenameReplacements.samples = filenameReplacements.samples.map(replacement => `${replacement}${extension}`);
  416. context.report({
  417. ...getMessage(filename, filenameReplacements, 'filename'),
  418. node,
  419. });
  420. },
  421. 'Program:exit'() {
  422. if (!options.checkVariables) {
  423. return;
  424. }
  425. checkScope(context.getScope());
  426. },
  427. };
  428. };
  429. const schema = {
  430. type: 'array',
  431. additionalItems: false,
  432. items: [
  433. {
  434. type: 'object',
  435. additionalProperties: false,
  436. properties: {
  437. checkProperties: {
  438. type: 'boolean',
  439. },
  440. checkVariables: {
  441. type: 'boolean',
  442. },
  443. checkDefaultAndNamespaceImports: {
  444. type: [
  445. 'boolean',
  446. 'string',
  447. ],
  448. pattern: 'internal',
  449. },
  450. checkShorthandImports: {
  451. type: [
  452. 'boolean',
  453. 'string',
  454. ],
  455. pattern: 'internal',
  456. },
  457. checkShorthandProperties: {
  458. type: 'boolean',
  459. },
  460. checkFilenames: {
  461. type: 'boolean',
  462. },
  463. extendDefaultReplacements: {
  464. type: 'boolean',
  465. },
  466. replacements: {
  467. $ref: '#/definitions/abbreviations',
  468. },
  469. extendDefaultAllowList: {
  470. type: 'boolean',
  471. },
  472. allowList: {
  473. $ref: '#/definitions/booleanObject',
  474. },
  475. ignore: {
  476. type: 'array',
  477. uniqueItems: true,
  478. },
  479. },
  480. },
  481. ],
  482. definitions: {
  483. abbreviations: {
  484. type: 'object',
  485. additionalProperties: {
  486. $ref: '#/definitions/replacements',
  487. },
  488. },
  489. replacements: {
  490. anyOf: [
  491. {
  492. enum: [
  493. false,
  494. ],
  495. },
  496. {
  497. $ref: '#/definitions/booleanObject',
  498. },
  499. ],
  500. },
  501. booleanObject: {
  502. type: 'object',
  503. additionalProperties: {
  504. type: 'boolean',
  505. },
  506. },
  507. },
  508. };
  509. /** @type {import('eslint').Rule.RuleModule} */
  510. module.exports = {
  511. create,
  512. meta: {
  513. type: 'suggestion',
  514. docs: {
  515. description: 'Prevent abbreviations.',
  516. },
  517. fixable: 'code',
  518. schema,
  519. messages,
  520. },
  521. };