You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
376 lines
11 KiB
376 lines
11 KiB
1 month ago
|
/**
|
||
|
* @fileoverview Keep order of properties in components
|
||
|
* @author Michał Sajnóg
|
||
|
*/
|
||
|
'use strict'
|
||
|
|
||
|
const utils = require('../utils')
|
||
|
const traverseNodes = require('vue-eslint-parser').AST.traverseNodes
|
||
|
|
||
|
/**
|
||
|
* @typedef {import('eslint-visitor-keys').VisitorKeys} VisitorKeys
|
||
|
*/
|
||
|
|
||
|
const defaultOrder = [
|
||
|
// Side Effects (triggers effects outside the component)
|
||
|
'el',
|
||
|
|
||
|
// Global Awareness (requires knowledge beyond the component)
|
||
|
'name',
|
||
|
'key', // for Nuxt
|
||
|
'parent',
|
||
|
|
||
|
// Component Type (changes the type of the component)
|
||
|
'functional',
|
||
|
|
||
|
// Template Modifiers (changes the way templates are compiled)
|
||
|
['delimiters', 'comments'],
|
||
|
|
||
|
// Template Dependencies (assets used in the template)
|
||
|
['components', 'directives', 'filters'],
|
||
|
|
||
|
// Composition (merges properties into the options)
|
||
|
'extends',
|
||
|
'mixins',
|
||
|
['provide', 'inject'], // for Vue.js 2.2.0+
|
||
|
|
||
|
// Page Options (component rendered as a router page)
|
||
|
'ROUTER_GUARDS', // for Vue Router
|
||
|
'layout', // for Nuxt
|
||
|
'middleware', // for Nuxt
|
||
|
'validate', // for Nuxt
|
||
|
'scrollToTop', // for Nuxt
|
||
|
'transition', // for Nuxt
|
||
|
'loading', // for Nuxt
|
||
|
|
||
|
// Interface (the interface to the component)
|
||
|
'inheritAttrs',
|
||
|
'model',
|
||
|
['props', 'propsData'],
|
||
|
'emits', // for Vue.js 3.x
|
||
|
|
||
|
// Note:
|
||
|
// The `setup` option is included in the "Composition" category,
|
||
|
// but the behavior of the `setup` option requires the definition of "Interface",
|
||
|
// so we prefer to put the `setup` option after the "Interface".
|
||
|
'setup', // for Vue 3.x
|
||
|
|
||
|
// Local State (local reactive properties)
|
||
|
'asyncData', // for Nuxt
|
||
|
'data',
|
||
|
'fetch', // for Nuxt
|
||
|
'head', // for Nuxt
|
||
|
'computed',
|
||
|
|
||
|
// Events (callbacks triggered by reactive events)
|
||
|
'watch',
|
||
|
'watchQuery', // for Nuxt
|
||
|
'LIFECYCLE_HOOKS',
|
||
|
|
||
|
// Non-Reactive Properties (instance properties independent of the reactivity system)
|
||
|
'methods',
|
||
|
|
||
|
// Rendering (the declarative description of the component output)
|
||
|
['template', 'render'],
|
||
|
'renderError'
|
||
|
]
|
||
|
|
||
|
/** @type { { [key: string]: string[] } } */
|
||
|
const groups = {
|
||
|
LIFECYCLE_HOOKS: [
|
||
|
'beforeCreate',
|
||
|
'created',
|
||
|
'beforeMount',
|
||
|
'mounted',
|
||
|
'beforeUpdate',
|
||
|
'updated',
|
||
|
'activated',
|
||
|
'deactivated',
|
||
|
'beforeUnmount', // for Vue.js 3.x
|
||
|
'unmounted', // for Vue.js 3.x
|
||
|
'beforeDestroy',
|
||
|
'destroyed',
|
||
|
'renderTracked', // for Vue.js 3.x
|
||
|
'renderTriggered', // for Vue.js 3.x
|
||
|
'errorCaptured' // for Vue.js 2.5.0+
|
||
|
],
|
||
|
ROUTER_GUARDS: ['beforeRouteEnter', 'beforeRouteUpdate', 'beforeRouteLeave']
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {(string | string[])[]} order
|
||
|
*/
|
||
|
function getOrderMap(order) {
|
||
|
/** @type {Map<string, number>} */
|
||
|
const orderMap = new Map()
|
||
|
|
||
|
for (const [i, property] of order.entries()) {
|
||
|
if (Array.isArray(property)) {
|
||
|
for (const p of property) {
|
||
|
orderMap.set(p, i)
|
||
|
}
|
||
|
} else {
|
||
|
orderMap.set(property, i)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return orderMap
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {Token} node
|
||
|
*/
|
||
|
function isComma(node) {
|
||
|
return node.type === 'Punctuator' && node.value === ','
|
||
|
}
|
||
|
|
||
|
const ARITHMETIC_OPERATORS = ['+', '-', '*', '/', '%', '**' /* es2016 */]
|
||
|
const BITWISE_OPERATORS = ['&', '|', '^', '~', '<<', '>>', '>>>']
|
||
|
const COMPARISON_OPERATORS = ['==', '!=', '===', '!==', '>', '>=', '<', '<=']
|
||
|
const RELATIONAL_OPERATORS = ['in', 'instanceof']
|
||
|
const ALL_BINARY_OPERATORS = new Set([
|
||
|
...ARITHMETIC_OPERATORS,
|
||
|
...BITWISE_OPERATORS,
|
||
|
...COMPARISON_OPERATORS,
|
||
|
...RELATIONAL_OPERATORS
|
||
|
])
|
||
|
const LOGICAL_OPERATORS = new Set(['&&', '||', '??' /* es2020 */])
|
||
|
|
||
|
/**
|
||
|
* Result `true` if the node is sure that there are no side effects
|
||
|
*
|
||
|
* Currently known side effects types
|
||
|
*
|
||
|
* node.type === 'CallExpression'
|
||
|
* node.type === 'NewExpression'
|
||
|
* node.type === 'UpdateExpression'
|
||
|
* node.type === 'AssignmentExpression'
|
||
|
* node.type === 'TaggedTemplateExpression'
|
||
|
* node.type === 'UnaryExpression' && node.operator === 'delete'
|
||
|
*
|
||
|
* @param {ASTNode} node target node
|
||
|
* @param {VisitorKeys} visitorKeys sourceCode.visitorKey
|
||
|
* @returns {boolean} no side effects
|
||
|
*/
|
||
|
function isNotSideEffectsNode(node, visitorKeys) {
|
||
|
let result = true
|
||
|
/** @type {ASTNode | null} */
|
||
|
let skipNode = null
|
||
|
traverseNodes(node, {
|
||
|
visitorKeys,
|
||
|
/** @param {ASTNode} node */
|
||
|
enterNode(node) {
|
||
|
if (!result || skipNode) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if (
|
||
|
// no side effects node
|
||
|
node.type === 'FunctionExpression' ||
|
||
|
node.type === 'Identifier' ||
|
||
|
node.type === 'Literal' ||
|
||
|
// es2015
|
||
|
node.type === 'ArrowFunctionExpression' ||
|
||
|
node.type === 'TemplateElement' ||
|
||
|
// typescript
|
||
|
node.type === 'TSAsExpression'
|
||
|
) {
|
||
|
skipNode = node
|
||
|
} else if (
|
||
|
node.type !== 'Property' &&
|
||
|
node.type !== 'ObjectExpression' &&
|
||
|
node.type !== 'ArrayExpression' &&
|
||
|
(node.type !== 'UnaryExpression' ||
|
||
|
!['!', '~', '+', '-', 'typeof'].includes(node.operator)) &&
|
||
|
(node.type !== 'BinaryExpression' ||
|
||
|
!ALL_BINARY_OPERATORS.has(node.operator)) &&
|
||
|
(node.type !== 'LogicalExpression' ||
|
||
|
!LOGICAL_OPERATORS.has(node.operator)) &&
|
||
|
node.type !== 'MemberExpression' &&
|
||
|
node.type !== 'ConditionalExpression' &&
|
||
|
// es2015
|
||
|
node.type !== 'SpreadElement' &&
|
||
|
node.type !== 'TemplateLiteral' &&
|
||
|
// es2020
|
||
|
node.type !== 'ChainExpression'
|
||
|
) {
|
||
|
// Can not be sure that a node has no side effects
|
||
|
result = false
|
||
|
}
|
||
|
},
|
||
|
/** @param {ASTNode} node */
|
||
|
leaveNode(node) {
|
||
|
if (skipNode === node) {
|
||
|
skipNode = null
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
|
||
|
return result
|
||
|
}
|
||
|
|
||
|
module.exports = {
|
||
|
meta: {
|
||
|
type: 'suggestion',
|
||
|
docs: {
|
||
|
description: 'enforce order of properties in components',
|
||
|
categories: ['vue3-recommended', 'vue2-recommended'],
|
||
|
url: 'https://eslint.vuejs.org/rules/order-in-components.html'
|
||
|
},
|
||
|
fixable: 'code', // null or "code" or "whitespace"
|
||
|
hasSuggestions: true,
|
||
|
schema: [
|
||
|
{
|
||
|
type: 'object',
|
||
|
properties: {
|
||
|
order: {
|
||
|
type: 'array'
|
||
|
}
|
||
|
},
|
||
|
additionalProperties: false
|
||
|
}
|
||
|
],
|
||
|
messages: {
|
||
|
order:
|
||
|
'The "{{name}}" property should be above the "{{firstUnorderedPropertyName}}" property on line {{line}}.',
|
||
|
reorderWithSideEffects:
|
||
|
'Manually move "{{name}}" property above "{{firstUnorderedPropertyName}}" property on line {{line}} (might break side effects).'
|
||
|
}
|
||
|
},
|
||
|
/** @param {RuleContext} context */
|
||
|
create(context) {
|
||
|
const options = context.options[0] || {}
|
||
|
/** @type {(string|string[])[]} */
|
||
|
const order = options.order || defaultOrder
|
||
|
/** @type {(string|string[])[]} */
|
||
|
const extendedOrder = order.map(
|
||
|
(property) =>
|
||
|
(typeof property === 'string' && groups[property]) || property
|
||
|
)
|
||
|
const orderMap = getOrderMap(extendedOrder)
|
||
|
const sourceCode = context.getSourceCode()
|
||
|
|
||
|
/**
|
||
|
* @param {string} name
|
||
|
*/
|
||
|
function getOrderPosition(name) {
|
||
|
const num = orderMap.get(name)
|
||
|
return num == null ? -1 : num
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {RuleFixer} fixer
|
||
|
* @param {Property} propertyNode
|
||
|
* @param {Property} unorderedPropertyNode
|
||
|
*/
|
||
|
function* handleFix(fixer, propertyNode, unorderedPropertyNode) {
|
||
|
const afterComma = sourceCode.getTokenAfter(propertyNode)
|
||
|
const hasAfterComma = isComma(afterComma)
|
||
|
|
||
|
const beforeComma = sourceCode.getTokenBefore(propertyNode)
|
||
|
const codeStart = beforeComma.range[1] // to include comments
|
||
|
const codeEnd = hasAfterComma
|
||
|
? afterComma.range[1]
|
||
|
: propertyNode.range[1]
|
||
|
|
||
|
const removeStart = hasAfterComma ? codeStart : beforeComma.range[0]
|
||
|
yield fixer.removeRange([removeStart, codeEnd])
|
||
|
|
||
|
const propertyCode =
|
||
|
sourceCode.text.slice(codeStart, codeEnd) + (hasAfterComma ? '' : ',')
|
||
|
const insertTarget = sourceCode.getTokenBefore(unorderedPropertyNode)
|
||
|
|
||
|
yield fixer.insertTextAfter(insertTarget, propertyCode)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {(Property | SpreadElement)[]} propertiesNodes
|
||
|
*/
|
||
|
function checkOrder(propertiesNodes) {
|
||
|
const properties = propertiesNodes
|
||
|
.filter(utils.isProperty)
|
||
|
.map((property) => ({
|
||
|
node: property,
|
||
|
name:
|
||
|
utils.getStaticPropertyName(property) ||
|
||
|
(property.key.type === 'Identifier' && property.key.name) ||
|
||
|
''
|
||
|
}))
|
||
|
|
||
|
for (const [i, property] of properties.entries()) {
|
||
|
const orderPos = getOrderPosition(property.name)
|
||
|
if (orderPos < 0) {
|
||
|
continue
|
||
|
}
|
||
|
const propertiesAbove = properties.slice(0, i)
|
||
|
const unorderedProperties = propertiesAbove
|
||
|
.filter(
|
||
|
(p) => getOrderPosition(p.name) > getOrderPosition(property.name)
|
||
|
)
|
||
|
.sort((p1, p2) =>
|
||
|
getOrderPosition(p1.name) > getOrderPosition(p2.name) ? 1 : -1
|
||
|
)
|
||
|
|
||
|
const firstUnorderedProperty = unorderedProperties[0]
|
||
|
|
||
|
if (firstUnorderedProperty) {
|
||
|
const line = firstUnorderedProperty.node.loc.start.line
|
||
|
const propertyNode = property.node
|
||
|
const firstUnorderedPropertyNode = firstUnorderedProperty.node
|
||
|
const hasSideEffectsPossibility = propertiesNodes
|
||
|
.slice(
|
||
|
propertiesNodes.indexOf(firstUnorderedPropertyNode),
|
||
|
propertiesNodes.indexOf(propertyNode) + 1
|
||
|
)
|
||
|
.some(
|
||
|
(property) =>
|
||
|
!isNotSideEffectsNode(property, sourceCode.visitorKeys)
|
||
|
)
|
||
|
|
||
|
context.report({
|
||
|
node: property.node,
|
||
|
messageId: 'order',
|
||
|
data: {
|
||
|
name: property.name,
|
||
|
firstUnorderedPropertyName: firstUnorderedProperty.name,
|
||
|
line
|
||
|
},
|
||
|
fix: hasSideEffectsPossibility
|
||
|
? undefined
|
||
|
: (fixer) =>
|
||
|
handleFix(fixer, propertyNode, firstUnorderedPropertyNode),
|
||
|
suggest: hasSideEffectsPossibility
|
||
|
? [
|
||
|
{
|
||
|
messageId: 'reorderWithSideEffects',
|
||
|
data: {
|
||
|
name: property.name,
|
||
|
firstUnorderedPropertyName: firstUnorderedProperty.name,
|
||
|
line
|
||
|
},
|
||
|
fix: (fixer) =>
|
||
|
handleFix(fixer, propertyNode, firstUnorderedPropertyNode)
|
||
|
}
|
||
|
]
|
||
|
: undefined
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return utils.compositingVisitors(
|
||
|
utils.executeOnVue(context, (obj) => {
|
||
|
checkOrder(obj.properties)
|
||
|
}),
|
||
|
utils.defineScriptSetupVisitor(context, {
|
||
|
onDefineOptionsEnter(node) {
|
||
|
if (node.arguments.length === 0) return
|
||
|
const define = node.arguments[0]
|
||
|
if (define.type !== 'ObjectExpression') return
|
||
|
checkOrder(define.properties)
|
||
|
}
|
||
|
})
|
||
|
)
|
||
|
}
|
||
|
}
|