prevent-abbreviations.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. 'use strict';
  2. const path = require('node: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 {defaultReplacements, defaultAllowList, defaultIgnore} = require('./shared/abbreviations.js');
  10. const {renameVariable} = require('./fix/index.js');
  11. const getScopes = require('./utils/get-scopes.js');
  12. const {isStaticRequire} = require('./ast/index.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. // `retVal` -> `['returnValue', 'Value']` -> `['returnValue']`
  113. for (const parts of samples) {
  114. for (let index = parts.length - 1; index > 0; index--) {
  115. const word = parts[index];
  116. if (/^[A-Za-z]+$/.test(word) && parts[index - 1].endsWith(parts[index])) {
  117. parts.splice(index, 1);
  118. }
  119. }
  120. }
  121. return {
  122. total,
  123. samples: samples.map(words => words.join('')),
  124. };
  125. };
  126. const getMessage = (discouragedName, replacements, nameTypeText) => {
  127. const {total, samples = []} = replacements;
  128. if (total === 1) {
  129. return {
  130. messageId: MESSAGE_ID_REPLACE,
  131. data: {
  132. nameTypeText,
  133. discouragedName,
  134. replacement: samples[0],
  135. },
  136. };
  137. }
  138. let replacementsText = samples
  139. .map(replacement => `\`${replacement}\``)
  140. .join(', ');
  141. const omittedReplacementsCount = total - samples.length;
  142. if (omittedReplacementsCount > 0) {
  143. replacementsText += `, ... (${omittedReplacementsCount > 99 ? '99+' : omittedReplacementsCount} more omitted)`;
  144. }
  145. return {
  146. messageId: MESSAGE_ID_SUGGESTION,
  147. data: {
  148. nameTypeText,
  149. discouragedName,
  150. replacementsText,
  151. },
  152. };
  153. };
  154. const isExportedIdentifier = identifier => {
  155. if (
  156. identifier.parent.type === 'VariableDeclarator'
  157. && identifier.parent.id === identifier
  158. ) {
  159. return (
  160. identifier.parent.parent.type === 'VariableDeclaration'
  161. && identifier.parent.parent.parent.type === 'ExportNamedDeclaration'
  162. );
  163. }
  164. if (
  165. identifier.parent.type === 'FunctionDeclaration'
  166. && identifier.parent.id === identifier
  167. ) {
  168. return identifier.parent.parent.type === 'ExportNamedDeclaration';
  169. }
  170. if (
  171. identifier.parent.type === 'ClassDeclaration'
  172. && identifier.parent.id === identifier
  173. ) {
  174. return identifier.parent.parent.type === 'ExportNamedDeclaration';
  175. }
  176. if (
  177. identifier.parent.type === 'TSTypeAliasDeclaration'
  178. && identifier.parent.id === identifier
  179. ) {
  180. return identifier.parent.parent.type === 'ExportNamedDeclaration';
  181. }
  182. return false;
  183. };
  184. const shouldFix = variable => getVariableIdentifiers(variable)
  185. .every(identifier =>
  186. !isExportedIdentifier(identifier)
  187. // In typescript parser, only `JSXOpeningElement` is added to variable
  188. // `<foo></foo>` -> `<bar></foo>` will cause parse error
  189. && identifier.type !== 'JSXIdentifier',
  190. );
  191. const isDefaultOrNamespaceImportName = identifier => {
  192. if (
  193. identifier.parent.type === 'ImportDefaultSpecifier'
  194. && identifier.parent.local === identifier
  195. ) {
  196. return true;
  197. }
  198. if (
  199. identifier.parent.type === 'ImportNamespaceSpecifier'
  200. && identifier.parent.local === identifier
  201. ) {
  202. return true;
  203. }
  204. if (
  205. identifier.parent.type === 'ImportSpecifier'
  206. && identifier.parent.local === identifier
  207. && identifier.parent.imported.type === 'Identifier'
  208. && identifier.parent.imported.name === 'default'
  209. ) {
  210. return true;
  211. }
  212. if (
  213. identifier.parent.type === 'VariableDeclarator'
  214. && identifier.parent.id === identifier
  215. && isStaticRequire(identifier.parent.init)
  216. ) {
  217. return true;
  218. }
  219. return false;
  220. };
  221. const isClassVariable = variable => {
  222. if (variable.defs.length !== 1) {
  223. return false;
  224. }
  225. const [definition] = variable.defs;
  226. return definition.type === 'ClassName';
  227. };
  228. const shouldReportIdentifierAsProperty = identifier => {
  229. if (
  230. identifier.parent.type === 'MemberExpression'
  231. && identifier.parent.property === identifier
  232. && !identifier.parent.computed
  233. && identifier.parent.parent.type === 'AssignmentExpression'
  234. && identifier.parent.parent.left === identifier.parent
  235. ) {
  236. return true;
  237. }
  238. if (
  239. identifier.parent.type === 'Property'
  240. && identifier.parent.key === identifier
  241. && !identifier.parent.computed
  242. && !identifier.parent.shorthand // Shorthand properties are reported and fixed as variables
  243. && identifier.parent.parent.type === 'ObjectExpression'
  244. ) {
  245. return true;
  246. }
  247. if (
  248. identifier.parent.type === 'ExportSpecifier'
  249. && identifier.parent.exported === identifier
  250. && identifier.parent.local !== identifier // Same as shorthand properties above
  251. ) {
  252. return true;
  253. }
  254. if (
  255. (
  256. identifier.parent.type === 'MethodDefinition'
  257. || identifier.parent.type === 'PropertyDefinition'
  258. )
  259. && identifier.parent.key === identifier
  260. && !identifier.parent.computed
  261. ) {
  262. return true;
  263. }
  264. return false;
  265. };
  266. const isInternalImport = node => {
  267. let source = '';
  268. if (node.type === 'Variable') {
  269. source = node.node.init.arguments[0].value;
  270. } else if (node.type === 'ImportBinding') {
  271. source = node.parent.source.value;
  272. }
  273. return (
  274. !source.includes('node_modules')
  275. && (source.startsWith('.') || source.startsWith('/'))
  276. );
  277. };
  278. /** @param {import('eslint').Rule.RuleContext} context */
  279. const create = context => {
  280. const options = prepareOptions(context.options[0]);
  281. const filenameWithExtension = context.getPhysicalFilename();
  282. // A `class` declaration produces two variables in two scopes:
  283. // the inner class scope, and the outer one (wherever the class is declared).
  284. // This map holds the outer ones to be later processed when the inner one is encountered.
  285. // For why this is not a eslint issue see https://github.com/eslint/eslint-scope/issues/48#issuecomment-464358754
  286. const identifierToOuterClassVariable = new WeakMap();
  287. const checkPossiblyWeirdClassVariable = variable => {
  288. if (isClassVariable(variable)) {
  289. if (variable.scope.type === 'class') { // The inner class variable
  290. const [definition] = variable.defs;
  291. const outerClassVariable = identifierToOuterClassVariable.get(definition.name);
  292. if (!outerClassVariable) {
  293. return checkVariable(variable);
  294. }
  295. // Create a normal-looking variable (like a `var` or a `function`)
  296. // For which a single `variable` holds all references, unlike with a `class`
  297. const combinedReferencesVariable = {
  298. name: variable.name,
  299. scope: variable.scope,
  300. defs: variable.defs,
  301. identifiers: variable.identifiers,
  302. references: [...variable.references, ...outerClassVariable.references],
  303. };
  304. // Call the common checker with the newly forged normalized class variable
  305. return checkVariable(combinedReferencesVariable);
  306. }
  307. // The outer class variable, we save it for later, when it's inner counterpart is encountered
  308. const [definition] = variable.defs;
  309. identifierToOuterClassVariable.set(definition.name, variable);
  310. return;
  311. }
  312. return checkVariable(variable);
  313. };
  314. // Holds a map from a `Scope` to a `Set` of new variable names generated by our fixer.
  315. // Used to avoid generating duplicate names, see for instance `let errCb, errorCb` test.
  316. const scopeToNamesGeneratedByFixer = new WeakMap();
  317. const isSafeName = (name, scopes) => scopes.every(scope => {
  318. const generatedNames = scopeToNamesGeneratedByFixer.get(scope);
  319. return !generatedNames || !generatedNames.has(name);
  320. });
  321. const checkVariable = variable => {
  322. if (variable.defs.length === 0) {
  323. return;
  324. }
  325. const [definition] = variable.defs;
  326. if (isDefaultOrNamespaceImportName(definition.name)) {
  327. if (!options.checkDefaultAndNamespaceImports) {
  328. return;
  329. }
  330. if (
  331. options.checkDefaultAndNamespaceImports === 'internal'
  332. && !isInternalImport(definition)
  333. ) {
  334. return;
  335. }
  336. }
  337. if (isShorthandImportLocal(definition.name)) {
  338. if (!options.checkShorthandImports) {
  339. return;
  340. }
  341. if (
  342. options.checkShorthandImports === 'internal'
  343. && !isInternalImport(definition)
  344. ) {
  345. return;
  346. }
  347. }
  348. if (
  349. !options.checkShorthandProperties
  350. && isShorthandPropertyValue(definition.name)
  351. ) {
  352. return;
  353. }
  354. const variableReplacements = getNameReplacements(variable.name, options);
  355. if (variableReplacements.total === 0) {
  356. return;
  357. }
  358. const scopes = [
  359. ...variable.references.map(reference => reference.from),
  360. variable.scope,
  361. ];
  362. variableReplacements.samples = variableReplacements.samples.map(
  363. name => avoidCapture(name, scopes, isSafeName),
  364. );
  365. const problem = {
  366. ...getMessage(definition.name.name, variableReplacements, 'variable'),
  367. node: definition.name,
  368. };
  369. if (
  370. variableReplacements.total === 1
  371. && shouldFix(variable)
  372. && variableReplacements.samples[0]
  373. && !variable.references.some(reference => reference.vueUsedInTemplate)
  374. ) {
  375. const [replacement] = variableReplacements.samples;
  376. for (const scope of scopes) {
  377. if (!scopeToNamesGeneratedByFixer.has(scope)) {
  378. scopeToNamesGeneratedByFixer.set(scope, new Set());
  379. }
  380. const generatedNames = scopeToNamesGeneratedByFixer.get(scope);
  381. generatedNames.add(replacement);
  382. }
  383. problem.fix = fixer => renameVariable(variable, replacement, fixer);
  384. }
  385. context.report(problem);
  386. };
  387. const checkVariables = scope => {
  388. for (const variable of scope.variables) {
  389. checkPossiblyWeirdClassVariable(variable);
  390. }
  391. };
  392. const checkScope = scope => {
  393. const scopes = getScopes(scope);
  394. for (const scope of scopes) {
  395. checkVariables(scope);
  396. }
  397. };
  398. return {
  399. Identifier(node) {
  400. if (!options.checkProperties) {
  401. return;
  402. }
  403. if (node.name === '__proto__') {
  404. return;
  405. }
  406. const identifierReplacements = getNameReplacements(node.name, options);
  407. if (identifierReplacements.total === 0) {
  408. return;
  409. }
  410. if (!shouldReportIdentifierAsProperty(node)) {
  411. return;
  412. }
  413. const problem = {
  414. ...getMessage(node.name, identifierReplacements, 'property'),
  415. node,
  416. };
  417. context.report(problem);
  418. },
  419. Program(node) {
  420. if (!options.checkFilenames) {
  421. return;
  422. }
  423. if (
  424. filenameWithExtension === '<input>'
  425. || filenameWithExtension === '<text>'
  426. ) {
  427. return;
  428. }
  429. const filename = path.basename(filenameWithExtension);
  430. const extension = path.extname(filename);
  431. const filenameReplacements = getNameReplacements(path.basename(filename, extension), options);
  432. if (filenameReplacements.total === 0) {
  433. return;
  434. }
  435. filenameReplacements.samples = filenameReplacements.samples.map(replacement => `${replacement}${extension}`);
  436. context.report({
  437. ...getMessage(filename, filenameReplacements, 'filename'),
  438. node,
  439. });
  440. },
  441. 'Program:exit'() {
  442. if (!options.checkVariables) {
  443. return;
  444. }
  445. checkScope(context.getScope());
  446. },
  447. };
  448. };
  449. const schema = {
  450. type: 'array',
  451. additionalItems: false,
  452. items: [
  453. {
  454. type: 'object',
  455. additionalProperties: false,
  456. properties: {
  457. checkProperties: {
  458. type: 'boolean',
  459. },
  460. checkVariables: {
  461. type: 'boolean',
  462. },
  463. checkDefaultAndNamespaceImports: {
  464. type: [
  465. 'boolean',
  466. 'string',
  467. ],
  468. pattern: 'internal',
  469. },
  470. checkShorthandImports: {
  471. type: [
  472. 'boolean',
  473. 'string',
  474. ],
  475. pattern: 'internal',
  476. },
  477. checkShorthandProperties: {
  478. type: 'boolean',
  479. },
  480. checkFilenames: {
  481. type: 'boolean',
  482. },
  483. extendDefaultReplacements: {
  484. type: 'boolean',
  485. },
  486. replacements: {
  487. $ref: '#/definitions/abbreviations',
  488. },
  489. extendDefaultAllowList: {
  490. type: 'boolean',
  491. },
  492. allowList: {
  493. $ref: '#/definitions/booleanObject',
  494. },
  495. ignore: {
  496. type: 'array',
  497. uniqueItems: true,
  498. },
  499. },
  500. },
  501. ],
  502. definitions: {
  503. abbreviations: {
  504. type: 'object',
  505. additionalProperties: {
  506. $ref: '#/definitions/replacements',
  507. },
  508. },
  509. replacements: {
  510. anyOf: [
  511. {
  512. enum: [
  513. false,
  514. ],
  515. },
  516. {
  517. $ref: '#/definitions/booleanObject',
  518. },
  519. ],
  520. },
  521. booleanObject: {
  522. type: 'object',
  523. additionalProperties: {
  524. type: 'boolean',
  525. },
  526. },
  527. },
  528. };
  529. /** @type {import('eslint').Rule.RuleModule} */
  530. module.exports = {
  531. create,
  532. meta: {
  533. type: 'suggestion',
  534. docs: {
  535. description: 'Prevent abbreviations.',
  536. },
  537. fixable: 'code',
  538. schema,
  539. messages,
  540. },
  541. };