require-expose.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. /**
  2. * @fileoverview Require `expose` in Vue components
  3. * @author Yosuke Ota <https://github.com/ota-meshi>
  4. */
  5. 'use strict'
  6. const {
  7. findVariable,
  8. isOpeningBraceToken,
  9. isClosingBraceToken
  10. } = require('@eslint-community/eslint-utils')
  11. const utils = require('../utils')
  12. const { getVueComponentDefinitionType } = require('../utils')
  13. const FIX_EXPOSE_BEFORE_OPTIONS = new Set([
  14. 'name',
  15. 'components',
  16. 'directives',
  17. 'extends',
  18. 'mixins',
  19. 'provide',
  20. 'inject',
  21. 'inheritAttrs',
  22. 'props',
  23. 'emits'
  24. ])
  25. /**
  26. * @param {Property | SpreadElement} node
  27. * @returns {node is ObjectExpressionProperty}
  28. */
  29. function isExposeProperty(node) {
  30. return (
  31. node.type === 'Property' &&
  32. utils.getStaticPropertyName(node) === 'expose' &&
  33. !node.computed
  34. )
  35. }
  36. /**
  37. * Get the callee member node from the given CallExpression
  38. * @param {CallExpression} node CallExpression
  39. */
  40. function getCalleeMemberNode(node) {
  41. const callee = utils.skipChainExpression(node.callee)
  42. if (callee.type === 'MemberExpression') {
  43. const name = utils.getStaticPropertyName(callee)
  44. if (name) {
  45. return { name, member: callee }
  46. }
  47. }
  48. return null
  49. }
  50. module.exports = {
  51. meta: {
  52. type: 'suggestion',
  53. docs: {
  54. description: 'require declare public properties using `expose`',
  55. categories: undefined,
  56. url: 'https://eslint.vuejs.org/rules/require-expose.html'
  57. },
  58. fixable: null,
  59. hasSuggestions: true,
  60. schema: [],
  61. messages: {
  62. requireExpose:
  63. 'The public properties of the component must be explicitly declared using `expose`. If the component does not have public properties, declare it empty.',
  64. addExposeOptionForEmpty:
  65. 'Add the `expose` option to give an empty array.',
  66. addExposeOptionForAll:
  67. 'Add the `expose` option to declare all properties.'
  68. }
  69. },
  70. /** @param {RuleContext} context */
  71. create(context) {
  72. if (utils.isScriptSetup(context)) {
  73. return {}
  74. }
  75. /**
  76. * @typedef {object} SetupContext
  77. * @property {Set<Identifier>} exposeReferenceIds
  78. * @property {Set<Identifier>} contextReferenceIds
  79. */
  80. /** @type {Map<ObjectExpression, SetupContext>} */
  81. const setupContexts = new Map()
  82. /** @type {Set<ObjectExpression>} */
  83. const calledExpose = new Set()
  84. /**
  85. * @typedef {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} FunctionNode
  86. */
  87. /**
  88. * @typedef {object} ScopeStack
  89. * @property {ScopeStack | null} upper
  90. * @property {FunctionNode} functionNode
  91. * @property {boolean} returnFunction
  92. */
  93. /**
  94. * @type {ScopeStack | null}
  95. */
  96. let scopeStack = null
  97. /** @type {Map<FunctionNode, ObjectExpression>} */
  98. const setupFunctions = new Map()
  99. /** @type {Set<ObjectExpression>} */
  100. const setupRender = new Set()
  101. /**
  102. * @param {Expression} node
  103. * @returns {boolean}
  104. */
  105. function isFunction(node) {
  106. if (
  107. node.type === 'ArrowFunctionExpression' ||
  108. node.type === 'FunctionExpression'
  109. ) {
  110. return true
  111. }
  112. if (node.type === 'Identifier') {
  113. const variable = findVariable(context.getScope(), node)
  114. if (variable) {
  115. for (const def of variable.defs) {
  116. if (def.type === 'FunctionName') {
  117. return true
  118. }
  119. if (def.type === 'Variable' && def.node.init) {
  120. return isFunction(def.node.init)
  121. }
  122. }
  123. }
  124. }
  125. return false
  126. }
  127. return utils.defineVueVisitor(context, {
  128. onSetupFunctionEnter(node, { node: vueNode }) {
  129. setupFunctions.set(node, vueNode)
  130. const contextParam = node.params[1]
  131. if (!contextParam) {
  132. // no arguments
  133. return
  134. }
  135. if (contextParam.type === 'RestElement') {
  136. // cannot check
  137. return
  138. }
  139. if (contextParam.type === 'ArrayPattern') {
  140. // cannot check
  141. return
  142. }
  143. /** @type {Set<Identifier>} */
  144. const contextReferenceIds = new Set()
  145. /** @type {Set<Identifier>} */
  146. const exposeReferenceIds = new Set()
  147. if (contextParam.type === 'ObjectPattern') {
  148. const exposeProperty = utils.findAssignmentProperty(
  149. contextParam,
  150. 'expose'
  151. )
  152. if (!exposeProperty) {
  153. return
  154. }
  155. const exposeParam = exposeProperty.value
  156. // `setup(props, {emit})`
  157. const variable =
  158. exposeParam.type === 'Identifier'
  159. ? findVariable(context.getScope(), exposeParam)
  160. : null
  161. if (!variable) {
  162. return
  163. }
  164. for (const reference of variable.references) {
  165. if (!reference.isRead()) {
  166. continue
  167. }
  168. exposeReferenceIds.add(reference.identifier)
  169. }
  170. } else if (contextParam.type === 'Identifier') {
  171. // `setup(props, context)`
  172. const variable = findVariable(context.getScope(), contextParam)
  173. if (!variable) {
  174. return
  175. }
  176. for (const reference of variable.references) {
  177. if (!reference.isRead()) {
  178. continue
  179. }
  180. contextReferenceIds.add(reference.identifier)
  181. }
  182. }
  183. setupContexts.set(vueNode, {
  184. contextReferenceIds,
  185. exposeReferenceIds
  186. })
  187. },
  188. CallExpression(node, { node: vueNode }) {
  189. if (calledExpose.has(vueNode)) {
  190. // already called
  191. return
  192. }
  193. // find setup context
  194. const setupContext = setupContexts.get(vueNode)
  195. if (setupContext) {
  196. const { contextReferenceIds, exposeReferenceIds } = setupContext
  197. if (
  198. node.callee.type === 'Identifier' &&
  199. exposeReferenceIds.has(node.callee)
  200. ) {
  201. // setup(props,{expose}) {expose()}
  202. calledExpose.add(vueNode)
  203. } else {
  204. const expose = getCalleeMemberNode(node)
  205. if (
  206. expose &&
  207. expose.name === 'expose' &&
  208. expose.member.object.type === 'Identifier' &&
  209. contextReferenceIds.has(expose.member.object)
  210. ) {
  211. // setup(props,context) {context.emit()}
  212. calledExpose.add(vueNode)
  213. }
  214. }
  215. }
  216. },
  217. /** @param {FunctionNode} node */
  218. ':function'(node) {
  219. scopeStack = {
  220. upper: scopeStack,
  221. functionNode: node,
  222. returnFunction: false
  223. }
  224. if (
  225. node.type === 'ArrowFunctionExpression' &&
  226. node.expression &&
  227. isFunction(node.body)
  228. ) {
  229. scopeStack.returnFunction = true
  230. }
  231. },
  232. ReturnStatement(node) {
  233. if (!scopeStack) {
  234. return
  235. }
  236. if (
  237. !scopeStack.returnFunction &&
  238. node.argument &&
  239. isFunction(node.argument)
  240. ) {
  241. scopeStack.returnFunction = true
  242. }
  243. },
  244. ':function:exit'(node) {
  245. if (scopeStack && scopeStack.returnFunction) {
  246. const vueNode = setupFunctions.get(node)
  247. if (vueNode) {
  248. setupRender.add(vueNode)
  249. }
  250. }
  251. scopeStack = scopeStack && scopeStack.upper
  252. },
  253. onVueObjectExit(component, { type }) {
  254. if (calledExpose.has(component)) {
  255. // `expose` function is called
  256. return
  257. }
  258. if (setupRender.has(component)) {
  259. // `setup` function is render function
  260. return
  261. }
  262. if (type === 'definition') {
  263. const defType = getVueComponentDefinitionType(component)
  264. if (defType === 'mixin') {
  265. return
  266. }
  267. }
  268. if (component.properties.some(isExposeProperty)) {
  269. // has `expose`
  270. return
  271. }
  272. context.report({
  273. node: component,
  274. messageId: 'requireExpose',
  275. suggest: buildSuggest(component, context)
  276. })
  277. }
  278. })
  279. }
  280. }
  281. /**
  282. * @param {ObjectExpression} object
  283. * @param {RuleContext} context
  284. * @returns {Rule.SuggestionReportDescriptor[]}
  285. */
  286. function buildSuggest(object, context) {
  287. const propertyNodes = object.properties.filter(utils.isProperty)
  288. const sourceCode = context.getSourceCode()
  289. const beforeOptionNode = propertyNodes.find((p) =>
  290. FIX_EXPOSE_BEFORE_OPTIONS.has(utils.getStaticPropertyName(p) || '')
  291. )
  292. const allProps = [
  293. ...new Set(
  294. utils.iterateProperties(
  295. object,
  296. new Set(['props', 'data', 'computed', 'setup', 'methods', 'watch'])
  297. )
  298. )
  299. ]
  300. return [
  301. {
  302. messageId: 'addExposeOptionForEmpty',
  303. fix: buildFix('expose: []')
  304. },
  305. ...(allProps.length > 0
  306. ? [
  307. {
  308. messageId: 'addExposeOptionForAll',
  309. fix: buildFix(
  310. `expose: [${allProps
  311. .map((p) => JSON.stringify(p.name))
  312. .join(', ')}]`
  313. )
  314. }
  315. ]
  316. : [])
  317. ]
  318. /**
  319. * @param {string} text
  320. */
  321. function buildFix(text) {
  322. /**
  323. * @param {RuleFixer} fixer
  324. */
  325. return (fixer) => {
  326. if (beforeOptionNode) {
  327. return fixer.insertTextAfter(beforeOptionNode, `,\n${text}`)
  328. } else if (object.properties.length > 0) {
  329. const after = propertyNodes[0] || object.properties[0]
  330. return fixer.insertTextAfter(
  331. sourceCode.getTokenBefore(after),
  332. `\n${text},`
  333. )
  334. } else {
  335. const objectLeftBrace = /** @type {Token} */ (
  336. sourceCode.getFirstToken(object, isOpeningBraceToken)
  337. )
  338. const objectRightBrace = /** @type {Token} */ (
  339. sourceCode.getLastToken(object, isClosingBraceToken)
  340. )
  341. return fixer.insertTextAfter(
  342. objectLeftBrace,
  343. `\n${text}${
  344. objectLeftBrace.loc.end.line < objectRightBrace.loc.start.line
  345. ? ''
  346. : '\n'
  347. }`
  348. )
  349. }
  350. }
  351. }
  352. }