no-static-only-class.js 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. 'use strict';
  2. const {isSemicolonToken} = require('eslint-utils');
  3. const getClassHeadLocation = require('./utils/get-class-head-location.js');
  4. const assertToken = require('./utils/assert-token.js');
  5. const {removeSpacesAfter} = require('./fix/index.js');
  6. const MESSAGE_ID = 'no-static-only-class';
  7. const messages = {
  8. [MESSAGE_ID]: 'Use an object instead of a class with only static members.',
  9. };
  10. const selector = [
  11. ':matches(ClassDeclaration, ClassExpression)',
  12. ':not([superClass], [decorators.length>0])',
  13. '[body.type="ClassBody"]',
  14. '[body.body.length>0]',
  15. ].join('');
  16. const isEqualToken = ({type, value}) => type === 'Punctuator' && value === '=';
  17. const isDeclarationOfExportDefaultDeclaration = node =>
  18. node.type === 'ClassDeclaration'
  19. && node.parent.type === 'ExportDefaultDeclaration'
  20. && node.parent.declaration === node;
  21. const isPropertyDefinition = node => node.type === 'PropertyDefinition';
  22. const isMethodDefinition = node => node.type === 'MethodDefinition';
  23. function isStaticMember(node) {
  24. const {
  25. private: isPrivate,
  26. static: isStatic,
  27. declare: isDeclare,
  28. readonly: isReadonly,
  29. accessibility,
  30. decorators,
  31. key,
  32. } = node;
  33. // Avoid matching unexpected node. For example: https://github.com/tc39/proposal-class-static-block
  34. if (!isPropertyDefinition(node) && !isMethodDefinition(node)) {
  35. return false;
  36. }
  37. if (!isStatic || isPrivate || key.type === 'PrivateIdentifier') {
  38. return false;
  39. }
  40. // TypeScript class
  41. if (
  42. isDeclare
  43. || isReadonly
  44. || typeof accessibility !== 'undefined'
  45. || (Array.isArray(decorators) && decorators.length > 0)
  46. // TODO: Remove this when we drop support for `@typescript-eslint/parser` v4
  47. || key.type === 'TSPrivateIdentifier'
  48. ) {
  49. return false;
  50. }
  51. return true;
  52. }
  53. function * switchClassMemberToObjectProperty(node, sourceCode, fixer) {
  54. const staticToken = sourceCode.getFirstToken(node);
  55. assertToken(staticToken, {
  56. expected: {type: 'Keyword', value: 'static'},
  57. ruleId: 'no-static-only-class',
  58. });
  59. yield fixer.remove(staticToken);
  60. yield removeSpacesAfter(staticToken, sourceCode, fixer);
  61. const maybeSemicolonToken = isPropertyDefinition(node)
  62. ? sourceCode.getLastToken(node)
  63. : sourceCode.getTokenAfter(node);
  64. const hasSemicolonToken = isSemicolonToken(maybeSemicolonToken);
  65. if (isPropertyDefinition(node)) {
  66. const {key, value} = node;
  67. if (value) {
  68. // Computed key may have `]` after `key`
  69. const equalToken = sourceCode.getTokenAfter(key, isEqualToken);
  70. yield fixer.replaceText(equalToken, ':');
  71. } else if (hasSemicolonToken) {
  72. yield fixer.insertTextBefore(maybeSemicolonToken, ': undefined');
  73. } else {
  74. yield fixer.insertTextAfter(node, ': undefined');
  75. }
  76. }
  77. yield (
  78. hasSemicolonToken
  79. ? fixer.replaceText(maybeSemicolonToken, ',')
  80. : fixer.insertTextAfter(node, ',')
  81. );
  82. }
  83. function switchClassToObject(node, sourceCode) {
  84. const {
  85. type,
  86. id,
  87. body,
  88. declare: isDeclare,
  89. abstract: isAbstract,
  90. implements: classImplements,
  91. parent,
  92. } = node;
  93. if (
  94. isDeclare
  95. || isAbstract
  96. || (Array.isArray(classImplements) && classImplements.length > 0)
  97. ) {
  98. return;
  99. }
  100. if (type === 'ClassExpression' && id) {
  101. return;
  102. }
  103. const isExportDefault = isDeclarationOfExportDefaultDeclaration(node);
  104. if (isExportDefault && id) {
  105. return;
  106. }
  107. for (const node of body.body) {
  108. if (
  109. isPropertyDefinition(node)
  110. && (
  111. node.typeAnnotation
  112. // This is a stupid way to check if `value` of `PropertyDefinition` uses `this`
  113. || (node.value && sourceCode.getText(node.value).includes('this'))
  114. )
  115. ) {
  116. return;
  117. }
  118. }
  119. return function * (fixer) {
  120. const classToken = sourceCode.getFirstToken(node);
  121. /* istanbul ignore next */
  122. assertToken(classToken, {
  123. expected: {type: 'Keyword', value: 'class'},
  124. ruleId: 'no-static-only-class',
  125. });
  126. if (isExportDefault || type === 'ClassExpression') {
  127. /*
  128. There are comments after return, and `{` is not on same line
  129. ```js
  130. function a() {
  131. return class // comment
  132. {
  133. static a() {}
  134. }
  135. }
  136. ```
  137. */
  138. if (
  139. type === 'ClassExpression'
  140. && parent.type === 'ReturnStatement'
  141. && body.loc.start.line !== parent.loc.start.line
  142. && sourceCode.text.slice(classToken.range[1], body.range[0]).trim()
  143. ) {
  144. yield fixer.replaceText(classToken, '{');
  145. const openingBraceToken = sourceCode.getFirstToken(body);
  146. yield fixer.remove(openingBraceToken);
  147. } else {
  148. yield fixer.replaceText(classToken, '');
  149. /*
  150. Avoid breaking case like
  151. ```js
  152. return class
  153. {};
  154. ```
  155. */
  156. yield removeSpacesAfter(classToken, sourceCode, fixer);
  157. }
  158. // There should not be ASI problem
  159. } else {
  160. yield fixer.replaceText(classToken, 'const');
  161. yield fixer.insertTextBefore(body, '= ');
  162. yield fixer.insertTextAfter(body, ';');
  163. }
  164. for (const node of body.body) {
  165. yield * switchClassMemberToObjectProperty(node, sourceCode, fixer);
  166. }
  167. };
  168. }
  169. function create(context) {
  170. const sourceCode = context.getSourceCode();
  171. return {
  172. [selector](node) {
  173. if (node.body.body.some(node => !isStaticMember(node))) {
  174. return;
  175. }
  176. return {
  177. node,
  178. loc: getClassHeadLocation(node, sourceCode),
  179. messageId: MESSAGE_ID,
  180. fix: switchClassToObject(node, sourceCode),
  181. };
  182. },
  183. };
  184. }
  185. /** @type {import('eslint').Rule.RuleModule} */
  186. module.exports = {
  187. create,
  188. meta: {
  189. type: 'suggestion',
  190. docs: {
  191. description: 'Forbid classes that only have static members.',
  192. },
  193. fixable: 'code',
  194. messages,
  195. },
  196. };