diff --git a/.changeset/spotty-kings-fry.md b/.changeset/spotty-kings-fry.md new file mode 100644 index 000000000..c379b3735 --- /dev/null +++ b/.changeset/spotty-kings-fry.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-svelte': minor +--- + +feat: added the `consistent-selector-style` rule 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 ad89e96f9..26f4b1d03 100644 --- a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts +++ b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts @@ -1,17 +1,267 @@ -import { createRule } from '../utils'; +import type { SvelteHTMLElement } from 'svelte-eslint-parser/lib/ast/html.js'; +import type { + SourceLocation, + SvelteAttribute, + SvelteDirective, + SvelteGenericsDirective, + SvelteShorthandAttribute, + SvelteSpecialDirective, + SvelteSpreadAttribute, + SvelteStyleDirective +} from 'svelte-eslint-parser/lib/ast'; +import type { AnyNode } from 'postcss'; +import { type Node as SelectorNode } from 'postcss-selector-parser'; +import { getSourceCode } from '../utils/compat.js'; +import { createRule } from '../utils/index.js'; +import type { RuleContext, SourceCode } from '../types.js'; + +interface RuleGlobals { + style: string[]; + classSelections: Map; + idSelections: Map; + typeSelections: Map; + context: RuleContext; + parserServices: SourceCode['parserServices']; +} export default createRule('consistent-selector-style', { meta: { docs: { description: 'enforce a consistent style for CSS selectors', category: 'Stylistic Issues', - recommended: false + recommended: false, + conflictWithPrettier: false + }, + schema: [ + { + type: 'object', + properties: { + // TODO: Add option to include global selectors + style: { + type: 'array', + items: { + enum: ['class', 'id', 'type'] + }, + minItems: 3, // TODO: Allow fewer items + maxItems: 3, + uniqueItems: true + } + }, + required: ['style'], + additionalProperties: false + } + ], + messages: { + classShouldBeId: 'Selector should select by ID instead of class', + classShouldBeType: 'Selector should select by element type instead of class', + idShouldBeClass: 'Selector should select by class instead of ID', + idShouldBeType: 'Selector should select by element type instead of ID', + typeShouldBeClass: 'Selector should select by class instead of element type', + typeShouldBeId: 'Selector should select by ID instead of element type' }, - schema: [], - messages: {}, type: 'suggestion' }, create(context) { - return {}; + const sourceCode = getSourceCode(context); + if (!sourceCode.parserServices.isSvelte) { + return {}; + } + + const style = context.options[0]?.style ?? ['type', 'id', 'class']; + + const classSelections: Map = new Map(); + const idSelections: Map = new Map(); + const typeSelections: Map = new Map(); + + return { + SvelteElement(node) { + 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); + } + for (const attribute of node.startTag.attributes) { + if (attribute.type !== 'SvelteAttribute' || attribute.key.name !== 'id') { + continue; + } + for (const value of attribute.value) { + if (value.type === 'SvelteLiteral') { + addToArrayMap(idSelections, value.value, node); + } + } + } + }, + 'Program:exit'() { + const styleContext = sourceCode.parserServices.getStyleContext!(); + if (styleContext.status !== 'success') { + return; + } + checkSelectorsInPostCSSNode(styleContext.sourceAst, { + style, + classSelections, + idSelections, + typeSelections, + context, + parserServices: sourceCode.parserServices + }); + } + }; } }); + +function addToArrayMap( + map: Map, + key: string, + value: SvelteHTMLElement +): void { + map.set(key, (map.get(key) ?? []).concat(value)); +} + +// TODO: Deduplicate +/** + * Extract all class names used in a HTML element attribute. + */ +function findClassesInAttribute( + attribute: + | SvelteAttribute + | SvelteShorthandAttribute + | SvelteSpreadAttribute + | SvelteDirective + | SvelteStyleDirective + | SvelteSpecialDirective + | SvelteGenericsDirective +): string[] { + if (attribute.type === 'SvelteAttribute' && attribute.key.name === 'class') { + return attribute.value.flatMap((value) => + value.type === 'SvelteLiteral' ? value.value.trim().split(/\s+/u) : [] + ); + } + if (attribute.type === 'SvelteDirective' && attribute.kind === 'Class') { + return [attribute.key.name.name]; + } + return []; +} + +function checkSelectorsInPostCSSNode(node: AnyNode, ruleGlobals: RuleGlobals): void { + if (node.type === 'rule') { + checkSelector(ruleGlobals.parserServices.getStyleSelectorAST(node), ruleGlobals); + } + if ( + (node.type === 'root' || node.type === 'rule' || node.type === 'atrule') && + node.nodes !== undefined + ) { + node.nodes.flatMap((node) => checkSelectorsInPostCSSNode(node, ruleGlobals)); + } +} + +function checkSelector(node: SelectorNode, ruleGlobals: RuleGlobals): void { + if (node.type === 'class') { + const selection = ruleGlobals.classSelections.get(node.value) ?? []; + for (const styleValue of ruleGlobals.style) { + if (styleValue === 'class') { + return; + } + if (styleValue === 'id' && couldBeId(selection)) { + ruleGlobals.context.report({ + messageId: 'classShouldBeId', + loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as SourceLocation + }); + return; + } + if (styleValue === 'type' && couldBeType(selection, ruleGlobals.typeSelections)) { + ruleGlobals.context.report({ + messageId: 'classShouldBeType', + loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as SourceLocation + }); + return; + } + } + } + if (node.type === 'id') { + const selection = ruleGlobals.idSelections.get(node.value) ?? []; + for (const styleValue of ruleGlobals.style) { + if (styleValue === 'class') { + ruleGlobals.context.report({ + messageId: 'idShouldBeClass', + loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as SourceLocation + }); + return; + } + if (styleValue === 'id') { + return; + } + if (styleValue === 'type' && couldBeType(selection, ruleGlobals.typeSelections)) { + ruleGlobals.context.report({ + messageId: 'idShouldBeType', + loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as SourceLocation + }); + return; + } + } + } + if (node.type === 'tag') { + const selection = ruleGlobals.typeSelections.get(node.value) ?? []; + for (const styleValue of ruleGlobals.style) { + if (styleValue === 'class') { + ruleGlobals.context.report({ + messageId: 'typeShouldBeClass', + loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as SourceLocation + }); + return; + } + if (styleValue === 'id' && couldBeId(selection)) { + ruleGlobals.context.report({ + messageId: 'typeShouldBeId', + loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as SourceLocation + }); + return; + } + if (styleValue === 'type') { + return; + } + } + } + if (node.type === 'pseudo' || node.type === 'root' || node.type === 'selector') { + node.nodes.flatMap((node) => checkSelector(node, ruleGlobals)); + } +} + +function couldBeId(selection: SvelteHTMLElement[]): boolean { + return selection.length <= 1; +} + +function couldBeType( + selection: SvelteHTMLElement[], + typeSelections: Map +): boolean { + const types = new Set(selection.map((node) => node.name.name)); + if (types.size > 1) { + return false; + } + if (types.size < 1) { + return true; + } + const type = [...types][0]; + const typeSelection = typeSelections.get(type); + return typeSelection !== undefined && arrayEquals(typeSelection, selection); +} + +function arrayEquals(array1: SvelteHTMLElement[], array2: SvelteHTMLElement[]): boolean { + function comparator(a: SvelteHTMLElement, b: SvelteHTMLElement): number { + return a.range[0] - b.range[0]; + } + + const array2Sorted = array2.slice().sort(comparator); + return ( + array1.length === array2.length && + array1 + .slice() + .sort(comparator) + .every(function (value, index) { + return value === array2Sorted[index]; + }) + ); +}