diff --git a/.changeset/two-hats-ask.md b/.changeset/two-hats-ask.md new file mode 100644 index 000000000..8d0295c45 --- /dev/null +++ b/.changeset/two-hats-ask.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-svelte': minor +--- + +feat(consistent-selector-style): added support for dynamic classes and IDs diff --git a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts index 5af44ce1a..8c2b75d33 100644 --- a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts +++ b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts @@ -6,10 +6,22 @@ import type { Node as SelectorNode, Tag as SelectorTag } from 'postcss-selector-parser'; +import type { SvelteHTMLElement } from 'svelte-eslint-parser/lib/ast'; import { findClassesInAttribute } from '../utils/ast-utils.js'; import { getSourceCode } from '../utils/compat.js'; +import { + extractExpressionPrefixLiteral, + extractExpressionSuffixLiteral +} from '../utils/expression-affixes.js'; import { createRule } from '../utils/index.js'; +interface Selections { + exact: Map<string, AST.SvelteHTMLElement[]>; + // [prefix, suffix] + affixes: Map<[string | null, string | null], AST.SvelteHTMLElement[]>; + universalSelector: boolean; +} + export default createRule('consistent-selector-style', { meta: { docs: { @@ -63,9 +75,24 @@ export default createRule('consistent-selector-style', { const style = context.options[0]?.style ?? ['type', 'id', 'class']; const whitelistedClasses: string[] = []; - const classSelections: Map<string, AST.SvelteHTMLElement[]> = new Map(); - const idSelections: Map<string, AST.SvelteHTMLElement[]> = new Map(); - const typeSelections: Map<string, AST.SvelteHTMLElement[]> = new Map(); + + const selections: { + class: Selections; + id: Selections; + type: Map<string, AST.SvelteHTMLElement[]>; + } = { + class: { + exact: new Map(), + affixes: new Map(), + universalSelector: false + }, + id: { + exact: new Map(), + affixes: new Map(), + universalSelector: false + }, + type: new Map() + }; /** * Checks selectors in a given PostCSS node @@ -110,10 +137,10 @@ export default createRule('consistent-selector-style', { * Checks a class selector */ function checkClassSelector(node: SelectorClass): void { - if (whitelistedClasses.includes(node.value)) { + if (selections.class.universalSelector || whitelistedClasses.includes(node.value)) { return; } - const selection = classSelections.get(node.value) ?? []; + const selection = matchSelection(selections.class, node.value); for (const styleValue of style) { if (styleValue === 'class') { return; @@ -125,7 +152,7 @@ export default createRule('consistent-selector-style', { }); return; } - if (styleValue === 'type' && canUseTypeSelector(selection, typeSelections)) { + if (styleValue === 'type' && canUseTypeSelector(selection, selections.type)) { context.report({ messageId: 'classShouldBeType', loc: styleSelectorNodeLoc(node) as AST.SourceLocation @@ -139,7 +166,10 @@ export default createRule('consistent-selector-style', { * Checks an ID selector */ function checkIdSelector(node: SelectorIdentifier): void { - const selection = idSelections.get(node.value) ?? []; + if (selections.id.universalSelector) { + return; + } + const selection = matchSelection(selections.id, node.value); for (const styleValue of style) { if (styleValue === 'class') { context.report({ @@ -151,7 +181,7 @@ export default createRule('consistent-selector-style', { if (styleValue === 'id') { return; } - if (styleValue === 'type' && canUseTypeSelector(selection, typeSelections)) { + if (styleValue === 'type' && canUseTypeSelector(selection, selections.type)) { context.report({ messageId: 'idShouldBeType', loc: styleSelectorNodeLoc(node) as AST.SourceLocation @@ -165,7 +195,7 @@ export default createRule('consistent-selector-style', { * Checks a type selector */ function checkTypeSelector(node: SelectorTag): void { - const selection = typeSelections.get(node.value) ?? []; + const selection = selections.type.get(node.value) ?? []; for (const styleValue of style) { if (styleValue === 'class') { context.report({ @@ -192,21 +222,39 @@ export default createRule('consistent-selector-style', { if (node.kind !== 'html') { return; } - addToArrayMap(typeSelections, node.name.name, node); - const classes = node.startTag.attributes.flatMap(findClassesInAttribute); - for (const className of classes) { - addToArrayMap(classSelections, className, node); - } + addToArrayMap(selections.type, node.name.name, node); for (const attribute of node.startTag.attributes) { if (attribute.type === 'SvelteDirective' && attribute.kind === 'Class') { whitelistedClasses.push(attribute.key.name.name); } - if (attribute.type !== 'SvelteAttribute' || attribute.key.name !== 'id') { + for (const className of findClassesInAttribute(attribute)) { + addToArrayMap(selections.class.exact, className, node); + } + if (attribute.type !== 'SvelteAttribute') { continue; } for (const value of attribute.value) { - if (value.type === 'SvelteLiteral') { - addToArrayMap(idSelections, value.value, node); + if (attribute.key.name === 'class' && value.type === 'SvelteMustacheTag') { + const prefix = extractExpressionPrefixLiteral(context, value.expression); + const suffix = extractExpressionSuffixLiteral(context, value.expression); + if (prefix === null && suffix === null) { + selections.class.universalSelector = true; + } else { + addToArrayMap(selections.class.affixes, [prefix, suffix], node); + } + } + if (attribute.key.name === 'id') { + if (value.type === 'SvelteLiteral') { + addToArrayMap(selections.id.exact, value.value, node); + } else if (value.type === 'SvelteMustacheTag') { + const prefix = extractExpressionPrefixLiteral(context, value.expression); + const suffix = extractExpressionSuffixLiteral(context, value.expression); + if (prefix === null && suffix === null) { + selections.id.universalSelector = true; + } else { + addToArrayMap(selections.id.affixes, [prefix, suffix], node); + } + } } } } @@ -228,14 +276,27 @@ export default createRule('consistent-selector-style', { /** * Helper function to add a value to a Map of arrays */ -function addToArrayMap( - map: Map<string, AST.SvelteHTMLElement[]>, - key: string, +function addToArrayMap<T>( + map: Map<T, AST.SvelteHTMLElement[]>, + key: T, value: AST.SvelteHTMLElement ): void { map.set(key, (map.get(key) ?? []).concat(value)); } +/** + * Finds all nodes in selections that could be matched by key + */ +function matchSelection(selections: Selections, key: string): SvelteHTMLElement[] { + const selection = selections.exact.get(key) ?? []; + selections.affixes.forEach((nodes, [prefix, suffix]) => { + if ((prefix === null || key.startsWith(prefix)) && (suffix === null || key.endsWith(suffix))) { + selection.push(...nodes); + } + }); + return selection; +} + /** * Checks whether a given selection could be obtained using an ID selector */ diff --git a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts index aaeb4c6c6..f53018607 100644 --- a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts +++ b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts @@ -3,6 +3,7 @@ import { createRule } from '../utils/index.js'; import { ReferenceTracker } from '@eslint-community/eslint-utils'; import { getSourceCode } from '../utils/compat.js'; import { findVariable } from '../utils/ast-utils.js'; +import { extractExpressionPrefixVariable } from '../utils/expression-affixes.js'; import type { RuleContext } from '../types.js'; import type { SvelteLiteral } from 'svelte-eslint-parser/lib/ast'; @@ -224,87 +225,8 @@ function expressionStartsWithBase( url: TSESTree.Expression, basePathNames: Set<TSESTree.Identifier> ): boolean { - switch (url.type) { - case 'BinaryExpression': - return binaryExpressionStartsWithBase(context, url, basePathNames); - case 'Identifier': - return variableStartsWithBase(context, url, basePathNames); - case 'MemberExpression': - return memberExpressionStartsWithBase(url, basePathNames); - case 'TemplateLiteral': - return templateLiteralStartsWithBase(context, url, basePathNames); - default: - return false; - } -} - -function binaryExpressionStartsWithBase( - context: RuleContext, - url: TSESTree.BinaryExpression, - basePathNames: Set<TSESTree.Identifier> -): boolean { - return ( - url.left.type !== 'PrivateIdentifier' && - expressionStartsWithBase(context, url.left, basePathNames) - ); -} - -function memberExpressionStartsWithBase( - url: TSESTree.MemberExpression, - basePathNames: Set<TSESTree.Identifier> -): boolean { - return url.property.type === 'Identifier' && basePathNames.has(url.property); -} - -function variableStartsWithBase( - context: RuleContext, - url: TSESTree.Identifier, - basePathNames: Set<TSESTree.Identifier> -): boolean { - if (basePathNames.has(url)) { - return true; - } - const variable = findVariable(context, url); - if ( - variable === null || - variable.identifiers.length !== 1 || - variable.identifiers[0].parent.type !== 'VariableDeclarator' || - variable.identifiers[0].parent.init === null - ) { - return false; - } - return expressionStartsWithBase(context, variable.identifiers[0].parent.init, basePathNames); -} - -function templateLiteralStartsWithBase( - context: RuleContext, - url: TSESTree.TemplateLiteral, - basePathNames: Set<TSESTree.Identifier> -): boolean { - const startingIdentifier = extractLiteralStartingExpression(url); - return ( - startingIdentifier !== undefined && - expressionStartsWithBase(context, startingIdentifier, basePathNames) - ); -} - -function extractLiteralStartingExpression( - templateLiteral: TSESTree.TemplateLiteral -): TSESTree.Expression | undefined { - const literalParts = [...templateLiteral.expressions, ...templateLiteral.quasis].sort((a, b) => - a.range[0] < b.range[0] ? -1 : 1 - ); - for (const part of literalParts) { - if (part.type === 'TemplateElement' && part.value.raw === '') { - // Skip empty quasi in the begining - continue; - } - if (part.type !== 'TemplateElement') { - return part; - } - return undefined; - } - return undefined; + const prefixVariable = extractExpressionPrefixVariable(context, url); + return prefixVariable !== null && basePathNames.has(prefixVariable); } function expressionIsEmpty(url: TSESTree.Expression): boolean { diff --git a/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts new file mode 100644 index 000000000..6c960746b --- /dev/null +++ b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts @@ -0,0 +1,209 @@ +import type { TSESTree } from '@typescript-eslint/types'; +import { findVariable } from './ast-utils.js'; +import type { RuleContext } from '../types.js'; +import type { SvelteLiteral } from 'svelte-eslint-parser/lib/ast'; + +// Variable prefix extraction + +export function extractExpressionPrefixVariable( + context: RuleContext, + expression: TSESTree.Expression +): TSESTree.Identifier | null { + switch (expression.type) { + case 'BinaryExpression': + return extractBinaryExpressionPrefixVariable(context, expression); + case 'Identifier': + return extractVariablePrefixVariable(context, expression); + case 'MemberExpression': + return extractMemberExpressionPrefixVariable(expression); + case 'TemplateLiteral': + return extractTemplateLiteralPrefixVariable(context, expression); + default: + return null; + } +} + +function extractBinaryExpressionPrefixVariable( + context: RuleContext, + expression: TSESTree.BinaryExpression +): TSESTree.Identifier | null { + return expression.left.type !== 'PrivateIdentifier' + ? extractExpressionPrefixVariable(context, expression.left) + : null; +} + +function extractVariablePrefixVariable( + context: RuleContext, + expression: TSESTree.Identifier +): TSESTree.Identifier | null { + const variable = findVariable(context, expression); + if ( + variable === null || + variable.identifiers.length !== 1 || + variable.identifiers[0].parent.type !== 'VariableDeclarator' || + variable.identifiers[0].parent.init === null + ) { + return expression; + } + return ( + extractExpressionPrefixVariable(context, variable.identifiers[0].parent.init) ?? expression + ); +} + +function extractMemberExpressionPrefixVariable( + expression: TSESTree.MemberExpression +): TSESTree.Identifier | null { + return expression.property.type === 'Identifier' ? expression.property : null; +} + +function extractTemplateLiteralPrefixVariable( + context: RuleContext, + expression: TSESTree.TemplateLiteral +): TSESTree.Identifier | null { + const literalParts = [...expression.expressions, ...expression.quasis].sort((a, b) => + a.range[0] < b.range[0] ? -1 : 1 + ); + for (const part of literalParts) { + if (part.type === 'TemplateElement' && part.value.raw === '') { + // Skip empty quasi in the begining + continue; + } + if (part.type !== 'TemplateElement') { + return extractExpressionPrefixVariable(context, part); + } + return null; + } + return null; +} + +// Literal prefix extraction + +export function extractExpressionPrefixLiteral( + context: RuleContext, + expression: SvelteLiteral | TSESTree.Node +): string | null { + switch (expression.type) { + case 'BinaryExpression': + return extractBinaryExpressionPrefixLiteral(context, expression); + case 'Identifier': + return extractVariablePrefixLiteral(context, expression); + case 'Literal': + return typeof expression.value === 'string' ? expression.value : null; + case 'SvelteLiteral': + return expression.value; + case 'TemplateLiteral': + return extractTemplateLiteralPrefixLiteral(context, expression); + default: + return null; + } +} + +function extractBinaryExpressionPrefixLiteral( + context: RuleContext, + expression: TSESTree.BinaryExpression +): string | null { + return expression.left.type !== 'PrivateIdentifier' + ? extractExpressionPrefixLiteral(context, expression.left) + : null; +} + +function extractVariablePrefixLiteral( + context: RuleContext, + expression: TSESTree.Identifier +): string | null { + const variable = findVariable(context, expression); + if ( + variable === null || + variable.identifiers.length !== 1 || + variable.identifiers[0].parent.type !== 'VariableDeclarator' || + variable.identifiers[0].parent.init === null + ) { + return null; + } + return extractExpressionPrefixLiteral(context, variable.identifiers[0].parent.init); +} + +function extractTemplateLiteralPrefixLiteral( + context: RuleContext, + expression: TSESTree.TemplateLiteral +): string | null { + const literalParts = [...expression.expressions, ...expression.quasis].sort((a, b) => + a.range[0] < b.range[0] ? -1 : 1 + ); + for (const part of literalParts) { + if (part.type === 'TemplateElement') { + if (part.value.raw === '') { + // Skip empty quasi + continue; + } + return part.value.raw; + } + return extractExpressionPrefixLiteral(context, part); + } + return null; +} + +// Literal suffix extraction + +export function extractExpressionSuffixLiteral( + context: RuleContext, + expression: SvelteLiteral | TSESTree.Node +): string | null { + switch (expression.type) { + case 'BinaryExpression': + return extractBinaryExpressionSuffixLiteral(context, expression); + case 'Identifier': + return extractVariableSuffixLiteral(context, expression); + case 'Literal': + return typeof expression.value === 'string' ? expression.value : null; + case 'SvelteLiteral': + return expression.value; + case 'TemplateLiteral': + return extractTemplateLiteralSuffixLiteral(context, expression); + default: + return null; + } +} + +function extractBinaryExpressionSuffixLiteral( + context: RuleContext, + expression: TSESTree.BinaryExpression +): string | null { + return extractExpressionSuffixLiteral(context, expression.right); +} + +function extractVariableSuffixLiteral( + context: RuleContext, + expression: TSESTree.Identifier +): string | null { + const variable = findVariable(context, expression); + if ( + variable === null || + variable.identifiers.length !== 1 || + variable.identifiers[0].parent.type !== 'VariableDeclarator' || + variable.identifiers[0].parent.init === null + ) { + return null; + } + return extractExpressionSuffixLiteral(context, variable.identifiers[0].parent.init); +} + +function extractTemplateLiteralSuffixLiteral( + context: RuleContext, + expression: TSESTree.TemplateLiteral +): string | null { + const literalParts = [...expression.expressions, ...expression.quasis].sort((a, b) => + a.range[0] < b.range[0] ? -1 : 1 + ); + for (const part of literalParts.reverse()) { + if (part.type === 'TemplateElement') { + if (part.value.raw === '') { + // Skip empty quasi + continue; + } + return part.value.raw; + } + return extractExpressionSuffixLiteral(context, part); + } + return null; +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-prefix01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-prefix01-input.svelte new file mode 100644 index 000000000..d3437cb3b --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-prefix01-input.svelte @@ -0,0 +1,33 @@ +<script> + import { value } from "package"; + + const derived = "link-three-" + value; +</script> + +<a>Click me!</a> + +<a class={"link-one-" + value}>Click me two!</a> + +<a class={"link-one-" + value}>Click me two!</a> + +<a class={`link-two-${value}`}>Click me three!</a> + +<a class={`link-two-${value}`}>Click me three!</a> + +<a class={derived}>Click me four!</a> + +<a class={derived}>Click me four!</a> + +<style> + .link-one-foo { + color: red; + } + + .link-two-foo { + color: red; + } + + .link-three-foo { + color: red; + } +</style> diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte new file mode 100644 index 000000000..5d6b127a3 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte @@ -0,0 +1,34 @@ +<script> + import { value } from "package"; + + const derived = value + "-link-three"; +</script> + +<a>Click me!</a> + +<a class={value + "-link-one"}>Click me two!</a> + +<a class={value + "-link-one"}>Click me two!</a> + +<a class={`${value}-link-two`}>Click me three!</a> + +<a class={`${value}-link-two`}>Click me three!</a> + + +<a class={derived}>Click me four!</a> + +<a class={derived}>Click me four!</a> + +<style> + .foo-link-one { + color: red; + } + + .foo-link-two { + color: red; + } + + .foo-link-three { + color: red; + } +</style> diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-universal01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-universal01-input.svelte new file mode 100644 index 000000000..72ea834f2 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-universal01-input.svelte @@ -0,0 +1,23 @@ +<script> + import { value } from "package"; +</script> + +<a>Click me!</a> + +<a class={value}>Click me two!</a> + +<a class={value}>Click me two!</a> + +<style> + .link-one { + color: red; + } + + .link-two { + color: red; + } + + .link-three { + color: red; + } +</style> diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-prefix01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-prefix01-input.svelte new file mode 100644 index 000000000..3e6743a71 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-prefix01-input.svelte @@ -0,0 +1,27 @@ +<script> + import { value } from "package"; + + const derived = "link-three-" + value; +</script> + +<a>Click me!</a> + +<a id={"link-one-" + value}>Click me two!</a> + +<a id={`link-two-${value}`}>Click me three!</a> + +<a id={derived}>Click me four!</a> + +<style> + #link-one-foo { + color: red; + } + + #link-two-foo { + color: red; + } + + #link-three-foo { + color: red; + } +</style> diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-suffix01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-suffix01-input.svelte new file mode 100644 index 000000000..dc701edaf --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-suffix01-input.svelte @@ -0,0 +1,27 @@ +<script> + import { value } from "package"; + + const derived = value + "-link-three"; +</script> + +<a>Click me!</a> + +<a id={value + "-link-one"}>Click me two!</a> + +<a id={`${value}-link-two`}>Click me three!</a> + +<a id={derived}>Click me four!</a> + +<style> + #foo-link-one { + color: red; + } + + #foo-link-two { + color: red; + } + + #foo-link-three { + color: red; + } +</style> diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-universal01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-universal01-input.svelte new file mode 100644 index 000000000..457936530 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-universal01-input.svelte @@ -0,0 +1,23 @@ +<script> + import { value } from "package"; +</script> + +<a>Click me!</a> + +<a id={value}>Click me two!</a> + +<a id={value}>Click me two!</a> + +<style> + #link-one { + color: red; + } + + #link-two { + color: red; + } + + #link-three { + color: red; + } +</style>