diff --git a/docs/rules/README.md b/docs/rules/README.md index 3d2d113d7..26ce0e59d 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -312,6 +312,7 @@ For example: | [vue/component-name-in-template-casing](./component-name-in-template-casing.md) | enforce specific casing for the component naming style in template | :wrench: | | [vue/component-options-name-casing](./component-options-name-casing.md) | enforce the casing of component name in `components` options | :wrench::bulb: | | [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce specific casing for custom event name | | +| [vue/define-macros-order](./define-macros-order.md) | enforce order of `defineEmits` and `defineProps` compiler macros | :wrench: | | [vue/html-button-has-type](./html-button-has-type.md) | disallow usage of button without an explicit type attribute | | | [vue/html-comment-content-newline](./html-comment-content-newline.md) | enforce unified line brake in HTML comments | :wrench: | | [vue/html-comment-content-spacing](./html-comment-content-spacing.md) | enforce unified spacing in HTML comments | :wrench: | diff --git a/docs/rules/define-macros-order.md b/docs/rules/define-macros-order.md new file mode 100644 index 000000000..d928e003a --- /dev/null +++ b/docs/rules/define-macros-order.md @@ -0,0 +1,72 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/define-macros-order +description: enforce order of `defineEmits` and `defineProps` compiler macros +--- +# vue/define-macros-order + +> enforce order of `defineEmits` and `defineProps` compiler macros + +- :exclamation: ***This rule has not been released yet.*** +- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule. + +## :book: Rule Details + +This rule reports the `defineProps` and `defineEmits` compiler macros when they are not the first statements in ` +``` + + + + + +```vue + + +``` + + + + + +```vue + + +``` + + + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/define-macros-order.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/define-macros-order.js) diff --git a/lib/index.js b/lib/index.js index d412a6bf1..39f91b515 100644 --- a/lib/index.js +++ b/lib/index.js @@ -27,6 +27,7 @@ module.exports = { 'component-options-name-casing': require('./rules/component-options-name-casing'), 'component-tags-order': require('./rules/component-tags-order'), 'custom-event-name-casing': require('./rules/custom-event-name-casing'), + 'define-macros-order': require('./rules/define-macros-order'), 'dot-location': require('./rules/dot-location'), 'dot-notation': require('./rules/dot-notation'), eqeqeq: require('./rules/eqeqeq'), diff --git a/lib/rules/define-macros-order.js b/lib/rules/define-macros-order.js new file mode 100644 index 000000000..9c8ea45e6 --- /dev/null +++ b/lib/rules/define-macros-order.js @@ -0,0 +1,288 @@ +/** + * @author Eduard Deisling + * See LICENSE file in root directory for full license. + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const utils = require('../utils') + +// ------------------------------------------------------------------------------ +// Helpers +// ------------------------------------------------------------------------------ + +const MACROS_EMITS = 'defineEmits' +const MACROS_PROPS = 'defineProps' +const ORDER = [MACROS_EMITS, MACROS_PROPS] +const DEFAULT_ORDER = [MACROS_PROPS, MACROS_EMITS] + +/** + * @param {ASTNode} node + */ +function isUseStrictStatement(node) { + return ( + node.type === 'ExpressionStatement' && + node.expression.type === 'Literal' && + node.expression.value === 'use strict' + ) +} + +/** + * Get an index of the first statement after imports and interfaces in order + * to place defineEmits and defineProps before this statement + * @param {Program} program + */ +function getTargetStatementPosition(program) { + const skipStatements = new Set([ + 'ImportDeclaration', + 'TSInterfaceDeclaration', + 'TSTypeAliasDeclaration', + 'DebuggerStatement', + 'EmptyStatement' + ]) + + for (const [index, item] of program.body.entries()) { + if (!skipStatements.has(item.type) && !isUseStrictStatement(item)) { + return index + } + } + + return -1 +} + +/** + * We need to handle cases like "const props = defineProps(...)" + * Define macros must be used only on top, so we can look for "Program" type + * inside node.parent.type + * @param {CallExpression|ASTNode} node + * @return {ASTNode} + */ +function getDefineMacrosStatement(node) { + if (!node.parent) { + throw new Error('Node has no parent') + } + + if (node.parent.type === 'Program') { + return node + } + + return getDefineMacrosStatement(node.parent) +} + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +/** @param {RuleContext} context */ +function create(context) { + const scriptSetup = utils.getScriptSetupElement(context) + + if (!scriptSetup) { + return {} + } + + const sourceCode = context.getSourceCode() + const options = context.options + /** @type {[string, string]} */ + const order = (options[0] && options[0].order) || DEFAULT_ORDER + /** @type {Map} */ + const macrosNodes = new Map() + + return utils.compositingVisitors( + utils.defineScriptSetupVisitor(context, { + onDefinePropsExit(node) { + macrosNodes.set(MACROS_PROPS, getDefineMacrosStatement(node)) + }, + onDefineEmitsExit(node) { + macrosNodes.set(MACROS_EMITS, getDefineMacrosStatement(node)) + } + }), + { + 'Program:exit'(program) { + const shouldFirstNode = macrosNodes.get(order[0]) + const shouldSecondNode = macrosNodes.get(order[1]) + const firstStatementIndex = getTargetStatementPosition(program) + const firstStatement = program.body[firstStatementIndex] + + // have both defineEmits and defineProps + if (shouldFirstNode && shouldSecondNode) { + const secondStatement = program.body[firstStatementIndex + 1] + + // need move only first + if (firstStatement === shouldSecondNode) { + reportNotOnTop(order[0], shouldFirstNode, firstStatement) + return + } + + // need move both defineEmits and defineProps + if (firstStatement !== shouldFirstNode) { + reportBothNotOnTop( + shouldFirstNode, + shouldSecondNode, + firstStatement + ) + return + } + + // need move only second + if (secondStatement !== shouldSecondNode) { + reportNotOnTop(order[1], shouldSecondNode, shouldFirstNode) + } + + return + } + + // have only first and need to move it + if (shouldFirstNode && firstStatement !== shouldFirstNode) { + reportNotOnTop(order[0], shouldFirstNode, firstStatement) + return + } + + // have only second and need to move it + if (shouldSecondNode && firstStatement !== shouldSecondNode) { + reportNotOnTop(order[1], shouldSecondNode, firstStatement) + } + } + } + ) + + /** + * @param {ASTNode} shouldFirstNode + * @param {ASTNode} shouldSecondNode + * @param {ASTNode} before + */ + function reportBothNotOnTop(shouldFirstNode, shouldSecondNode, before) { + context.report({ + node: shouldFirstNode, + loc: shouldFirstNode.loc, + messageId: 'macrosNotOnTop', + data: { + macro: order[0] + }, + fix(fixer) { + return [ + ...moveNodeBefore(fixer, shouldFirstNode, before), + ...moveNodeBefore(fixer, shouldSecondNode, before) + ] + } + }) + } + + /** + * @param {string} macro + * @param {ASTNode} node + * @param {ASTNode} before + */ + function reportNotOnTop(macro, node, before) { + context.report({ + node, + loc: node.loc, + messageId: 'macrosNotOnTop', + data: { + macro + }, + fix(fixer) { + return moveNodeBefore(fixer, node, before) + } + }) + } + + /** + * Move all lines of "node" with its comments to before the "target" + * @param {RuleFixer} fixer + * @param {ASTNode} node + * @param {ASTNode} target + */ + function moveNodeBefore(fixer, node, target) { + // get comments under tokens(if any) + const beforeNodeToken = sourceCode.getTokenBefore(node) + const nodeComment = sourceCode.getTokenAfter(beforeNodeToken, { + includeComments: true + }) + const nextNodeComment = sourceCode.getTokenAfter(node, { + includeComments: true + }) + // get positions of what we need to remove + const cutStart = getLineStartIndex(nodeComment, beforeNodeToken) + const cutEnd = getLineStartIndex(nextNodeComment, node) + // get space before target + const beforeTargetToken = sourceCode.getTokenBefore(target) + const targetComment = sourceCode.getTokenAfter(beforeTargetToken, { + includeComments: true + }) + const textSpace = getTextBetweenTokens(beforeTargetToken, targetComment) + // make insert text: comments + node + space before target + const textNode = sourceCode.getText( + node, + node.range[0] - nodeComment.range[0] + ) + const insertText = textNode + textSpace + + return [ + fixer.insertTextBefore(targetComment, insertText), + fixer.removeRange([cutStart, cutEnd]) + ] + } + + /** + * @param {ASTNode} tokenBefore + * @param {ASTNode} tokenAfter + */ + function getTextBetweenTokens(tokenBefore, tokenAfter) { + return sourceCode.text.slice(tokenBefore.range[1], tokenAfter.range[0]) + } + + /** + * Get position of the beginning of the token's line(or prevToken end if no line) + * @param {ASTNode} token + * @param {ASTNode} prevToken + */ + function getLineStartIndex(token, prevToken) { + // if we have next token on the same line - get index right before that token + if (token.loc.start.line === prevToken.loc.end.line) { + return prevToken.range[1] + } + + return sourceCode.getIndexFromLoc({ + line: token.loc.start.line, + column: 0 + }) + } +} + +module.exports = { + meta: { + type: 'layout', + docs: { + description: + 'enforce order of `defineEmits` and `defineProps` compiler macros', + categories: undefined, + url: 'https://eslint.vuejs.org/rules/define-macros-order.html' + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + order: { + type: 'array', + items: { + enum: Object.values(ORDER) + }, + uniqueItems: true, + additionalItems: false + } + }, + additionalProperties: false + } + ], + messages: { + macrosNotOnTop: + '{{macro}} should be the first statement in ` + `, + options: optionsPropsFirst + }, + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { + parser: require.resolve('@typescript-eslint/parser') + } + }, + { + filename: 'test.vue', + code: ` + + `, + options: optionsPropsFirst + }, + { + filename: 'test.vue', + code: ` + + `, + options: optionsPropsFirst + }, + { + filename: 'test.vue', + code: ` + + `, + options: optionsPropsFirst + }, + { + filename: 'test.vue', + code: ` + + `, + options: optionsEmitsFirst + } + ], + invalid: [ + { + filename: 'test.vue', + code: ` + + `, + output: ` + + `, + options: optionsEmitsFirst, + errors: [ + { + message: message('defineEmits'), + line: 5 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + output: ` + + `, + options: optionsPropsFirst, + errors: [ + { + message: message('defineProps'), + line: 8 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + output: ` + + `, + options: optionsPropsFirst, + errors: [ + { + message: message('defineProps'), + line: 6 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + output: ` + + `, + options: optionsEmitsFirst, + errors: [ + { + message: message('defineEmits'), + line: 8 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + output: ` + + `, + parserOptions: { + parser: require.resolve('@typescript-eslint/parser') + }, + options: optionsEmitsFirst, + errors: [ + { + message: message('defineEmits'), + line: 12 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + output: ` + + `, + parserOptions: { + parser: require.resolve('@typescript-eslint/parser') + }, + errors: [ + { + message: message('defineProps'), + line: 10 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + output: ` + + `, + parserOptions: { + parser: require.resolve('@typescript-eslint/parser') + }, + options: optionsEmitsFirst, + errors: [ + { + message: message('defineEmits'), + line: 16 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + output: ` + + `, + options: optionsEmitsFirst, + errors: [ + { + message: message('defineEmits'), + line: 3 + } + ] + } + ] +})