Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve prefer-style-directive #125

Merged
merged 1 commit into from
Apr 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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",
Expand Down
77 changes: 33 additions & 44 deletions src/rules/prefer-style-directive.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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) =>
Expand All @@ -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}`)
}
},
Expand All @@ -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)
}
}

Expand All @@ -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

Expand All @@ -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
}
Expand Down Expand Up @@ -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)
},
}
},
Expand Down
1 change: 1 addition & 0 deletions src/utils/css-utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./style-attribute"
156 changes: 156 additions & 0 deletions src/utils/css-utils/style-attribute.ts
Original file line number Diff line number Diff line change
@@ -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,
]
}
8 changes: 8 additions & 0 deletions src/utils/css-utils/template-safe-parser.ts
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions src/utils/css-utils/template-tokenize.ts
Original file line number Diff line number Diff line change
@@ -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<Tokenize>): Tokenizer {
const tokenizer = tokenize(...args)

/** nextToken */
function nextToken(
...args: Parameters<Tokenizer["nextToken"]>
): ReturnType<Tokenizer["nextToken"]> {
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
Loading