diff --git a/README.md b/README.md index 72f0bf788..3614ffdec 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ These rules relate to possible syntax or logic errors in Svelte code: | Rule ID | Description | | |:--------|:------------|:---| | [@ota-meshi/svelte/no-dupe-else-if-blocks](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-dupe-else-if-blocks.html) | disallow duplicate conditions in `{#if}` / `{:else if}` chains | :star: | +| [@ota-meshi/svelte/no-dynamic-slot-name](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-dynamic-slot-name.html) | disallow dynamic slot name | :star::wrench: | | [@ota-meshi/svelte/no-not-function-handler](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-not-function-handler.html) | disallow use of not function in event handler | :star: | | [@ota-meshi/svelte/no-object-in-text-mustaches](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-object-in-text-mustaches.html) | disallow objects in text mustache interpolation | :star: | | [@ota-meshi/svelte/valid-compile](https://ota-meshi.github.io/eslint-plugin-svelte/rules/valid-compile.html) | disallow warnings when compiling. | :star: | diff --git a/docs/rules/README.md b/docs/rules/README.md index 7bf67423b..46e95ce3d 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -16,6 +16,7 @@ These rules relate to possible syntax or logic errors in Svelte code: | Rule ID | Description | | |:--------|:------------|:---| | [@ota-meshi/svelte/no-dupe-else-if-blocks](./no-dupe-else-if-blocks.md) | disallow duplicate conditions in `{#if}` / `{:else if}` chains | :star: | +| [@ota-meshi/svelte/no-dynamic-slot-name](./no-dynamic-slot-name.md) | disallow dynamic slot name | :star::wrench: | | [@ota-meshi/svelte/no-not-function-handler](./no-not-function-handler.md) | disallow use of not function in event handler | :star: | | [@ota-meshi/svelte/no-object-in-text-mustaches](./no-object-in-text-mustaches.md) | disallow objects in text mustache interpolation | :star: | | [@ota-meshi/svelte/valid-compile](./valid-compile.md) | disallow warnings when compiling. | :star: | diff --git a/docs/rules/no-dynamic-slot-name.md b/docs/rules/no-dynamic-slot-name.md new file mode 100644 index 000000000..9cf15d212 --- /dev/null +++ b/docs/rules/no-dynamic-slot-name.md @@ -0,0 +1,49 @@ +--- +pageClass: "rule-details" +sidebarDepth: 0 +title: "@ota-meshi/svelte/no-dynamic-slot-name" +description: "disallow dynamic slot name" +--- + +# @ota-meshi/svelte/no-dynamic-slot-name + +> disallow dynamic slot name + +- :exclamation: **_This rule has not been released yet._** +- :gear: This rule is included in `"plugin:@ota-meshi/svelte/recommended"`. +- :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 dynamically specified `` name. +Dynamic `` names are not allowed in Svelte, so you must use static names. + +The auto-fix of this rule can be replaced with a static `` name if the expression given to the `` name is static and resolvable. + + + + + +```svelte + + + + + + + +``` + + + +## :wrench: Options + +Nothing. + +## :mag: Implementation + +- [Rule source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/src/rules/no-dynamic-slot-name.ts) +- [Test source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/tests/src/rules/no-dynamic-slot-name.ts) diff --git a/docs/rules/valid-compile.md b/docs/rules/valid-compile.md index b403a908b..5eb17bcba 100644 --- a/docs/rules/valid-compile.md +++ b/docs/rules/valid-compile.md @@ -35,7 +35,7 @@ This rule uses Svelte compiler to check the source code. -Note that we exclude reports for some checks, such as `missing-declaration`, which you can check with different ESLint rules. +Note that we exclude reports for some checks, such as `missing-declaration`, and `dynamic-slot-name`, which you can check with different ESLint rules. ## :wrench: Options diff --git a/src/configs/recommended.ts b/src/configs/recommended.ts index c4db9c76e..03c499b30 100644 --- a/src/configs/recommended.ts +++ b/src/configs/recommended.ts @@ -10,6 +10,7 @@ export = { "@ota-meshi/svelte/no-at-debug-tags": "warn", "@ota-meshi/svelte/no-at-html-tags": "error", "@ota-meshi/svelte/no-dupe-else-if-blocks": "error", + "@ota-meshi/svelte/no-dynamic-slot-name": "error", "@ota-meshi/svelte/no-inner-declarations": "error", "@ota-meshi/svelte/no-not-function-handler": "error", "@ota-meshi/svelte/no-object-in-text-mustaches": "error", diff --git a/src/rules/html-quotes.ts b/src/rules/html-quotes.ts index 6d0559966..34019a5c6 100644 --- a/src/rules/html-quotes.ts +++ b/src/rules/html-quotes.ts @@ -5,8 +5,9 @@ import { isNotOpeningBraceToken, isOpeningParenToken, } from "eslint-utils" -import type { NodeOrToken } from "../types" import { createRule } from "../utils" +import type { QuoteAndRange } from "../utils/ast-utils" +import { getAttributeValueQuoteAndRange } from "../utils/ast-utils" const QUOTE_CHARS = { double: '"', @@ -61,56 +62,6 @@ export default createRule("html-quotes", { context.options[0]?.dynamic?.avoidInvalidUnquotedInHTML, ) - type QuoteAndRange = { - quote: "unquoted" | "double" | "single" - range: [number, number] - } - - /** Get the quote and range from given attribute values */ - function getQuoteAndRange( - attr: - | AST.SvelteAttribute - | AST.SvelteDirective - | AST.SvelteSpecialDirective, - valueTokens: NodeOrToken[], - ): QuoteAndRange | null { - const valueFirstToken = valueTokens[0] - const valueLastToken = valueTokens[valueTokens.length - 1] - const eqToken = sourceCode.getTokenAfter(attr.key) - if ( - !eqToken || - eqToken.value !== "=" || - valueFirstToken.range![0] < eqToken.range[1] - ) { - // invalid - return null - } - const beforeTokens = sourceCode.getTokensBetween(eqToken, valueFirstToken) - if (beforeTokens.length === 0) { - return { - quote: "unquoted", - range: [valueFirstToken.range![0], valueLastToken.range![1]], - } - } else if ( - beforeTokens.length > 1 || - (beforeTokens[0].value !== '"' && beforeTokens[0].value !== "'") - ) { - // invalid - return null - } - const beforeToken = beforeTokens[0] - const afterToken = sourceCode.getTokenAfter(valueLastToken) - if (!afterToken || afterToken.value !== beforeToken.value) { - // invalid - return null - } - - return { - quote: beforeToken.value === '"' ? "double" : "single", - range: [beforeToken.range[0], afterToken.range[1]], - } - } - /** Checks whether the given text can remove quotes in HTML. */ function canBeUnquotedInHTML(text: string) { return !/[\s"'<=>`]/u.test(text) @@ -202,11 +153,8 @@ export default createRule("html-quotes", { } /** Verify for standard attribute */ - function verifyForValues( - attr: AST.SvelteAttribute, - valueNodes: AST.SvelteAttribute["value"], - ) { - const quoteAndRange = getQuoteAndRange(attr, valueNodes) + function verifyForValues(attr: AST.SvelteAttribute) { + const quoteAndRange = getAttributeValueQuoteAndRange(attr, context) verifyQuote(preferQuote, quoteAndRange) } @@ -217,7 +165,7 @@ export default createRule("html-quotes", { kind: "text" }, ) { - const quoteAndRange = getQuoteAndRange(attr, [valueNode]) + const quoteAndRange = getAttributeValueQuoteAndRange(attr, context) const text = sourceCode.text.slice(...valueNode.range) verifyQuote( avoidInvalidUnquotedInHTML && !canBeUnquotedInHTML(text) @@ -251,7 +199,7 @@ export default createRule("html-quotes", { ) { return } - const quoteAndRange = getQuoteAndRange(attr, [beforeToken, afterToken]) + const quoteAndRange = getAttributeValueQuoteAndRange(attr, context) const text = sourceCode.text.slice( beforeToken.range[0], afterToken.range[1], @@ -272,7 +220,7 @@ export default createRule("html-quotes", { ) { verifyForDynamicMustacheTag(node, node.value[0]) } else if (node.value.length >= 1) { - verifyForValues(node, node.value) + verifyForValues(node) } }, "SvelteDirective, SvelteSpecialDirective"( diff --git a/src/rules/no-dynamic-slot-name.ts b/src/rules/no-dynamic-slot-name.ts new file mode 100644 index 000000000..ac08d71b0 --- /dev/null +++ b/src/rules/no-dynamic-slot-name.ts @@ -0,0 +1,95 @@ +import type { AST } from "svelte-eslint-parser" +import type * as ESTree from "estree" +import { createRule } from "../utils" +import { + findVariable, + getAttributeValueQuoteAndRange, + getStringIfConstant, +} from "../utils/ast-utils" + +export default createRule("no-dynamic-slot-name", { + meta: { + docs: { + description: "disallow dynamic slot name", + category: "Possible Errors", + recommended: true, + }, + fixable: "code", + schema: [], + messages: { + unexpected: "`` name cannot be dynamic.", + requireValue: "`` name requires a value.", + }, + type: "problem", + }, + create(context) { + return { + "SvelteElement[name.name='slot'] > SvelteStartTag.startTag > SvelteAttribute[key.name='name']"( + node: AST.SvelteAttribute, + ) { + if (node.value.length === 0) { + context.report({ + node, + messageId: "requireValue", + }) + return + } + for (const vNode of node.value) { + if (vNode.type === "SvelteMustacheTag") { + context.report({ + node: vNode, + messageId: "unexpected", + fix(fixer) { + const text = getStaticText(vNode.expression) + if (text == null) { + return null + } + + if (node.value.length === 1) { + const range = getAttributeValueQuoteAndRange( + node, + context, + )!.range + return fixer.replaceTextRange(range, `"${text}"`) + } + const range = vNode.range + return fixer.replaceTextRange(range, text) + }, + }) + } + } + }, + } + + /** + * Get static text from given expression + */ + function getStaticText(node: ESTree.Expression) { + const expr = findRootExpression(node) + return getStringIfConstant(expr) + } + + /** Find data expression */ + function findRootExpression( + node: ESTree.Expression, + already = new Set(), + ): ESTree.Expression { + if (node.type !== "Identifier" || already.has(node)) { + return node + } + already.add(node) + const variable = findVariable(context, node) + if (!variable || variable.defs.length !== 1) { + return node + } + const def = variable.defs[0] + if (def.type === "Variable") { + if (def.parent.kind === "const" && def.node.init) { + const init = def.node.init + return findRootExpression(init, already) + } + } + return node + } + }, +}) diff --git a/src/rules/valid-compile.ts b/src/rules/valid-compile.ts index 201a72d32..237b79eb2 100644 --- a/src/rules/valid-compile.ts +++ b/src/rules/valid-compile.ts @@ -87,7 +87,7 @@ export default createRule("valid-compile", { const sourceCode = context.getSourceCode() const text = sourceCode.text - const ignores = ["missing-declaration"] + const ignores = ["missing-declaration", "dynamic-slot-name"] /** * report diff --git a/src/utils/ast-utils.ts b/src/utils/ast-utils.ts index 770995e23..969f3a6c4 100644 --- a/src/utils/ast-utils.ts +++ b/src/utils/ast-utils.ts @@ -229,3 +229,111 @@ export function getScope( return scopeManager.scopes[0] } + +export type QuoteAndRange = { + quote: "unquoted" | "double" | "single" + range: [number, number] +} +/** Get the quote and range from given attribute values */ +export function getAttributeValueQuoteAndRange( + attr: + | SvAST.SvelteAttribute + | SvAST.SvelteDirective + | SvAST.SvelteSpecialDirective, + context: RuleContext, +): QuoteAndRange | null { + const sourceCode = context.getSourceCode() + const valueTokens = getAttributeValueRangeTokens(attr, sourceCode) + if (valueTokens == null) { + return null + } + const { firstToken: valueFirstToken, lastToken: valueLastToken } = valueTokens + const eqToken = sourceCode.getTokenAfter(attr.key) + if ( + !eqToken || + eqToken.value !== "=" || + valueFirstToken.range[0] < eqToken.range[1] + ) { + // invalid + return null + } + const beforeTokens = sourceCode.getTokensBetween(eqToken, valueFirstToken) + if (beforeTokens.length === 0) { + return { + quote: "unquoted", + range: [valueFirstToken.range[0], valueLastToken.range[1]], + } + } else if ( + beforeTokens.length > 1 || + (beforeTokens[0].value !== '"' && beforeTokens[0].value !== "'") + ) { + // invalid + return null + } + const beforeToken = beforeTokens[0] + const afterToken = sourceCode.getTokenAfter(valueLastToken) + if (!afterToken || afterToken.value !== beforeToken.value) { + // invalid + return null + } + + return { + quote: beforeToken.value === '"' ? "double" : "single", + range: [beforeToken.range[0], afterToken.range[1]], + } +} + +/** Get the value tokens from given attribute */ +function getAttributeValueRangeTokens( + attr: + | SvAST.SvelteAttribute + | SvAST.SvelteDirective + | SvAST.SvelteSpecialDirective, + sourceCode: SourceCode, +) { + if (attr.type === "SvelteAttribute") { + if (!attr.value.length) { + return null + } + const firstToken = attr.value[0] + const lastToken = attr.value[attr.value.length - 1] + return { + firstToken, + lastToken, + } + } + let firstToken, lastToken + if (attr.expression == null) { + return null + } + if ( + attr.key.range[0] <= attr.expression.range![0] && + attr.expression.range![1] <= attr.key.range[1] + ) { + // shorthand + return null + } + firstToken = sourceCode.getTokenBefore(attr.expression) + lastToken = sourceCode.getTokenAfter(attr.expression) + while ( + firstToken && + lastToken && + eslintUtils.isOpeningParenToken(firstToken) && + eslintUtils.isClosingParenToken(lastToken) + ) { + firstToken = sourceCode.getTokenBefore(firstToken) + lastToken = sourceCode.getTokenAfter(lastToken) + } + if ( + !firstToken || + !lastToken || + eslintUtils.isNotOpeningBraceToken(firstToken) || + eslintUtils.isNotClosingBraceToken(lastToken) + ) { + return null + } + return { + firstToken, + lastToken, + } +} diff --git a/src/utils/rules.ts b/src/utils/rules.ts index e9d71effd..d34fe0690 100644 --- a/src/utils/rules.ts +++ b/src/utils/rules.ts @@ -8,6 +8,7 @@ import maxAttributesPerLine from "../rules/max-attributes-per-line" import noAtDebugTags from "../rules/no-at-debug-tags" import noAtHtmlTags from "../rules/no-at-html-tags" import noDupeElseIfBlocks from "../rules/no-dupe-else-if-blocks" +import noDynamicSlotName from "../rules/no-dynamic-slot-name" import noInnerDeclarations from "../rules/no-inner-declarations" import noNotFunctionHandler from "../rules/no-not-function-handler" import noObjectInTextMustaches from "../rules/no-object-in-text-mustaches" @@ -29,6 +30,7 @@ export const rules = [ noAtDebugTags, noAtHtmlTags, noDupeElseIfBlocks, + noDynamicSlotName, noInnerDeclarations, noNotFunctionHandler, noObjectInTextMustaches, diff --git a/tests/fixtures/rules/no-dynamic-slot-name/invalid/bool-slot01-errors.json b/tests/fixtures/rules/no-dynamic-slot-name/invalid/bool-slot01-errors.json new file mode 100644 index 000000000..2a7cd69e2 --- /dev/null +++ b/tests/fixtures/rules/no-dynamic-slot-name/invalid/bool-slot01-errors.json @@ -0,0 +1,7 @@ +[ + { + "message": "`` name requires a value.", + "line": 1, + "column": 7 + } +] diff --git a/tests/fixtures/rules/no-dynamic-slot-name/invalid/bool-slot01-input.svelte b/tests/fixtures/rules/no-dynamic-slot-name/invalid/bool-slot01-input.svelte new file mode 100644 index 000000000..39acf5953 --- /dev/null +++ b/tests/fixtures/rules/no-dynamic-slot-name/invalid/bool-slot01-input.svelte @@ -0,0 +1 @@ + diff --git a/tests/fixtures/rules/no-dynamic-slot-name/invalid/bool-slot01-output.svelte b/tests/fixtures/rules/no-dynamic-slot-name/invalid/bool-slot01-output.svelte new file mode 100644 index 000000000..39acf5953 --- /dev/null +++ b/tests/fixtures/rules/no-dynamic-slot-name/invalid/bool-slot01-output.svelte @@ -0,0 +1 @@ + diff --git a/tests/fixtures/rules/no-dynamic-slot-name/invalid/literal-slot01-errors.json b/tests/fixtures/rules/no-dynamic-slot-name/invalid/literal-slot01-errors.json new file mode 100644 index 000000000..3c677e18a --- /dev/null +++ b/tests/fixtures/rules/no-dynamic-slot-name/invalid/literal-slot01-errors.json @@ -0,0 +1,7 @@ +[ + { + "message": "`` name cannot be dynamic.", + "line": 1, + "column": 12 + } +] diff --git a/tests/fixtures/rules/no-dynamic-slot-name/invalid/literal-slot01-input.svelte b/tests/fixtures/rules/no-dynamic-slot-name/invalid/literal-slot01-input.svelte new file mode 100644 index 000000000..d57fb9b28 --- /dev/null +++ b/tests/fixtures/rules/no-dynamic-slot-name/invalid/literal-slot01-input.svelte @@ -0,0 +1 @@ + diff --git a/tests/fixtures/rules/no-dynamic-slot-name/invalid/literal-slot01-output.svelte b/tests/fixtures/rules/no-dynamic-slot-name/invalid/literal-slot01-output.svelte new file mode 100644 index 000000000..a25a9ab8f --- /dev/null +++ b/tests/fixtures/rules/no-dynamic-slot-name/invalid/literal-slot01-output.svelte @@ -0,0 +1 @@ + diff --git a/tests/fixtures/rules/no-dynamic-slot-name/invalid/var-slot01-errors.json b/tests/fixtures/rules/no-dynamic-slot-name/invalid/var-slot01-errors.json new file mode 100644 index 000000000..8b5b1a12a --- /dev/null +++ b/tests/fixtures/rules/no-dynamic-slot-name/invalid/var-slot01-errors.json @@ -0,0 +1,7 @@ +[ + { + "message": "`` name cannot be dynamic.", + "line": 5, + "column": 12 + } +] diff --git a/tests/fixtures/rules/no-dynamic-slot-name/invalid/var-slot01-input.svelte b/tests/fixtures/rules/no-dynamic-slot-name/invalid/var-slot01-input.svelte new file mode 100644 index 000000000..87e1a41aa --- /dev/null +++ b/tests/fixtures/rules/no-dynamic-slot-name/invalid/var-slot01-input.svelte @@ -0,0 +1,5 @@ + + + diff --git a/tests/fixtures/rules/no-dynamic-slot-name/invalid/var-slot01-output.svelte b/tests/fixtures/rules/no-dynamic-slot-name/invalid/var-slot01-output.svelte new file mode 100644 index 000000000..30e1906d3 --- /dev/null +++ b/tests/fixtures/rules/no-dynamic-slot-name/invalid/var-slot01-output.svelte @@ -0,0 +1,5 @@ + + + diff --git a/tests/fixtures/rules/no-dynamic-slot-name/valid/slot01-input.svelte b/tests/fixtures/rules/no-dynamic-slot-name/valid/slot01-input.svelte new file mode 100644 index 000000000..a25a9ab8f --- /dev/null +++ b/tests/fixtures/rules/no-dynamic-slot-name/valid/slot01-input.svelte @@ -0,0 +1 @@ + diff --git a/tests/fixtures/rules/valid-compile/invalid/dyamic-slot01-errors.json b/tests/fixtures/rules/valid-compile/invalid/dyamic-slot01-errors.json deleted file mode 100644 index 59bf566cd..000000000 --- a/tests/fixtures/rules/valid-compile/invalid/dyamic-slot01-errors.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "message": " name cannot be dynamic(dynamic-slot-name)", - "line": 5, - "column": 7 - } -] diff --git a/tests/fixtures/rules/valid-compile/invalid/dyamic-slot01-input.svelte b/tests/fixtures/rules/valid-compile/invalid/dyamic-slot01-input.svelte deleted file mode 100644 index ea5ab5077..000000000 --- a/tests/fixtures/rules/valid-compile/invalid/dyamic-slot01-input.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/tests/fixtures/rules/valid-compile/valid/dyamic-slot01-input.svelte b/tests/fixtures/rules/valid-compile/valid/dyamic-slot01-input.svelte new file mode 100644 index 000000000..87e1a41aa --- /dev/null +++ b/tests/fixtures/rules/valid-compile/valid/dyamic-slot01-input.svelte @@ -0,0 +1,5 @@ + + + diff --git a/tests/src/rules/no-dynamic-slot-name.ts b/tests/src/rules/no-dynamic-slot-name.ts new file mode 100644 index 000000000..bf6e81df7 --- /dev/null +++ b/tests/src/rules/no-dynamic-slot-name.ts @@ -0,0 +1,16 @@ +import { RuleTester } from "eslint" +import rule from "../../../src/rules/no-dynamic-slot-name" +import { loadTestCases } from "../../utils/utils" + +const tester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + sourceType: "module", + }, +}) + +tester.run( + "no-dynamic-slot-name", + rule as any, + loadTestCases("no-dynamic-slot-name"), +)