Skip to content

Commit

Permalink
Add vue/quote-props rule (#1769)
Browse files Browse the repository at this point in the history
  • Loading branch information
ota-meshi committed Jan 21, 2022
1 parent 32d1fb7 commit f527e27
Show file tree
Hide file tree
Showing 8 changed files with 337 additions and 43 deletions.
1 change: 1 addition & 0 deletions docs/rules/README.md
Expand Up @@ -399,6 +399,7 @@ The following rules extend the rules provided by ESLint itself and apply them to
| [vue/object-shorthand](./object-shorthand.md) | require or disallow method and property shorthand syntax for object literals in `<template>` | :wrench: |
| [vue/operator-linebreak](./operator-linebreak.md) | enforce consistent linebreak style for operators in `<template>` | :wrench: |
| [vue/prefer-template](./prefer-template.md) | require template literals instead of string concatenation in `<template>` | :wrench: |
| [vue/quote-props](./quote-props.md) | require quotes around object literal property names in `<template>` | :wrench: |
| [vue/space-in-parens](./space-in-parens.md) | enforce consistent spacing inside parentheses in `<template>` | :wrench: |
| [vue/space-infix-ops](./space-infix-ops.md) | require spacing around infix operators in `<template>` | :wrench: |
| [vue/space-unary-ops](./space-unary-ops.md) | enforce consistent spacing before or after unary operators in `<template>` | :wrench: |
Expand Down
27 changes: 27 additions & 0 deletions docs/rules/quote-props.md
@@ -0,0 +1,27 @@
---
pageClass: rule-details
sidebarDepth: 0
title: vue/quote-props
description: require quotes around object literal property names in `<template>`
---
# vue/quote-props

> require quotes around object literal property names in `<template>`
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
- :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.

This rule is the same rule as core [quote-props] rule but it applies to the expressions in `<template>`.

## :books: Further Reading

- [quote-props]

[quote-props]: https://eslint.org/docs/rules/quote-props

## :mag: Implementation

- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/quote-props.js)
- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/quote-props.js)

