diff --git a/packages/@tailwindcss-node/src/urls.ts b/packages/@tailwindcss-node/src/urls.ts index e35b9d280a06..d6bc229266ce 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 0085a2fd9f8e..d32d96979cb0 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 13b2c04499b9..0ea3bd691082 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 d7113501f390..a80aa75e03e8 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 fb5306caa1b4..3fd84af7c0f7 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 1c4223e0fbec..c7bcc5e28801 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 72c67230bc0b..63a621c6a298 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 e57e1c2a3133..02cdfb8528fe 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 +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) + } } - } - } -} -// 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 + // Once computed, we never need to compute this again + Object.defineProperty(this, 'context', { value: context }) + return context }, - ) => 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 - } + get parent() { + let parent = (this.path().pop() as Extract) ?? null - // 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, '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 5f13cce99c51..ba7172f8e03d 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 23d706c8c028..c081c3b472b8 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 8df31af09091..eae3e3b2cd04 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 766264742bca..6a9f580b6274 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 4aca5feeccf1..5954040017d2 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 1331f5d3a2d5..82ac77eb1d6e 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 230ef9fa73f4..9c33e73e9764 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 + + 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')) + } - 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 + // 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.: `1rem + 2rem`, `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 ( - 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] ?? ''}`)) + 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 } - 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 ( + 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 385898b32af8..bb3e305e4b0f 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 8c668b6946fb..6cf77bc81f40 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 489fe5ffa9bf..fd1638b470f1 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 + }) - if (node.kind === 'at-rule') { - return [`${node.name} ${node.params}`] - } + // 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 [] - }) + if (node.kind === 'at-rule') { + return [`${node.name} ${node.params}`] + } - // Build a v3-style nested selector - let selector = '' + return [] + }) - for (let i = group.length - 1; i >= 0; i--) { - selector = selector === '' ? group[i] : `${group[i]} { ${selector} }` - } + // 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 2a22835226f5..132359ccc275 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 0d28fdc4fe7e..184eb12869d4 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 493309b6cc82..0fcc76fc9871 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 284bcd9d6832..31f8a094f23d 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 c0c0bd7daf17..f4a7a28d2b55 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 - - // 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 + walk(valueAst, (valueNode) => { + if (valueNode.kind !== 'function') return + + // 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) } - - // 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 + // 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 + } - let replacement = resolveValueFunction(modifier, valueNode, designSystem) - if (replacement) { - resolvedModifierFn = true - replaceWith(replacement.nodes) - return ValueParser.ValueWalkAction.Skip - } + usedModifierFn = true - // Drop the declaration in case we couldn't resolve the value - usedModifierFn ||= false - replaceDeclarationWith([]) - return ValueParser.ValueWalkAction.Stop + let replacement = resolveValueFunction(modifier, valueNode, designSystem) + if (replacement) { + resolvedModifierFn = true + return WalkAction.ReplaceSkip(replacement.nodes) } - }) ?? ValueParser.ValueWalkAction.Continue - if (result === ValueParser.ValueWalkAction.Continue) { - node.value = ValueParser.toCss(valueAst) + // 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 d19743413e55..57ac4c3a5d27 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 e7ece47c0dbf..f12949505f83 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 ece63ceba9eb..c9f41f80963b 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 0008e1c77c2e..82a2b8592cfe 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 000000000000..09720cc52c78 --- /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 000000000000..ccb384256c3f --- /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.`, + ) + } + } + } +}