Skip to content

Commit d15cd6b

Browse files
authored
Improve prefer-style-directive (#125)
1 parent 6f52dd4 commit d15cd6b

File tree

15 files changed

+300
-51
lines changed

15 files changed

+300
-51
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"debug": "^4.3.1",
5858
"eslint-utils": "^3.0.0",
5959
"postcss": "^8.4.5",
60+
"postcss-safe-parser": "^6.0.0",
6061
"sourcemap-codec": "^1.4.8",
6162
"svelte-eslint-parser": "^0.16.0"
6263
},
@@ -84,6 +85,7 @@
8485
"@types/estree": "^0.0.51",
8586
"@types/mocha": "^9.0.0",
8687
"@types/node": "^16.0.0",
88+
"@types/postcss-safe-parser": "^5.0.1",
8789
"@typescript-eslint/eslint-plugin": "^5.4.0",
8890
"@typescript-eslint/parser": "^5.4.1-0",
8991
"@typescript-eslint/parser-v4": "npm:@typescript-eslint/parser@4",

src/rules/prefer-style-directive.ts

Lines changed: 33 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
import type { AST } from "svelte-eslint-parser"
22
import type * as ESTree from "estree"
3-
import type { Root } from "postcss"
4-
import { parse as parseCss } from "postcss"
53
import { createRule } from "../utils"
6-
7-
/** Parse for CSS */
8-
function safeParseCss(cssCode: string) {
9-
try {
10-
return parseCss(cssCode)
11-
} catch {
12-
return null
13-
}
14-
}
4+
import type { SvelteStyleRoot } from "../utils/css-utils"
5+
import { parseStyleAttributeValue, safeParseCss } from "../utils/css-utils"
156

