Generalize the walk implementation (#19126)

This PR generalizes the `walk` implementations we have. What's important
here is that we currently have multiple `walk` implementations, one for
the AST, one for the `SelectorParser`, one for the `ValueParser`.

Sometimes, we also need to go up the tree in a depth-first manner. For
that, we have `walkDepth` implementations.

The funny thing is, all these implementations are very very similar,
even the kinds of trees are very similar. They are just objects with
`nodes: []` as children.

So this PR introduces a generic `walk` function that can work on all of
these trees.

There are also some situations where you need to go down and back up the
tree. For this reason, we added an `enter` and `exit` phase:

```ts
walk(ast, {
  enter(node, ctx) {},
  exit(node, ctx) {},
})
```

This means that you don't need to `walk(ast)` and later `walkDepth(ast)`
in case you wanted to do something _after_ visiting all nodes.

The API of these walk functions also slightly changed to fix some
problems we've had before. One is the `replaceWith` function. You could
technically call it multiple times, but that doesn't make sense so
instead you always have to return an explicit `WalkAction`. The
possibilities are:

```ts
// The ones we already had
WalkAction.Continue // Continue walking as normal, the default behavior
WalkAction.Skip // Skip walking the `nodes` of the current node
WalkAction.Stop // Stop the entire walk

// The new ones
WalkAction.Replace(newNode) // Replace the current node, and continue walking the new node(s)
WalkAction.ReplaceSkip(newNode) // Replace the current node, but don't walk the new node(s)
WalkAction.ReplaceStop(newNode) // Replace the current node, but stop the entire walk
```

To make sure that we can walk in both directions, and to make sure we
have proper control over when to walk which nodes, the `walk` function
is implemented in an iterative manner using a stack instead of
recursion.

This also means that a `WalkAction.Stop` or `WalkAction.ReplaceStop`
will immediately stop the walk, without unwinding the entire call stack.

Some notes: 
- The CSS AST does have `context` nodes, for this we can build up the
context lazily when we need it. I added a `cssContext(ctx)` that gives
you an enhanced context including the `context` object that you can read
information from.
- The second argument of the `walk` function can still be a normal
function, which is equivalent to `{ enter: fn }`.

Let's also take a look at some numbers. With this new implementation,
each `walk` is roughly ~1.3-1.5x faster than before. If you look at the
memory usage (especially in Bun) we go from `~2.2GB` peak memory usage,
to `~300mb` peak memory usage.

Some benchmarks on small and big trees (M1 Max):

<img width="2062" height="1438" alt="image"
src="https://github.com/user-attachments/assets/5ec8c22a-9de8-4e08-869a-18c0d30eb7e8"
/>

<img width="2062" height="1246" alt="image"
src="https://github.com/user-attachments/assets/e89d4b8e-29ca-4aee-8fd2-b7c043d3bbf4"
/>

We also ran some benchmarks on @thecrypticace's M3 Max:

<img width="1598" height="1452" alt="image"
src="https://github.com/user-attachments/assets/3b06b6fe-2497-4f24-a428-1a0e2af3896a"
/>

In node the memory difference isn't that big, but the performance itself
is still better:

<img width="2034" height="1586" alt="image"
src="https://github.com/user-attachments/assets/ef28ae14-b53e-4912-9621-531f3b02898f"
/>


In summary:

1. Single `walk` implementation for multiple use cases
2. Support for `enter` and `exit` phases
3. New `WalkAction` possibilities for better control
4. Overall better performance
5. ... and lower memory usage

## Test plan

1. All tests still pass (but had to adjust some of the APIs if `walk`
was used inside tests).
2. Added new tests for the `walk` implementation
3. Ran local benchmarks to verify the performance improvements
This commit is contained in:
Robin Malfait 2025-10-15 21:28:12 +02:00 • committed by GitHub
parent fc63ce7fbc
commit acb27ef9e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2179 additions and 736 deletions

View file

@ -5,8 +5,9 @@
// Minor modifications have been made to work with the Tailwind CSS codebase
import * as path from 'node:path'
import { toCss, walk } from '../../tailwindcss/src/ast'
import { toCss } from '../../tailwindcss/src/ast'
import { parse } from '../../tailwindcss/src/css-parser'
import { walk } from '../../tailwindcss/src/walk'
import { normalizePath } from './normalize-path'
const cssUrlRE =

View file

@ -5,6 +5,7 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { toKeyPath } from '../../../../tailwindcss/src/utils/to-key-path'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
import { walk, WalkAction } from '../../../../tailwindcss/src/walk'
import * as version from '../../utils/version'
// Defaults in v4
@ -117,7 +118,7 @@ function substituteFunctionsInValue(
ast: ValueParser.ValueAstNode[],
handle: (value: string, fallback?: string) => string | null,
) {
ValueParser.walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
if (node.kind === 'function' && node.value === 'theme') {
if (node.nodes.length < 1) return
@ -155,7 +156,7 @@ function substituteFunctionsInValue(
fallbackValues.length > 0 ? handle(path, ValueParser.toCss(fallbackValues)) : handle(path)
if (replacement === null) return
replaceWith(ValueParser.parse(replacement))
return WalkAction.Replace(ValueParser.parse(replacement))
}
})

View file

@ -1,9 +1,9 @@
import { walk, WalkAction } from '../../../../tailwindcss/src/ast'
import { cloneCandidate, type Candidate, type Variant } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import type { Writable } from '../../../../tailwindcss/src/types'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
import { walk, WalkAction } from '../../../../tailwindcss/src/walk'
export function migrateAutomaticVarInjection(
designSystem: DesignSystem,

View file

@ -5,6 +5,7 @@ import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infe
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { toKeyPath } from '../../../../tailwindcss/src/utils/to-key-path'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
import { walk, WalkAction } from '../../../../tailwindcss/src/walk'
export const enum Convert {
All = 0,
@ -27,7 +28,7 @@ export function createConverter(designSystem: DesignSystem, { prettyPrint = fals
let themeModifierCount = 0
// Analyze AST
ValueParser.walk(ast, (node) => {
walk(ast, (node) => {
if (node.kind !== 'function') return
if (node.value !== 'theme') return
@ -35,19 +36,19 @@ export function createConverter(designSystem: DesignSystem, { prettyPrint = fals
themeUsageCount += 1
// Figure out if a modifier is used
ValueParser.walk(node.nodes, (child) => {
walk(node.nodes, (child) => {
// If we see a `,`, it means that we have a fallback value
if (child.kind === 'separator' && child.value.includes(',')) {
return ValueParser.ValueWalkAction.Stop
return WalkAction.Stop
}
// If we see a `/`, we have a modifier
else if (child.kind === 'word' && child.value === '/') {
themeModifierCount += 1
return ValueParser.ValueWalkAction.Stop
return WalkAction.Stop
}
return ValueParser.ValueWalkAction.Skip
return WalkAction.Skip
})
})
@ -172,7 +173,7 @@ function substituteFunctionsInValue(
ast: ValueParser.ValueAstNode[],
handle: (value: string, fallback?: string) => string | null,
) {
ValueParser.walk(ast, (node, { parent, replaceWith }) => {
walk(ast, (node, ctx) => {
if (node.kind === 'function' && node.value === 'theme') {
if (node.nodes.length < 1) return
@ -210,10 +211,10 @@ function substituteFunctionsInValue(
fallbackValues.length > 0 ? handle(path, ValueParser.toCss(fallbackValues)) : handle(path)
if (replacement === null) return
if (parent) {
let idx = parent.nodes.indexOf(node) - 1
if (ctx.parent) {
let idx = ctx.parent.nodes.indexOf(node) - 1
while (idx !== -1) {
let previous = parent.nodes[idx]
let previous = ctx.parent.nodes[idx]
// Skip the space separator
if (previous.kind === 'separator' && previous.value.trim() === '') {
idx -= 1
@ -241,7 +242,7 @@ function substituteFunctionsInValue(
}
}
replaceWith(ValueParser.parse(replacement))
return WalkAction.Replace(ValueParser.parse(replacement))
}
})

View file

@ -1,7 +1,8 @@
import { walk, type AstNode } from '../../../../tailwindcss/src/ast'
import { type AstNode } from '../../../../tailwindcss/src/ast'
import { type Variant } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { walk } from '../../../../tailwindcss/src/walk'
import * as version from '../../utils/version'
export function migrateVariantOrder(

View file

@ -1,10 +1,11 @@
import { Features } from '.'
import { cloneAstNode, rule, toCss, walk, WalkAction, type AstNode } from './ast'
import { cloneAstNode, rule, toCss, type AstNode } from './ast'
import { compileCandidates } from './compile'
import type { DesignSystem } from './design-system'
import type { SourceLocation } from './source-maps/source'
import { DefaultMap } from './utils/default-map'
import { segment } from './utils/segment'
import { walk, WalkAction } from './walk'
export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
let features = Features.None
@ -25,7 +26,7 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
let definitions = new DefaultMap(() => new Set<AstNode>())
// Collect all new `@utility` definitions and all `@apply` rules first
walk([root], (node, { parent, path }) => {
walk([root], (node, ctx) => {
if (node.kind !== 'at-rule') return
// Do not allow `@apply` rules inside `@keyframes` rules.
@ -61,16 +62,15 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
if (node.name === '@apply') {
// `@apply` cannot be top-level, so we need to have a parent such that we
// can replace the `@apply` node with the actual utility classes later.
if (parent === null) return
if (ctx.parent === null) return
features |= Features.AtApply
parents.add(parent)
parents.add(ctx.parent)
for (let dependency of resolveApplyDependencies(node, designSystem)) {
// Mark every parent in the path as having a dependency to that utility.
for (let parent of path) {
if (parent === node) continue
for (let parent of ctx.path()) {
if (!parents.has(parent)) continue
dependencies.get(parent).add(dependency)
}
@ -158,7 +158,7 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
for (let parent of sorted) {
if (!('nodes' in parent)) continue
walk(parent.nodes, (child, { replaceWith }) => {
walk(parent.nodes, (child) => {
if (child.kind !== 'at-rule' || child.name !== '@apply') return
let parts = child.params.split(/(\s+)/g)
@ -291,7 +291,7 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
}
}
replaceWith(newNodes)
return WalkAction.Replace(newNodes)
}
})
}

View file

@ -2,17 +2,17 @@ import { expect, it } from 'vitest'
import {
atRule,
context,
cssContext,
decl,
optimizeAst,
styleRule,
toCss,
walk,
WalkAction,
type AstNode,
} from './ast'
import * as CSS from './css-parser'
import { buildDesignSystem } from './design-system'
import { Theme } from './theme'
import { walk, WalkAction } from './walk'
const css = String.raw
const defaultDesignSystem = buildDesignSystem(new Theme())
@ -31,7 +31,7 @@ it('should pretty print an AST', () => {
})
it('allows the placement of context nodes', () => {
const ast = [
let ast: AstNode[] = [
styleRule('.foo', [decl('color', 'red')]),
context({ context: 'a' }, [
styleRule('.bar', [
@ -48,17 +48,18 @@ it('allows the placement of context nodes', () => {
let blueContext
let greenContext
walk(ast, (node, { context }) => {
walk(ast, (node, _ctx) => {
if (node.kind !== 'declaration') return
let ctx = cssContext(_ctx)
switch (node.value) {
case 'red':
redContext = context
redContext = ctx.context
break
case 'blue':
blueContext = context
blueContext = ctx.context
break
case 'green':
greenContext = context
greenContext = ctx.context
break
}
})
@ -292,25 +293,25 @@ it('should not emit exact duplicate declarations in the same rule', () => {
it('should only visit children once when calling `replaceWith` with single element array', () => {
let visited = new Set()
let ast = [
let ast: AstNode[] = [
atRule('@media', '', [styleRule('.foo', [decl('color', 'blue')])]),
styleRule('.bar', [decl('color', 'blue')]),
]
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
if (visited.has(node)) {
throw new Error('Visited node twice')
}
visited.add(node)
if (node.kind === 'at-rule') replaceWith(node.nodes)
if (node.kind === 'at-rule') return WalkAction.Replace(node.nodes)
})
})
it('should only visit children once when calling `replaceWith` with multi-element array', () => {
let visited = new Set()
let ast = [
let ast: AstNode[] = [
atRule('@media', '', [
context({}, [
styleRule('.foo', [decl('color', 'red')]),
@ -320,19 +321,20 @@ it('should only visit children once when calling `replaceWith` with multi-elemen
styleRule('.bar', [decl('color', 'green')]),
]
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
let key = id(node)
if (visited.has(key)) {
throw new Error('Visited node twice')
}
visited.add(key)
if (node.kind === 'at-rule') replaceWith(node.nodes)
if (node.kind === 'at-rule') return WalkAction.Replace(node.nodes)
})
expect(visited).toMatchInlineSnapshot(`
Set {
"@media ",
"<context>",
".foo",
"color: red",
".baz",
@ -348,14 +350,13 @@ it('should never visit children when calling `replaceWith` with `WalkAction.Skip
let inner = styleRule('.foo', [decl('color', 'blue')])
let ast = [atRule('@media', '', [inner]), styleRule('.bar', [decl('color', 'blue')])]
let ast: AstNode[] = [atRule('@media', '', [inner]), styleRule('.bar', [decl('color', 'blue')])]
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
visited.add(node)
if (node.kind === 'at-rule') {
replaceWith(node.nodes)
return WalkAction.Skip
return WalkAction.ReplaceSkip(node.nodes)
}
})
@ -413,11 +414,10 @@ it('should skip the correct number of children based on the replaced children no
decl('--index', '4'),
]
let visited: string[] = []
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
visited.push(id(node))
if (node.kind === 'declaration' && node.value === '2') {
replaceWith([])
return WalkAction.Skip
return WalkAction.ReplaceSkip([])
}
})
@ -441,11 +441,10 @@ it('should skip the correct number of children based on the replaced children no
decl('--index', '4'),
]
let visited: string[] = []
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
visited.push(id(node))
if (node.kind === 'declaration' && node.value === '2') {
replaceWith([])
return WalkAction.Continue
return WalkAction.Replace([])
}
})
@ -469,11 +468,10 @@ it('should skip the correct number of children based on the replaced children no
decl('--index', '4'),
]
let visited: string[] = []
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
visited.push(id(node))
if (node.kind === 'declaration' && node.value === '2') {
replaceWith([decl('--index', '2.1')])
return WalkAction.Skip
return WalkAction.ReplaceSkip([decl('--index', '2.1')])
}
})
@ -497,11 +495,10 @@ it('should skip the correct number of children based on the replaced children no
decl('--index', '4'),
]
let visited: string[] = []
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
visited.push(id(node))
if (node.kind === 'declaration' && node.value === '2') {
replaceWith([decl('--index', '2.1')])
return WalkAction.Continue
return WalkAction.Replace([decl('--index', '2.1')])
}
})
@ -526,11 +523,10 @@ it('should skip the correct number of children based on the replaced children no
decl('--index', '4'),
]
let visited: string[] = []
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
visited.push(id(node))
if (node.kind === 'declaration' && node.value === '2') {
replaceWith([decl('--index', '2.1'), decl('--index', '2.2')])
return WalkAction.Skip
return WalkAction.ReplaceSkip([decl('--index', '2.1'), decl('--index', '2.2')])
}
})
@ -554,11 +550,10 @@ it('should skip the correct number of children based on the replaced children no
decl('--index', '4'),
]
let visited: string[] = []
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
visited.push(id(node))
if (node.kind === 'declaration' && node.value === '2') {
replaceWith([decl('--index', '2.1'), decl('--index', '2.2')])
return WalkAction.Continue
return WalkAction.Replace([decl('--index', '2.1'), decl('--index', '2.2')])
}
})

View file

@ -6,6 +6,7 @@ import { Theme, ThemeOptions } from './theme'
import { DefaultMap } from './utils/default-map'
import { extractUsedVariables } from './utils/variables'
import * as ValueParser from './value-parser'
import { walk, WalkAction, type VisitContext } from './walk'
const AT_SIGN = 0x40
@ -184,157 +185,33 @@ export function cloneAstNode<T extends AstNode>(node: T): T {
}
}
export const enum WalkAction {
/** Continue walking, which is the default */
Continue,
/** Skip visiting the children of this node */
Skip,
/** Stop the walk entirely */
Stop,
}
export function walk(
ast: AstNode[],
visit: (
node: AstNode,
utils: {
parent: AstNode | null
replaceWith(newNode: AstNode | AstNode[]): void
context: Record<string, string | boolean>
path: AstNode[]
},
) => void | WalkAction,
path: AstNode[] = [],
context: Record<string, string | boolean> = {},
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let parent = path[path.length - 1] ?? null
// We want context nodes to be transparent in walks. This means that
// whenever we encounter one, we immediately walk through its children and
// furthermore we also don't update the parent.
if (node.kind === 'context') {
if (walk(node.nodes, visit, path, { ...context, ...node.context }) === WalkAction.Stop) {
return WalkAction.Stop
}
continue
}
path.push(node)
let replacedNode = false
let replacedNodeOffset = 0
let status =
visit(node, {
parent,
context,
path,
replaceWith(newNode) {
if (replacedNode) return
replacedNode = true
if (Array.isArray(newNode)) {
if (newNode.length === 0) {
ast.splice(i, 1)
replacedNodeOffset = 0
} else if (newNode.length === 1) {
ast[i] = newNode[0]
replacedNodeOffset = 1
} else {
ast.splice(i, 1, ...newNode)
replacedNodeOffset = newNode.length
}
} else {
ast[i] = newNode
replacedNodeOffset = 1
}
},
}) ?? WalkAction.Continue
path.pop()
// We want to visit or skip the newly replaced node(s), which start at the
// current index (i). By decrementing the index here, the next loop will
// process this position (containing the replaced node) again.
if (replacedNode) {
if (status === WalkAction.Continue) {
i--
} else {
i += replacedNodeOffset - 1
}
continue
}
// Stop the walk entirely
if (status === WalkAction.Stop) return WalkAction.Stop
// Skip visiting the children of this node
if (status === WalkAction.Skip) continue
if ('nodes' in node) {
path.push(node)
let result = walk(node.nodes, visit, path, context)
path.pop()
if (result === WalkAction.Stop) {
return WalkAction.Stop
}
}
}
}
// This is a depth-first traversal of the AST
export function walkDepth(
ast: AstNode[],
visit: (
node: AstNode,
utils: {
parent: AstNode | null
path: AstNode[]
context: Record<string, string | boolean>
replaceWith(newNode: AstNode[]): void
},
) => void,
path: AstNode[] = [],
context: Record<string, string | boolean> = {},
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let parent = path[path.length - 1] ?? null
if (node.kind === 'rule' || node.kind === 'at-rule') {
path.push(node)
walkDepth(node.nodes, visit, path, context)
path.pop()
} else if (node.kind === 'context') {
walkDepth(node.nodes, visit, path, { ...context, ...node.context })
continue
}
path.push(node)
visit(node, {
parent,
context,
path,
replaceWith(newNode) {
if (Array.isArray(newNode)) {
if (newNode.length === 0) {
ast.splice(i, 1)
} else if (newNode.length === 1) {
ast[i] = newNode[0]
} else {
ast.splice(i, 1, ...newNode)
}
} else {
ast[i] = newNode
export function cssContext(
ctx: VisitContext<AstNode>,
): VisitContext<AstNode> & { context: Record<string, string | boolean> } {
return {
depth: ctx.depth,
get context() {
let context: Record<string, string | boolean> = {}
for (let child of ctx.path()) {
if (child.kind === 'context') {
Object.assign(context, child.context)
}
}
// Skip over the newly inserted nodes (being depth-first it doesn't make sense to visit them)
i += newNode.length - 1
},
})
path.pop()
// Once computed, we never need to compute this again
Object.defineProperty(this, 'context', { value: context })
return context
},
get parent() {
let parent = (this.path().pop() as Extract<AstNode, { nodes: AstNode[] }>) ?? null
// Once computed, we never need to compute this again
Object.defineProperty(this, 'parent', { value: parent })
return parent
},
path() {
return ctx.path().filter((n) => n.kind !== 'context')
},
}
}
@ -642,12 +519,12 @@ export function optimizeAst(
let ast = ValueParser.parse(declaration.value)
let requiresPolyfill = false
ValueParser.walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
if (node.kind !== 'function' || node.value !== 'color-mix') return
let containsUnresolvableVars = false
let containsCurrentcolor = false
ValueParser.walk(node.nodes, (node, { replaceWith }) => {
walk(node.nodes, (node) => {
if (node.kind == 'word' && node.value.toLowerCase() === 'currentcolor') {
containsCurrentcolor = true
requiresPolyfill = true
@ -691,7 +568,7 @@ export function optimizeAst(
}
} while (varNode)
replaceWith({ kind: 'word', value: inlinedColor })
return WalkAction.Replace({ kind: 'word', value: inlinedColor } as const)
})
if (containsUnresolvableVars || containsCurrentcolor) {
@ -702,7 +579,7 @@ export function optimizeAst(
let firstColorValue =
node.nodes.length > separatorIndex ? node.nodes[separatorIndex + 1] : null
if (!firstColorValue) return
replaceWith(firstColorValue)
return WalkAction.Replace(firstColorValue)
} else if (requiresPolyfill) {
// Change the colorspace to `srgb` since the fallback values should not be represented as
// `oklab(…)` functions again as their support in Safari <16 is very limited.
@ -1005,9 +882,10 @@ export function toCss(ast: AstNode[], track?: boolean) {
function findNode(ast: AstNode[], fn: (node: AstNode) => boolean): AstNode[] | null {
let foundPath: AstNode[] = []
walk(ast, (node, { path }) => {
walk(ast, (node, ctx) => {
if (fn(node)) {
foundPath = [...path]
foundPath = ctx.path()
foundPath.push(node)
return WalkAction.Stop
}
})

View file

@ -1,7 +1,8 @@
import { Features } from '.'
import { atRule, context, walk, WalkAction, type AstNode } from './ast'
import { atRule, context, type AstNode } from './ast'
import * as CSS from './css-parser'
import * as ValueParser from './value-parser'
import { walk, WalkAction } from './walk'
type LoadStylesheet = (
id: string,
@ -22,7 +23,7 @@ export async function substituteAtImports(
let features = Features.None
let promises: Promise<void>[] = []
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
if (node.kind === 'at-rule' && (node.name === '@import' || node.name === '@reference')) {
let parsed = parseImportParams(ValueParser.parse(node.params))
if (parsed === null) return
@ -66,11 +67,9 @@ export async function substituteAtImports(
})(),
)
replaceWith(contextNode)
// The resolved Stylesheets already have their transitive @imports
// resolved, so we can skip walking them.
return WalkAction.Skip
return WalkAction.ReplaceSkip(contextNode)
}
})

View file

@ -4,6 +4,7 @@ import { DefaultMap } from './utils/default-map'
import { isValidArbitrary } from './utils/is-valid-arbitrary'
import { segment } from './utils/segment'
import * as ValueParser from './value-parser'
import { walk, WalkAction } from './walk'
const COLON = 0x3a
const DASH = 0x2d
@ -1025,8 +1026,8 @@ const printArbitraryValueCache = new DefaultMap<string, string>((input) => {
let drop = new Set<ValueParser.ValueAstNode>()
ValueParser.walk(ast, (node, { parent }) => {
let parentArray = parent === null ? ast : (parent.nodes ?? [])
walk(ast, (node, ctx) => {
let parentArray = ctx.parent === null ? ast : (ctx.parent.nodes ?? [])
// Handle operators (e.g.: inside of `calc(…)`)
if (
@ -1064,10 +1065,10 @@ const printArbitraryValueCache = new DefaultMap<string, string>((input) => {
})
if (drop.size > 0) {
ValueParser.walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
if (drop.has(node)) {
drop.delete(node)
replaceWith([])
return WalkAction.ReplaceSkip([])
}
})
}

View file

@ -26,6 +26,7 @@ import { replaceObject } from './utils/replace-object'
import { segment } from './utils/segment'
import { toKeyPath } from './utils/to-key-path'
import * as ValueParser from './value-parser'
import { walk, WalkAction } from './walk'
export interface CanonicalizeOptions {
/**
@ -289,7 +290,7 @@ const converterCache = new DefaultMap((ds: DesignSystem) => {
let themeModifierCount = 0
// Analyze AST
ValueParser.walk(ast, (node) => {
walk(ast, (node) => {
if (node.kind !== 'function') return
if (node.value !== 'theme') return
@ -297,19 +298,19 @@ const converterCache = new DefaultMap((ds: DesignSystem) => {
themeUsageCount += 1
// Figure out if a modifier is used
ValueParser.walk(node.nodes, (child) => {
walk(node.nodes, (child) => {
// If we see a `,`, it means that we have a fallback value
if (child.kind === 'separator' && child.value.includes(',')) {
return ValueParser.ValueWalkAction.Stop
return WalkAction.Stop
}
// If we see a `/`, we have a modifier
else if (child.kind === 'word' && child.value === '/') {
themeModifierCount += 1
return ValueParser.ValueWalkAction.Stop
return WalkAction.Stop
}
return ValueParser.ValueWalkAction.Skip
return WalkAction.Skip
})
})
@ -434,7 +435,7 @@ function substituteFunctionsInValue(
ast: ValueParser.ValueAstNode[],
handle: (value: string, fallback?: string) => string | null,
) {
ValueParser.walk(ast, (node, { parent, replaceWith }) => {
walk(ast, (node, ctx) => {
if (node.kind === 'function' && node.value === 'theme') {
if (node.nodes.length < 1) return
@ -472,10 +473,10 @@ function substituteFunctionsInValue(
fallbackValues.length > 0 ? handle(path, ValueParser.toCss(fallbackValues)) : handle(path)
if (replacement === null) return
if (parent) {
let idx = parent.nodes.indexOf(node) - 1
if (ctx.parent) {
let idx = ctx.parent.nodes.indexOf(node) - 1
while (idx !== -1) {
let previous = parent.nodes[idx]
let previous = ctx.parent.nodes[idx]
// Skip the space separator
if (previous.kind === 'separator' && previous.value.trim() === '') {
idx -= 1
@ -503,7 +504,7 @@ function substituteFunctionsInValue(
}
}
replaceWith(ValueParser.parse(replacement))
return WalkAction.Replace(ValueParser.parse(replacement))
}
})
@ -780,7 +781,7 @@ function allVariablesAreUsed(
.join('\n')
let isSafeMigration = true
ValueParser.walk(ValueParser.parse(value), (node) => {
walk(ValueParser.parse(value), (node) => {
if (node.kind === 'function' && node.value === 'var') {
let variable = node.nodes[0].value
let r = new RegExp(`var\\(${variable}[,)]\\s*`, 'g')
@ -792,7 +793,7 @@ function allVariablesAreUsed(
replacementAsCss.includes(`${variable}:`)
) {
isSafeMigration = false
return ValueParser.ValueWalkAction.Stop
return WalkAction.Stop
}
}
})
@ -1240,16 +1241,16 @@ function modernizeArbitraryValuesVariant(
let parsed = ValueParser.parse(SelectorParser.toCss(ast))
let containsNot = false
ValueParser.walk(parsed, (node, { replaceWith }) => {
walk(parsed, (node) => {
if (node.kind === 'word' && node.value === 'not') {
containsNot = true
replaceWith([])
return WalkAction.Replace([])
}
})
// Remove unnecessary whitespace
parsed = ValueParser.parse(ValueParser.toCss(parsed))
ValueParser.walk(parsed, (node) => {
walk(parsed, (node) => {
if (node.kind === 'separator' && node.value !== ' ' && node.value.trim() === '') {
// node.value contains at least 2 spaces. Normalize it to a single
// space.

View file

@ -1,8 +1,9 @@
import { Features } from '..'
import { styleRule, toCss, walk, WalkAction, type AstNode } from '../ast'
import { cssContext, styleRule, toCss, type AstNode } from '../ast'
import type { DesignSystem } from '../design-system'
import type { SourceLocation } from '../source-maps/source'
import { segment } from '../utils/segment'
import { walk, WalkAction } from '../walk'
import { applyConfigToTheme } from './apply-config-to-theme'
import { applyKeyframesToTheme } from './apply-keyframes-to-theme'
import { createCompatConfig } from './config/create-compat-config'
@ -50,12 +51,13 @@ export async function applyCompatibilityHooks({
src: SourceLocation | undefined
}[] = []
walk(ast, (node, { parent, replaceWith, context }) => {
walk(ast, (node, _ctx) => {
if (node.kind !== 'at-rule') return
let ctx = cssContext(_ctx)
// Collect paths from `@plugin` at-rules
if (node.name === '@plugin') {
if (parent !== null) {
if (ctx.parent !== null) {
throw new Error('`@plugin` cannot be nested.')
}
@ -110,16 +112,15 @@ export async function applyCompatibilityHooks({
pluginPaths.push([
{
id: pluginPath,
base: context.base as string,
reference: !!context.reference,
base: ctx.context.base as string,
reference: !!ctx.context.reference,
src: node.src,
},
Object.keys(options).length > 0 ? options : null,
])
replaceWith([])
features |= Features.JsPluginCompat
return
return WalkAction.Replace([])
}
// Collect paths from `@config` at-rules
@ -128,19 +129,18 @@ export async function applyCompatibilityHooks({
throw new Error('`@config` cannot have a body.')
}
if (parent !== null) {
if (ctx.parent !== null) {
throw new Error('`@config` cannot be nested.')
}
configPaths.push({
id: node.params.slice(1, -1),
base: context.base as string,
reference: !!context.reference,
base: ctx.context.base as string,
reference: !!ctx.context.reference,
src: node.src,
})
replaceWith([])
features |= Features.JsPluginCompat
return
return WalkAction.Replace([])
}
})
@ -386,18 +386,18 @@ function upgradeToFullPluginSupport({
if (typeof resolvedConfig.important === 'string') {
let wrappingSelector = resolvedConfig.important
walk(ast, (node, { replaceWith, parent }) => {
walk(ast, (node, _ctx) => {
if (node.kind !== 'at-rule') return
if (node.name !== '@tailwind' || node.params !== 'utilities') return
let ctx = cssContext(_ctx)
// The AST node was already manually wrapped so there's nothing to do
if (parent?.kind === 'rule' && parent.selector === wrappingSelector) {
if (ctx.parent?.kind === 'rule' && ctx.parent.selector === wrappingSelector) {
return WalkAction.Stop
}
replaceWith(styleRule(wrappingSelector, [node]))
return WalkAction.Stop
return WalkAction.ReplaceStop(styleRule(wrappingSelector, [node]))
})
}

View file

@ -1,6 +1,6 @@
import type { Features } from '..'
import { substituteAtApply } from '../apply'
import { atRule, cloneAstNode, decl, rule, walk, type AstNode } from '../ast'
import { atRule, cloneAstNode, decl, rule, type AstNode } from '../ast'
import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candidate'
import { substituteFunctions } from '../css-functions'
import * as CSS from '../css-parser'
@ -14,6 +14,7 @@ import { inferDataType } from '../utils/infer-data-type'
import { segment } from '../utils/segment'
import { toKeyPath } from '../utils/to-key-path'
import { compoundsForSelectors, IS_VALID_VARIANT_NAME, substituteAtSlot } from '../variants'
import { walk, WalkAction } from '../walk'
import type { ResolvedConfig, UserConfig } from './config/types'
import { createThemeFn } from './plugin-functions'
@ -281,7 +282,7 @@ export function buildPluginApi({
let selectorAst = SelectorParser.parse(name)
let foundValidUtility = false
SelectorParser.walk(selectorAst, (node) => {
walk(selectorAst, (node) => {
if (
node.kind === 'selector' &&
node.value[0] === '.' &&
@ -301,7 +302,7 @@ export function buildPluginApi({
}
if (node.kind === 'function' && node.value === ':not') {
return SelectorParser.SelectorWalkAction.Skip
return WalkAction.Skip
}
})
@ -318,7 +319,7 @@ export function buildPluginApi({
walk(ast, (node) => {
if (node.kind === 'rule') {
let selectorAst = SelectorParser.parse(node.selector)
SelectorParser.walk(selectorAst, (node) => {
walk(selectorAst, (node) => {
if (node.kind === 'selector' && node.value[0] === '.') {
node.value = `.${designSystem.theme.prefix}\\:${node.value.slice(1)}`
}
@ -612,7 +613,7 @@ function replaceNestedClassNameReferences(
walk(ast, (node) => {
if (node.kind === 'rule') {
let selectorAst = SelectorParser.parse(node.selector)
SelectorParser.walk(selectorAst, (node) => {
walk(selectorAst, (node) => {
if (node.kind === 'selector' && node.value === `.${utilityName}`) {
node.value = `.${escape(rawCandidate)}`
}

View file

@ -1,13 +1,4 @@
import {
atRule,
decl,
rule,
walk,
WalkAction,
type AstNode,
type Rule,
type StyleRule,
} from './ast'
import { atRule, decl, rule, type AstNode, type Rule, type StyleRule } from './ast'
import { type Candidate, type Variant } from './candidate'
import { CompileAstFlags, type DesignSystem } from './design-system'
import GLOBAL_PROPERTY_ORDER from './property-order'
@ -15,6 +6,7 @@ import { asColor, type Utility } from './utilities'
import { compare } from './utils/compare'
import { escape } from './utils/escape'
import type { Variants } from './variants'
import { walk, WalkAction } from './walk'
export function compileCandidates(
rawCandidates: Iterable<string>,

View file

@ -1,6 +1,7 @@
import { dimensions } from './utils/dimensions'
import { isLength } from './utils/infer-data-type'
import * as ValueParser from './value-parser'
import { walk, WalkAction } from './walk'
// Assumption: We already assume that we receive somewhat valid `calc()`
// expressions. So we will see `calc(1 + 1)` and not `calc(1+1)`
@ -8,106 +9,106 @@ export function constantFoldDeclaration(input: string, rem: number | null = null
let folded = false
let valueAst = ValueParser.parse(input)
ValueParser.walkDepth(valueAst, (valueNode, { replaceWith }) => {
// Canonicalize dimensions to their simplest form. This includes:
// - Convert `-0`, `+0`, `0.0`, … to `0`
// - Convert `-0px`, `+0em`, `0.0rem`, … to `0`
// - Convert units to an equivalent unit
if (
valueNode.kind === 'word' &&
valueNode.value !== '0' // Already `0`, nothing to do
) {
let canonical = canonicalizeDimension(valueNode.value, rem)
if (canonical === null) return // Couldn't be canonicalized, nothing to do
if (canonical === valueNode.value) return // Already in canonical form, nothing to do
folded = true
replaceWith(ValueParser.word(canonical))
return
}
// Constant fold `calc()` expressions with two operands and one operator
else if (
valueNode.kind === 'function' &&
(valueNode.value === 'calc' || valueNode.value === '')
) {
// [
// { kind: 'word', value: '0.25rem' }, 0
// { kind: 'separator', value: ' ' }, 1
// { kind: 'word', value: '*' }, 2
// { kind: 'separator', value: ' ' }, 3
// { kind: 'word', value: '256' } 4
// ]
if (valueNode.nodes.length !== 5) return
let lhs = dimensions.get(valueNode.nodes[0].value)
let operator = valueNode.nodes[2].value
let rhs = dimensions.get(valueNode.nodes[4].value)
// Nullify entire expression when multiplying by `0`, e.g.: `calc(0 * 100vw)` -> `0`
//
// TODO: Ensure it's safe to do so based on the data types?
walk(valueAst, {
exit(valueNode) {
// Canonicalize dimensions to their simplest form. This includes:
// - Convert `-0`, `+0`, `0.0`, … to `0`
// - Convert `-0px`, `+0em`, `0.0rem`, … to `0`
// - Convert units to an equivalent unit
if (
operator === '*' &&
((lhs?.[0] === 0 && lhs?.[1] === null) || // 0 * something
(rhs?.[0] === 0 && rhs?.[1] === null)) // something * 0
valueNode.kind === 'word' &&
valueNode.value !== '0' // Already `0`, nothing to do
) {
let canonical = canonicalizeDimension(valueNode.value, rem)
if (canonical === null) return // Couldn't be canonicalized, nothing to do
if (canonical === valueNode.value) return // Already in canonical form, nothing to do
folded = true
replaceWith(ValueParser.word('0'))
return
return WalkAction.ReplaceSkip(ValueParser.word(canonical))
}
// We're not dealing with dimensions, so we can't fold this
if (lhs === null || rhs === null) {
return
}
// Constant fold `calc()` expressions with two operands and one operator
else if (
valueNode.kind === 'function' &&
(valueNode.value === 'calc' || valueNode.value === '')
) {
// [
// { kind: 'word', value: '0.25rem' }, 0
// { kind: 'separator', value: ' ' }, 1
// { kind: 'word', value: '*' }, 2
// { kind: 'separator', value: ' ' }, 3
// { kind: 'word', value: '256' } 4
// ]
if (valueNode.nodes.length !== 5) return
switch (operator) {
case '*': {
if (
lhs[1] === rhs[1] || // Same Units, e.g.: `1rem * 2rem`, `8 * 6`
(lhs[1] === null && rhs[1] !== null) || // Unitless * Unit, e.g.: `2 * 1rem`
(lhs[1] !== null && rhs[1] === null) // Unit * Unitless, e.g.: `1rem * 2`
) {
folded = true
replaceWith(ValueParser.word(`${lhs[0] * rhs[0]}${lhs[1] ?? ''}`))
}
break
let lhs = dimensions.get(valueNode.nodes[0].value)
let operator = valueNode.nodes[2].value
let rhs = dimensions.get(valueNode.nodes[4].value)
// Nullify entire expression when multiplying by `0`, e.g.: `calc(0 * 100vw)` -> `0`
//
// TODO: Ensure it's safe to do so based on the data types?
if (
operator === '*' &&
((lhs?.[0] === 0 && lhs?.[1] === null) || // 0 * something
(rhs?.[0] === 0 && rhs?.[1] === null)) // something * 0
) {
folded = true
return WalkAction.ReplaceSkip(ValueParser.word('0'))
}
case '+': {
if (
lhs[1] === rhs[1] // Same unit or unitless, e.g.: `1rem + 2rem`, `8 + 6`
) {
folded = true
replaceWith(ValueParser.word(`${lhs[0] + rhs[0]}${lhs[1] ?? ''}`))
}
break
// We're not dealing with dimensions, so we can't fold this
if (lhs === null || rhs === null) {
return
}
case '-': {
if (
lhs[1] === rhs[1] // Same unit or unitless, e.g.: `2rem - 1rem`, `8 - 6`
) {
folded = true
replaceWith(ValueParser.word(`${lhs[0] - rhs[0]}${lhs[1] ?? ''}`))
switch (operator) {
case '*': {
if (
lhs[1] === rhs[1] || // Same Units, e.g.: `1rem * 2rem`, `8 * 6`
(lhs[1] === null && rhs[1] !== null) || // Unitless * Unit, e.g.: `2 * 1rem`
(lhs[1] !== null && rhs[1] === null) // Unit * Unitless, e.g.: `1rem * 2`
) {
folded = true
return WalkAction.ReplaceSkip(ValueParser.word(`${lhs[0] * rhs[0]}${lhs[1] ?? ''}`))
}
break
}
break
}
case '/': {
if (
rhs[0] !== 0 && // Don't divide by zero
((lhs[1] === null && rhs[1] === null) || // Unitless / Unitless, e.g.: `8 / 2`
(lhs[1] !== null && rhs[1] === null)) // Unit / Unitless, e.g.: `1rem / 2`
) {
folded = true
replaceWith(ValueParser.word(`${lhs[0] / rhs[0]}${lhs[1] ?? ''}`))
case '+': {
if (
lhs[1] === rhs[1] // Same unit or unitless, e.g.: `1rem + 2rem`, `8 + 6`
) {
folded = true
return WalkAction.ReplaceSkip(ValueParser.word(`${lhs[0] + rhs[0]}${lhs[1] ?? ''}`))
}
break
}
case '-': {
if (
lhs[1] === rhs[1] // Same unit or unitless, e.g.: `2rem - 1rem`, `8 - 6`
) {
folded = true
return WalkAction.ReplaceSkip(ValueParser.word(`${lhs[0] - rhs[0]}${lhs[1] ?? ''}`))
}
break
}
case '/': {
if (
rhs[0] !== 0 && // Don't divide by zero
((lhs[1] === null && rhs[1] === null) || // Unitless / Unitless, e.g.: `8 / 2`
(lhs[1] !== null && rhs[1] === null)) // Unit / Unitless, e.g.: `1rem / 2`
) {
folded = true
return WalkAction.ReplaceSkip(ValueParser.word(`${lhs[0] / rhs[0]}${lhs[1] ?? ''}`))
}
break
}
break
}
}
}
},
})
return folded ? ValueParser.toCss(valueAst) : input

View file

@ -1,9 +1,10 @@
import { Features } from '.'
import { walk, type AstNode } from './ast'
import { type AstNode } from './ast'
import type { DesignSystem } from './design-system'
import { withAlpha } from './utilities'
import { segment } from './utils/segment'
import * as ValueParser from './value-parser'
import { walk, WalkAction } from './walk'
const CSS_FUNCTIONS: Record<
string,
@ -187,7 +188,7 @@ export function substituteFunctionsInValue(
designSystem: DesignSystem,
): string {
let ast = ValueParser.parse(value)
ValueParser.walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
if (node.kind === 'function' && node.value in CSS_FUNCTIONS) {
let args = segment(ValueParser.toCss(node.nodes).trim(), ',').map((x) => x.trim())
let result = CSS_FUNCTIONS[node.value as keyof typeof CSS_FUNCTIONS](
@ -195,7 +196,7 @@ export function substituteFunctionsInValue(
source,
...args,
)
return replaceWith(ValueParser.parse(result))
return WalkAction.Replace(ValueParser.parse(result))
}
})
@ -223,7 +224,7 @@ function eventuallyUnquote(value: string) {
}
function injectFallbackForInitialFallback(ast: ValueParser.ValueAstNode[], fallback: string): void {
ValueParser.walk(ast, (node) => {
walk(ast, (node) => {
if (node.kind !== 'function') return
if (node.value !== 'var' && node.value !== 'theme' && node.value !== '--theme') return

View file

@ -6,13 +6,12 @@ import {
comment,
context,
context as contextNode,
cssContext,
decl,
optimizeAst,
rule,
styleRule,
toCss,
walk,
WalkAction,
type AstNode,
type AtRule,
type Context,
@ -34,6 +33,7 @@ import { escape, unescape } from './utils/escape'
import { segment } from './utils/segment'
import { topologicalSort } from './utils/topological-sort'
import { compoundsForSelectors, IS_VALID_VARIANT_NAME, substituteAtVariant } from './variants'
import { walk, WalkAction } from './walk'
export type Config = UserConfig
const IS_VALID_PREFIX = /^[a-z]+$/
@ -166,8 +166,9 @@ async function parseCss(
let root = null as Root
// Handle at-rules
walk(ast, (node, { parent, replaceWith, context }) => {
walk(ast, (node, _ctx) => {
if (node.kind !== 'at-rule') return
let ctx = cssContext(_ctx)
// Find `@tailwind utilities` so that we can later replace it with the
// actual generated utility class CSS.
@ -177,16 +178,14 @@ async function parseCss(
) {
// Any additional `@tailwind utilities` nodes can be removed
if (utilitiesNode !== null) {
replaceWith([])
return
return WalkAction.Replace([])
}
// When inside `@reference` we should treat `@tailwind utilities` as if
// it wasn't there in the first place. This should also let `build()`
// return the cached static AST.
if (context.reference) {
replaceWith([])
return
if (ctx.context.reference) {
return WalkAction.Replace([])
}
let params = segment(node.params, ' ')
@ -210,7 +209,7 @@ async function parseCss(
}
root = {
base: (context.sourceBase as string) ?? (context.base as string),
base: (ctx.context.sourceBase as string) ?? (ctx.context.base as string),
pattern: path.slice(1, -1),
}
}
@ -222,7 +221,7 @@ async function parseCss(
// Collect custom `@utility` at-rules
if (node.name === '@utility') {
if (parent !== null) {
if (ctx.parent !== null) {
throw new Error('`@utility` cannot be nested.')
}
@ -260,7 +259,7 @@ async function parseCss(
throw new Error('`@source` cannot have a body.')
}
if (parent !== null) {
if (ctx.parent !== null) {
throw new Error('`@source` cannot be nested.')
}
@ -298,20 +297,20 @@ async function parseCss(
}
} else {
sources.push({
base: context.base as string,
base: ctx.context.base as string,
pattern: source,
negated: not,
})
}
replaceWith([])
return
return WalkAction.ReplaceSkip([])
}
// Apply `@variant` at-rules
if (node.name === '@variant') {
// Legacy `@variant` at-rules containing `@slot` or without a body should
// be considered a `@custom-variant` at-rule.
if (parent === null) {
if (ctx.parent === null) {
// Body-less `@variant`, e.g.: `@variant foo (…);`
if (node.nodes.length === 0) {
node.name = '@custom-variant'
@ -350,13 +349,10 @@ async function parseCss(
// Register custom variants from `@custom-variant` at-rules
if (node.name === '@custom-variant') {
if (parent !== null) {
if (ctx.parent !== null) {
throw new Error('`@custom-variant` cannot be nested.')
}
// Remove `@custom-variant` at-rule so it's not included in the compiled CSS
replaceWith([])
let [name, selector] = segment(node.params, ' ')
if (!IS_VALID_VARIANT_NAME.test(name)) {
@ -417,8 +413,6 @@ async function parseCss(
)
})
customVariantDependencies.set(name, new Set<string>())
return
}
// Variants without a selector, but with a body:
@ -448,9 +442,10 @@ async function parseCss(
designSystem.variants.fromAst(name, node.nodes, designSystem)
})
customVariantDependencies.set(name, dependencies)
return
}
// Remove `@custom-variant` at-rule so it's not included in the compiled CSS
return WalkAction.ReplaceSkip([])
}
if (node.name === '@media') {
@ -462,13 +457,14 @@ async function parseCss(
if (param.startsWith('source(')) {
let path = param.slice(7, -1)
walk(node.nodes, (child, { replaceWith }) => {
walk(node.nodes, (child) => {
if (child.kind !== 'at-rule') return
if (child.name === '@tailwind' && child.params === 'utilities') {
child.params += ` source(${path})`
replaceWith([contextNode({ sourceBase: context.base }, [child])])
return WalkAction.Stop
return WalkAction.ReplaceStop([
contextNode({ sourceBase: ctx.context.base }, [child]),
])
}
})
}
@ -483,6 +479,7 @@ async function parseCss(
let hasReference = themeParams.includes('reference')
walk(node.nodes, (child) => {
if (child.kind === 'context') return
if (child.kind !== 'at-rule') {
if (hasReference) {
throw new Error(
@ -535,8 +532,10 @@ async function parseCss(
if (unknownParams.length > 0) {
node.params = unknownParams.join(' ')
} else if (params.length > 0) {
replaceWith(node.nodes)
return WalkAction.Replace(node.nodes)
}
return WalkAction.Continue
}
// Handle `@theme`
@ -545,7 +544,7 @@ async function parseCss(
features |= Features.AtTheme
if (context.reference) {
if (ctx.context.reference) {
themeOptions |= ThemeOptions.REFERENCE
}
@ -589,11 +588,10 @@ async function parseCss(
if (!firstThemeRule) {
firstThemeRule = styleRule(':root, :host', [])
firstThemeRule.src = node.src
replaceWith([firstThemeRule])
return WalkAction.ReplaceSkip(firstThemeRule)
} else {
replaceWith([])
return WalkAction.ReplaceSkip([])
}
return WalkAction.Skip
}
})
@ -685,11 +683,11 @@ async function parseCss(
// Remove `@utility`, we couldn't replace it before yet because we had to
// handle the nested `@apply` at-rules first.
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
if (node.kind !== 'at-rule') return
if (node.name === '@utility') {
replaceWith([])
return WalkAction.Replace([])
}
// The `@utility` has to be top-level, therefore we don't have to traverse

View file

@ -1,8 +1,9 @@
import { styleRule, walkDepth } from './ast'
import { styleRule } from './ast'
import { applyVariant } from './compile'
import type { DesignSystem } from './design-system'
import { compare } from './utils/compare'
import { DefaultMap } from './utils/default-map'
import { walk } from './walk'
export { canonicalizeCandidates, type CanonicalizeOptions } from './canonicalize-candidates'
interface ClassMetadata {
@ -180,42 +181,47 @@ export function getVariants(design: DesignSystem) {
// Produce v3-style selector strings in the face of nested rules
// this is more visible for things like group-*, not-*, etc…
walkDepth(node.nodes, (node, { path }) => {
if (node.kind !== 'rule' && node.kind !== 'at-rule') return
if (node.nodes.length > 0) return
walk(node.nodes, {
exit(node, ctx) {
if (node.kind !== 'rule' && node.kind !== 'at-rule') return
if (node.nodes.length > 0) return
// Sort at-rules before style rules
path.sort((a, b) => {
let aIsAtRule = a.kind === 'at-rule'
let bIsAtRule = b.kind === 'at-rule'
let path = ctx.path()
path.push(node)
if (aIsAtRule && !bIsAtRule) return -1
if (!aIsAtRule && bIsAtRule) return 1
// Sort at-rules before style rules
path.sort((a, b) => {
let aIsAtRule = a.kind === 'at-rule'
let bIsAtRule = b.kind === 'at-rule'
return 0
})
if (aIsAtRule && !bIsAtRule) return -1
if (!aIsAtRule && bIsAtRule) return 1
// A list of the selectors / at rules encountered to get to this point
let group = path.flatMap((node) => {
if (node.kind === 'rule') {
return node.selector === '&' ? [] : [node.selector]
return 0
})
// A list of the selectors / at rules encountered to get to this point
let group = path.flatMap((node) => {
if (node.kind === 'rule') {
return node.selector === '&' ? [] : [node.selector]
}
if (node.kind === 'at-rule') {
return [`${node.name} ${node.params}`]
}
return []
})
// Build a v3-style nested selector
let selector = ''
for (let i = group.length - 1; i >= 0; i--) {
selector = selector === '' ? group[i] : `${group[i]} { ${selector} }`
}
if (node.kind === 'at-rule') {
return [`${node.name} ${node.params}`]
}
return []
})
// Build a v3-style nested selector
let selector = ''
for (let i = group.length - 1; i >= 0; i--) {
selector = selector === '' ? group[i] : `${group[i]} { ${selector} }`
}
selectors.push(selector)
selectors.push(selector)
},
})
return selectors

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { parse, toCss, walk } from './selector-parser'
import { parse, toCss } from './selector-parser'
import { walk, WalkAction } from './walk'
describe('parse', () => {
it('should parse a simple selector', () => {
@ -194,9 +195,9 @@ describe('toCss', () => {
describe('walk', () => {
it('can be used to replace a function call', () => {
const ast = parse('.foo:hover:not(.bar:focus)')
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
if (node.kind === 'function' && node.value === ':not') {
replaceWith({ kind: 'selector', value: '.inverted-bar' })
return WalkAction.Replace({ kind: 'selector', value: '.inverted-bar' } as const)
}
})
expect(toCss(ast)).toBe('.foo:hover.inverted-bar')

View file

@ -30,7 +30,6 @@ export type SelectorAstNode =
| SelectorNode
| SelectorSeparatorNode
| SelectorValueNode
type SelectorParentNode = SelectorFunctionNode | null
function combinator(value: string): SelectorCombinatorNode {
return {
@ -68,83 +67,6 @@ function value(value: string): SelectorValueNode {
}
}
export const enum SelectorWalkAction {
/** Continue walking, which is the default */
Continue,
/** Skip visiting the children of this node */
Skip,
/** Stop the walk entirely */
Stop,
}
export function walk(
ast: SelectorAstNode[],
visit: (
node: SelectorAstNode,
utils: {
parent: SelectorParentNode
replaceWith(newNode: SelectorAstNode | SelectorAstNode[]): void
},
) => void | SelectorWalkAction,
parent: SelectorParentNode = null,
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let replacedNode = false
let replacedNodeOffset = 0
let status =
visit(node, {
parent,
replaceWith(newNode) {
if (replacedNode) return
replacedNode = true
if (Array.isArray(newNode)) {
if (newNode.length === 0) {
ast.splice(i, 1)
replacedNodeOffset = 0
} else if (newNode.length === 1) {
ast[i] = newNode[0]
replacedNodeOffset = 1
} else {
ast.splice(i, 1, ...newNode)
replacedNodeOffset = newNode.length
}
} else {
ast[i] = newNode
replacedNodeOffset = 1
}
},
}) ?? SelectorWalkAction.Continue
// We want to visit or skip the newly replaced node(s), which start at the
// current index (i). By decrementing the index here, the next loop will
// process this position (containing the replaced node) again.
if (replacedNode) {
if (status === SelectorWalkAction.Continue) {
i--
} else {
i += replacedNodeOffset - 1
}
continue
}
// Stop the walk entirely
if (status === SelectorWalkAction.Stop) return SelectorWalkAction.Stop
// Skip visiting the children of this node
if (status === SelectorWalkAction.Skip) continue
if (node.kind === 'function') {
if (walk(node.nodes, visit, node) === SelectorWalkAction.Stop) {
return SelectorWalkAction.Stop
}
}
}
}
export function toCss(ast: SelectorAstNode[]) {
let css = ''
for (const node of ast) {

View file

@ -1,5 +1,5 @@
import { substituteAtApply } from './apply'
import { atRule, styleRule, toCss, walk, type AstNode } from './ast'
import { atRule, styleRule, toCss, type AstNode } from './ast'
import { printArbitraryValue } from './candidate'
import { constantFoldDeclaration } from './constant-fold-declaration'
import { CompileAstFlags, type DesignSystem } from './design-system'
@ -8,6 +8,7 @@ import { ThemeOptions } from './theme'
import { DefaultMap } from './utils/default-map'
import { isValidSpacingMultiplier } from './utils/infer-data-type'
import * as ValueParser from './value-parser'
import { walk, WalkAction } from './walk'
const FLOATING_POINT_PERCENTAGE = /\d*\.\d+(?:[eE][+-]?\d+)?%/g
@ -69,11 +70,11 @@ export const computeUtilitySignature = new DefaultMap((options: SignatureOptions
// Optimize the AST. This is needed such that any internal intermediate
// nodes are gone. This will also cleanup declaration nodes with undefined
// values or `--tw-sort` declarations.
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
// Optimize declarations
if (node.kind === 'declaration') {
if (node.value === undefined || node.property === '--tw-sort') {
replaceWith([])
return WalkAction.Replace([])
}
// Normalize percentages by removing unnecessary dots and zeros.
@ -90,17 +91,17 @@ export const computeUtilitySignature = new DefaultMap((options: SignatureOptions
// Replace special nodes with its children
else if (node.kind === 'context' || node.kind === 'at-root') {
replaceWith(node.nodes)
return WalkAction.Replace(node.nodes)
}
// Remove comments
else if (node.kind === 'comment') {
replaceWith([])
return WalkAction.Replace([])
}
// Remove at-rules that are not needed for the signature
else if (node.kind === 'at-rule' && node.name === '@property') {
replaceWith([])
return WalkAction.Replace([])
}
})
@ -151,7 +152,7 @@ export const computeUtilitySignature = new DefaultMap((options: SignatureOptions
let valueAst = ValueParser.parse(node.value)
let seen = new Set<string>()
ValueParser.walk(valueAst, (valueNode, { replaceWith }) => {
walk(valueAst, (valueNode) => {
if (valueNode.kind !== 'function') return
if (valueNode.value !== 'var') return
@ -204,7 +205,7 @@ export const computeUtilitySignature = new DefaultMap((options: SignatureOptions
let constructedValue = `${valueNode.nodes[0].value},${variableValue}`
if (nodeAsString === constructedValue) {
changed = true
replaceWith(ValueParser.parse(variableValue))
return WalkAction.Replace(ValueParser.parse(variableValue))
}
}
}
@ -328,7 +329,7 @@ export const computeVariantSignature = new DefaultMap((options: SignatureOptions
else if (node.kind === 'rule') {
let selectorAst = SelectorParser.parse(node.selector)
let changed = false
SelectorParser.walk(selectorAst, (node, { replaceWith }) => {
walk(selectorAst, (node) => {
if (node.kind === 'separator' && node.value !== ' ') {
node.value = node.value.trim()
changed = true
@ -342,7 +343,7 @@ export const computeVariantSignature = new DefaultMap((options: SignatureOptions
// E.g.: `:is(.foo)` → `.foo`
if (node.nodes.length === 1) {
changed = true
replaceWith(node.nodes)
return WalkAction.Replace(node.nodes)
}
// A selector with the universal selector `*` followed by a pseudo
@ -355,7 +356,7 @@ export const computeVariantSignature = new DefaultMap((options: SignatureOptions
node.nodes[1].value[0] === ':'
) {
changed = true
replaceWith(node.nodes[1])
return WalkAction.Replace(node.nodes[1])
}
}

View file

@ -1,5 +1,6 @@
import { walk, type AstNode } from '../ast'
import { type AstNode } from '../ast'
import { DefaultMap } from '../utils/default-map'
import { walk } from '../walk'
import { createLineTable, type LineTable, type Position } from './line-table'
import type { Source } from './source'

View file

@ -5,7 +5,6 @@ import {
decl,
rule,
styleRule,
walk,
type AstNode,
type AtRule,
type Declaration,
@ -27,6 +26,7 @@ import {
import { replaceShadowColors } from './utils/replace-shadow-colors'
import { segment } from './utils/segment'
import * as ValueParser from './value-parser'
import { walk, WalkAction } from './walk'
const IS_VALID_STATIC_UTILITY_NAME = /^-?[a-z][a-zA-Z0-9/%._-]*$/
const IS_VALID_FUNCTIONAL_UTILITY_NAME = /^-?[a-z][a-zA-Z0-9/%._-]*-\*$/
@ -5894,7 +5894,7 @@ export function createCssUtility(node: AtRule) {
//
// Once Prettier / Biome handle these better (e.g.: not crashing without
// `\\*` or not inserting whitespace) then most of these can go away.
ValueParser.walk(declarationValueAst, (fn) => {
walk(declarationValueAst, (fn) => {
if (fn.kind !== 'function') return
// Track usage of `--spacing(…)`
@ -5904,7 +5904,7 @@ export function createCssUtility(node: AtRule) {
// using the full `--spacing` theme scale.
!(storage['--modifier'].usedSpacingNumber && storage['--value'].usedSpacingNumber)
) {
ValueParser.walk(fn.nodes, (node) => {
walk(fn.nodes, (node) => {
if (node.kind !== 'function') return
if (node.value !== '--value' && node.value !== '--modifier') return
const key = node.value
@ -5923,12 +5923,12 @@ export function createCssUtility(node: AtRule) {
storage['--modifier'].usedSpacingNumber &&
storage['--value'].usedSpacingNumber
) {
return ValueParser.ValueWalkAction.Stop
return WalkAction.Stop
}
}
}
})
return ValueParser.ValueWalkAction.Continue
return WalkAction.Continue
}
if (fn.value !== '--value' && fn.value !== '--modifier') return
@ -5987,9 +5987,9 @@ export function createCssUtility(node: AtRule) {
let dataType = node.value
let copy = structuredClone(fn)
let sentinelValue = '¶'
ValueParser.walk(copy.nodes, (node, { replaceWith }) => {
walk(copy.nodes, (node) => {
if (node.kind === 'word' && node.value === dataType) {
replaceWith({ kind: 'word', value: sentinelValue })
return WalkAction.ReplaceSkip({ kind: 'word', value: sentinelValue } as const)
}
})
let underline = '^'.repeat(ValueParser.toCss([node]).length)
@ -6048,66 +6048,68 @@ export function createCssUtility(node: AtRule) {
// Whether `--value(ratio)` was resolved
let resolvedRatioValue = false
walk([atRule], (node, { parent, replaceWith: replaceDeclarationWith }) => {
walk<AstNode>([atRule], (node, ctx) => {
let parent = ctx.parent
if (parent?.kind !== 'rule' && parent?.kind !== 'at-rule') return
if (node.kind !== 'declaration') return
if (!node.value) return
let shouldRemoveDeclaration = false
let valueAst = ValueParser.parse(node.value)
let result =
ValueParser.walk(valueAst, (valueNode, { replaceWith }) => {
if (valueNode.kind !== 'function') return
walk(valueAst, (valueNode) => {
if (valueNode.kind !== 'function') return
// Value function, e.g.: `--value(integer)`
if (valueNode.value === '--value') {
usedValueFn = true
// Value function, e.g.: `--value(integer)`
if (valueNode.value === '--value') {
usedValueFn = true
let resolved = resolveValueFunction(value, valueNode, designSystem)
if (resolved) {
resolvedValueFn = true
if (resolved.ratio) {
resolvedRatioValue = true
} else {
resolvedDeclarations.set(node, parent)
}
replaceWith(resolved.nodes)
return ValueParser.ValueWalkAction.Skip
let resolved = resolveValueFunction(value, valueNode, designSystem)
if (resolved) {
resolvedValueFn = true
if (resolved.ratio) {
resolvedRatioValue = true
} else {
resolvedDeclarations.set(node, parent)
}
// Drop the declaration in case we couldn't resolve the value
usedValueFn ||= false
replaceDeclarationWith([])
return ValueParser.ValueWalkAction.Stop
return WalkAction.ReplaceSkip(resolved.nodes)
}
// Modifier function, e.g.: `--modifier(integer)`
else if (valueNode.value === '--modifier') {
// If there is no modifier present in the candidate, then the
// declaration can be removed.
if (modifier === null) {
replaceDeclarationWith([])
return ValueParser.ValueWalkAction.Stop
}
// Drop the declaration in case we couldn't resolve the value
usedValueFn ||= false
shouldRemoveDeclaration = true
return WalkAction.Stop
}
usedModifierFn = true
let replacement = resolveValueFunction(modifier, valueNode, designSystem)
if (replacement) {
resolvedModifierFn = true
replaceWith(replacement.nodes)
return ValueParser.ValueWalkAction.Skip
}
// Drop the declaration in case we couldn't resolve the value
usedModifierFn ||= false
replaceDeclarationWith([])
return ValueParser.ValueWalkAction.Stop
// Modifier function, e.g.: `--modifier(integer)`
else if (valueNode.value === '--modifier') {
// If there is no modifier present in the candidate, then the
// declaration can be removed.
if (modifier === null) {
shouldRemoveDeclaration = true
return WalkAction.Stop
}
}) ?? ValueParser.ValueWalkAction.Continue
if (result === ValueParser.ValueWalkAction.Continue) {
node.value = ValueParser.toCss(valueAst)
usedModifierFn = true
let replacement = resolveValueFunction(modifier, valueNode, designSystem)
if (replacement) {
resolvedModifierFn = true
return WalkAction.ReplaceSkip(replacement.nodes)
}
// Drop the declaration in case we couldn't resolve the value
usedModifierFn ||= false
shouldRemoveDeclaration = true
return WalkAction.Stop
}
})
if (shouldRemoveDeclaration) {
return WalkAction.ReplaceSkip([])
}
node.value = ValueParser.toCss(valueAst)
})
// Used `--value(…)` but nothing resolved

View file

@ -1,17 +1,18 @@
import * as ValueParser from '../value-parser'
import { walk, WalkAction } from '../walk'
export function extractUsedVariables(raw: string): string[] {
let variables: string[] = []
ValueParser.walk(ValueParser.parse(raw), (node) => {
walk(ValueParser.parse(raw), (node) => {
if (node.kind !== 'function' || node.value !== 'var') return
ValueParser.walk(node.nodes, (child) => {
walk(node.nodes, (child) => {
if (child.kind !== 'word' || child.value[0] !== '-' || child.value[1] !== '-') return
variables.push(child.value)
})
return ValueParser.ValueWalkAction.Skip
return WalkAction.Skip
})
return variables
}

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { parse, toCss, walk } from './value-parser'
import { parse, toCss } from './value-parser'
import { walk, WalkAction } from './walk'
describe('parse', () => {
it('should parse a value', () => {
@ -207,9 +208,9 @@ describe('walk', () => {
it('can be used to replace a function call', () => {
const ast = parse('(min-width: 600px) and (max-width: theme(lg))')
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
if (node.kind === 'function' && node.value === 'theme') {
replaceWith({ kind: 'word', value: '64rem' })
return WalkAction.Replace({ kind: 'word', value: '64rem' } as const)
}
})

View file

@ -15,7 +15,6 @@ export type ValueSeparatorNode = {
}
export type ValueAstNode = ValueWordNode | ValueFunctionNode | ValueSeparatorNode
type ValueParentNode = ValueFunctionNode | null
export function word(value: string): ValueWordNode {
return {
@ -39,147 +38,6 @@ function separator(value: string): ValueSeparatorNode {
}
}
export const enum ValueWalkAction {
/** Continue walking, which is the default */
Continue,
/** Skip visiting the children of this node */
Skip,
/** Stop the walk entirely */
Stop,
}
export function walk(
ast: ValueAstNode[],
visit: (
node: ValueAstNode,
utils: {
parent: ValueParentNode
replaceWith(newNode: ValueAstNode | ValueAstNode[]): void
},
) => void | ValueWalkAction,
parent: ValueParentNode = null,
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let replacedNode = false
let replacedNodeOffset = 0
let status =
visit(node, {
parent,
replaceWith(newNode) {
if (replacedNode) return
replacedNode = true
if (Array.isArray(newNode)) {
if (newNode.length === 0) {
ast.splice(i, 1)
replacedNodeOffset = 0
} else if (newNode.length === 1) {
ast[i] = newNode[0]
replacedNodeOffset = 1
} else {
ast.splice(i, 1, ...newNode)
replacedNodeOffset = newNode.length
}
} else {
ast[i] = newNode
}
},
}) ?? ValueWalkAction.Continue
// We want to visit or skip the newly replaced node(s), which start at the
// current index (i). By decrementing the index here, the next loop will
// process this position (containing the replaced node) again.
if (replacedNode) {
if (status === ValueWalkAction.Continue) {
i--
} else {
i += replacedNodeOffset - 1
}
continue
}
// Stop the walk entirely
if (status === ValueWalkAction.Stop) return ValueWalkAction.Stop
// Skip visiting the children of this node
if (status === ValueWalkAction.Skip) continue
if (node.kind === 'function') {
if (walk(node.nodes, visit, node) === ValueWalkAction.Stop) {
return ValueWalkAction.Stop
}
}
}
}
export function walkDepth(
ast: ValueAstNode[],
visit: (
node: ValueAstNode,
utils: {
parent: ValueParentNode
replaceWith(newNode: ValueAstNode | ValueAstNode[]): void
},
) => void | ValueWalkAction,
parent: ValueParentNode = null,
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
if (node.kind === 'function') {
if (walkDepth(node.nodes, visit, node) === ValueWalkAction.Stop) {
return ValueWalkAction.Stop
}
}
let replacedNode = false
let replacedNodeOffset = 0
let status =
visit(node, {
parent,
replaceWith(newNode) {
if (replacedNode) return
replacedNode = true
if (Array.isArray(newNode)) {
if (newNode.length === 0) {
ast.splice(i, 1)
replacedNodeOffset = 0
} else if (newNode.length === 1) {
ast[i] = newNode[0]
replacedNodeOffset = 1
} else {
ast.splice(i, 1, ...newNode)
replacedNodeOffset = newNode.length
}
} else {
ast[i] = newNode
}
},
}) ?? ValueWalkAction.Continue
// We want to visit or skip the newly replaced node(s), which start at the
// current index (i). By decrementing the index here, the next loop will
// process this position (containing the replaced node) again.
if (replacedNode) {
if (status === ValueWalkAction.Continue) {
i--
} else {
i += replacedNodeOffset - 1
}
continue
}
// Stop the walk entirely
if (status === ValueWalkAction.Stop) return ValueWalkAction.Stop
// Skip visiting the children of this node
if (status === ValueWalkAction.Skip) continue
}
}
export function toCss(ast: ValueAstNode[]) {
let css = ''
for (const node of ast) {

View file

@ -1,13 +1,11 @@
import { Features } from '.'
import {
WalkAction,
atRoot,
atRule,
cloneAstNode,
decl,
rule,
styleRule,
walk,
type AstNode,
type AtRule,
type Rule,
@ -21,6 +19,7 @@ import { compareBreakpoints } from './utils/compare-breakpoints'
import { DefaultMap } from './utils/default-map'
import { isPositiveInteger } from './utils/infer-data-type'
import { segment } from './utils/segment'
import { walk, WalkAction } from './walk'
export const IS_VALID_VARIANT_NAME = /^@?[a-z0-9][a-zA-Z0-9_-]*(?<![_-])$/
@ -449,7 +448,7 @@ export function createVariants(theme: Theme): Variants {
let didApply = false
walk([ruleNode], (node, { path }) => {
walk([ruleNode], (node, ctx) => {
if (node.kind !== 'rule' && node.kind !== 'at-rule') return WalkAction.Continue
if (node.nodes.length > 0) return WalkAction.Continue
@ -457,11 +456,14 @@ export function createVariants(theme: Theme): Variants {
let atRules: AtRule[] = []
let styleRules: StyleRule[] = []
for (let parent of path) {
if (parent.kind === 'at-rule') {
atRules.push(parent)
} else if (parent.kind === 'rule') {
styleRules.push(parent)
let path = ctx.path()
path.push(node)
for (let node of path) {
if (node.kind === 'at-rule') {
atRules.push(node)
} else if (node.kind === 'rule') {
styleRules.push(node)
}
}
@ -525,11 +527,11 @@ export function createVariants(theme: Theme): Variants {
let didApply = false
walk([ruleNode], (node, { path }) => {
walk([ruleNode], (node, ctx) => {
if (node.kind !== 'rule') return WalkAction.Continue
// Throw out any candidates with variants using nested style rules
for (let parent of path.slice(0, -1)) {
for (let parent of ctx.path()) {
if (parent.kind !== 'rule') continue
didApply = false
@ -577,11 +579,11 @@ export function createVariants(theme: Theme): Variants {
let didApply = false
walk([ruleNode], (node, { path }) => {
walk([ruleNode], (node, ctx) => {
if (node.kind !== 'rule') return WalkAction.Continue
// Throw out any candidates with variants using nested style rules
for (let parent of path.slice(0, -1)) {
for (let parent of ctx.path()) {
if (parent.kind !== 'rule') continue
didApply = false
@ -725,11 +727,11 @@ export function createVariants(theme: Theme): Variants {
let didApply = false
walk([ruleNode], (node, { path }) => {
walk([ruleNode], (node, ctx) => {
if (node.kind !== 'rule') return WalkAction.Continue
// Throw out any candidates with variants using nested style rules
for (let parent of path.slice(0, -1)) {
for (let parent of ctx.path()) {
if (parent.kind !== 'rule') continue
didApply = false
@ -760,11 +762,11 @@ export function createVariants(theme: Theme): Variants {
let didApply = false
walk([ruleNode], (node, { path }) => {
walk([ruleNode], (node, ctx) => {
if (node.kind !== 'rule') return WalkAction.Continue
// Throw out any candidates with variants using nested style rules
for (let parent of path.slice(0, -1)) {
for (let parent of ctx.path()) {
if (parent.kind !== 'rule') continue
didApply = false
@ -1191,10 +1193,10 @@ function quoteAttributeValue(input: string) {
}
export function substituteAtSlot(ast: AstNode[], nodes: AstNode[]) {
walk(ast, (node, { replaceWith }) => {
walk(ast, (node) => {
// Replace `@slot` with rule nodes
if (node.kind === 'at-rule' && node.name === '@slot') {
replaceWith(nodes)
return WalkAction.Replace(nodes)
}
// Wrap `@keyframes` and `@property` in `AtRoot` nodes
@ -1207,7 +1209,7 @@ export function substituteAtSlot(ast: AstNode[], nodes: AstNode[]) {
export function substituteAtVariant(ast: AstNode[], designSystem: DesignSystem): Features {
let features = Features.None
walk(ast, (variantNode, { replaceWith }) => {
walk(ast, (variantNode) => {
if (variantNode.kind !== 'at-rule' || variantNode.name !== '@variant') return
// Starting with the `&` rule node
@ -1226,8 +1228,8 @@ export function substituteAtVariant(ast: AstNode[], designSystem: DesignSystem):
}
// Update the variant at-rule node, to be the `&` rule node
replaceWith(node)
features |= Features.Variants
return WalkAction.Replace(node)
})
return features
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,181 @@
const enum WalkKind {
Continue,
Skip,
Stop,
Replace,
ReplaceSkip,
ReplaceStop,
}
export const WalkAction = {
Continue: { kind: WalkKind.Continue } as const,
Skip: { kind: WalkKind.Skip } as const,
Stop: { kind: WalkKind.Stop } as const,
Replace: <T>(nodes: T | T[]) =>
({ kind: WalkKind.Replace, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const,
ReplaceSkip: <T>(nodes: T | T[]) =>
({ kind: WalkKind.ReplaceSkip, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const,
ReplaceStop: <T>(nodes: T | T[]) =>
({ kind: WalkKind.ReplaceStop, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const,
} as const
type WalkAction = typeof WalkAction
type WalkResult<T> =
| WalkAction['Continue']
| WalkAction['Skip']
| WalkAction['Stop']
| ReturnType<typeof WalkAction.Replace<T>>
| ReturnType<typeof WalkAction.ReplaceSkip<T>>
| ReturnType<typeof WalkAction.ReplaceStop<T>>
type EnterResult<T> = WalkResult<T>
type ExitResult<T> = Exclude<WalkResult<T>, { kind: WalkKind.Skip }>
type Parent<T> = T & { nodes: T[] }
export interface VisitContext<T> {
parent: Parent<T> | null
depth: number
path: () => T[]
}
export function walk<T extends object>(
ast: T[],
hooks:
| ((node: T, ctx: VisitContext<T>) => EnterResult<T> | void) // Old API, enter only
| {
enter?: (node: T, ctx: VisitContext<T>) => EnterResult<T> | void
exit?: (node: T, ctx: VisitContext<T>) => ExitResult<T> | void
},
): void {
if (typeof hooks === 'function') walkImplementation(ast, hooks)
else walkImplementation(ast, hooks.enter, hooks.exit)
}
function walkImplementation<T extends { nodes?: T[] }>(
ast: T[],
enter: (node: T, ctx: VisitContext<T>) => EnterResult<T> | void = () => WalkAction.Continue,
exit: (node: T, ctx: VisitContext<T>) => ExitResult<T> | void = () => WalkAction.Continue,
) {
let stack: [nodes: T[], offset: number, parent: Parent<T> | null][] = [[ast, 0, null]]
let ctx: VisitContext<T> = {
parent: null,
depth: 0,
path() {
let path: T[] = []
for (let i = 1; i < stack.length; i++) {
let parent = stack[i][2]
if (parent) path.push(parent)
}
return path
},
}
while (stack.length > 0) {
let depth = stack.length - 1
let frame = stack[depth]
let nodes = frame[0]
let offset = frame[1]
let parent = frame[2]
// Done with this level
if (offset >= nodes.length) {
stack.pop()
continue
}
ctx.parent = parent
ctx.depth = depth
// Enter phase (offsets are positive)
if (offset >= 0) {
let node = nodes[offset]
let result = enter(node, ctx) ?? WalkAction.Continue
switch (result.kind) {
case WalkKind.Continue: {
if (node.nodes && node.nodes.length > 0) {
stack.push([node.nodes, 0, node as Parent<T>])
}
frame[1] = ~offset // Prepare for exit phase, same offset
continue
}
case WalkKind.Stop:
return // Stop immediately
case WalkKind.Skip: {
frame[1] = ~offset // Prepare for exit phase, same offset
continue
}
case WalkKind.Replace: {
nodes.splice(offset, 1, ...result.nodes)
continue // Re-process at same offset
}
case WalkKind.ReplaceStop: {
nodes.splice(offset, 1, ...result.nodes)
return // Stop immediately
}
case WalkKind.ReplaceSkip: {
nodes.splice(offset, 1, ...result.nodes)
frame[1] += result.nodes.length // Advance to next sibling past replacements
continue
}
default: {
result satisfies never
throw new Error(
// @ts-expect-error enterResult.kind may be invalid
`Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in enter.`,
)
}
}
}
// Exit phase for nodes[~offset]
let index = ~offset // Two's complement to get original offset
let node = nodes[index]
let result = exit(node, ctx) ?? WalkAction.Continue
switch (result.kind) {
case WalkKind.Continue:
frame[1] = index + 1 // Advance to next sibling
continue
case WalkKind.Stop:
return // Stop immediately
case WalkKind.Replace: {
nodes.splice(index, 1, ...result.nodes)
frame[1] = index + result.nodes.length // Advance to next sibling past replacements
continue
}
case WalkKind.ReplaceStop: {
nodes.splice(index, 1, ...result.nodes)
return // Stop immediately
}
case WalkKind.ReplaceSkip: {
nodes.splice(index, 1, ...result.nodes)
frame[1] = index + result.nodes.length // Advance to next sibling past replacements
continue
}
default: {
result satisfies never
throw new Error(
// @ts-expect-error `result.kind` could still be filled with an invalid value
`Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in exit.`,
)
}
}
}
}