diff --git a/docs/rules/v-bind-style.md b/docs/rules/v-bind-style.md index 23be4a8cc..271506c31 100644 --- a/docs/rules/v-bind-style.md +++ b/docs/rules/v-bind-style.md @@ -32,16 +32,20 @@ This rule enforces `v-bind` directive style which you should use shorthand or lo ## :wrench: Options -Default is set to `shorthand`. - ```json { - "vue/v-bind-style": ["error", "shorthand" | "longform"] + "vue/v-bind-style": ["error", "shorthand" | "longform", { + "sameNameShorthand": "ignore" | "always" | "never" + }] } ``` - `"shorthand"` (default) ... requires using shorthand. - `"longform"` ... requires using long form. +- `sameNameShorthand` ... enforce the `v-bind` same-name shorthand style (Vue 3.4+). + - `"ignore"` (default) ... ignores the same-name shorthand style. + - `"always"` ... always enforces same-name shorthand where possible. + - `"never"` ... always disallow same-name shorthand where possible. ### `"longform"` @@ -59,6 +63,38 @@ Default is set to `shorthand`. +### `{ "sameNameShorthand": "always" }` + + + +```vue + +``` + + + +### `{ "sameNameShorthand": "never" }` + + + +```vue + +``` + + + ## :books: Further Reading - [Style guide - Directive shorthands](https://vuejs.org/style-guide/rules-strongly-recommended.html#directive-shorthands) diff --git a/lib/rules/v-bind-style.js b/lib/rules/v-bind-style.js index 18911cfc3..847db33a2 100644 --- a/lib/rules/v-bind-style.js +++ b/lib/rules/v-bind-style.js @@ -6,6 +6,41 @@ 'use strict' const utils = require('../utils') +const casing = require('../utils/casing') + +/** + * @typedef { VDirectiveKey & { name: VIdentifier & { name: 'bind' }, argument: VExpressionContainer | VIdentifier } } VBindDirectiveKey + * @typedef { VDirective & { key: VBindDirectiveKey } } VBindDirective + */ + +/** + * @param {VBindDirective} node + * @returns {boolean} + */ +function isSameName(node) { + const attrName = + node.key.argument.type === 'VIdentifier' ? node.key.argument.rawName : null + const valueName = + node.value?.expression?.type === 'Identifier' + ? node.value.expression.name + : null + return Boolean( + attrName && + valueName && + casing.camelCase(attrName) === casing.camelCase(valueName) + ) +} + +/** + * @param {VBindDirectiveKey} key + * @returns {number} + */ +function getCutStart(key) { + const modifiers = key.modifiers + return modifiers.length > 0 + ? modifiers[modifiers.length - 1].range[1] + : key.argument.range[1] +} module.exports = { meta: { @@ -16,60 +51,113 @@ module.exports = { url: 'https://eslint.vuejs.org/rules/v-bind-style.html' }, fixable: 'code', - schema: [{ enum: ['shorthand', 'longform'] }], + schema: [ + { enum: ['shorthand', 'longform'] }, + { + type: 'object', + properties: { + sameNameShorthand: { enum: ['always', 'never', 'ignore'] } + }, + additionalProperties: false + } + ], messages: { expectedLonghand: "Expected 'v-bind' before ':'.", unexpectedLonghand: "Unexpected 'v-bind' before ':'.", - expectedLonghandForProp: "Expected 'v-bind:' instead of '.'." + expectedLonghandForProp: "Expected 'v-bind:' instead of '.'.", + expectedShorthand: 'Expected same-name shorthand.', + unexpectedShorthand: 'Unexpected same-name shorthand.' } }, /** @param {RuleContext} context */ create(context) { const preferShorthand = context.options[0] !== 'longform' + /** @type {"always" | "never" | "ignore"} */ + const sameNameShorthand = context.options[1]?.sameNameShorthand || 'ignore' - return utils.defineTemplateBodyVisitor(context, { - /** @param {VDirective} node */ - "VAttribute[directive=true][key.name.name='bind'][key.argument!=null]"( - node - ) { - const shorthandProp = node.key.name.rawName === '.' - const shorthand = node.key.name.rawName === ':' || shorthandProp - if (shorthand === preferShorthand) { - return - } + /** @param {VBindDirective} node */ + function checkAttributeStyle(node) { + const shorthandProp = node.key.name.rawName === '.' + const shorthand = node.key.name.rawName === ':' || shorthandProp + if (shorthand === preferShorthand) { + return + } - let messageId = 'expectedLonghand' - if (preferShorthand) { - messageId = 'unexpectedLonghand' - } else if (shorthandProp) { - messageId = 'expectedLonghandForProp' - } + let messageId = 'expectedLonghand' + if (preferShorthand) { + messageId = 'unexpectedLonghand' + } else if (shorthandProp) { + messageId = 'expectedLonghandForProp' + } + + context.report({ + node, + loc: node.loc, + messageId, + *fix(fixer) { + if (preferShorthand) { + yield fixer.remove(node.key.name) + } else { + yield fixer.insertTextBefore(node, 'v-bind') + + if (shorthandProp) { + // Replace `.` by `:`. + yield fixer.replaceText(node.key.name, ':') - context.report({ - node, - loc: node.loc, - messageId, - *fix(fixer) { - if (preferShorthand) { - yield fixer.remove(node.key.name) - } else { - yield fixer.insertTextBefore(node, 'v-bind') - - if (shorthandProp) { - // Replace `.` by `:`. - yield fixer.replaceText(node.key.name, ':') - - // Insert `.prop` modifier if it doesn't exist. - const modifier = node.key.modifiers[0] - const isAutoGeneratedPropModifier = - modifier.name === 'prop' && modifier.rawName === '' - if (isAutoGeneratedPropModifier) { - yield fixer.insertTextBefore(modifier, '.prop') - } + // Insert `.prop` modifier if it doesn't exist. + const modifier = node.key.modifiers[0] + const isAutoGeneratedPropModifier = + modifier.name === 'prop' && modifier.rawName === '' + if (isAutoGeneratedPropModifier) { + yield fixer.insertTextBefore(modifier, '.prop') } } } - }) + } + }) + } + + /** @param {VBindDirective} node */ + function checkAttributeSameName(node) { + if (sameNameShorthand === 'ignore' || !isSameName(node)) return + + const preferShorthand = sameNameShorthand === 'always' + const isShorthand = utils.isVBindSameNameShorthand(node) + if (isShorthand === preferShorthand) { + return + } + + const messageId = preferShorthand + ? 'expectedShorthand' + : 'unexpectedShorthand' + + context.report({ + node, + loc: node.loc, + messageId, + *fix(fixer) { + if (preferShorthand) { + /** @type {Range} */ + const valueRange = [getCutStart(node.key), node.range[1]] + + yield fixer.removeRange(valueRange) + } else if (node.key.argument.type === 'VIdentifier') { + yield fixer.insertTextAfter( + node, + `="${casing.camelCase(node.key.argument.rawName)}"` + ) + } + } + }) + } + + return utils.defineTemplateBodyVisitor(context, { + /** @param {VBindDirective} node */ + "VAttribute[directive=true][key.name.name='bind'][key.argument!=null]"( + node + ) { + checkAttributeSameName(node) + checkAttributeStyle(node) } }) } diff --git a/tests/lib/rules/v-bind-style.js b/tests/lib/rules/v-bind-style.js index 26490790a..b678bf783 100644 --- a/tests/lib/rules/v-bind-style.js +++ b/tests/lib/rules/v-bind-style.js @@ -12,6 +12,9 @@ const tester = new RuleTester({ languageOptions: { parser: require('vue-eslint-parser'), ecmaVersion: 2015 } }) +const expectedShorthand = 'Expected same-name shorthand.' +const unexpectedShorthand = 'Unexpected same-name shorthand.' + tester.run('v-bind-style', rule, { valid: [ { @@ -34,7 +37,7 @@ tester.run('v-bind-style', rule, { { filename: 'test.vue', code: '', - options: ['longform'] + options: ['longform', { sameNameShorthand: 'ignore' }] }, // Don't enforce `.prop` shorthand because of experimental. @@ -55,6 +58,72 @@ tester.run('v-bind-style', rule, { filename: 'test.vue', code: '', options: ['shorthand'] + }, + // same-name shorthand: never + { + filename: 'test.vue', + code: '', + options: ['shorthand', { sameNameShorthand: 'never' }] + }, + { + filename: 'test.vue', + code: '', + options: ['longform', { sameNameShorthand: 'never' }] + }, + { + // modifier + filename: 'test.vue', + code: ` + + `, + options: ['shorthand', { sameNameShorthand: 'never' }] + }, + { + filename: 'test.vue', + code: '', + options: ['longform', { sameNameShorthand: 'never' }] + }, + { + // camel case + filename: 'test.vue', + code: '', + options: ['shorthand', { sameNameShorthand: 'never' }] + }, + // same-name shorthand: always + { + filename: 'test.vue', + code: '', + options: ['shorthand', { sameNameShorthand: 'always' }] + }, + { + filename: 'test.vue', + code: '', + options: ['longform', { sameNameShorthand: 'always' }] + }, + { + // modifier + filename: 'test.vue', + code: ` + + `, + options: ['shorthand', { sameNameShorthand: 'always' }] + }, + { + filename: 'test.vue', + code: '', + options: ['longform', { sameNameShorthand: 'always' }] + }, + { + // camel case + filename: 'test.vue', + code: '', + options: ['shorthand', { sameNameShorthand: 'always' }] } ], invalid: [ @@ -120,6 +189,89 @@ tester.run('v-bind-style', rule, { output: '', options: ['longform'], errors: ["Expected 'v-bind' before ':'."] + }, + // same-name shorthand: never + { + filename: 'test.vue', + code: '', + output: '', + options: ['shorthand', { sameNameShorthand: 'never' }], + errors: [unexpectedShorthand] + }, + { + filename: 'test.vue', + code: '', + output: '', + options: ['longform', { sameNameShorthand: 'never' }], + errors: [unexpectedShorthand] + }, + { + // modifier + filename: 'test.vue', + code: '', + output: '', + options: ['shorthand', { sameNameShorthand: 'never' }], + errors: [unexpectedShorthand] + }, + { + filename: 'test.vue', + code: '', + output: '', + options: ['shorthand', { sameNameShorthand: 'never' }], + errors: [unexpectedShorthand] + }, + { + filename: 'test.vue', + code: '', + output: '', + options: ['longform', { sameNameShorthand: 'never' }], + errors: [unexpectedShorthand, "Expected 'v-bind:' instead of '.'."] + }, + { + // camel case + filename: 'test.vue', + code: '', + output: '', + options: ['shorthand', { sameNameShorthand: 'never' }], + errors: [unexpectedShorthand] + }, + // same-name shorthand: always + { + filename: 'test.vue', + code: '', + output: '', + options: ['shorthand', { sameNameShorthand: 'always' }], + errors: [expectedShorthand] + }, + { + filename: 'test.vue', + code: '', + output: '', + options: ['longform', { sameNameShorthand: 'always' }], + errors: [expectedShorthand] + }, + { + // modifier + filename: 'test.vue', + code: '', + output: '', + options: ['shorthand', { sameNameShorthand: 'always' }], + errors: [expectedShorthand] + }, + { + filename: 'test.vue', + code: '', + output: '', + options: ['shorthand', { sameNameShorthand: 'always' }], + errors: [expectedShorthand] + }, + { + // camel case + filename: 'test.vue', + code: '', + output: '', + options: ['shorthand', { sameNameShorthand: 'always' }], + errors: [expectedShorthand] } ] })