167
/** Checks wether the given node is string literal or not */
178
function isStringLiteral(
@@ -42,12 +33,11 @@ export default createRule("prefer-style-directive", {
4233
*/
4334
function processStyleValue(
4435
node: AST.SvelteAttribute,
45-
root: Root,
36+
root: SvelteStyleRoot,
4637
mustacheTags: AST.SvelteMustacheTagText[],
4738
) {
48-
const valueStartIndex = node.value[0].range[0]
49-
50-
root.walkDecls((decl) => {
39+
root.walk((decl) => {
40+
if (decl.type !== "decl" || decl.important) return
5141
if (
5242
node.parent.attributes.some(
5343
(attr) =>
@@ -59,38 +49,28 @@ export default createRule("prefer-style-directive", {
5949
return
6050
}
6151

62-
const declRange: AST.Range = [
63-
valueStartIndex + decl.source!.start!.offset,
64-
valueStartIndex + decl.source!.end!.offset + 1,
65-
]
6652
if (
6753
mustacheTags.some(
6854
(tag) =>
69-
(tag.range[0] < declRange[0] && declRange[0] < tag.range[1]) ||
70-
(tag.range[0] < declRange[1] && declRange[1] < tag.range[1]),
55+
(tag.range[0] < decl.range[0] && decl.range[0] < tag.range[1]) ||
56+
(tag.range[0] < decl.range[1] && decl.range[1] < tag.range[1]),
7157
)
7258
) {
7359
// intersection
7460
return
7561
}
76-
const declValueStartIndex =
77-
declRange[0] + decl.prop.length + (decl.raws.between || "").length
78-
const declValueRange: AST.Range = [
79-
declValueStartIndex,
80-
declValueStartIndex + (decl.raws.value?.value || decl.value).length,
81-
]
8262

8363
context.report({
8464
node,
8565
messageId: "unexpected",
8666
*fix(fixer) {
8767
const styleDirective = `style:${decl.prop}="${sourceCode.text.slice(
88-
...declValueRange,
68+
...decl.valueRange,
8969
)}"`
9070
if (root.nodes.length === 1 && root.nodes[0] === decl) {
9171
yield fixer.replaceTextRange(node.range, styleDirective)
9272
} else {
93-
yield fixer.removeRange(declRange)
73+
yield fixer.removeRange(decl.range)
9474
yield fixer.insertTextAfterRange(node.range, ` ${styleDirective}`)
9575
}
9676
},
@@ -104,9 +84,10 @@ export default createRule("prefer-style-directive", {
10484
function processMustacheTags(
10585
mustacheTags: AST.SvelteMustacheTagText[],
10686
attrNode: AST.SvelteAttribute,
87+
root: SvelteStyleRoot | null,
10788
) {
10889
for (const mustacheTag of mustacheTags) {
109-
processMustacheTag(mustacheTag, attrNode)
90+
processMustacheTag(mustacheTag, attrNode, root)
11091
}
11192
}
11293

@@ -116,6 +97,7 @@ export default createRule("prefer-style-directive", {
11697
function processMustacheTag(
11798
mustacheTag: AST.SvelteMustacheTagText,
11899
attrNode: AST.SvelteAttribute,
100+
root: SvelteStyleRoot | null,
119101
) {
120102
const node = mustacheTag.expression
121103

@@ -132,14 +114,30 @@ export default createRule("prefer-style-directive", {
132114
// e.g. t ? 'top: 20px' : 'left: 30px'
133115
return
134116
}
117+
118+
if (root) {
119+
let foundIntersection = false
120+
root.walk((n) => {
121+
if (
122+
mustacheTag.range[0] < n.range[1] &&
123+
n.range[0] < mustacheTag.range[1]
124+
) {
125+
foundIntersection = true
126+
}
127+
})
128+
if (foundIntersection) {
129+
return
130+
}
131+
}
132+
135133
const positive = node.alternate.value === ""
136-
const root = safeParseCss(
134+
const inlineRoot = safeParseCss(
137135
positive ? node.consequent.value : node.alternate.value,
138136
)
139-
if (!root || root.nodes.length !== 1) {
137+
if (!inlineRoot || inlineRoot.nodes.length !== 1) {
140138
return
141139
}
142-
const decl = root.nodes[0]
140+
const decl = inlineRoot.nodes[0]
143141
if (decl.type !== "decl") {
144142
return
145143
}
@@ -221,20 +219,11 @@ export default createRule("prefer-style-directive", {
221219
const mustacheTags = node.value.filter(
222220
(v): v is AST.SvelteMustacheTagText => v.type === "SvelteMustacheTag",
223221
)
224-
const cssCode = node.value
225-
.map((value) => {
226-
if (value.type === "SvelteMustacheTag") {
227-
return "_".repeat(value.range[1] - value.range[0])
228-
}
229-
return sourceCode.getText(value)
230-
})
231-
.join("")
232-
const root = safeParseCss(cssCode)
222+
const root = parseStyleAttributeValue(node, context)
233223
if (root) {
234224
processStyleValue(node, root, mustacheTags)
235-
} else {
236-
processMustacheTags(mustacheTags, node)
237225
}
226+
processMustacheTags(mustacheTags, node, root)
238227
},
239228
}
240229
},

src/utils/css-utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./style-attribute"
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { AST } from "svelte-eslint-parser"
2+
import type { RuleContext } from "../../types"
3+
import Parser from "./template-safe-parser"
4+
import type { Root, ChildNode, AnyNode } from "postcss"
5+
import { Input } from "postcss"
6+
7+
/** Parse for CSS */
8+
export function safeParseCss(css: string): Root | null {
9+
try {
10+
const input = new Input(css)
11+
12+
const parser = new Parser(input)
13+
parser.parse()
14+
15+
return parser.root
16+
} catch {
17+
return null
18+
}
19+
}
20+
21+
/**
22+
* Parse style attribute value
23+
*/
24+
export function parseStyleAttributeValue(
25+
node: AST.SvelteAttribute,
26+
context: RuleContext,
27+
): SvelteStyleRoot | null {
28+
const valueStartIndex = node.value[0].range[0]
29+
const sourceCode = context.getSourceCode()
30+
const cssCode = node.value.map((value) => sourceCode.getText(value)).join("")
31+
const root = safeParseCss(cssCode)
32+
if (!root) {
33+
return root
34+
}
35+
const ctx: Ctx = {
36+
valueStartIndex,
37+
value: node.value,
38+
context,
39+
}
40+
41+
const nodes = root.nodes.map((n) => convertChild(n, ctx))
42+
return {
43+
type: "root",
44+
nodes,
45+
walk(cb) {
46+
const targets = [...nodes]
47+
let target
48+
while ((target = targets.shift())) {
49+
cb(target)
50+
if (target.nodes) {
51+
targets.push(...target.nodes)
52+
}
53+
}
54+
},
55+
}
56+
}
57+
58+
export interface SvelteStyleNode {
59+
range: AST.Range
60+
nodes?: SvelteStyleChildNode[]
61+
}
62+
export interface SvelteStyleRoot {
63+
type: "root"
64+
nodes: SvelteStyleChildNode[]
65+
walk(cb: (node: SvelteStyleChildNode) => void): void
66+
}
67+
export interface SvelteStyleAtRule extends SvelteStyleNode {
68+
type: "atrule"
69+
nodes: SvelteStyleChildNode[]
70+
}
71+
export interface SvelteStyleRule extends SvelteStyleNode {
72+
type: "rule"
73+
nodes: SvelteStyleChildNode[]
74+
}
75+
export interface SvelteStyleDeclaration extends SvelteStyleNode {
76+
type: "decl"
77+
prop: string
78+
value: string
79+
important: boolean
80+
valueRange: AST.Range
81+
}
82+
export interface SvelteStyleComment extends SvelteStyleNode {
83+
type: "comment"
84+
}
85+
86+
export type SvelteStyleChildNode =
87+
| SvelteStyleAtRule
88+
| SvelteStyleRule
89+
| SvelteStyleDeclaration
90+
| SvelteStyleComment
91+
92+
type Ctx = {
93+
valueStartIndex: number
94+
value: AST.SvelteAttribute["value"]
95+
context: RuleContext
96+
}
97+
98+
/** convert child node */
99+
function convertChild(node: ChildNode, ctx: Ctx): SvelteStyleChildNode {
100+
if (node.type === "decl") {
101+
const range = convertRange(node, ctx)
102+
const declValueStartIndex =
103+
range[0] + node.prop.length + (node.raws.between || "").length
104+
const valueRange: AST.Range = [
105+
declValueStartIndex,
106+
declValueStartIndex + (node.raws.value?.value || node.value).length,
107+
]
108+
return {
109+
type: "decl",
110+
prop: node.prop,
111+
value: node.value,
112+
important: node.important,
113+
range,
114+
valueRange,
115+
}
116+
}
117+
if (node.type === "atrule") {
118+
const range = convertRange(node, ctx)
119+
let nodes: SvelteStyleChildNode[] | null = null
120+
return {
121+
type: "atrule",
122+
range,
123+
get nodes() {
124+
return nodes ?? (nodes = node.nodes.map((n) => convertChild(n, ctx)))
125+
},
126+
}
127+
}
128+
if (node.type === "rule") {
129+
const range = convertRange(node, ctx)
130+
let nodes: SvelteStyleChildNode[] | null = null
131+
return {
132+
type: "rule",
133+
range,
134+
get nodes() {
135+
return nodes ?? (nodes = node.nodes.map((n) => convertChild(n, ctx)))
136+
},
137+
}
138+
}
139+
if (node.type === "comment") {
140+
const range = convertRange(node, ctx)
141+
return {
142+
type: "comment",
143+
range,
144+
}
145+
}
146+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ignore
147+
throw new Error(`unknown node:${(node as any).type}`)
148+
}
149+
150+
/** convert range */
151+
function convertRange(node: AnyNode, ctx: Ctx): AST.Range {
152+
return [
153+
ctx.valueStartIndex + node.source!.start!.offset,
154+
ctx.valueStartIndex + node.source!.end!.offset + 1,
155+
]
156+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import SafeParser from "postcss-safe-parser/lib/safe-parser"
2+
import templateTokenize from "./template-tokenize"
3+
class TemplateSafeParser extends SafeParser {
4+
protected createTokenizer(): void {
5+
this.tokenizer = templateTokenize(this.input, { ignoreErrors: true })
6+
}
7+
}
8+
export default TemplateSafeParser
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { Tokenizer, Token } from "postcss/lib/tokenize"
2+
import tokenize from "postcss/lib/tokenize"
3+
4+
type Tokenize = typeof tokenize
5+
6+
/** Tokenize */
7+
function templateTokenize(...args: Parameters<Tokenize>): Tokenizer {
8+
const tokenizer = tokenize(...args)
9+
10+
/** nextToken */
11+
function nextToken(
12+
...args: Parameters<Tokenizer["nextToken"]>
13+
): ReturnType<Tokenizer["nextToken"]> {
14+
const returned = []
15+
let token: Token | undefined, lastPos
16+
let depth = 0
17+
18+
while ((token = tokenizer.nextToken(...args))) {
19+
if (token[0] !== "word") {
20+
if (token[0] === "{") {
21+
++depth
22+
} else if (token[0] === "}") {
23+
--depth
24+
}
25+
}
26+
if (depth || returned.length) {
27+
lastPos = token[3] || token[2] || lastPos
28+
returned.push(token)
29+
}
30+
if (!depth) {
31+
break
32+
}
33+
}
34+
if (returned.length) {
35+
token = [
36+
"word",
37+
returned.map((token) => token[1]).join(""),
38+
returned[0][2],
39+
lastPos,
40+
]
41+
}
42+
return token
43+
}
44+
45+
return Object.assign({}, tokenizer, {
46+
nextToken,
47+
})
48+
}
49+
50+
export default templateTokenize

0 commit comments

Comments
 (0)