<sup>Taken with ❤️ [from ESLint core](https://eslint.org/docs/rules/quote-props)</sup>
1 change: 1 addition & 0 deletions lib/index.js
Expand Up @@ -160,6 +160,7 @@ module.exports = {
'prefer-separate-static-class': require('./rules/prefer-separate-static-class'),
'prefer-template': require('./rules/prefer-template'),
'prop-name-casing': require('./rules/prop-name-casing'),
'quote-props': require('./rules/quote-props'),
'require-component-is': require('./rules/require-component-is'),
'require-default-prop': require('./rules/require-default-prop'),
'require-direct-export': require('./rules/require-direct-export'),
Expand Down
54 changes: 54 additions & 0 deletions lib/rules/quote-props.js
@@ -0,0 +1,54 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'

const { wrapCoreRule, flatten } = require('../utils')

// eslint-disable-next-line no-invalid-meta, no-invalid-meta-docs-categories
module.exports = wrapCoreRule('quote-props', {
skipDynamicArguments: true,
preprocess(context, { wrapContextToOverrideProperties, defineVisitor }) {
const sourceCode = context.getSourceCode()
/**
* @type {'"' | "'" | null}
*/
let htmlQuote = null
defineVisitor({
/** @param {VExpressionContainer} node */
'VAttribute > VExpressionContainer.value'(node) {
const text = sourceCode.getText(node)
const firstChar = text[0]
htmlQuote = firstChar === "'" || firstChar === '"' ? firstChar : null
},
'VAttribute > VExpressionContainer.value:exit'() {
htmlQuote = null
}
})

wrapContextToOverrideProperties({
// Override the report method and replace the quotes in the fixed text with safe quotes.
report(descriptor) {
if (htmlQuote) {
const expectedQuote = htmlQuote === '"' ? "'" : '"'
context.report({
...descriptor,
*fix(fixer) {
for (const fix of flatten(
descriptor.fix && descriptor.fix(fixer)
)) {
yield fixer.replaceTextRange(
fix.range,
fix.text.replace(/["']/gu, expectedQuote)
)
}
}
})
} else {
context.report(descriptor)
}
}
})
}
})
167 changes: 127 additions & 40 deletions lib/utils/index.js
Expand Up @@ -79,6 +79,41 @@ function getCoreRule(name) {
return map.get(name) || null
}

/**
* @template {object} T
* @param {T} target
* @param {Partial<T>[]} propsArray
* @returns {T}
*/
function newProxy(target, ...propsArray) {
const result = new Proxy(
{},
{
get(_object, key) {
for (const props of propsArray) {
if (key in props) {
// @ts-expect-error
return props[key]
}
}
// @ts-expect-error
return target[key]
},

has(_object, key) {
return key in target
},
ownKeys(_object) {
return Reflect.ownKeys(target)
},
getPrototypeOf(_object) {
return Reflect.getPrototypeOf(target)
}
}
)
return /** @type {T} */ (result)
}

/**
* Wrap the rule context object to override methods which access to tokens (such as getTokenAfter).
* @param {RuleContext} context The rule context object.
Expand Down Expand Up @@ -147,18 +182,16 @@ function wrapContextToOverrideTokenMethods(context, tokenStore, options) {
})
return result
}
const sourceCode = new Proxy(Object.assign({}, eslintSourceCode), {
get(_object, key) {
if (key === 'tokensAndComments') {
const sourceCode = newProxy(
eslintSourceCode,
{
get tokensAndComments() {
return getTokensAndComments()
}
if (key === 'getNodeByRangeIndex') {
return getNodeByRangeIndex
}
// @ts-expect-error
return key in tokenStore ? tokenStore[key] : eslintSourceCode[key]
}
})
},
getNodeByRangeIndex
},
tokenStore
)

const containerScopes = new WeakMap()

Expand All @@ -183,23 +216,13 @@ function wrapContextToOverrideTokenMethods(context, tokenStore, options) {
const eslintScope = createRequire(require.resolve('eslint'))(
'eslint-scope'
)
const expStmt = new Proxy(exprContainer, {
get(_object, key) {
if (key === 'type') {
return 'ExpressionStatement'
}
// @ts-expect-error
return exprContainer[key]
}
const expStmt = newProxy(exprContainer, {
// @ts-expect-error
type: 'ExpressionStatement'
})
const scopeProgram = new Proxy(programNode, {
get(_object, key) {
if (key === 'body') {
return [expStmt]
}
// @ts-expect-error
return programNode[key]
}
const scopeProgram = newProxy(programNode, {
// @ts-expect-error
body: [expStmt]
})
const scope = eslintScope.analyze(scopeProgram, {
ignoreEval: true,
Expand All @@ -218,9 +241,7 @@ function wrapContextToOverrideTokenMethods(context, tokenStore, options) {

return null
}
return {
// @ts-expect-error
__proto__: context,
return newProxy(context, {
getSourceCode() {
return sourceCode
},
Expand All @@ -232,7 +253,7 @@ function wrapContextToOverrideTokenMethods(context, tokenStore, options) {

return context.getDeclaredVariables(node)
}
}
})
}

/**
Expand Down Expand Up @@ -262,9 +283,7 @@ function wrapContextToOverrideReportMethodToSkipDynamicArgument(context) {
leaveNode() {}
})

return {
// @ts-expect-error
__proto__: context,
return newProxy(context, {
report(descriptor, ...args) {
let range = null
if (descriptor.loc) {
Expand All @@ -289,7 +308,7 @@ function wrapContextToOverrideReportMethodToSkipDynamicArgument(context) {
}
context.report(descriptor, ...args)
}
}
})
}

// ------------------------------------------------------------------------------
Expand Down Expand Up @@ -322,6 +341,25 @@ module.exports = {
*/
defineDocumentVisitor,

/**
* @callback WrapCoreRuleCreate
* @param {RuleContext} ruleContext
* @param {WrapCoreRuleCreateContext} wrapContext
* @returns {TemplateListener}
*
* @typedef {object} WrapCoreRuleCreateContext
* @property {RuleListener} coreHandlers
*/
/**
* @callback WrapCoreRulePreprocess
* @param {RuleContext} ruleContext
* @param {WrapCoreRulePreprocessContext} wrapContext
* @returns {void}
*
* @typedef {object} WrapCoreRulePreprocessContext
* @property { (override: Partial<RuleContext>) => RuleContext } wrapContextToOverrideProperties Wrap the rule context object to override
* @property { (visitor: TemplateListener) => void } defineVisitor Define template body visitor
*/
/**
* Wrap a given core rule to apply it to Vue.js template.
* @param {string} coreRuleName The name of the core rule implementation to wrap.
Expand All @@ -330,7 +368,8 @@ module.exports = {
* @param {boolean} [options.skipDynamicArguments] If `true`, skip validation within dynamic arguments.
* @param {boolean} [options.skipDynamicArgumentsReport] If `true`, skip report within dynamic arguments.
* @param {boolean} [options.applyDocument] If `true`, apply check to document fragment.
* @param { (context: RuleContext, options: { coreHandlers: RuleListener }) => TemplateListener } [options.create] If define, extend core rule.
* @param {WrapCoreRulePreprocess} [options.preprocess] Preprocess to calling create of core rule.
* @param {WrapCoreRuleCreate} [options.create] If define, extend core rule.
* @returns {RuleModule} The wrapped rule implementation.
*/
wrapCoreRule(coreRuleName, options) {
Expand Down Expand Up @@ -366,6 +405,7 @@ module.exports = {
skipDynamicArguments,
skipDynamicArgumentsReport,
applyDocument,
preprocess,
create
} = options || {}
return {
Expand All @@ -387,12 +427,25 @@ module.exports = {
wrapContextToOverrideReportMethodToSkipDynamicArgument(context)
}

// Move `Program` handlers to `VElement[parent.type!='VElement']`
/** @type {TemplateListener} */
const handlers = {}

if (preprocess) {
preprocess(context, {
wrapContextToOverrideProperties(override) {
context = newProxy(context, override)
return context
},
defineVisitor(visitor) {
compositingVisitors(handlers, visitor)
}
})
}

const coreHandlers = coreRule.create(context)
compositingVisitors(handlers, coreHandlers)

const handlers = /** @type {TemplateListener} */ (
Object.assign({}, coreHandlers)
)
// Move `Program` handlers to `VElement[parent.type!='VElement']`
if (handlers.Program) {
handlers[
applyDocument
Expand Down Expand Up @@ -462,6 +515,13 @@ module.exports = {
* @returns {v is T}
*/
isDef,
/**
* Flattens arrays, objects and iterable objects.
* @template T
* @param {T | Iterable<T> | null | undefined} v
* @returns {T[]}
*/
flatten,
/**
* Get the previous sibling element of the given element.
* @param {VElement} node The element node to get the previous sibling element.
Expand Down Expand Up @@ -1837,6 +1897,33 @@ function isDef(v) {
return v != null
}

/**
* Flattens arrays, objects and iterable objects.
* @template T
* @param {T | Iterable<T> | null | undefined} v
* @returns {T[]}
*/
function flatten(v) {
/** @type {T[]} */
const result = []
if (v) {
if (isIterable(v)) {
result.push(...v)
} else {
result.push(v)
}
}
return result
}

/**
* @param {*} v
* @returns {v is Iterable<any>}
*/
function isIterable(v) {
return v && Symbol.iterator in v
}

// ------------------------------------------------------------------------------
// Nodejs Helpers
// ------------------------------------------------------------------------------
Expand Down

0 comments on commit f527e27

Please sign in to comment.