Skip to content

Commit

Permalink
feat(consistent-selector-style): added rule implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
marekdedic committed Dec 14, 2024
1 parent a048f5e commit 550e246
Show file tree
Hide file tree
Showing 2 changed files with 260 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .changeset/spotty-kings-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-svelte': minor
---

feat: added the `consistent-selector-style` rule
260 changes: 255 additions & 5 deletions packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts
Original file line number Diff line number Diff line change
@@ -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<string, SvelteHTMLElement[]>;
idSelections: Map<string, SvelteHTMLElement[]>;
typeSelections: Map<string, SvelteHTMLElement[]>;
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

Check warning on line 39 in packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected 'todo' comment: 'TODO: Add option to include global...'
style: {
type: 'array',
items: {
enum: ['class', 'id', 'type']
},
minItems: 3, // TODO: Allow fewer items

Check warning on line 45 in packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected 'todo' comment: '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<string, SvelteHTMLElement[]> = new Map();
const idSelections: Map<string, SvelteHTMLElement[]> = new Map();
const typeSelections: Map<string, SvelteHTMLElement[]> = 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<string, SvelteHTMLElement[]>,
key: string,
value: SvelteHTMLElement
): void {
map.set(key, (map.get(key) ?? []).concat(value));
}

// TODO: Deduplicate

Check warning on line 123 in packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected 'todo' comment: '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<string, SvelteHTMLElement[]>
): 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];
})
);
}

0 comments on commit 550e246

Please sign in to comment.