From 5cf4ba503e22a77e43316e65991c200a2689adb2 Mon Sep 17 00:00:00 2001 From: waynzh Date: Sun, 28 Jan 2024 14:19:45 +0800 Subject: [PATCH 1/8] feat(v-bind-style): add `sameNameShorthand` option --- lib/rules/v-bind-style.js | 184 +++++++++++++++++++++++++------- tests/lib/rules/v-bind-style.js | 154 +++++++++++++++++++++++++- 2 files changed, 297 insertions(+), 41 deletions(-) diff --git a/lib/rules/v-bind-style.js b/lib/rules/v-bind-style.js index 18911cfc3..345cd1e89 100644 --- a/lib/rules/v-bind-style.js +++ b/lib/rules/v-bind-style.js @@ -6,6 +6,56 @@ '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) { + /** @returns {string | null} */ + function getAttributeName() { + // not support VExpressionContainer e.g. :[attribute] + if (node.key.argument.type === 'VIdentifier') { + return node.key.argument.rawName + } + + return null + } + + /** @returns {string | null} */ + function getValueName() { + if (node.value?.expression?.type === 'Identifier') { + return node.value.expression.name + } + + return null + } + + const attrName = getAttributeName() + const valueName = getValueName() + 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 +66,114 @@ 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 shorthand same name.', + unexpectedShorthand: 'Unexpected shorthand same name.' } }, /** @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 checkPropForm(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 checkPropSameName(node) { + if (sameNameShorthand === 'ignore' || !isSameName(node)) return + + const preferShorthand = sameNameShorthand === 'always' + const isShortHand = utils.isVBindSameNameShorthand(node) + if (isShortHand === preferShorthand) { + return + } + + let messageId = 'unexpectedShorthand' + if (preferShorthand) { + messageId = 'expectedShorthand' + } + + 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 + ) { + checkPropSameName(node) + checkPropForm(node) } }) } diff --git a/tests/lib/rules/v-bind-style.js b/tests/lib/rules/v-bind-style.js index 26490790a..8a542c8a3 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 shorthand same name.' +const unexpectedShorthand = 'Unexpected shorthand same name.' + 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] } ] }) From a6632700b99b6fc0666529d739b6b4f68fa64e18 Mon Sep 17 00:00:00 2001 From: waynzh Date: Sun, 28 Jan 2024 15:01:26 +0800 Subject: [PATCH 2/8] docs: add `sameNameShorthand` --- docs/rules/v-bind-style.md | 42 +++++++++++++++++++++++++++++++-- lib/rules/v-bind-style.js | 4 ++-- tests/lib/rules/v-bind-style.js | 4 ++-- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/rules/v-bind-style.md b/docs/rules/v-bind-style.md index 23be4a8cc..158ca0e1c 100644 --- a/docs/rules/v-bind-style.md +++ b/docs/rules/v-bind-style.md @@ -32,16 +32,22 @@ This rule enforces `v-bind` directive style which you should use shorthand or lo ## :wrench: Options -Default is set to `shorthand`. +Default style is set to `shorthand`. And default same-name shorthand is `ignore`. ```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 +65,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 345cd1e89..bb6a0bfe1 100644 --- a/lib/rules/v-bind-style.js +++ b/lib/rules/v-bind-style.js @@ -80,8 +80,8 @@ module.exports = { expectedLonghand: "Expected 'v-bind' before ':'.", unexpectedLonghand: "Unexpected 'v-bind' before ':'.", expectedLonghandForProp: "Expected 'v-bind:' instead of '.'.", - expectedShorthand: 'Expected shorthand same name.', - unexpectedShorthand: 'Unexpected shorthand same name.' + expectedShorthand: 'Expected same-name shorthand.', + unexpectedShorthand: 'Unexpected same-name shorthand.' } }, /** @param {RuleContext} context */ diff --git a/tests/lib/rules/v-bind-style.js b/tests/lib/rules/v-bind-style.js index 8a542c8a3..b678bf783 100644 --- a/tests/lib/rules/v-bind-style.js +++ b/tests/lib/rules/v-bind-style.js @@ -12,8 +12,8 @@ const tester = new RuleTester({ languageOptions: { parser: require('vue-eslint-parser'), ecmaVersion: 2015 } }) -const expectedShorthand = 'Expected shorthand same name.' -const unexpectedShorthand = 'Unexpected shorthand same name.' +const expectedShorthand = 'Expected same-name shorthand.' +const unexpectedShorthand = 'Unexpected same-name shorthand.' tester.run('v-bind-style', rule, { valid: [ From ab66c1e005bfe000e27e490cd4ba42df9bc82806 Mon Sep 17 00:00:00 2001 From: waynzh Date: Sun, 28 Jan 2024 15:17:49 +0800 Subject: [PATCH 3/8] refactor: rename --- lib/rules/v-bind-style.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/rules/v-bind-style.js b/lib/rules/v-bind-style.js index bb6a0bfe1..c330b5060 100644 --- a/lib/rules/v-bind-style.js +++ b/lib/rules/v-bind-style.js @@ -91,7 +91,7 @@ module.exports = { const sameNameShorthand = context.options[1]?.sameNameShorthand || 'ignore' /** @param {VBindDirective} node */ - function checkPropForm(node) { + function checkAttributeStyle(node) { const shorthandProp = node.key.name.rawName === '.' const shorthand = node.key.name.rawName === ':' || shorthandProp if (shorthand === preferShorthand) { @@ -133,7 +133,7 @@ module.exports = { } /** @param {VBindDirective} node */ - function checkPropSameName(node) { + function checkAttributeSameName(node) { if (sameNameShorthand === 'ignore' || !isSameName(node)) return const preferShorthand = sameNameShorthand === 'always' @@ -172,8 +172,8 @@ module.exports = { "VAttribute[directive=true][key.name.name='bind'][key.argument!=null]"( node ) { - checkPropSameName(node) - checkPropForm(node) + checkAttributeSameName(node) + checkAttributeStyle(node) } }) } From 155c09ead10b9f9dcdcac91801f48c437e36e5d9 Mon Sep 17 00:00:00 2001 From: Wayne Zhang Date: Mon, 29 Jan 2024 19:37:36 +0800 Subject: [PATCH 4/8] Update docs/rules/v-bind-style.md Co-authored-by: Flo Edelmann --- docs/rules/v-bind-style.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/rules/v-bind-style.md b/docs/rules/v-bind-style.md index 158ca0e1c..271506c31 100644 --- a/docs/rules/v-bind-style.md +++ b/docs/rules/v-bind-style.md @@ -32,8 +32,6 @@ This rule enforces `v-bind` directive style which you should use shorthand or lo ## :wrench: Options -Default style is set to `shorthand`. And default same-name shorthand is `ignore`. - ```json { "vue/v-bind-style": ["error", "shorthand" | "longform", { From 1b9326245a410591da901ffcc18393476778b769 Mon Sep 17 00:00:00 2001 From: Wayne Zhang Date: Mon, 29 Jan 2024 19:38:14 +0800 Subject: [PATCH 5/8] Update lib/rules/v-bind-style.js Co-authored-by: Flo Edelmann --- lib/rules/v-bind-style.js | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/lib/rules/v-bind-style.js b/lib/rules/v-bind-style.js index c330b5060..78ac0aa72 100644 --- a/lib/rules/v-bind-style.js +++ b/lib/rules/v-bind-style.js @@ -18,27 +18,12 @@ const casing = require('../utils/casing') * @returns {boolean} */ function isSameName(node) { - /** @returns {string | null} */ - function getAttributeName() { - // not support VExpressionContainer e.g. :[attribute] - if (node.key.argument.type === 'VIdentifier') { - return node.key.argument.rawName - } - - return null - } - - /** @returns {string | null} */ - function getValueName() { - if (node.value?.expression?.type === 'Identifier') { - return node.value.expression.name - } - - return null - } - - const attrName = getAttributeName() - const valueName = getValueName() + 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 && From d65e903ceb106dc3a5331444be1a130d7bb8565e Mon Sep 17 00:00:00 2001 From: Wayne Zhang Date: Mon, 29 Jan 2024 19:38:45 +0800 Subject: [PATCH 6/8] Update lib/rules/v-bind-style.js Co-authored-by: Flo Edelmann --- lib/rules/v-bind-style.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/rules/v-bind-style.js b/lib/rules/v-bind-style.js index 78ac0aa72..bcaf10155 100644 --- a/lib/rules/v-bind-style.js +++ b/lib/rules/v-bind-style.js @@ -127,10 +127,7 @@ module.exports = { return } - let messageId = 'unexpectedShorthand' - if (preferShorthand) { - messageId = 'expectedShorthand' - } + const messageId = preferShorthand ? 'expectedShorthand' : 'unexpectedShorthand' context.report({ node, From ed638b4af4550b25e18b25576de89e5c1a73ea32 Mon Sep 17 00:00:00 2001 From: Wayne Zhang Date: Mon, 29 Jan 2024 19:39:18 +0800 Subject: [PATCH 7/8] Update lib/rules/v-bind-style.js Co-authored-by: Flo Edelmann --- lib/rules/v-bind-style.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/rules/v-bind-style.js b/lib/rules/v-bind-style.js index bcaf10155..d96b2a064 100644 --- a/lib/rules/v-bind-style.js +++ b/lib/rules/v-bind-style.js @@ -122,8 +122,8 @@ module.exports = { if (sameNameShorthand === 'ignore' || !isSameName(node)) return const preferShorthand = sameNameShorthand === 'always' - const isShortHand = utils.isVBindSameNameShorthand(node) - if (isShortHand === preferShorthand) { + const isShorthand = utils.isVBindSameNameShorthand(node) + if (isShorthand === preferShorthand) { return } From d944a7b636daf5126ca8745a8ee841a0de6f7226 Mon Sep 17 00:00:00 2001 From: waynzh Date: Mon, 29 Jan 2024 19:42:24 +0800 Subject: [PATCH 8/8] feat: lint format --- lib/rules/v-bind-style.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/rules/v-bind-style.js b/lib/rules/v-bind-style.js index d96b2a064..847db33a2 100644 --- a/lib/rules/v-bind-style.js +++ b/lib/rules/v-bind-style.js @@ -18,12 +18,12 @@ const casing = require('../utils/casing') * @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 + 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 && @@ -127,7 +127,9 @@ module.exports = { return } - const messageId = preferShorthand ? 'expectedShorthand' : 'unexpectedShorthand' + const messageId = preferShorthand + ? 'expectedShorthand' + : 'unexpectedShorthand' context.report({ node,