define-macros-order.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. /**
  2. * @author Eduard Deisling
  3. * See LICENSE file in root directory for full license.
  4. */
  5. 'use strict'
  6. const utils = require('../utils')
  7. const MACROS_EMITS = 'defineEmits'
  8. const MACROS_PROPS = 'defineProps'
  9. const MACROS_OPTIONS = 'defineOptions'
  10. const MACROS_SLOTS = 'defineSlots'
  11. const MACROS_MODEL = 'defineModel'
  12. const MACROS_EXPOSE = 'defineExpose'
  13. const KNOWN_MACROS = new Set([
  14. MACROS_EMITS,
  15. MACROS_PROPS,
  16. MACROS_OPTIONS,
  17. MACROS_SLOTS,
  18. MACROS_MODEL,
  19. MACROS_EXPOSE
  20. ])
  21. const DEFAULT_ORDER = [MACROS_PROPS, MACROS_EMITS]
  22. /**
  23. * @param {VElement} scriptSetup
  24. * @param {ASTNode} node
  25. */
  26. function inScriptSetup(scriptSetup, node) {
  27. return (
  28. scriptSetup.range[0] <= node.range[0] &&
  29. node.range[1] <= scriptSetup.range[1]
  30. )
  31. }
  32. /**
  33. * @param {ASTNode} node
  34. */
  35. function isUseStrictStatement(node) {
  36. return (
  37. node.type === 'ExpressionStatement' &&
  38. node.expression.type === 'Literal' &&
  39. node.expression.value === 'use strict'
  40. )
  41. }
  42. /**
  43. * Get an index of the first statement after imports and interfaces in order
  44. * to place defineEmits and defineProps before this statement
  45. * @param {VElement} scriptSetup
  46. * @param {Program} program
  47. */
  48. function getTargetStatementPosition(scriptSetup, program) {
  49. const skipStatements = new Set([
  50. 'ImportDeclaration',
  51. 'TSInterfaceDeclaration',
  52. 'TSTypeAliasDeclaration',
  53. 'DebuggerStatement',
  54. 'EmptyStatement',
  55. 'ExportNamedDeclaration'
  56. ])
  57. for (const [index, item] of program.body.entries()) {
  58. if (
  59. inScriptSetup(scriptSetup, item) &&
  60. !skipStatements.has(item.type) &&
  61. !isUseStrictStatement(item)
  62. ) {
  63. return index
  64. }
  65. }
  66. return -1
  67. }
  68. /**
  69. * We need to handle cases like "const props = defineProps(...)"
  70. * Define macros must be used only on top, so we can look for "Program" type
  71. * inside node.parent.type
  72. * @param {CallExpression|ASTNode} node
  73. * @return {ASTNode}
  74. */
  75. function getDefineMacrosStatement(node) {
  76. if (!node.parent) {
  77. throw new Error('Node has no parent')
  78. }
  79. if (node.parent.type === 'Program') {
  80. return node
  81. }
  82. return getDefineMacrosStatement(node.parent)
  83. }
  84. /** @param {RuleContext} context */
  85. function create(context) {
  86. const scriptSetup = utils.getScriptSetupElement(context)
  87. if (!scriptSetup) {
  88. return {}
  89. }
  90. const sourceCode = context.getSourceCode()
  91. const options = context.options
  92. /** @type {[string, string]} */
  93. const order = (options[0] && options[0].order) || DEFAULT_ORDER
  94. /** @type {boolean} */
  95. const defineExposeLast = (options[0] && options[0].defineExposeLast) || false
  96. /** @type {Map<string, ASTNode[]>} */
  97. const macrosNodes = new Map()
  98. /** @type {ASTNode} */
  99. let defineExposeNode
  100. if (order.includes(MACROS_EXPOSE) && defineExposeLast) {
  101. throw new Error(
  102. "`defineExpose` macro can't be in the `order` array if `defineExposeLast` is true."
  103. )
  104. }
  105. return utils.compositingVisitors(
  106. utils.defineScriptSetupVisitor(context, {
  107. onDefinePropsExit(node) {
  108. macrosNodes.set(MACROS_PROPS, [getDefineMacrosStatement(node)])
  109. },
  110. onDefineEmitsExit(node) {
  111. macrosNodes.set(MACROS_EMITS, [getDefineMacrosStatement(node)])
  112. },
  113. onDefineOptionsExit(node) {
  114. macrosNodes.set(MACROS_OPTIONS, [getDefineMacrosStatement(node)])
  115. },
  116. onDefineSlotsExit(node) {
  117. macrosNodes.set(MACROS_SLOTS, [getDefineMacrosStatement(node)])
  118. },
  119. onDefineModelExit(node) {
  120. const currentModelMacros = macrosNodes.get(MACROS_MODEL) ?? []
  121. currentModelMacros.push(getDefineMacrosStatement(node))
  122. macrosNodes.set(MACROS_MODEL, currentModelMacros)
  123. },
  124. onDefineExposeExit(node) {
  125. defineExposeNode = getDefineMacrosStatement(node)
  126. },
  127. /** @param {CallExpression} node */
  128. 'Program > ExpressionStatement > CallExpression, Program > VariableDeclaration > VariableDeclarator > CallExpression'(
  129. node
  130. ) {
  131. if (
  132. node.callee &&
  133. node.callee.type === 'Identifier' &&
  134. order.includes(node.callee.name) &&
  135. !KNOWN_MACROS.has(node.callee.name)
  136. ) {
  137. macrosNodes.set(node.callee.name, [getDefineMacrosStatement(node)])
  138. }
  139. }
  140. }),
  141. {
  142. 'Program:exit'(program) {
  143. /**
  144. * @typedef {object} OrderedData
  145. * @property {string} name
  146. * @property {ASTNode} node
  147. */
  148. const firstStatementIndex = getTargetStatementPosition(
  149. scriptSetup,
  150. program
  151. )
  152. const orderedList = order
  153. .flatMap((name) => {
  154. const nodes = macrosNodes.get(name) ?? []
  155. return nodes.map((node) => ({ name, node }))
  156. })
  157. .filter(
  158. /** @returns {data is OrderedData} */
  159. (data) => utils.isDef(data.node)
  160. )
  161. // check last node
  162. if (defineExposeLast) {
  163. const lastNode = program.body[program.body.length - 1]
  164. if (defineExposeNode && lastNode !== defineExposeNode) {
  165. reportExposeNotOnBottom(defineExposeNode, lastNode)
  166. }
  167. }
  168. for (const [index, should] of orderedList.entries()) {
  169. const targetStatement = program.body[firstStatementIndex + index]
  170. if (should.node !== targetStatement) {
  171. let moveTargetNodes = orderedList
  172. .slice(index)
  173. .map(({ node }) => node)
  174. const targetStatementIndex =
  175. moveTargetNodes.indexOf(targetStatement)
  176. if (targetStatementIndex !== -1) {
  177. moveTargetNodes = moveTargetNodes.slice(0, targetStatementIndex)
  178. }
  179. reportNotOnTop(should.name, moveTargetNodes, targetStatement)
  180. return
  181. }
  182. }
  183. }
  184. }
  185. )
  186. /**
  187. * @param {string} macro
  188. * @param {ASTNode[]} nodes
  189. * @param {ASTNode} before
  190. */
  191. function reportNotOnTop(macro, nodes, before) {
  192. context.report({
  193. node: nodes[0],
  194. loc: nodes[0].loc,
  195. messageId: 'macrosNotOnTop',
  196. data: {
  197. macro
  198. },
  199. *fix(fixer) {
  200. for (const node of nodes) {
  201. yield* moveNodeBefore(fixer, node, before)
  202. }
  203. }
  204. })
  205. }
  206. /**
  207. * @param {ASTNode} node
  208. * @param {ASTNode} lastNode
  209. */
  210. function reportExposeNotOnBottom(node, lastNode) {
  211. context.report({
  212. node,
  213. loc: node.loc,
  214. messageId: 'defineExposeNotTheLast',
  215. suggest: [
  216. {
  217. messageId: 'putExposeAtTheLast',
  218. fix(fixer) {
  219. return moveNodeToLast(fixer, node, lastNode)
  220. }
  221. }
  222. ]
  223. })
  224. }
  225. /**
  226. * Move all lines of "node" with its comments to after the "target"
  227. * @param {RuleFixer} fixer
  228. * @param {ASTNode} node
  229. * @param {ASTNode} target
  230. */
  231. function moveNodeToLast(fixer, node, target) {
  232. // get comments under tokens(if any)
  233. const beforeNodeToken = sourceCode.getTokenBefore(node)
  234. const nodeComment = sourceCode.getTokenAfter(beforeNodeToken, {
  235. includeComments: true
  236. })
  237. const nextNodeComment = sourceCode.getTokenAfter(node, {
  238. includeComments: true
  239. })
  240. // remove position: node (and comments) to next node (and comments)
  241. const cutStart = getLineStartIndex(nodeComment, beforeNodeToken)
  242. const cutEnd = getLineStartIndex(nextNodeComment, node)
  243. // insert text: comment + node
  244. const textNode = sourceCode.getText(
  245. node,
  246. node.range[0] - beforeNodeToken.range[1]
  247. )
  248. return [
  249. fixer.insertTextAfter(target, textNode),
  250. fixer.removeRange([cutStart, cutEnd])
  251. ]
  252. }
  253. /**
  254. * Move all lines of "node" with its comments to before the "target"
  255. * @param {RuleFixer} fixer
  256. * @param {ASTNode} node
  257. * @param {ASTNode} target
  258. */
  259. function moveNodeBefore(fixer, node, target) {
  260. // get comments under tokens(if any)
  261. const beforeNodeToken = sourceCode.getTokenBefore(node)
  262. const nodeComment = sourceCode.getTokenAfter(beforeNodeToken, {
  263. includeComments: true
  264. })
  265. const nextNodeComment = sourceCode.getTokenAfter(node, {
  266. includeComments: true
  267. })
  268. // get positions of what we need to remove
  269. const cutStart = getLineStartIndex(nodeComment, beforeNodeToken)
  270. const cutEnd = getLineStartIndex(nextNodeComment, node)
  271. // get space before target
  272. const beforeTargetToken = sourceCode.getTokenBefore(target)
  273. const targetComment = sourceCode.getTokenAfter(beforeTargetToken, {
  274. includeComments: true
  275. })
  276. // make insert text: comments + node + space before target
  277. const textNode = sourceCode.getText(
  278. node,
  279. node.range[0] - nodeComment.range[0]
  280. )
  281. const insertText = getInsertText(textNode, target)
  282. return [
  283. fixer.insertTextBefore(targetComment, insertText),
  284. fixer.removeRange([cutStart, cutEnd])
  285. ]
  286. }
  287. /**
  288. * Get result text to insert
  289. * @param {string} textNode
  290. * @param {ASTNode} target
  291. */
  292. function getInsertText(textNode, target) {
  293. const afterTargetComment = sourceCode.getTokenAfter(target, {
  294. includeComments: true
  295. })
  296. const afterText = sourceCode.text.slice(
  297. target.range[1],
  298. afterTargetComment.range[0]
  299. )
  300. // handle case when a();b() -> b()a();
  301. const invalidResult = !textNode.endsWith(';') && !afterText.includes('\n')
  302. return textNode + afterText + (invalidResult ? ';' : '')
  303. }
  304. /**
  305. * Get position of the beginning of the token's line(or prevToken end if no line)
  306. * @param {ASTNode|Token} token
  307. * @param {ASTNode|Token} prevToken
  308. */
  309. function getLineStartIndex(token, prevToken) {
  310. // if we have next token on the same line - get index right before that token
  311. if (token.loc.start.line === prevToken.loc.end.line) {
  312. return prevToken.range[1]
  313. }
  314. return sourceCode.getIndexFromLoc({
  315. line: token.loc.start.line,
  316. column: 0
  317. })
  318. }
  319. }
  320. module.exports = {
  321. meta: {
  322. type: 'layout',
  323. docs: {
  324. description:
  325. 'enforce order of compiler macros (`defineProps`, `defineEmits`, etc.)',
  326. categories: undefined,
  327. url: 'https://eslint.vuejs.org/rules/define-macros-order.html'
  328. },
  329. fixable: 'code',
  330. hasSuggestions: true,
  331. schema: [
  332. {
  333. type: 'object',
  334. properties: {
  335. order: {
  336. type: 'array',
  337. items: {
  338. type: 'string',
  339. minLength: 1
  340. },
  341. uniqueItems: true,
  342. additionalItems: false
  343. },
  344. defineExposeLast: {
  345. type: 'boolean'
  346. }
  347. },
  348. additionalProperties: false
  349. }
  350. ],
  351. messages: {
  352. macrosNotOnTop:
  353. '{{macro}} should be the first statement in `<script setup>` (after any potential import statements or type definitions).',
  354. defineExposeNotTheLast:
  355. '`defineExpose` should be the last statement in `<script setup>`.',
  356. putExposeAtTheLast:
  357. 'Put `defineExpose` as the last statement in `<script setup>`.'
  358. }
  359. },
  360. create
  361. }