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 6a603a7
Show file tree
Hide file tree
Showing 4 changed files with 253 additions and 40 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
226 changes: 221 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,233 @@
import { createRule } from '../utils';
import type { AST } from 'svelte-eslint-parser';
import type { AnyNode } from 'postcss';
import { type Node as SelectorNode } from 'postcss-selector-parser';
import { findClassesInAttribute } from '../utils/ast-utils.js';
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, AST.SvelteHTMLElement[]>;
idSelections: Map<string, AST.SvelteHTMLElement[]>;
typeSelections: Map<string, AST.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 30 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 36 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, AST.SvelteHTMLElement[]> = new Map();
const idSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
const typeSelections: Map<string, AST.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, AST.SvelteHTMLElement[]>,
key: string,
value: AST.SvelteHTMLElement
): void {
map.set(key, (map.get(key) ?? []).concat(value));
}

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 AST.SourceLocation
});
return;
}
if (styleValue === 'type' && couldBeType(selection, ruleGlobals.typeSelections)) {
ruleGlobals.context.report({
messageId: 'classShouldBeType',
loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as AST.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 AST.SourceLocation
});
return;
}
if (styleValue === 'id') {
return;
}
if (styleValue === 'type' && couldBeType(selection, ruleGlobals.typeSelections)) {
ruleGlobals.context.report({
messageId: 'idShouldBeType',
loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as AST.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 AST.SourceLocation
});
return;
}
if (styleValue === 'id' && couldBeId(selection)) {
ruleGlobals.context.report({
messageId: 'typeShouldBeId',
loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as AST.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: AST.SvelteHTMLElement[]): boolean {
return selection.length <= 1;
}

function couldBeType(
selection: AST.SvelteHTMLElement[],
typeSelections: Map<string, AST.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: AST.SvelteHTMLElement[], array2: AST.SvelteHTMLElement[]): boolean {
function comparator(a: AST.SvelteHTMLElement, b: AST.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];
})
);
}
38 changes: 3 additions & 35 deletions packages/eslint-plugin-svelte/src/rules/no-unused-class-name.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import { createRule } from '../utils/index.js';
import type {
SourceLocation,
SvelteAttribute,
SvelteDirective,
SvelteGenericsDirective,
SvelteShorthandAttribute,
SvelteSpecialDirective,
SvelteSpreadAttribute,
SvelteStyleDirective
} from 'svelte-eslint-parser/lib/ast';
import type { AST } from 'svelte-eslint-parser';
import type { AnyNode } from 'postcss';
import { default as selectorParser, type Node as SelectorNode } from 'postcss-selector-parser';
import { findClassesInAttribute } from '../utils/ast-utils.js';
import { getSourceCode } from '../utils/compat.js';

export default createRule('no-unused-class-name', {
Expand Down Expand Up @@ -43,7 +35,7 @@ export default createRule('no-unused-class-name', {
return {};
}
const allowedClassNames = context.options[0]?.allowedClassNames ?? [];
const classesUsedInTemplate: Record<string, SourceLocation> = {};
const classesUsedInTemplate: Record<string, AST.SourceLocation> = {};

return {
SvelteElement(node) {
Expand Down Expand Up @@ -75,30 +67,6 @@ export default createRule('no-unused-class-name', {
}
});

/**
* 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 [];
}

/**
* Extract all class names used in a PostCSS node.
*/
Expand Down
24 changes: 24 additions & 0 deletions packages/eslint-plugin-svelte/src/utils/ast-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,30 @@ function getAttributeValueRangeTokens(
};
}

/**
* Extract all class names used in a HTML element attribute.
*/
export function findClassesInAttribute(
attribute:
| SvAST.SvelteAttribute
| SvAST.SvelteShorthandAttribute
| SvAST.SvelteSpreadAttribute
| SvAST.SvelteDirective
| SvAST.SvelteStyleDirective
| SvAST.SvelteSpecialDirective
| SvAST.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 [];
}

/**
* Returns name of SvelteElement
*/
Expand Down

0 comments on commit 6a603a7

Please sign in to comment.