From 294bf5e78c12ade97f9f411a4ac5bffd879a28ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Tue, 18 Mar 2025 23:54:02 +0100 Subject: [PATCH 1/7] chore(consistent-selector-style): selections as an object --- .../src/rules/consistent-selector-style.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) 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..21c7524b3 100644 --- a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts +++ b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts @@ -63,9 +63,16 @@ export default createRule('consistent-selector-style', { const style = context.options[0]?.style ?? ['type', 'id', 'class']; const whitelistedClasses: string[] = []; - const classSelections: Map = new Map(); - const idSelections: Map = new Map(); - const typeSelections: Map = new Map(); + + const selections: { + class: Map; + id: Map; + type: Map; + } = { + class: new Map(), + id: new Map(), + type: new Map() + }; /** * Checks selectors in a given PostCSS node @@ -113,7 +120,7 @@ export default createRule('consistent-selector-style', { if (whitelistedClasses.includes(node.value)) { return; } - const selection = classSelections.get(node.value) ?? []; + const selection = selections.class.get(node.value) ?? []; for (const styleValue of style) { if (styleValue === 'class') { return; @@ -125,7 +132,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 +146,7 @@ export default createRule('consistent-selector-style', { * Checks an ID selector */ function checkIdSelector(node: SelectorIdentifier): void { - const selection = idSelections.get(node.value) ?? []; + const selection = selections.id.get(node.value) ?? []; for (const styleValue of style) { if (styleValue === 'class') { context.report({ @@ -151,7 +158,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 +172,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,10 +199,10 @@ export default createRule('consistent-selector-style', { if (node.kind !== 'html') { return; } - addToArrayMap(typeSelections, node.name.name, node); + addToArrayMap(selections.type, node.name.name, node); const classes = node.startTag.attributes.flatMap(findClassesInAttribute); for (const className of classes) { - addToArrayMap(classSelections, className, node); + addToArrayMap(selections.class, className, node); } for (const attribute of node.startTag.attributes) { if (attribute.type === 'SvelteDirective' && attribute.kind === 'Class') { @@ -206,7 +213,7 @@ export default createRule('consistent-selector-style', { } for (const value of attribute.value) { if (value.type === 'SvelteLiteral') { - addToArrayMap(idSelections, value.value, node); + addToArrayMap(selections.id, value.value, node); } } } From cc96baa33410d5b2247bf4ff6aaecd7f12c6d45f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Wed, 19 Mar 2025 02:28:14 +0100 Subject: [PATCH 2/7] chore: extracted expression affix funtions from no-navigation-without-base --- .../src/rules/no-navigation-without-base.ts | 84 +------------------ .../src/utils/expression-affixes.ts | 76 +++++++++++++++++ 2 files changed, 79 insertions(+), 81 deletions(-) create mode 100644 packages/eslint-plugin-svelte/src/utils/expression-affixes.ts 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 ): 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 -): boolean { - return ( - url.left.type !== 'PrivateIdentifier' && - expressionStartsWithBase(context, url.left, basePathNames) - ); -} - -function memberExpressionStartsWithBase( - url: TSESTree.MemberExpression, - basePathNames: Set -): boolean { - return url.property.type === 'Identifier' && basePathNames.has(url.property); -} - -function variableStartsWithBase( - context: RuleContext, - url: TSESTree.Identifier, - basePathNames: Set -): 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 -): 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..13783d8a6 --- /dev/null +++ b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts @@ -0,0 +1,76 @@ +import type { TSESTree } from '@typescript-eslint/types'; +import { findVariable } from './ast-utils.js'; +import type { RuleContext } from '../types.js'; + +// 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; +} From a2a31602b71bdc0e6640def50862931bb6d6aa7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Thu, 20 Mar 2025 19:17:32 +0100 Subject: [PATCH 3/7] feat(consistent-selector-style): matching dynamic class name prefixes --- .../src/rules/consistent-selector-style.ts | 46 ++++++++++--- .../src/utils/expression-affixes.ts | 68 +++++++++++++++++++ .../class-dynamic-prefix01-input.svelte | 33 +++++++++ 3 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-prefix01-input.svelte 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 21c7524b3..8fcf0e1e2 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,17 @@ 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 } from '../utils/expression-affixes.js'; import { createRule } from '../utils/index.js'; +interface Selections { + exact: Map; + prefixes: Map; +} + export default createRule('consistent-selector-style', { meta: { docs: { @@ -65,11 +72,14 @@ export default createRule('consistent-selector-style', { const whitelistedClasses: string[] = []; const selections: { - class: Map; + class: Selections; id: Map; type: Map; } = { - class: new Map(), + class: { + exact: new Map(), + prefixes: new Map() + }, id: new Map(), type: new Map() }; @@ -120,7 +130,7 @@ export default createRule('consistent-selector-style', { if (whitelistedClasses.includes(node.value)) { return; } - const selection = selections.class.get(node.value) ?? []; + const selection = matchSelection(selections.class, node.value); for (const styleValue of style) { if (styleValue === 'class') { return; @@ -200,19 +210,24 @@ export default createRule('consistent-selector-style', { return; } addToArrayMap(selections.type, node.name.name, node); - const classes = node.startTag.attributes.flatMap(findClassesInAttribute); - for (const className of classes) { - addToArrayMap(selections.class, className, 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') { + if (attribute.key.name === 'class' && value.type === 'SvelteMustacheTag') { + const prefix = extractExpressionPrefixLiteral(context, value.expression); + if (prefix !== null) { + addToArrayMap(selections.class.prefixes, prefix, node); + } + } + if (attribute.key.name === 'id' && value.type === 'SvelteLiteral') { addToArrayMap(selections.id, value.value, node); } } @@ -243,6 +258,19 @@ function addToArrayMap( 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.prefixes.forEach((nodes, prefix) => { + if (key.startsWith(prefix)) { + 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/utils/expression-affixes.ts b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts index 13783d8a6..3994e435f 100644 --- a/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts +++ b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts @@ -1,6 +1,7 @@ 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 @@ -74,3 +75,70 @@ function extractTemplateLiteralPrefixVariable( } 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 in the begining + continue; + } + return part.value.raw; + } + return extractExpressionPrefixLiteral(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 @@ + + +Click me! + +Click me two! + +Click me two! + +Click me three! + +Click me three! + +Click me four! + +Click me four! + + From 9e859b9b1f02f73e7d8dfa9b9fd1f0e8f0476f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Thu, 20 Mar 2025 19:21:08 +0100 Subject: [PATCH 4/7] feat(consistent-selector-style): matching dynamic ID prefixes --- .../src/rules/consistent-selector-style.ts | 20 ++++++++++---- .../id-dynamic-prefix01-input.svelte | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-prefix01-input.svelte 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 8fcf0e1e2..6f63c0a67 100644 --- a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts +++ b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts @@ -73,14 +73,17 @@ export default createRule('consistent-selector-style', { const selections: { class: Selections; - id: Map; + id: Selections; type: Map; } = { class: { exact: new Map(), prefixes: new Map() }, - id: new Map(), + id: { + exact: new Map(), + prefixes: new Map() + }, type: new Map() }; @@ -156,7 +159,7 @@ export default createRule('consistent-selector-style', { * Checks an ID selector */ function checkIdSelector(node: SelectorIdentifier): void { - const selection = selections.id.get(node.value) ?? []; + const selection = matchSelection(selections.id, node.value); for (const styleValue of style) { if (styleValue === 'class') { context.report({ @@ -227,8 +230,15 @@ export default createRule('consistent-selector-style', { addToArrayMap(selections.class.prefixes, prefix, node); } } - if (attribute.key.name === 'id' && value.type === 'SvelteLiteral') { - addToArrayMap(selections.id, value.value, 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); + if (prefix !== null) { + addToArrayMap(selections.id.prefixes, prefix, node); + } + } } } } 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 @@ + + +Click me! + +Click me two! + +Click me three! + +Click me four! + + From 639b26519beae076f8e8eb0283eb56fc6daa5815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Thu, 20 Mar 2025 19:28:19 +0100 Subject: [PATCH 5/7] feat(consistent-selector-style): switching off for universal selector --- .../src/rules/consistent-selector-style.ts | 16 ++++++++++--- .../class-dynamic-universal01-input.svelte | 23 +++++++++++++++++++ .../id-dynamic-universal01-input.svelte | 23 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-universal01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-universal01-input.svelte 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 6f63c0a67..4ef109317 100644 --- a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts +++ b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts @@ -15,6 +15,7 @@ import { createRule } from '../utils/index.js'; interface Selections { exact: Map; prefixes: Map; + universalSelector: boolean; } export default createRule('consistent-selector-style', { @@ -78,11 +79,13 @@ export default createRule('consistent-selector-style', { } = { class: { exact: new Map(), - prefixes: new Map() + prefixes: new Map(), + universalSelector: false }, id: { exact: new Map(), - prefixes: new Map() + prefixes: new Map(), + universalSelector: false }, type: new Map() }; @@ -130,7 +133,7 @@ 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 = matchSelection(selections.class, node.value); @@ -159,6 +162,9 @@ export default createRule('consistent-selector-style', { * Checks an ID selector */ function checkIdSelector(node: SelectorIdentifier): void { + if (selections.id.universalSelector) { + return; + } const selection = matchSelection(selections.id, node.value); for (const styleValue of style) { if (styleValue === 'class') { @@ -228,6 +234,8 @@ export default createRule('consistent-selector-style', { const prefix = extractExpressionPrefixLiteral(context, value.expression); if (prefix !== null) { addToArrayMap(selections.class.prefixes, prefix, node); + } else { + selections.class.universalSelector = true; } } if (attribute.key.name === 'id') { @@ -237,6 +245,8 @@ export default createRule('consistent-selector-style', { const prefix = extractExpressionPrefixLiteral(context, value.expression); if (prefix !== null) { addToArrayMap(selections.id.prefixes, prefix, node); + } else { + selections.id.universalSelector = true; } } } 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 @@ + + +Click me! + +Click me two! + +Click me two! + + 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 @@ + + +Click me! + +Click me two! + +Click me two! + + From b6f2ee648f9fc972a6728ee042f80ad6b042321b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Fri, 21 Mar 2025 00:07:51 +0100 Subject: [PATCH 6/7] feat(consistent-selector-style): matching dynamic suffixes --- .../src/rules/consistent-selector-style.ts | 36 +++++----- .../src/utils/expression-affixes.ts | 67 ++++++++++++++++++- .../class-dynamic-suffix01-input.svelte | 34 ++++++++++ .../id-dynamic-suffix01-input.svelte | 27 ++++++++ 4 files changed, 148 insertions(+), 16 deletions(-) create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-suffix01-input.svelte 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 4ef109317..8c2b75d33 100644 --- a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts +++ b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts @@ -9,12 +9,16 @@ import type { import type { SvelteHTMLElement } from 'svelte-eslint-parser/lib/ast'; import { findClassesInAttribute } from '../utils/ast-utils.js'; import { getSourceCode } from '../utils/compat.js'; -import { extractExpressionPrefixLiteral } from '../utils/expression-affixes.js'; +import { + extractExpressionPrefixLiteral, + extractExpressionSuffixLiteral +} from '../utils/expression-affixes.js'; import { createRule } from '../utils/index.js'; interface Selections { exact: Map; - prefixes: Map; + // [prefix, suffix] + affixes: Map<[string | null, string | null], AST.SvelteHTMLElement[]>; universalSelector: boolean; } @@ -79,12 +83,12 @@ export default createRule('consistent-selector-style', { } = { class: { exact: new Map(), - prefixes: new Map(), + affixes: new Map(), universalSelector: false }, id: { exact: new Map(), - prefixes: new Map(), + affixes: new Map(), universalSelector: false }, type: new Map() @@ -232,10 +236,11 @@ export default createRule('consistent-selector-style', { for (const value of attribute.value) { if (attribute.key.name === 'class' && value.type === 'SvelteMustacheTag') { const prefix = extractExpressionPrefixLiteral(context, value.expression); - if (prefix !== null) { - addToArrayMap(selections.class.prefixes, prefix, node); - } else { + 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') { @@ -243,10 +248,11 @@ export default createRule('consistent-selector-style', { addToArrayMap(selections.id.exact, value.value, node); } else if (value.type === 'SvelteMustacheTag') { const prefix = extractExpressionPrefixLiteral(context, value.expression); - if (prefix !== null) { - addToArrayMap(selections.id.prefixes, prefix, node); - } else { + const suffix = extractExpressionSuffixLiteral(context, value.expression); + if (prefix === null && suffix === null) { selections.id.universalSelector = true; + } else { + addToArrayMap(selections.id.affixes, [prefix, suffix], node); } } } @@ -270,9 +276,9 @@ export default createRule('consistent-selector-style', { /** * Helper function to add a value to a Map of arrays */ -function addToArrayMap( - map: Map, - key: string, +function addToArrayMap( + map: Map, + key: T, value: AST.SvelteHTMLElement ): void { map.set(key, (map.get(key) ?? []).concat(value)); @@ -283,8 +289,8 @@ function addToArrayMap( */ function matchSelection(selections: Selections, key: string): SvelteHTMLElement[] { const selection = selections.exact.get(key) ?? []; - selections.prefixes.forEach((nodes, prefix) => { - if (key.startsWith(prefix)) { + selections.affixes.forEach((nodes, [prefix, suffix]) => { + if ((prefix === null || key.startsWith(prefix)) && (suffix === null || key.endsWith(suffix))) { selection.push(...nodes); } }); diff --git a/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts index 3994e435f..6c960746b 100644 --- a/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts +++ b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts @@ -133,7 +133,7 @@ function extractTemplateLiteralPrefixLiteral( for (const part of literalParts) { if (part.type === 'TemplateElement') { if (part.value.raw === '') { - // Skip empty quasi in the begining + // Skip empty quasi continue; } return part.value.raw; @@ -142,3 +142,68 @@ function extractTemplateLiteralPrefixLiteral( } 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-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 @@ + + +Click me! + +Click me two! + +Click me two! + +Click me three! + +Click me three! + + +Click me four! + +Click me four! + + 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 @@ + + +Click me! + +Click me two! + +Click me three! + +Click me four! + + From 2ea7ba7abf7b6af7dab91059b744b7cbe7e2dd2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Fri, 21 Mar 2025 00:12:06 +0100 Subject: [PATCH 7/7] chore(consistent-selector-style): added a changeset --- .changeset/two-hats-ask.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/two-hats-ask.md 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