function-component-definition.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. /**
  2. * @fileoverview Standardize the way function component get defined
  3. * @author Stefan Wullems
  4. */
  5. 'use strict';
  6. const arrayIncludes = require('array-includes');
  7. const Components = require('../util/Components');
  8. const docsUrl = require('../util/docsUrl');
  9. const reportC = require('../util/report');
  10. const getText = require('../util/eslint').getText;
  11. const propsUtil = require('../util/props');
  12. // ------------------------------------------------------------------------------
  13. // Rule Definition
  14. // ------------------------------------------------------------------------------
  15. function buildFunction(template, parts) {
  16. return Object.keys(parts).reduce(
  17. (acc, key) => acc.replace(`{${key}}`, () => parts[key] || ''),
  18. template
  19. );
  20. }
  21. const NAMED_FUNCTION_TEMPLATES = {
  22. 'function-declaration': 'function {name}{typeParams}({params}){returnType} {body}',
  23. 'arrow-function': '{varType} {name}{typeAnnotation} = {typeParams}({params}){returnType} => {body}',
  24. 'function-expression': '{varType} {name}{typeAnnotation} = function{typeParams}({params}){returnType} {body}',
  25. };
  26. const UNNAMED_FUNCTION_TEMPLATES = {
  27. 'function-expression': 'function{typeParams}({params}){returnType} {body}',
  28. 'arrow-function': '{typeParams}({params}){returnType} => {body}',
  29. };
  30. function hasOneUnconstrainedTypeParam(node) {
  31. const nodeTypeArguments = propsUtil.getTypeArguments(node);
  32. return nodeTypeArguments
  33. && nodeTypeArguments.params
  34. && nodeTypeArguments.params.length === 1
  35. && !nodeTypeArguments.params[0].constraint;
  36. }
  37. function hasName(node) {
  38. return (
  39. node.type === 'FunctionDeclaration'
  40. || node.parent.type === 'VariableDeclarator'
  41. );
  42. }
  43. function getNodeText(prop, source) {
  44. if (!prop) return null;
  45. return source.slice(prop.range[0], prop.range[1]);
  46. }
  47. function getName(node) {
  48. if (node.type === 'FunctionDeclaration') {
  49. return node.id.name;
  50. }
  51. if (
  52. node.type === 'ArrowFunctionExpression'
  53. || node.type === 'FunctionExpression'
  54. ) {
  55. return hasName(node) && node.parent.id.name;
  56. }
  57. }
  58. function getParams(node, source) {
  59. if (node.params.length === 0) return null;
  60. return source.slice(
  61. node.params[0].range[0],
  62. node.params[node.params.length - 1].range[1]
  63. );
  64. }
  65. function getBody(node, source) {
  66. const range = node.body.range;
  67. if (node.body.type !== 'BlockStatement') {
  68. return ['{', ` return ${source.slice(range[0], range[1])}`, '}'].join('\n');
  69. }
  70. return source.slice(range[0], range[1]);
  71. }
  72. function getTypeAnnotation(node, source) {
  73. if (!hasName(node) || node.type === 'FunctionDeclaration') return;
  74. if (
  75. node.type === 'ArrowFunctionExpression'
  76. || node.type === 'FunctionExpression'
  77. ) {
  78. return getNodeText(node.parent.id.typeAnnotation, source);
  79. }
  80. }
  81. function isUnfixableBecauseOfExport(node) {
  82. return (
  83. node.type === 'FunctionDeclaration'
  84. && node.parent
  85. && node.parent.type === 'ExportDefaultDeclaration'
  86. );
  87. }
  88. function isFunctionExpressionWithName(node) {
  89. return node.type === 'FunctionExpression' && node.id && node.id.name;
  90. }
  91. const messages = {
  92. 'function-declaration': 'Function component is not a function declaration',
  93. 'function-expression': 'Function component is not a function expression',
  94. 'arrow-function': 'Function component is not an arrow function',
  95. };
  96. /** @type {import('eslint').Rule.RuleModule} */
  97. module.exports = {
  98. meta: {
  99. docs: {
  100. description: 'Enforce a specific function type for function components',
  101. category: 'Stylistic Issues',
  102. recommended: false,
  103. url: docsUrl('function-component-definition'),
  104. },
  105. fixable: 'code',
  106. messages,
  107. schema: [
  108. {
  109. type: 'object',
  110. properties: {
  111. namedComponents: {
  112. anyOf: [
  113. {
  114. enum: [
  115. 'function-declaration',
  116. 'arrow-function',
  117. 'function-expression',
  118. ],
  119. },
  120. {
  121. type: 'array',
  122. items: {
  123. type: 'string',
  124. enum: [
  125. 'function-declaration',
  126. 'arrow-function',
  127. 'function-expression',
  128. ],
  129. },
  130. },
  131. ],
  132. },
  133. unnamedComponents: {
  134. anyOf: [
  135. { enum: ['arrow-function', 'function-expression'] },
  136. {
  137. type: 'array',
  138. items: {
  139. type: 'string',
  140. enum: ['arrow-function', 'function-expression'],
  141. },
  142. },
  143. ],
  144. },
  145. },
  146. },
  147. ],
  148. },
  149. create: Components.detect((context, components) => {
  150. const configuration = context.options[0] || {};
  151. let fileVarType = 'var';
  152. const namedConfig = [].concat(
  153. configuration.namedComponents || 'function-declaration'
  154. );
  155. const unnamedConfig = [].concat(
  156. configuration.unnamedComponents || 'function-expression'
  157. );
  158. function getFixer(node, options) {
  159. const source = getText(context);
  160. const typeAnnotation = getTypeAnnotation(node, source);
  161. if (options.type === 'function-declaration' && typeAnnotation) {
  162. return;
  163. }
  164. if (options.type === 'arrow-function' && hasOneUnconstrainedTypeParam(node)) {
  165. return;
  166. }
  167. if (isUnfixableBecauseOfExport(node)) return;
  168. if (isFunctionExpressionWithName(node)) return;
  169. let varType = fileVarType;
  170. if (
  171. (node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression')
  172. && node.parent.type === 'VariableDeclarator'
  173. ) {
  174. varType = node.parent.parent.kind;
  175. }
  176. const nodeTypeArguments = propsUtil.getTypeArguments(node);
  177. return (fixer) => fixer.replaceTextRange(
  178. options.range,
  179. buildFunction(options.template, {
  180. typeAnnotation,
  181. typeParams: getNodeText(nodeTypeArguments, source),
  182. params: getParams(node, source),
  183. returnType: getNodeText(node.returnType, source),
  184. body: getBody(node, source),
  185. name: getName(node),
  186. varType,
  187. })
  188. );
  189. }
  190. function report(node, options) {
  191. reportC(context, messages[options.messageId], options.messageId, {
  192. node,
  193. fix: getFixer(node, options.fixerOptions),
  194. });
  195. }
  196. function validate(node, functionType) {
  197. if (!components.get(node)) return;
  198. if (node.parent && node.parent.type === 'Property') return;
  199. if (hasName(node) && !arrayIncludes(namedConfig, functionType)) {
  200. report(node, {
  201. messageId: namedConfig[0],
  202. fixerOptions: {
  203. type: namedConfig[0],
  204. template: NAMED_FUNCTION_TEMPLATES[namedConfig[0]],
  205. range:
  206. node.type === 'FunctionDeclaration'
  207. ? node.range
  208. : node.parent.parent.range,
  209. },
  210. });
  211. }
  212. if (!hasName(node) && !arrayIncludes(unnamedConfig, functionType)) {
  213. report(node, {
  214. messageId: unnamedConfig[0],
  215. fixerOptions: {
  216. type: unnamedConfig[0],
  217. template: UNNAMED_FUNCTION_TEMPLATES[unnamedConfig[0]],
  218. range: node.range,
  219. },
  220. });
  221. }
  222. }
  223. // --------------------------------------------------------------------------
  224. // Public
  225. // --------------------------------------------------------------------------
  226. const validatePairs = [];
  227. let hasES6OrJsx = false;
  228. return {
  229. FunctionDeclaration(node) {
  230. validatePairs.push([node, 'function-declaration']);
  231. },
  232. ArrowFunctionExpression(node) {
  233. validatePairs.push([node, 'arrow-function']);
  234. },
  235. FunctionExpression(node) {
  236. validatePairs.push([node, 'function-expression']);
  237. },
  238. VariableDeclaration(node) {
  239. hasES6OrJsx = hasES6OrJsx || node.kind === 'const' || node.kind === 'let';
  240. },
  241. 'Program:exit'() {
  242. if (hasES6OrJsx) fileVarType = 'const';
  243. validatePairs.forEach((pair) => validate(pair[0], pair[1]));
  244. },
  245. 'ImportDeclaration, ExportNamedDeclaration, ExportDefaultDeclaration, ExportAllDeclaration, ExportSpecifier, ExportDefaultSpecifier, JSXElement, TSExportAssignment, TSImportEqualsDeclaration'() {
  246. hasES6OrJsx = true;
  247. },
  248. };
  249. }),
  250. };