define-macros-order.js 11 KB

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