From acb27ef9e9229385ca0a30923be5d711b66cc90e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 15 Oct 2025 21:28:12 +0200 Subject: [PATCH] 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): image image We also ran some benchmarks on @thecrypticace's M3 Max: image In node the memory difference isn't that big, but the performance itself is still better: image 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 --- packages/@tailwindcss-node/src/urls.ts | 3 +- .../src/codemods/css/migrate-preflight.ts | 5 +- .../migrate-automatic-var-injection.ts | 2 +- .../codemods/template/migrate-theme-to-var.ts | 21 +- .../template/migrate-variant-order.ts | 3 +- packages/tailwindcss/src/apply.ts | 16 +- packages/tailwindcss/src/ast.test.ts | 65 +- packages/tailwindcss/src/ast.ts | 188 +- packages/tailwindcss/src/at-import.ts | 9 +- packages/tailwindcss/src/candidate.ts | 9 +- .../src/canonicalize-candidates.ts | 31 +- .../src/compat/apply-compat-hooks.ts | 34 +- packages/tailwindcss/src/compat/plugin-api.ts | 11 +- packages/tailwindcss/src/compile.ts | 12 +- .../src/constant-fold-declaration.ts | 171 +- packages/tailwindcss/src/css-functions.ts | 9 +- packages/tailwindcss/src/index.ts | 64 +- packages/tailwindcss/src/intellisense.ts | 68 +- .../tailwindcss/src/selector-parser.test.ts | 7 +- packages/tailwindcss/src/selector-parser.ts | 78 - packages/tailwindcss/src/signatures.ts | 23 +- .../tailwindcss/src/source-maps/source-map.ts | 3 +- packages/tailwindcss/src/utilities.ts | 108 +- packages/tailwindcss/src/utils/variables.ts | 7 +- packages/tailwindcss/src/value-parser.test.ts | 7 +- packages/tailwindcss/src/value-parser.ts | 142 -- packages/tailwindcss/src/variants.ts | 42 +- packages/tailwindcss/src/walk.test.ts | 1596 +++++++++++++++++ packages/tailwindcss/src/walk.ts | 181 ++ 29 files changed, 2179 insertions(+), 736 deletions(-) create mode 100644 packages/tailwindcss/src/walk.test.ts create mode 100644 packages/tailwindcss/src/walk.ts diff --git a/packages/@tailwindcss-node/src/urls.ts b/packages/@tailwindcss-node/src/urls.ts index e35b9d280..d6bc22926 100644 --- a/packages/@tailwindcss-node/src/urls.ts +++ b/packages/@tailwindcss-node/src/urls.ts @@ -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 = diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.ts index 0085a2fd9..d32d96979 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.ts @@ -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)) } }) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts index 13b2c0449..0ea3bd691 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts @@ -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, diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts index d7113501f..a80aa75e0 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts @@ -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)) } }) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts index fb5306caa..3fd84af7c 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-variant-order.ts @@ -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( diff --git a/packages/tailwindcss/src/apply.ts b/packages/tailwindcss/src/apply.ts index 1c4223e0f..c7bcc5e28 100644 --- a/packages/tailwindcss/src/apply.ts +++ b/packages/tailwindcss/src/apply.ts @@ -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()) // 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) } }) } diff --git a/packages/tailwindcss/src/ast.test.ts b/packages/tailwindcss/src/ast.test.ts index 72c67230b..63a621c6a 100644 --- a/packages/tailwindcss/src/ast.test.ts +++ b/packages/tailwindcss/src/ast.test.ts @@ -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 ", + "", ".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')]) } }) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index e57e1c2a3..02cdfb852 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -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(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 - path: AstNode[] - }, - ) => void | WalkAction, - path: AstNode[] = [], - context: Record = {}, -) { - 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 - replaceWith(newNode: AstNode[]): void - }, - ) => void, - path: AstNode[] = [], - context: Record = {}, -) { - 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, +): VisitContext & { context: Record } { + return { + depth: ctx.depth, + get context() { + let context: Record = {} + 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) ?? 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 } }) diff --git a/packages/tailwindcss/src/at-import.ts b/packages/tailwindcss/src/at-import.ts index 5f13cce99..ba7172f8e 100644 --- a/packages/tailwindcss/src/at-import.ts +++ b/packages/tailwindcss/src/at-import.ts @@ -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[] = [] - 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) } }) diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index 23d706c8c..c081c3b47 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -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((input) => { let drop = new Set() - 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((input) => { }) if (drop.size > 0) { - ValueParser.walk(ast, (node, { replaceWith }) => { + walk(ast, (node) => { if (drop.has(node)) { drop.delete(node) - replaceWith([]) + return WalkAction.ReplaceSkip([]) } }) } diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 8df31af09..eae3e3b2c 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -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. diff --git a/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts index 766264742..6a9f580b6 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -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])) }) } diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index 4aca5feec..595404001 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -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)}` } diff --git a/packages/tailwindcss/src/compile.ts b/packages/tailwindcss/src/compile.ts index 1331f5d3a..82ac77eb1 100644 --- a/packages/tailwindcss/src/compile.ts +++ b/packages/tailwindcss/src/compile.ts @@ -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, diff --git a/packages/tailwindcss/src/constant-fold-declaration.ts b/packages/tailwindcss/src/constant-fold-declaration.ts index 230ef9fa7..9c33e73e9 100644 --- a/packages/tailwindcss/src/constant-fold-declaration.ts +++ b/packages/tailwindcss/src/constant-fold-declaration.ts @@ -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 diff --git a/packages/tailwindcss/src/css-functions.ts b/packages/tailwindcss/src/css-functions.ts index 385898b32..bb3e305e4 100644 --- a/packages/tailwindcss/src/css-functions.ts +++ b/packages/tailwindcss/src/css-functions.ts @@ -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 diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 8c668b694..6cf77bc81 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -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()) - - 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 diff --git a/packages/tailwindcss/src/intellisense.ts b/packages/tailwindcss/src/intellisense.ts index 489fe5ffa..fd1638b47 100644 --- a/packages/tailwindcss/src/intellisense.ts +++ b/packages/tailwindcss/src/intellisense.ts @@ -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 diff --git a/packages/tailwindcss/src/selector-parser.test.ts b/packages/tailwindcss/src/selector-parser.test.ts index 2a2283522..132359ccc 100644 --- a/packages/tailwindcss/src/selector-parser.test.ts +++ b/packages/tailwindcss/src/selector-parser.test.ts @@ -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') diff --git a/packages/tailwindcss/src/selector-parser.ts b/packages/tailwindcss/src/selector-parser.ts index 0d28fdc4f..184eb1286 100644 --- a/packages/tailwindcss/src/selector-parser.ts +++ b/packages/tailwindcss/src/selector-parser.ts @@ -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) { diff --git a/packages/tailwindcss/src/signatures.ts b/packages/tailwindcss/src/signatures.ts index 493309b6c..0fcc76fc9 100644 --- a/packages/tailwindcss/src/signatures.ts +++ b/packages/tailwindcss/src/signatures.ts @@ -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() - 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]) } } diff --git a/packages/tailwindcss/src/source-maps/source-map.ts b/packages/tailwindcss/src/source-maps/source-map.ts index 284bcd9d6..31f8a094f 100644 --- a/packages/tailwindcss/src/source-maps/source-map.ts +++ b/packages/tailwindcss/src/source-maps/source-map.ts @@ -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' diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index c0c0bd7da..f4a7a28d2 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -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([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 diff --git a/packages/tailwindcss/src/utils/variables.ts b/packages/tailwindcss/src/utils/variables.ts index d19743413..57ac4c3a5 100644 --- a/packages/tailwindcss/src/utils/variables.ts +++ b/packages/tailwindcss/src/utils/variables.ts @@ -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 } diff --git a/packages/tailwindcss/src/value-parser.test.ts b/packages/tailwindcss/src/value-parser.test.ts index e7ece47c0..f12949505 100644 --- a/packages/tailwindcss/src/value-parser.test.ts +++ b/packages/tailwindcss/src/value-parser.test.ts @@ -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) } }) diff --git a/packages/tailwindcss/src/value-parser.ts b/packages/tailwindcss/src/value-parser.ts index ece63ceba..c9f41f809 100644 --- a/packages/tailwindcss/src/value-parser.ts +++ b/packages/tailwindcss/src/value-parser.ts @@ -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) { diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 0008e1c77..82a2b8592 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -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_-]*(? { + 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 } diff --git a/packages/tailwindcss/src/walk.test.ts b/packages/tailwindcss/src/walk.test.ts new file mode 100644 index 000000000..09720cc52 --- /dev/null +++ b/packages/tailwindcss/src/walk.test.ts @@ -0,0 +1,1596 @@ +import { describe, expect, test } from 'vitest' +import { decl, rule, toCss, type AstNode as CSSAstNode } from './ast' +import { walk, WalkAction } from './walk' + +type AstNode = { kind: string } | { kind: string; nodes: AstNode[] } + +describe('AST Enter (function)', () => { + test('visit all nodes in an AST', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']) + }) + + test('visit all nodes in an AST and calculate their path', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let paths: string[] = [] + walk(ast, (node, ctx) => { + let path = ctx.path().map((n) => n.kind) + if (path.length === 0) path.unshift('ø') + path.push(node.kind) + + paths.push(path.join(' → ') || 'ø') + }) + + expect(`\n${paths.join('\n')}\n`).toMatchInlineSnapshot(` + " + ø → a + a → b + a → b → c + a → d + a → d → e + a → d → e → f + a → g + a → g → h + ø → i + " + `) + }) + + test("skip a node's children (first node)", () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, (node) => { + visited.push(node.kind) + + if (node.kind === 'b') { + return WalkAction.Skip + } + }) + + expect(visited).toEqual(['a', 'b', 'd', 'e', 'f', 'g', 'h', 'i']) + }) + + test("skip a node's children (middle node)", () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, (node) => { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Skip + } + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'g', 'h', 'i']) + }) + + test("skip a node's children (last node)", () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, (node) => { + visited.push(node.kind) + + if (node.kind === 'g') { + return WalkAction.Skip + } + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'i']) + }) + + test('stop entirely', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, (node) => { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Stop + } + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd']) + }) + + test('replace a node, and visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, (node) => { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Replace([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) + + test('replace a node, and not visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, (node) => { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.ReplaceSkip([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'g', 'h', 'i']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) + + test('replace a leaf node, and visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, (node) => { + visited.push(node.kind) + + if (node.kind === 'f') { + return WalkAction.Replace([ + { kind: 'foo1', nodes: [{ kind: 'bar1' }] }, + { kind: 'foo2', nodes: [{ kind: 'bar2' }] }, + ]) + } + }) + + expect(visited).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'foo1', + 'bar1', + 'foo2', + 'bar2', + 'g', + 'h', + 'i', + ]) + }) + + test('replace a leaf node, and not visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, (node) => { + visited.push(node.kind) + + if (node.kind === 'f') { + return WalkAction.ReplaceSkip([ + { kind: 'foo1', nodes: [{ kind: 'bar1' }] }, + { kind: 'foo2', nodes: [{ kind: 'bar2' }] }, + ]) + } + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'foo1', + 'bar1', + 'foo2', + 'bar2', + 'g', + 'h', + 'i', + ]) + }) + + test('replace a node, and stop the walk entirely', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, (node) => { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.ReplaceStop([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) +}) + +describe('AST Enter (obj)', () => { + test('visit all nodes in an AST', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + }, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']) + }) + + test('visit all nodes in an AST and calculate their path', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let paths: string[] = [] + walk(ast, { + enter(node, ctx) { + let path = ctx.path().map((n) => n.kind) + if (path.length === 0) path.unshift('ø') + path.push(node.kind) + + paths.push(path.join(' → ') || 'ø') + }, + }) + + expect(`\n${paths.join('\n')}\n`).toMatchInlineSnapshot(` + " + ø → a + a → b + a → b → c + a → d + a → d → e + a → d → e → f + a → g + a → g → h + ø → i + " + `) + }) + + test("skip a node's children (first node)", () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'b') { + return WalkAction.Skip + } + }, + }) + + expect(visited).toEqual(['a', 'b', 'd', 'e', 'f', 'g', 'h', 'i']) + }) + + test("skip a node's children (middle node)", () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Skip + } + }, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'g', 'h', 'i']) + }) + + test("skip a node's children (last node)", () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'g') { + return WalkAction.Skip + } + }, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'i']) + }) + + test('stop entirely', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Stop + } + }, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd']) + }) + + test('replace a node, and visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Replace([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) + + test('replace a node, and not visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.ReplaceSkip([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'g', 'h', 'i']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) + + test('replace a leaf node, and visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'f') { + return WalkAction.Replace([ + { kind: 'foo1', nodes: [{ kind: 'bar1' }] }, + { kind: 'foo2', nodes: [{ kind: 'bar2' }] }, + ]) + } + }, + }) + + expect(visited).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'foo1', + 'bar1', + 'foo2', + 'bar2', + 'g', + 'h', + 'i', + ]) + }) + + test('replace a leaf node, and not visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'f') { + return WalkAction.ReplaceSkip([ + { kind: 'foo1', nodes: [{ kind: 'bar1' }] }, + { kind: 'foo2', nodes: [{ kind: 'bar2' }] }, + ]) + } + }, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'foo1', + 'bar1', + 'foo2', + 'bar2', + 'g', + 'h', + 'i', + ]) + }) + + test('replace a node, and stop the walk entirely', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.ReplaceStop([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) +}) + +describe('AST Exit (obj)', () => { + test('visit all nodes in an AST', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + exit(node) { + visited.push(node.kind) + }, + }) + + expect(visited).toEqual(['c', 'b', 'f', 'e', 'd', 'h', 'g', 'a', 'i']) + }) + + test('visit all nodes in an AST and calculate their path', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let paths: string[] = [] + walk(ast, { + exit(node, ctx) { + let path = ctx.path().map((n) => n.kind) + if (path.length === 0) path.unshift('ø') + path.push(node.kind) + + paths.push(path.join(' → ') || 'ø') + }, + }) + + expect(`\n${paths.join('\n')}\n`).toMatchInlineSnapshot(` + " + a → b → c + a → b + a → d → e → f + a → d → e + a → d + a → g → h + a → g + ø → a + ø → i + " + `) + }) + + test('stop entirely', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + exit(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Stop + } + }, + }) + + expect(visited).toEqual(['c', 'b', 'f', 'e', 'd']) + }) + + test('replace a node, and not visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + exit(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Replace([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }, + }) + + expect(visited).toEqual(['c', 'b', 'f', 'e', 'd', 'h', 'g', 'a', 'i']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) + + test('replace a leaf node, and not visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + exit(node) { + visited.push(node.kind) + + if (node.kind === 'f') { + return WalkAction.Replace([{ kind: 'f1' }, { kind: 'f2' }]) + } + }, + }) + + expect(visited).toEqual(['c', 'b', 'f', 'e', 'd', 'h', 'g', 'a', 'i']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e', 'f1', 'f2', 'g', 'h', 'i']) + }) + + test('replace a node, and stop the walk entirely', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + exit(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.ReplaceStop([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }, + }) + + expect(visited).toEqual(['c', 'b', 'f', 'e', 'd']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) +}) + +describe('AST Enter & Exit', () => { + test('visit all nodes in an AST', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node, ctx) { + visited.push(`${' '.repeat(ctx.depth)} Enter(${node.kind})`) + }, + exit(node, ctx) { + visited.push(`${' '.repeat(ctx.depth)} Exit(${node.kind})`) + }, + }) + + expect(`\n${visited.join('\n')}\n`).toMatchInlineSnapshot(` + " + Enter(a) + Enter(b) + Enter(c) + Exit(c) + Exit(b) + Enter(d) + Enter(e) + Enter(f) + Exit(f) + Exit(e) + Exit(d) + Enter(g) + Enter(h) + Exit(h) + Exit(g) + Exit(a) + Enter(i) + Exit(i) + " + `) + }) + + test('visit all nodes in an AST and calculate their path', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let paths: string[] = [] + walk(ast, { + enter(node, ctx) { + let path = ctx.path().map((n) => n.kind) + if (path.length === 0) path.unshift('ø') + path.push(node.kind) + + paths.push(`Enter(${path.join(' → ') || 'ø'})`) + }, + exit(node, ctx) { + let path = ctx.path().map((n) => n.kind) + if (path.length === 0) path.unshift('ø') + path.push(node.kind) + + paths.push(`Exit(${path.join(' → ') || 'ø'})`) + }, + }) + + expect(`\n${paths.join('\n')}\n`).toMatchInlineSnapshot(` + " + Enter(ø → a) + Enter(a → b) + Enter(a → b → c) + Exit(a → b → c) + Exit(a → b) + Enter(a → d) + Enter(a → d → e) + Enter(a → d → e → f) + Exit(a → d → e → f) + Exit(a → d → e) + Exit(a → d) + Enter(a → g) + Enter(a → g → h) + Exit(a → g → h) + Exit(a → g) + Exit(ø → a) + Enter(ø → i) + Exit(ø → i) + " + `) + }) + + test('"real" world use case', () => { + let ast: CSSAstNode[] = [ + rule('.example', [ + decl('margin-top', '12px'), + decl('padding', '8px'), + decl('margin', '16px 18px'), + decl('colors', 'red'), + ]), + ] + + walk(ast, { + enter(node) { + // Expand `margin` shorthand into multiple properties + if (node.kind === 'declaration' && node.property === 'margin' && node.value) { + let [y, x] = node.value.split(' ') + return WalkAction.Replace([ + decl('margin-top', y), + decl('margin-bottom', y), + decl('margin-left', x), + decl('margin-right', x), + ]) + } + + // These properties should not be uppercased, so skip them + else if (node.kind === 'declaration' && node.property === 'colors' && node.value) { + return WalkAction.ReplaceSkip([ + decl('color', node.value), + decl('background-color', node.value), + decl('border-color', node.value), + ]) + } + + // Make all properties uppercase (this should see the expanded margin properties as well) + // but it should not see the `color` property as we skipped it above. + else if (node.kind === 'declaration') { + node.property = node.property.toUpperCase() + } + }, + + exit(node) { + // Sort declarations alphabetically within a rule (this should see the + // nodes after transformations in `enter` phase) + if (node.kind === 'rule') { + node.nodes.sort((a, z) => { + if (a.kind === 'declaration' && z.kind === 'declaration') { + return a.property.localeCompare(z.property) + } + + // Stable sort + return 0 + }) + } + }, + }) + + expect(toCss(ast)).toMatchInlineSnapshot(` + ".example { + background-color: red; + border-color: red; + color: red; + MARGIN-BOTTOM: 16px; + MARGIN-LEFT: 18px; + MARGIN-RIGHT: 18px; + MARGIN-TOP: 12px; + MARGIN-TOP: 16px; + PADDING: 8px; + } + " + `) + }) + + describe('enter phase', () => { + test('visit all nodes in an AST', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + }, + exit() {}, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']) + }) + + test("skip a node's children (first node)", () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'b') { + return WalkAction.Skip + } + }, + exit() {}, + }) + + expect(visited).toEqual(['a', 'b', 'd', 'e', 'f', 'g', 'h', 'i']) + }) + + test("skip a node's children (middle node)", () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Skip + } + }, + exit() {}, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'g', 'h', 'i']) + }) + + test("skip a node's children (last node)", () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'g') { + return WalkAction.Skip + } + }, + exit() {}, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'i']) + }) + + test('stop entirely', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Stop + } + }, + exit() {}, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd']) + }) + + test('replace a node, and visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Replace([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }, + exit() {}, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) + + test('replace a node, and not visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.ReplaceSkip([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }, + exit() {}, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'g', 'h', 'i']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) + + test('replace a leaf node, and visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'f') { + return WalkAction.Replace([ + { kind: 'foo1', nodes: [{ kind: 'bar1' }] }, + { kind: 'foo2', nodes: [{ kind: 'bar2' }] }, + ]) + } + }, + exit() {}, + }) + + expect(visited).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'foo1', + 'bar1', + 'foo2', + 'bar2', + 'g', + 'h', + 'i', + ]) + }) + + test('replace a leaf node, and not visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'f') { + return WalkAction.ReplaceSkip([ + { kind: 'foo1', nodes: [{ kind: 'bar1' }] }, + { kind: 'foo2', nodes: [{ kind: 'bar2' }] }, + ]) + } + }, + exit() {}, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'foo1', + 'bar1', + 'foo2', + 'bar2', + 'g', + 'h', + 'i', + ]) + }) + + test('replace a node, and stop the walk entirely', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.ReplaceStop([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }, + exit() {}, + }) + + expect(visited).toEqual(['a', 'b', 'c', 'd']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) + }) + + describe('exit phase', () => { + test('visit all nodes in an AST', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter() {}, + exit(node) { + visited.push(node.kind) + }, + }) + + expect(visited).toEqual(['c', 'b', 'f', 'e', 'd', 'h', 'g', 'a', 'i']) + }) + + test('stop entirely', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter() {}, + exit(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Stop + } + }, + }) + + expect(visited).toEqual(['c', 'b', 'f', 'e', 'd']) + }) + + test('replace a node, and not visit the replacements', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter() {}, + exit(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.Replace([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }, + }) + + expect(visited).toEqual(['c', 'b', 'f', 'e', 'd', 'h', 'g', 'a', 'i']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) + + test('replace a node, and stop the walk entirely', () => { + let ast: AstNode[] = [ + { + kind: 'a', + nodes: [ + { kind: 'b', nodes: [{ kind: 'c' }] }, + { kind: 'd', nodes: [{ kind: 'e', nodes: [{ kind: 'f' }] }] }, + { kind: 'g', nodes: [{ kind: 'h' }] }, + ], + }, + { kind: 'i' }, + ] + + let visited: string[] = [] + walk(ast, { + enter() {}, + exit(node) { + visited.push(node.kind) + + if (node.kind === 'd') { + return WalkAction.ReplaceStop([ + { kind: 'e1', nodes: [{ kind: 'f1' }] }, + { kind: 'e2', nodes: [{ kind: 'f2' }] }, + ]) + } + }, + }) + + expect(visited).toEqual(['c', 'b', 'f', 'e', 'd']) + + // Let's walk the mutated AST again, to ensure that we properly replaced the + // node with new nodes. + visited.splice(0) + walk(ast, (node) => { + visited.push(node.kind) + }) + + expect(visited).toEqual(['a', 'b', 'c', 'e1', 'f1', 'e2', 'f2', 'g', 'h', 'i']) + }) + }) +}) diff --git a/packages/tailwindcss/src/walk.ts b/packages/tailwindcss/src/walk.ts new file mode 100644 index 000000000..ccb384256 --- /dev/null +++ b/packages/tailwindcss/src/walk.ts @@ -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: (nodes: T | T[]) => + ({ kind: WalkKind.Replace, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const, + ReplaceSkip: (nodes: T | T[]) => + ({ kind: WalkKind.ReplaceSkip, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const, + ReplaceStop: (nodes: T | T[]) => + ({ kind: WalkKind.ReplaceStop, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const, +} as const + +type WalkAction = typeof WalkAction +type WalkResult = + | WalkAction['Continue'] + | WalkAction['Skip'] + | WalkAction['Stop'] + | ReturnType> + | ReturnType> + | ReturnType> + +type EnterResult = WalkResult +type ExitResult = Exclude, { kind: WalkKind.Skip }> + +type Parent = T & { nodes: T[] } + +export interface VisitContext { + parent: Parent | null + depth: number + path: () => T[] +} + +export function walk( + ast: T[], + hooks: + | ((node: T, ctx: VisitContext) => EnterResult | void) // Old API, enter only + | { + enter?: (node: T, ctx: VisitContext) => EnterResult | void + exit?: (node: T, ctx: VisitContext) => ExitResult | void + }, +): void { + if (typeof hooks === 'function') walkImplementation(ast, hooks) + else walkImplementation(ast, hooks.enter, hooks.exit) +} + +function walkImplementation( + ast: T[], + enter: (node: T, ctx: VisitContext) => EnterResult | void = () => WalkAction.Continue, + exit: (node: T, ctx: VisitContext) => ExitResult | void = () => WalkAction.Continue, +) { + let stack: [nodes: T[], offset: number, parent: Parent | null][] = [[ast, 0, null]] + let ctx: VisitContext = { + 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]) + } + + 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.`, + ) + } + } + } +}