Skip to content

Commit a28fbbc

Browse files
committed
feat(consistent-selector-style): added rule implementation
1 parent ae71286 commit a28fbbc

File tree

4 files changed

+309
-41
lines changed

4 files changed

+309
-41
lines changed

packages/eslint-plugin-svelte/src/rule-types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export interface RuleOptions {
3838
* enforce a consistent style for CSS selectors
3939
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/consistent-selector-style/
4040
*/
41-
'svelte/consistent-selector-style'?: Linter.RuleEntry<[]>
41+
'svelte/consistent-selector-style'?: Linter.RuleEntry<SvelteConsistentSelectorStyle>
4242
/**
4343
* derived store should use same variable names between values and callback
4444
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/derived-has-same-inputs-outputs/
@@ -392,6 +392,11 @@ type SvelteButtonHasType = []|[{
392392
type SvelteCommentDirective = []|[{
393393
reportUnusedDisableDirectives?: boolean
394394
}]
395+
// ----- svelte/consistent-selector-style -----
396+
type SvelteConsistentSelectorStyle = []|[{
397+
398+
style?: [("class" | "id" | "type"), ("class" | "id" | "type"), ("class" | "id" | "type")]
399+
}]
395400
// ----- svelte/first-attribute-linebreak -----
396401
type SvelteFirstAttributeLinebreak = []|[{
397402
multiline?: ("below" | "beside")
Lines changed: 276 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,288 @@
1-
import { createRule } from '../utils';
1+
import type { AST } from 'svelte-eslint-parser';
2+
import type { AnyNode } from 'postcss';
3+
import type {
4+
ClassName as SelectorClass,
5+
Identifier as SelectorIdentifier,
6+
Node as SelectorNode,
7+
Tag as SelectorTag
8+
} from 'postcss-selector-parser';
9+
import { findClassesInAttribute } from '../utils/ast-utils.js';
10+
import { getSourceCode } from '../utils/compat.js';
11+
import { createRule } from '../utils/index.js';
12+
import type { RuleContext, SourceCode } from '../types.js';
13+
14+
interface RuleGlobals {
15+
style: string[];
16+
classSelections: Map<string, AST.SvelteHTMLElement[]>;
17+
idSelections: Map<string, AST.SvelteHTMLElement[]>;
18+
typeSelections: Map<string, AST.SvelteHTMLElement[]>;
19+
context: RuleContext;
20+
getStyleSelectorAST: NonNullable<SourceCode['parserServices']['getStyleSelectorAST']>;
21+
styleSelectorNodeLoc: NonNullable<SourceCode['parserServices']['styleSelectorNodeLoc']>;
22+
}
223

324
export default createRule('consistent-selector-style', {
425
meta: {
526
docs: {
627
description: 'enforce a consistent style for CSS selectors',
728
category: 'Stylistic Issues',
8-
recommended: false
29+
recommended: false,
30+
conflictWithPrettier: false
31+
},
32+
schema: [
33+
{
34+
type: 'object',
35+
properties: {
36+
// TODO: Add option to include global selectors
37+
style: {
38+
type: 'array',
39+
items: {
40+
enum: ['class', 'id', 'type']
41+
},
42+
minItems: 3, // TODO: Allow fewer items
43+
maxItems: 3,
44+
uniqueItems: true
45+
}
46+
},
47+
additionalProperties: false
48+
}
49+
],
50+
messages: {
51+
classShouldBeId: 'Selector should select by ID instead of class',
52+
classShouldBeType: 'Selector should select by element type instead of class',
53+
idShouldBeClass: 'Selector should select by class instead of ID',
54+
idShouldBeType: 'Selector should select by element type instead of ID',
55+
typeShouldBeClass: 'Selector should select by class instead of element type',
56+
typeShouldBeId: 'Selector should select by ID instead of element type'
957
},
10-
schema: [],
11-
messages: {},
1258
type: 'suggestion'
1359
},
1460
create(context) {
15-
return {};
61+
const sourceCode = getSourceCode(context);
62+
if (!sourceCode.parserServices.isSvelte) {
63+
return {};
64+
}
65+
66+
const style = context.options[0]?.style ?? ['type', 'id', 'class'];
67+
68+
const classSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
69+
const idSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
70+
const typeSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
71+
72+
return {
73+
SvelteElement(node) {
74+
if (node.kind !== 'html') {
75+
return;
76+
}
77+
addToArrayMap(typeSelections, node.name.name, node);
78+
const classes = node.startTag.attributes.flatMap(findClassesInAttribute);
79+
for (const className of classes) {
80+
addToArrayMap(classSelections, className, node);
81+
}
82+
for (const attribute of node.startTag.attributes) {
83+
if (attribute.type !== 'SvelteAttribute' || attribute.key.name !== 'id') {
84+
continue;
85+
}
86+
for (const value of attribute.value) {
87+
if (value.type === 'SvelteLiteral') {
88+
addToArrayMap(idSelections, value.value, node);
89+
}
90+
}
91+
}
92+
},
93+
'Program:exit'() {
94+
const styleContext = sourceCode.parserServices.getStyleContext!();
95+
if (
96+
styleContext.status !== 'success' ||
97+
sourceCode.parserServices.getStyleSelectorAST === undefined ||
98+
sourceCode.parserServices.styleSelectorNodeLoc === undefined
99+
) {
100+
return;
101+
}
102+
checkSelectorsInPostCSSNode(styleContext.sourceAst, {
103+
style,
104+
classSelections,
105+
idSelections,
106+
typeSelections,
107+
context,
108+
getStyleSelectorAST: sourceCode.parserServices.getStyleSelectorAST,
109+
styleSelectorNodeLoc: sourceCode.parserServices.styleSelectorNodeLoc
110+
});
111+
}
112+
};
16113
}
17114
});
115+
116+
/**
117+
* Helper function to add a value to a Map of arrays
118+
*/
119+
function addToArrayMap(
120+
map: Map<string, AST.SvelteHTMLElement[]>,
121+
key: string,
122+
value: AST.SvelteHTMLElement
123+
): void {
124+
map.set(key, (map.get(key) ?? []).concat(value));
125+
}
126+
127+
/**
128+
* Checks selectors in a given PostCSS node
129+
*/
130+
function checkSelectorsInPostCSSNode(node: AnyNode, ruleGlobals: RuleGlobals): void {
131+
if (node.type === 'rule') {
132+
checkSelector(ruleGlobals.getStyleSelectorAST(node), ruleGlobals);
133+
}
134+
if (
135+
(node.type === 'root' ||
136+
(node.type === 'rule' && node.selector !== ':global') ||
137+
node.type === 'atrule') &&
138+
node.nodes !== undefined
139+
) {
140+
node.nodes.flatMap((node) => checkSelectorsInPostCSSNode(node, ruleGlobals));
141+
}
142+
}
143+
144+
/**
145+
* Checks an individual selector
146+
*/
147+
function checkSelector(node: SelectorNode, ruleGlobals: RuleGlobals): void {
148+
if (node.type === 'class') {
149+
checkClassSelector(node, ruleGlobals);
150+
}
151+
if (node.type === 'id') {
152+
checkIdSelector(node, ruleGlobals);
153+
}
154+
if (node.type === 'tag') {
155+
checkTypeSelector(node, ruleGlobals);
156+
}
157+
if (
158+
(node.type === 'pseudo' && node.value !== ':global') ||
159+
node.type === 'root' ||
160+
node.type === 'selector'
161+
) {
162+
node.nodes.flatMap((node) => checkSelector(node, ruleGlobals));
163+
}
164+
}
165+
166+
/**
167+
* Checks a class selector
168+
*/
169+
function checkClassSelector(node: SelectorClass, ruleGlobals: RuleGlobals): void {
170+
const selection = ruleGlobals.classSelections.get(node.value) ?? [];
171+
for (const styleValue of ruleGlobals.style) {
172+
if (styleValue === 'class') {
173+
return;
174+
}
175+
if (styleValue === 'id' && couldBeId(selection)) {
176+
ruleGlobals.context.report({
177+
messageId: 'classShouldBeId',
178+
loc: ruleGlobals.styleSelectorNodeLoc(node) as AST.SourceLocation
179+
});
180+
return;
181+
}
182+
if (styleValue === 'type' && couldBeType(selection, ruleGlobals.typeSelections)) {
183+
ruleGlobals.context.report({
184+
messageId: 'classShouldBeType',
185+
loc: ruleGlobals.styleSelectorNodeLoc(node) as AST.SourceLocation
186+
});
187+
return;
188+
}
189+
}
190+
}
191+
192+
/**
193+
* Checks an ID selector
194+
*/
195+
function checkIdSelector(node: SelectorIdentifier, ruleGlobals: RuleGlobals): void {
196+
const selection = ruleGlobals.idSelections.get(node.value) ?? [];
197+
for (const styleValue of ruleGlobals.style) {
198+
if (styleValue === 'class') {
199+
ruleGlobals.context.report({
200+
messageId: 'idShouldBeClass',
201+
loc: ruleGlobals.styleSelectorNodeLoc(node) as AST.SourceLocation
202+
});
203+
return;
204+
}
205+
if (styleValue === 'id') {
206+
return;
207+
}
208+
if (styleValue === 'type' && couldBeType(selection, ruleGlobals.typeSelections)) {
209+
ruleGlobals.context.report({
210+
messageId: 'idShouldBeType',
211+
loc: ruleGlobals.styleSelectorNodeLoc(node) as AST.SourceLocation
212+
});
213+
return;
214+
}
215+
}
216+
}
217+
218+
/**
219+
* Checks a type selector
220+
*/
221+
function checkTypeSelector(node: SelectorTag, ruleGlobals: RuleGlobals): void {
222+
const selection = ruleGlobals.typeSelections.get(node.value) ?? [];
223+
for (const styleValue of ruleGlobals.style) {
224+
if (styleValue === 'class') {
225+
ruleGlobals.context.report({
226+
messageId: 'typeShouldBeClass',
227+
loc: ruleGlobals.styleSelectorNodeLoc(node) as AST.SourceLocation
228+
});
229+
return;
230+
}
231+
if (styleValue === 'id' && couldBeId(selection)) {
232+
ruleGlobals.context.report({
233+
messageId: 'typeShouldBeId',
234+
loc: ruleGlobals.styleSelectorNodeLoc(node) as AST.SourceLocation
235+
});
236+
return;
237+
}
238+
if (styleValue === 'type') {
239+
return;
240+
}
241+
}
242+
}
243+
244+
/**
245+
* Checks whether a given selection could be obtained using an ID selector
246+
*/
247+
function couldBeId(selection: AST.SvelteHTMLElement[]): boolean {
248+
return selection.length <= 1;
249+
}
250+
251+
/**
252+
* Checks whether a given selection could be obtained using a type selector
253+
*/
254+
function couldBeType(
255+
selection: AST.SvelteHTMLElement[],
256+
typeSelections: Map<string, AST.SvelteHTMLElement[]>
257+
): boolean {
258+
const types = new Set(selection.map((node) => node.name.name));
259+
if (types.size > 1) {
260+
return false;
261+
}
262+
if (types.size < 1) {
263+
return true;
264+
}
265+
const type = [...types][0];
266+
const typeSelection = typeSelections.get(type);
267+
return typeSelection !== undefined && arrayEquals(typeSelection, selection);
268+
}
269+
270+
/**
271+
* Compares two arrays for item equality
272+
*/
273+
function arrayEquals(array1: AST.SvelteHTMLElement[], array2: AST.SvelteHTMLElement[]): boolean {
274+
function comparator(a: AST.SvelteHTMLElement, b: AST.SvelteHTMLElement): number {
275+
return a.range[0] - b.range[0];
276+
}
277+
278+
const array2Sorted = array2.slice().sort(comparator);
279+
return (
280+
array1.length === array2.length &&
281+
array1
282+
.slice()
283+
.sort(comparator)
284+
.every(function (value, index) {
285+
return value === array2Sorted[index];
286+
})
287+
);
288+
}

packages/eslint-plugin-svelte/src/rules/no-unused-class-name.ts

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
11
import { createRule } from '../utils/index.js';
2-
import type {
3-
SourceLocation,
4-
SvelteAttribute,
5-
SvelteDirective,
6-
SvelteGenericsDirective,
7-
SvelteShorthandAttribute,
8-
SvelteSpecialDirective,
9-
SvelteSpreadAttribute,
10-
SvelteStyleDirective
11-
} from 'svelte-eslint-parser/lib/ast';
2+
import type { AST } from 'svelte-eslint-parser';
123
import type { AnyNode } from 'postcss';
134
import type { Node as SelectorNode } from 'postcss-selector-parser';
5+
import { findClassesInAttribute } from '../utils/ast-utils.js';
146
import { getSourceCode } from '../utils/compat.js';
157
import type { SourceCode } from '../types.js';
168

@@ -44,7 +36,7 @@ export default createRule('no-unused-class-name', {
4436
return {};
4537
}
4638
const allowedClassNames = context.options[0]?.allowedClassNames ?? [];
47-
const classesUsedInTemplate: Record<string, SourceLocation> = {};
39+
const classesUsedInTemplate: Record<string, AST.SourceLocation> = {};
4840

4941
return {
5042
SvelteElement(node) {
@@ -78,30 +70,6 @@ export default createRule('no-unused-class-name', {
7870
}
7971
});
8072

81-
/**
82-
* Extract all class names used in a HTML element attribute.
83-
*/
84-
function findClassesInAttribute(
85-
attribute:
86-
| SvelteAttribute
87-
| SvelteShorthandAttribute
88-
| SvelteSpreadAttribute
89-
| SvelteDirective
90-
| SvelteStyleDirective
91-
| SvelteSpecialDirective
92-
| SvelteGenericsDirective
93-
): string[] {
94-
if (attribute.type === 'SvelteAttribute' && attribute.key.name === 'class') {
95-
return attribute.value.flatMap((value) =>
96-
value.type === 'SvelteLiteral' ? value.value.trim().split(/\s+/u) : []
97-
);
98-
}
99-
if (attribute.type === 'SvelteDirective' && attribute.kind === 'Class') {
100-
return [attribute.key.name.name];
101-
}
102-
return [];
103-
}
104-
10573
/**
10674
* Extract all class names used in a PostCSS node.
10775
*/

0 commit comments

Comments
 (0)