diff --git a/package.json b/package.json index 25618da8b..8b1e7e59d 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "debug": "^4.3.1", "eslint-utils": "^3.0.0", "postcss": "^8.4.5", + "postcss-safe-parser": "^6.0.0", "sourcemap-codec": "^1.4.8", "svelte-eslint-parser": "^0.16.0" }, @@ -84,6 +85,7 @@ "@types/estree": "^0.0.51", "@types/mocha": "^9.0.0", "@types/node": "^16.0.0", + "@types/postcss-safe-parser": "^5.0.1", "@typescript-eslint/eslint-plugin": "^5.4.0", "@typescript-eslint/parser": "^5.4.1-0", "@typescript-eslint/parser-v4": "npm:@typescript-eslint/parser@4", diff --git a/src/rules/prefer-style-directive.ts b/src/rules/prefer-style-directive.ts index 524204612..390e9830c 100644 --- a/src/rules/prefer-style-directive.ts +++ b/src/rules/prefer-style-directive.ts @@ -1,17 +1,8 @@ import type { AST } from "svelte-eslint-parser" import type * as ESTree from "estree" -import type { Root } from "postcss" -import { parse as parseCss } from "postcss" import { createRule } from "../utils" - -/** Parse for CSS */ -function safeParseCss(cssCode: string) { - try { - return parseCss(cssCode) - } catch { - return null - } -} +import type { SvelteStyleRoot } from "../utils/css-utils" +import { parseStyleAttributeValue, safeParseCss } from "../utils/css-utils" /** Checks wether the given node is string literal or not */ function isStringLiteral( @@ -42,12 +33,11 @@ export default createRule("prefer-style-directive", { */ function processStyleValue( node: AST.SvelteAttribute, - root: Root, + root: SvelteStyleRoot, mustacheTags: AST.SvelteMustacheTagText[], ) { - const valueStartIndex = node.value[0].range[0] - - root.walkDecls((decl) => { + root.walk((decl) => { + if (decl.type !== "decl" || decl.important) return if ( node.parent.attributes.some( (attr) => @@ -59,38 +49,28 @@ export default createRule("prefer-style-directive", { return } - const declRange: AST.Range = [ - valueStartIndex + decl.source!.start!.offset, - valueStartIndex + decl.source!.end!.offset + 1, - ] if ( mustacheTags.some( (tag) => - (tag.range[0] < declRange[0] && declRange[0] < tag.range[1]) || - (tag.range[0] < declRange[1] && declRange[1] < tag.range[1]), + (tag.range[0] < decl.range[0] && decl.range[0] < tag.range[1]) || + (tag.range[0] < decl.range[1] && decl.range[1] < tag.range[1]), ) ) { // intersection return } - const declValueStartIndex = - declRange[0] + decl.prop.length + (decl.raws.between || "").length - const declValueRange: AST.Range = [ - declValueStartIndex, - declValueStartIndex + (decl.raws.value?.value || decl.value).length, - ] context.report({ node, messageId: "unexpected", *fix(fixer) { const styleDirective = `style:${decl.prop}="${sourceCode.text.slice( - ...declValueRange, + ...decl.valueRange, )}"` if (root.nodes.length === 1 && root.nodes[0] === decl) { yield fixer.replaceTextRange(node.range, styleDirective) } else { - yield fixer.removeRange(declRange) + yield fixer.removeRange(decl.range) yield fixer.insertTextAfterRange(node.range, ` ${styleDirective}`) } }, @@ -104,9 +84,10 @@ export default createRule("prefer-style-directive", { function processMustacheTags( mustacheTags: AST.SvelteMustacheTagText[], attrNode: AST.SvelteAttribute, + root: SvelteStyleRoot | null, ) { for (const mustacheTag of mustacheTags) { - processMustacheTag(mustacheTag, attrNode) + processMustacheTag(mustacheTag, attrNode, root) } } @@ -116,6 +97,7 @@ export default createRule("prefer-style-directive", { function processMustacheTag( mustacheTag: AST.SvelteMustacheTagText, attrNode: AST.SvelteAttribute, + root: SvelteStyleRoot | null, ) { const node = mustacheTag.expression @@ -132,14 +114,30 @@ export default createRule("prefer-style-directive", { // e.g. t ? 'top: 20px' : 'left: 30px' return } + + if (root) { + let foundIntersection = false + root.walk((n) => { + if ( + mustacheTag.range[0] < n.range[1] && + n.range[0] < mustacheTag.range[1] + ) { + foundIntersection = true + } + }) + if (foundIntersection) { + return + } + } + const positive = node.alternate.value === "" - const root = safeParseCss( + const inlineRoot = safeParseCss( positive ? node.consequent.value : node.alternate.value, ) - if (!root || root.nodes.length !== 1) { + if (!inlineRoot || inlineRoot.nodes.length !== 1) { return } - const decl = root.nodes[0] + const decl = inlineRoot.nodes[0] if (decl.type !== "decl") { return } @@ -221,20 +219,11 @@ export default createRule("prefer-style-directive", { const mustacheTags = node.value.filter( (v): v is AST.SvelteMustacheTagText => v.type === "SvelteMustacheTag", ) - const cssCode = node.value - .map((value) => { - if (value.type === "SvelteMustacheTag") { - return "_".repeat(value.range[1] - value.range[0]) - } - return sourceCode.getText(value) - }) - .join("") - const root = safeParseCss(cssCode) + const root = parseStyleAttributeValue(node, context) if (root) { processStyleValue(node, root, mustacheTags) - } else { - processMustacheTags(mustacheTags, node) } + processMustacheTags(mustacheTags, node, root) }, } }, diff --git a/src/utils/css-utils/index.ts b/src/utils/css-utils/index.ts new file mode 100644 index 000000000..33553b62b --- /dev/null +++ b/src/utils/css-utils/index.ts @@ -0,0 +1 @@ +export * from "./style-attribute" diff --git a/src/utils/css-utils/style-attribute.ts b/src/utils/css-utils/style-attribute.ts new file mode 100644 index 000000000..625c1a3e4 --- /dev/null +++ b/src/utils/css-utils/style-attribute.ts @@ -0,0 +1,156 @@ +import type { AST } from "svelte-eslint-parser" +import type { RuleContext } from "../../types" +import Parser from "./template-safe-parser" +import type { Root, ChildNode, AnyNode } from "postcss" +import { Input } from "postcss" + +/** Parse for CSS */ +export function safeParseCss(css: string): Root | null { + try { + const input = new Input(css) + + const parser = new Parser(input) + parser.parse() + + return parser.root + } catch { + return null + } +} + +/** + * Parse style attribute value + */ +export function parseStyleAttributeValue( + node: AST.SvelteAttribute, + context: RuleContext, +): SvelteStyleRoot | null { + const valueStartIndex = node.value[0].range[0] + const sourceCode = context.getSourceCode() + const cssCode = node.value.map((value) => sourceCode.getText(value)).join("") + const root = safeParseCss(cssCode) + if (!root) { + return root + } + const ctx: Ctx = { + valueStartIndex, + value: node.value, + context, + } + + const nodes = root.nodes.map((n) => convertChild(n, ctx)) + return { + type: "root", + nodes, + walk(cb) { + const targets = [...nodes] + let target + while ((target = targets.shift())) { + cb(target) + if (target.nodes) { + targets.push(...target.nodes) + } + } + }, + } +} + +export interface SvelteStyleNode { + range: AST.Range + nodes?: SvelteStyleChildNode[] +} +export interface SvelteStyleRoot { + type: "root" + nodes: SvelteStyleChildNode[] + walk(cb: (node: SvelteStyleChildNode) => void): void +} +export interface SvelteStyleAtRule extends SvelteStyleNode { + type: "atrule" + nodes: SvelteStyleChildNode[] +} +export interface SvelteStyleRule extends SvelteStyleNode { + type: "rule" + nodes: SvelteStyleChildNode[] +} +export interface SvelteStyleDeclaration extends SvelteStyleNode { + type: "decl" + prop: string + value: string + important: boolean + valueRange: AST.Range +} +export interface SvelteStyleComment extends SvelteStyleNode { + type: "comment" +} + +export type SvelteStyleChildNode = + | SvelteStyleAtRule + | SvelteStyleRule + | SvelteStyleDeclaration + | SvelteStyleComment + +type Ctx = { + valueStartIndex: number + value: AST.SvelteAttribute["value"] + context: RuleContext +} + +/** convert child node */ +function convertChild(node: ChildNode, ctx: Ctx): SvelteStyleChildNode { + if (node.type === "decl") { + const range = convertRange(node, ctx) + const declValueStartIndex = + range[0] + node.prop.length + (node.raws.between || "").length + const valueRange: AST.Range = [ + declValueStartIndex, + declValueStartIndex + (node.raws.value?.value || node.value).length, + ] + return { + type: "decl", + prop: node.prop, + value: node.value, + important: node.important, + range, + valueRange, + } + } + if (node.type === "atrule") { + const range = convertRange(node, ctx) + let nodes: SvelteStyleChildNode[] | null = null + return { + type: "atrule", + range, + get nodes() { + return nodes ?? (nodes = node.nodes.map((n) => convertChild(n, ctx))) + }, + } + } + if (node.type === "rule") { + const range = convertRange(node, ctx) + let nodes: SvelteStyleChildNode[] | null = null + return { + type: "rule", + range, + get nodes() { + return nodes ?? (nodes = node.nodes.map((n) => convertChild(n, ctx))) + }, + } + } + if (node.type === "comment") { + const range = convertRange(node, ctx) + return { + type: "comment", + range, + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ignore + throw new Error(`unknown node:${(node as any).type}`) +} + +/** convert range */ +function convertRange(node: AnyNode, ctx: Ctx): AST.Range { + return [ + ctx.valueStartIndex + node.source!.start!.offset, + ctx.valueStartIndex + node.source!.end!.offset + 1, + ] +} diff --git a/src/utils/css-utils/template-safe-parser.ts b/src/utils/css-utils/template-safe-parser.ts new file mode 100644 index 000000000..a7c74f277 --- /dev/null +++ b/src/utils/css-utils/template-safe-parser.ts @@ -0,0 +1,8 @@ +import SafeParser from "postcss-safe-parser/lib/safe-parser" +import templateTokenize from "./template-tokenize" +class TemplateSafeParser extends SafeParser { + protected createTokenizer(): void { + this.tokenizer = templateTokenize(this.input, { ignoreErrors: true }) + } +} +export default TemplateSafeParser diff --git a/src/utils/css-utils/template-tokenize.ts b/src/utils/css-utils/template-tokenize.ts new file mode 100644 index 000000000..c6d6cf6ce --- /dev/null +++ b/src/utils/css-utils/template-tokenize.ts @@ -0,0 +1,50 @@ +import type { Tokenizer, Token } from "postcss/lib/tokenize" +import tokenize from "postcss/lib/tokenize" + +type Tokenize = typeof tokenize + +/** Tokenize */ +function templateTokenize(...args: Parameters): Tokenizer { + const tokenizer = tokenize(...args) + + /** nextToken */ + function nextToken( + ...args: Parameters + ): ReturnType { + const returned = [] + let token: Token | undefined, lastPos + let depth = 0 + + while ((token = tokenizer.nextToken(...args))) { + if (token[0] !== "word") { + if (token[0] === "{") { + ++depth + } else if (token[0] === "}") { + --depth + } + } + if (depth || returned.length) { + lastPos = token[3] || token[2] || lastPos + returned.push(token) + } + if (!depth) { + break + } + } + if (returned.length) { + token = [ + "word", + returned.map((token) => token[1]).join(""), + returned[0][2], + lastPos, + ] + } + return token + } + + return Object.assign({}, tokenizer, { + nextToken, + }) +} + +export default templateTokenize diff --git a/tests/fixtures/rules/prefer-style-directive/invalid/ternary01-errors.json b/tests/fixtures/rules/prefer-style-directive/invalid/ternary01-errors.json index 8d12032f9..d85d6e062 100644 --- a/tests/fixtures/rules/prefer-style-directive/invalid/ternary01-errors.json +++ b/tests/fixtures/rules/prefer-style-directive/invalid/ternary01-errors.json @@ -1,4 +1,9 @@ [ + { + "message": "Can use style directives instead.", + "line": 2, + "column": 3 + }, { "message": "Can use style directives instead.", "line": 4, diff --git a/tests/fixtures/rules/prefer-style-directive/invalid/ternary01-output.svelte b/tests/fixtures/rules/prefer-style-directive/invalid/ternary01-output.svelte index af37613a4..a7d1ac17e 100644 --- a/tests/fixtures/rules/prefer-style-directive/invalid/ternary01-output.svelte +++ b/tests/fixtures/rules/prefer-style-directive/invalid/ternary01-output.svelte @@ -1,8 +1,5 @@
diff --git a/tests/fixtures/rules/prefer-style-directive/invalid/ternary02-errors.json b/tests/fixtures/rules/prefer-style-directive/invalid/ternary02-errors.json index dc2c656fe..b31e83f0b 100644 --- a/tests/fixtures/rules/prefer-style-directive/invalid/ternary02-errors.json +++ b/tests/fixtures/rules/prefer-style-directive/invalid/ternary02-errors.json @@ -1,4 +1,9 @@ [ + { + "message": "Can use style directives instead.", + "line": 2, + "column": 3 + }, { "message": "Can use style directives instead.", "line": 4, diff --git a/tests/fixtures/rules/prefer-style-directive/invalid/ternary02-output.svelte b/tests/fixtures/rules/prefer-style-directive/invalid/ternary02-output.svelte index c150a4c2f..28b157dca 100644 --- a/tests/fixtures/rules/prefer-style-directive/invalid/ternary02-output.svelte +++ b/tests/fixtures/rules/prefer-style-directive/invalid/ternary02-output.svelte @@ -1,6 +1,4 @@
diff --git a/tests/fixtures/rules/prefer-style-directive/invalid/ternary04-errors.json b/tests/fixtures/rules/prefer-style-directive/invalid/ternary04-errors.json new file mode 100644 index 000000000..d07093c24 --- /dev/null +++ b/tests/fixtures/rules/prefer-style-directive/invalid/ternary04-errors.json @@ -0,0 +1,7 @@ +[ + { + "message": "Can use style directives instead.", + "line": 1, + "column": 6 + } +] diff --git a/tests/fixtures/rules/prefer-style-directive/invalid/ternary04-input.svelte b/tests/fixtures/rules/prefer-style-directive/invalid/ternary04-input.svelte new file mode 100644 index 000000000..1b5636a3b --- /dev/null +++ b/tests/fixtures/rules/prefer-style-directive/invalid/ternary04-input.svelte @@ -0,0 +1 @@ +
...
diff --git a/tests/fixtures/rules/prefer-style-directive/invalid/ternary04-output.svelte b/tests/fixtures/rules/prefer-style-directive/invalid/ternary04-output.svelte new file mode 100644 index 000000000..aed6b0c6b --- /dev/null +++ b/tests/fixtures/rules/prefer-style-directive/invalid/ternary04-output.svelte @@ -0,0 +1 @@ +
...
diff --git a/typings/postcss-safe-parser/lib/safe-parser/index.d.ts b/typings/postcss-safe-parser/lib/safe-parser/index.d.ts new file mode 100644 index 000000000..530ca21ed --- /dev/null +++ b/typings/postcss-safe-parser/lib/safe-parser/index.d.ts @@ -0,0 +1,17 @@ +import type { Tokenizer } from "postcss/lib/tokenize" +import type { Root, Input } from "postcss" + +declare class Parser { + protected input: Input + + public root: Root + + protected tokenizer: Tokenizer + + public constructor(input: Input) + + public parse(): void + + protected createTokenizer(): void +} +export default Parser diff --git a/typings/postcss/lib/tokenize/index.d.ts b/typings/postcss/lib/tokenize/index.d.ts new file mode 100644 index 000000000..db6131b2f --- /dev/null +++ b/typings/postcss/lib/tokenize/index.d.ts @@ -0,0 +1,12 @@ +import type { Input } from "postcss" +export type Token = [string, string, number?, number?] +export type Tokenizer = { + back: (token: Token) => void + nextToken: (opts?: { ignoreUnclosed?: boolean }) => Token | undefined + endOfFile: () => boolean + position: () => number +} +export default function tokenizer( + input: Input, + options?: { ignoreErrors?: boolean }, +): Tokenizer