From 5d9c7af3cf5ca1d3e04a80f6b3b5c686ab376137 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 14 Oct 2025 17:37:57 +0200 Subject: [PATCH 1/8] do not destructure from `context` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While refactoring, I ran into issues because sometimes we destructure `parent` which happens to be a "global" value that TypeScript is fine with if it's undefined (tests would crash). Using `ctx`, also helped to know where we are using all context related values. This will make future commits a bit more readable as well. Might go back to a destructured pattern, but let's do this for now. In future commits you will also see that often the `ctx` is just gone, because instead of using `ctx.replaceWith()` becomes a `return …` --- .../src/codemods/css/migrate-preflight.ts | 4 +- .../codemods/template/migrate-theme-to-var.ts | 10 ++--- packages/tailwindcss/src/apply.ts | 12 ++--- packages/tailwindcss/src/ast.test.ts | 44 +++++++++---------- packages/tailwindcss/src/ast.ts | 12 ++--- packages/tailwindcss/src/at-import.ts | 4 +- packages/tailwindcss/src/candidate.ts | 8 ++-- .../src/canonicalize-candidates.ts | 14 +++--- .../src/compat/apply-compat-hooks.ts | 24 +++++----- packages/tailwindcss/src/css-functions.ts | 4 +- packages/tailwindcss/src/index.ts | 40 ++++++++--------- .../tailwindcss/src/selector-parser.test.ts | 4 +- packages/tailwindcss/src/signatures.ts | 20 ++++----- packages/tailwindcss/src/utilities.ts | 13 +++--- packages/tailwindcss/src/value-parser.test.ts | 4 +- packages/tailwindcss/src/variants.ts | 28 ++++++------ 16 files changed, 123 insertions(+), 122 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.ts index 0085a2fd9f8e..e6fc0cbeca63 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-preflight.ts @@ -117,7 +117,7 @@ function substituteFunctionsInValue( ast: ValueParser.ValueAstNode[], handle: (value: string, fallback?: string) => string | null, ) { - ValueParser.walk(ast, (node, { replaceWith }) => { + ValueParser.walk(ast, (node, ctx) => { if (node.kind === 'function' && node.value === 'theme') { if (node.nodes.length < 1) return @@ -155,7 +155,7 @@ function substituteFunctionsInValue( fallbackValues.length > 0 ? handle(path, ValueParser.toCss(fallbackValues)) : handle(path) if (replacement === null) return - replaceWith(ValueParser.parse(replacement)) + ctx.replaceWith(ValueParser.parse(replacement)) } }) 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..d9b87d513c18 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 @@ -172,7 +172,7 @@ function substituteFunctionsInValue( ast: ValueParser.ValueAstNode[], handle: (value: string, fallback?: string) => string | null, ) { - ValueParser.walk(ast, (node, { parent, replaceWith }) => { + ValueParser.walk(ast, (node, ctx) => { if (node.kind === 'function' && node.value === 'theme') { if (node.nodes.length < 1) return @@ -210,10 +210,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 +241,7 @@ function substituteFunctionsInValue( } } - replaceWith(ValueParser.parse(replacement)) + ctx.replaceWith(ValueParser.parse(replacement)) } }) diff --git a/packages/tailwindcss/src/apply.ts b/packages/tailwindcss/src/apply.ts index 1c4223e0fbec..a5be59ca52c9 100644 --- a/packages/tailwindcss/src/apply.ts +++ b/packages/tailwindcss/src/apply.ts @@ -25,7 +25,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,15 +61,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) { + for (let parent of ctx.path) { if (parent === node) continue 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, ctx) => { 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) + ctx.replaceWith(newNodes) } }) } diff --git a/packages/tailwindcss/src/ast.test.ts b/packages/tailwindcss/src/ast.test.ts index 72c67230bc0b..86a5e3a6f4c8 100644 --- a/packages/tailwindcss/src/ast.test.ts +++ b/packages/tailwindcss/src/ast.test.ts @@ -48,17 +48,17 @@ it('allows the placement of context nodes', () => { let blueContext let greenContext - walk(ast, (node, { context }) => { + walk(ast, (node, ctx) => { if (node.kind !== 'declaration') return 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 } }) @@ -297,13 +297,13 @@ it('should only visit children once when calling `replaceWith` with single eleme styleRule('.bar', [decl('color', 'blue')]), ] - walk(ast, (node, { replaceWith }) => { + walk(ast, (node, ctx) => { 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') ctx.replaceWith(node.nodes) }) }) @@ -320,14 +320,14 @@ it('should only visit children once when calling `replaceWith` with multi-elemen styleRule('.bar', [decl('color', 'green')]), ] - walk(ast, (node, { replaceWith }) => { + walk(ast, (node, ctx) => { 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') ctx.replaceWith(node.nodes) }) expect(visited).toMatchInlineSnapshot(` @@ -350,11 +350,11 @@ it('should never visit children when calling `replaceWith` with `WalkAction.Skip let ast = [atRule('@media', '', [inner]), styleRule('.bar', [decl('color', 'blue')])] - walk(ast, (node, { replaceWith }) => { + walk(ast, (node, ctx) => { visited.add(node) if (node.kind === 'at-rule') { - replaceWith(node.nodes) + ctx.replaceWith(node.nodes) return WalkAction.Skip } }) @@ -413,10 +413,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, ctx) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - replaceWith([]) + ctx.replaceWith([]) return WalkAction.Skip } }) @@ -441,10 +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, ctx) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - replaceWith([]) + ctx.replaceWith([]) return WalkAction.Continue } }) @@ -469,10 +469,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, ctx) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - replaceWith([decl('--index', '2.1')]) + ctx.replaceWith([decl('--index', '2.1')]) return WalkAction.Skip } }) @@ -497,10 +497,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, ctx) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - replaceWith([decl('--index', '2.1')]) + ctx.replaceWith([decl('--index', '2.1')]) return WalkAction.Continue } }) @@ -526,10 +526,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, ctx) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - replaceWith([decl('--index', '2.1'), decl('--index', '2.2')]) + ctx.replaceWith([decl('--index', '2.1'), decl('--index', '2.2')]) return WalkAction.Skip } }) @@ -554,10 +554,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, ctx) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - replaceWith([decl('--index', '2.1'), decl('--index', '2.2')]) + ctx.replaceWith([decl('--index', '2.1'), decl('--index', '2.2')]) return WalkAction.Continue } }) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index e57e1c2a3133..77af0b15a50c 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -642,12 +642,12 @@ export function optimizeAst( let ast = ValueParser.parse(declaration.value) let requiresPolyfill = false - ValueParser.walk(ast, (node, { replaceWith }) => { + ValueParser.walk(ast, (node, ctx) => { if (node.kind !== 'function' || node.value !== 'color-mix') return let containsUnresolvableVars = false let containsCurrentcolor = false - ValueParser.walk(node.nodes, (node, { replaceWith }) => { + ValueParser.walk(node.nodes, (node, ctx) => { if (node.kind == 'word' && node.value.toLowerCase() === 'currentcolor') { containsCurrentcolor = true requiresPolyfill = true @@ -691,7 +691,7 @@ export function optimizeAst( } } while (varNode) - replaceWith({ kind: 'word', value: inlinedColor }) + ctx.replaceWith({ kind: 'word', value: inlinedColor }) }) if (containsUnresolvableVars || containsCurrentcolor) { @@ -702,7 +702,7 @@ export function optimizeAst( let firstColorValue = node.nodes.length > separatorIndex ? node.nodes[separatorIndex + 1] : null if (!firstColorValue) return - replaceWith(firstColorValue) + ctx.replaceWith(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 +1005,9 @@ 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] return WalkAction.Stop } }) diff --git a/packages/tailwindcss/src/at-import.ts b/packages/tailwindcss/src/at-import.ts index 5f13cce99c51..ee2efb8a45be 100644 --- a/packages/tailwindcss/src/at-import.ts +++ b/packages/tailwindcss/src/at-import.ts @@ -22,7 +22,7 @@ export async function substituteAtImports( let features = Features.None let promises: Promise[] = [] - walk(ast, (node, { replaceWith }) => { + walk(ast, (node, ctx) => { if (node.kind === 'at-rule' && (node.name === '@import' || node.name === '@reference')) { let parsed = parseImportParams(ValueParser.parse(node.params)) if (parsed === null) return @@ -66,7 +66,7 @@ export async function substituteAtImports( })(), ) - replaceWith(contextNode) + ctx.replaceWith(contextNode) // The resolved Stylesheets already have their transitive @imports // resolved, so we can skip walking them. diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index 23d706c8c028..5154a19b6398 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -1025,8 +1025,8 @@ const printArbitraryValueCache = new DefaultMap((input) => { let drop = new Set() - ValueParser.walk(ast, (node, { parent }) => { - let parentArray = parent === null ? ast : (parent.nodes ?? []) + ValueParser.walk(ast, (node, ctx) => { + let parentArray = ctx.parent === null ? ast : (ctx.parent.nodes ?? []) // Handle operators (e.g.: inside of `calc(…)`) if ( @@ -1064,10 +1064,10 @@ const printArbitraryValueCache = new DefaultMap((input) => { }) if (drop.size > 0) { - ValueParser.walk(ast, (node, { replaceWith }) => { + ValueParser.walk(ast, (node, ctx) => { if (drop.has(node)) { drop.delete(node) - replaceWith([]) + ctx.replaceWith([]) } }) } diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 8df31af09091..6f47652271cb 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -434,7 +434,7 @@ function substituteFunctionsInValue( ast: ValueParser.ValueAstNode[], handle: (value: string, fallback?: string) => string | null, ) { - ValueParser.walk(ast, (node, { parent, replaceWith }) => { + ValueParser.walk(ast, (node, ctx) => { if (node.kind === 'function' && node.value === 'theme') { if (node.nodes.length < 1) return @@ -472,10 +472,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 +503,7 @@ function substituteFunctionsInValue( } } - replaceWith(ValueParser.parse(replacement)) + ctx.replaceWith(ValueParser.parse(replacement)) } }) @@ -1240,10 +1240,10 @@ function modernizeArbitraryValuesVariant( let parsed = ValueParser.parse(SelectorParser.toCss(ast)) let containsNot = false - ValueParser.walk(parsed, (node, { replaceWith }) => { + ValueParser.walk(parsed, (node, ctx) => { if (node.kind === 'word' && node.value === 'not') { containsNot = true - replaceWith([]) + ctx.replaceWith([]) } }) diff --git a/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts index 766264742bca..0eeb58aaeaef 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -50,12 +50,12 @@ export async function applyCompatibilityHooks({ src: SourceLocation | undefined }[] = [] - walk(ast, (node, { parent, replaceWith, context }) => { + walk(ast, (node, ctx) => { if (node.kind !== 'at-rule') return // 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,14 +110,14 @@ 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([]) + ctx.replaceWith([]) features |= Features.JsPluginCompat return } @@ -128,17 +128,17 @@ 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([]) + ctx.replaceWith([]) features |= Features.JsPluginCompat return } @@ -386,16 +386,16 @@ 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 // 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])) + ctx.replaceWith(styleRule(wrappingSelector, [node])) return WalkAction.Stop }) diff --git a/packages/tailwindcss/src/css-functions.ts b/packages/tailwindcss/src/css-functions.ts index 385898b32af8..659d5180b51d 100644 --- a/packages/tailwindcss/src/css-functions.ts +++ b/packages/tailwindcss/src/css-functions.ts @@ -187,7 +187,7 @@ export function substituteFunctionsInValue( designSystem: DesignSystem, ): string { let ast = ValueParser.parse(value) - ValueParser.walk(ast, (node, { replaceWith }) => { + ValueParser.walk(ast, (node, ctx) => { 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 +195,7 @@ export function substituteFunctionsInValue( source, ...args, ) - return replaceWith(ValueParser.parse(result)) + return ctx.replaceWith(ValueParser.parse(result)) } }) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 8c668b6946fb..a749178aebf4 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -166,7 +166,7 @@ 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 // Find `@tailwind utilities` so that we can later replace it with the @@ -177,15 +177,15 @@ async function parseCss( ) { // Any additional `@tailwind utilities` nodes can be removed if (utilitiesNode !== null) { - replaceWith([]) + ctx.replaceWith([]) return } // 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([]) + if (ctx.context.reference) { + ctx.replaceWith([]) return } @@ -210,7 +210,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 +222,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 +260,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,12 +298,12 @@ async function parseCss( } } else { sources.push({ - base: context.base as string, + base: ctx.context.base as string, pattern: source, negated: not, }) } - replaceWith([]) + ctx.replaceWith([]) return } @@ -311,7 +311,7 @@ async function parseCss( 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,12 +350,12 @@ 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([]) + ctx.replaceWith([]) let [name, selector] = segment(node.params, ' ') @@ -462,12 +462,12 @@ async function parseCss( if (param.startsWith('source(')) { let path = param.slice(7, -1) - walk(node.nodes, (child, { replaceWith }) => { + walk(node.nodes, (child, ctx) => { if (child.kind !== 'at-rule') return if (child.name === '@tailwind' && child.params === 'utilities') { child.params += ` source(${path})` - replaceWith([contextNode({ sourceBase: context.base }, [child])]) + ctx.replaceWith([contextNode({ sourceBase: ctx.context.base }, [child])]) return WalkAction.Stop } }) @@ -535,7 +535,7 @@ async function parseCss( if (unknownParams.length > 0) { node.params = unknownParams.join(' ') } else if (params.length > 0) { - replaceWith(node.nodes) + ctx.replaceWith(node.nodes) } } @@ -545,7 +545,7 @@ async function parseCss( features |= Features.AtTheme - if (context.reference) { + if (ctx.context.reference) { themeOptions |= ThemeOptions.REFERENCE } @@ -589,9 +589,9 @@ async function parseCss( if (!firstThemeRule) { firstThemeRule = styleRule(':root, :host', []) firstThemeRule.src = node.src - replaceWith([firstThemeRule]) + ctx.replaceWith([firstThemeRule]) } else { - replaceWith([]) + ctx.replaceWith([]) } return WalkAction.Skip } @@ -685,11 +685,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, ctx) => { if (node.kind !== 'at-rule') return if (node.name === '@utility') { - replaceWith([]) + ctx.replaceWith([]) } // The `@utility` has to be top-level, therefore we don't have to traverse diff --git a/packages/tailwindcss/src/selector-parser.test.ts b/packages/tailwindcss/src/selector-parser.test.ts index 2a22835226f5..217df49ac643 100644 --- a/packages/tailwindcss/src/selector-parser.test.ts +++ b/packages/tailwindcss/src/selector-parser.test.ts @@ -194,9 +194,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, ctx) => { if (node.kind === 'function' && node.value === ':not') { - replaceWith({ kind: 'selector', value: '.inverted-bar' }) + ctx.replaceWith({ kind: 'selector', value: '.inverted-bar' }) } }) expect(toCss(ast)).toBe('.foo:hover.inverted-bar') diff --git a/packages/tailwindcss/src/signatures.ts b/packages/tailwindcss/src/signatures.ts index 493309b6cc82..47acf21a0131 100644 --- a/packages/tailwindcss/src/signatures.ts +++ b/packages/tailwindcss/src/signatures.ts @@ -69,11 +69,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, ctx) => { // Optimize declarations if (node.kind === 'declaration') { if (node.value === undefined || node.property === '--tw-sort') { - replaceWith([]) + ctx.replaceWith([]) } // Normalize percentages by removing unnecessary dots and zeros. @@ -90,17 +90,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) + ctx.replaceWith(node.nodes) } // Remove comments else if (node.kind === 'comment') { - replaceWith([]) + ctx.replaceWith([]) } // Remove at-rules that are not needed for the signature else if (node.kind === 'at-rule' && node.name === '@property') { - replaceWith([]) + ctx.replaceWith([]) } }) @@ -151,7 +151,7 @@ export const computeUtilitySignature = new DefaultMap((options: SignatureOptions let valueAst = ValueParser.parse(node.value) let seen = new Set() - ValueParser.walk(valueAst, (valueNode, { replaceWith }) => { + ValueParser.walk(valueAst, (valueNode, ctx) => { if (valueNode.kind !== 'function') return if (valueNode.value !== 'var') return @@ -204,7 +204,7 @@ export const computeUtilitySignature = new DefaultMap((options: SignatureOptions let constructedValue = `${valueNode.nodes[0].value},${variableValue}` if (nodeAsString === constructedValue) { changed = true - replaceWith(ValueParser.parse(variableValue)) + ctx.replaceWith(ValueParser.parse(variableValue)) } } } @@ -328,7 +328,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 }) => { + SelectorParser.walk(selectorAst, (node, ctx) => { if (node.kind === 'separator' && node.value !== ' ') { node.value = node.value.trim() changed = true @@ -342,7 +342,7 @@ export const computeVariantSignature = new DefaultMap((options: SignatureOptions // E.g.: `:is(.foo)` → `.foo` if (node.nodes.length === 1) { changed = true - replaceWith(node.nodes) + ctx.replaceWith(node.nodes) } // A selector with the universal selector `*` followed by a pseudo @@ -355,7 +355,7 @@ export const computeVariantSignature = new DefaultMap((options: SignatureOptions node.nodes[1].value[0] === ':' ) { changed = true - replaceWith(node.nodes[1]) + ctx.replaceWith(node.nodes[1]) } } diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index c0c0bd7daf17..cec43ff368e6 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -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 }) => { + ValueParser.walk(copy.nodes, (node, ctx) => { if (node.kind === 'word' && node.value === dataType) { - replaceWith({ kind: 'word', value: sentinelValue }) + ctx.replaceWith({ kind: 'word', value: sentinelValue }) } }) let underline = '^'.repeat(ValueParser.toCss([node]).length) @@ -6048,7 +6048,8 @@ 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 @@ -6076,7 +6077,7 @@ export function createCssUtility(node: AtRule) { // Drop the declaration in case we couldn't resolve the value usedValueFn ||= false - replaceDeclarationWith([]) + ctx.replaceWith([]) return ValueParser.ValueWalkAction.Stop } @@ -6085,7 +6086,7 @@ export function createCssUtility(node: AtRule) { // If there is no modifier present in the candidate, then the // declaration can be removed. if (modifier === null) { - replaceDeclarationWith([]) + ctx.replaceWith([]) return ValueParser.ValueWalkAction.Stop } @@ -6100,7 +6101,7 @@ export function createCssUtility(node: AtRule) { // Drop the declaration in case we couldn't resolve the value usedModifierFn ||= false - replaceDeclarationWith([]) + ctx.replaceWith([]) return ValueParser.ValueWalkAction.Stop } }) ?? ValueParser.ValueWalkAction.Continue diff --git a/packages/tailwindcss/src/value-parser.test.ts b/packages/tailwindcss/src/value-parser.test.ts index e7ece47c0dbf..ac186e451f00 100644 --- a/packages/tailwindcss/src/value-parser.test.ts +++ b/packages/tailwindcss/src/value-parser.test.ts @@ -207,9 +207,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, ctx) => { if (node.kind === 'function' && node.value === 'theme') { - replaceWith({ kind: 'word', value: '64rem' }) + ctx.replaceWith({ kind: 'word', value: '64rem' }) } }) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 0008e1c77c2e..3209562559a5 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -449,7 +449,7 @@ export function createVariants(theme: Theme): Variants { let didApply = false - walk([ruleNode], (node, { path }) => { + walk([ruleNode], (node, ctx) => { if (node.kind !== 'rule' && node.kind !== 'at-rule') return WalkAction.Continue if (node.nodes.length > 0) return WalkAction.Continue @@ -457,7 +457,7 @@ export function createVariants(theme: Theme): Variants { let atRules: AtRule[] = [] let styleRules: StyleRule[] = [] - for (let parent of path) { + for (let parent of ctx.path) { if (parent.kind === 'at-rule') { atRules.push(parent) } else if (parent.kind === 'rule') { @@ -525,11 +525,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.slice(0, -1)) { if (parent.kind !== 'rule') continue didApply = false @@ -577,11 +577,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.slice(0, -1)) { if (parent.kind !== 'rule') continue didApply = false @@ -725,11 +725,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.slice(0, -1)) { if (parent.kind !== 'rule') continue didApply = false @@ -760,11 +760,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.slice(0, -1)) { if (parent.kind !== 'rule') continue didApply = false @@ -1191,10 +1191,10 @@ function quoteAttributeValue(input: string) { } export function substituteAtSlot(ast: AstNode[], nodes: AstNode[]) { - walk(ast, (node, { replaceWith }) => { + walk(ast, (node, ctx) => { // Replace `@slot` with rule nodes if (node.kind === 'at-rule' && node.name === '@slot') { - replaceWith(nodes) + ctx.replaceWith(nodes) } // Wrap `@keyframes` and `@property` in `AtRoot` nodes @@ -1207,7 +1207,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, ctx) => { if (variantNode.kind !== 'at-rule' || variantNode.name !== '@variant') return // Starting with the `&` rule node @@ -1226,7 +1226,7 @@ export function substituteAtVariant(ast: AstNode[], designSystem: DesignSystem): } // Update the variant at-rule node, to be the `&` rule node - replaceWith(node) + ctx.replaceWith(node) features |= Features.Variants }) return features From 96167663693118dff70d2be71683c34b1918911d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 14 Oct 2025 13:30:32 +0200 Subject: [PATCH 2/8] introduce generalized walk method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is completely generic, the only requirement is that "parent" nodes have `{ nodes: T[] }` as the signature. It's a new iterative walk with minimal memory usage and performance better than the recursive based walk function we had. This also adds new functionality where we now can have an `enter` and `exit` phase. The `exit` phase is like a depth-first where we visit leaf nodes first. If you have both, we only do a single walk down and up again instead of you having to write 2 separate walks that traverse the entire tree. Some API changes: 1. `replaceWith(…)` doesn't exist anymore, instead you return any of the `WalkAction`s. E.g.: `return WalkAction.Replace(…)` 2. `path` is not build while traversing, instead it's a function you can call to lazily compute it because in most cases we don't even need this functionality. One benefit is that there is no call stack, so the moment you stop a walk it's an instant return without unwinding the call stack. All actions you can do are: - `WalkAction.Continue` - `WalkAction.Skip` - `WalkAction.Stop` - `WalkAction.Replace(…)` — replace nodes and continue walking the new nodes - `WalkAction.ReplaceSkip(…)` — replace nodes and skip walking the new nodes - `WalkAction.ReplaceStop(…)` — replace nodes and stop the entire traversal --- packages/tailwindcss/src/walk.test.ts | 1596 +++++++++++++++++++++++++ packages/tailwindcss/src/walk.ts | 476 ++++++++ 2 files changed, 2072 insertions(+) create mode 100644 packages/tailwindcss/src/walk.test.ts create mode 100644 packages/tailwindcss/src/walk.ts 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..2a10b0815895 --- /dev/null +++ b/packages/tailwindcss/src/walk.ts @@ -0,0 +1,476 @@ +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[] } + +interface VisitContext { + parent: Parent | null + depth: number + path: () => T[] +} + +function replace(nodes: T[], index: number, replacements: T[]) { + switch (replacements.length) { + case 0: + nodes.splice(index, 1) + break + case 1: + nodes[index] = replacements[0] + break + default: + nodes.splice(index, 1, ...replacements) + break + } +} + +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 + } + | { + enter: (node: T, ctx: VisitContext) => EnterResult | void + exit?: never + } + | { + enter?: never + exit: (node: T, ctx: VisitContext) => ExitResult | void + }, +): void { + if (typeof hooks === 'function') walkEnter(ast, hooks) + else if (hooks.enter && hooks.exit) walkEnterExit(ast, hooks.enter, hooks.exit) + else if (hooks.enter) walkEnter(ast, hooks.enter) + else if (hooks.exit) walkExit(ast, hooks.exit) +} + +function walkEnter( + ast: T[], + enter: (node: T, ctx: VisitContext) => EnterResult | void, +) { + 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 + } + + let node = nodes[offset] + + ctx.parent = parent + ctx.depth = depth + + 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]) + } + + stack[depth][1]++ // Advance to next sibling + continue + } + + case WalkKind.Stop: + return // Stop immediately + + case WalkKind.Skip: + stack[depth][1]++ // Advance to next sibling + continue + + case WalkKind.Replace: { + // Replace current node, with new nodes. No need to change the offset + // because we want to re-visit the current index, which now contains the + // new nodes. + replace(nodes, offset, result.nodes) + continue + } + + case WalkKind.ReplaceStop: { + replace(nodes, offset, result.nodes) // Replace current node + return // Stop immediately + } + + case WalkKind.ReplaceSkip: { + replace(nodes, offset, result.nodes) // Replace current node + stack[depth][1] += 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 in with an invalid value + `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in enter.`, + ) + } + } + } +} + +function walkExit( + ast: T[], + exit: (node: T, ctx: VisitContext) => ExitResult | void, +) { + 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. We need to enter the AST so we can go back up again + if (offset >= 0) { + let node = nodes[offset] + + if (node.nodes && node.nodes.length > 0) { + stack[depth][1] = ~offset // Prepare for actual exit phase + stack.push([node.nodes, 0, node as Parent]) + continue + } + + // Leaf node: run exit immediately + let result = exit(node, ctx) ?? WalkAction.Continue + + switch (result.kind) { + case WalkKind.Continue: { + stack[depth][1]++ // Advance to next sibling + continue + } + + case WalkKind.Stop: + return // Stop immediately + + case WalkKind.ReplaceStop: { + replace(nodes, offset, result.nodes) + return // Stop immediately + } + + case WalkKind.Replace: + case WalkKind.ReplaceSkip: { + replace(nodes, offset, result.nodes) + stack[depth][1] += result.nodes.length + continue + } + + default: { + result satisfies never + throw new Error( + // @ts-expect-error `result.kind` could still be filled in with an invalid value + `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in exit (leaf).`, + ) + } + } + } + + // Actual exit phase for nodes[~offset] + let index = ~offset + let node = nodes[index] + + let result = exit(node, ctx) ?? WalkAction.Continue + + switch (result.kind) { + case WalkKind.Continue: { + stack[depth][1] = index + 1 // Advance to next sibling + continue + } + + case WalkKind.Stop: + return // Stop immediately + + case WalkKind.ReplaceStop: { + replace(nodes, index, result.nodes) + return // Stop immediately + } + + case WalkKind.Replace: + case WalkKind.ReplaceSkip: { + replace(nodes, index, result.nodes) + stack[depth][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 in with an invalid value + `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in exit.`, + ) + } + } + } +} + +function walkEnterExit( + ast: T[], + enter: (node: T, ctx: VisitContext) => EnterResult | void, + exit: (node: T, ctx: VisitContext) => EnterResult | void, +) { + 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[depth][1] = ~offset // Prepare for exit phase, same offset + stack.push([node.nodes, 0, node as Parent]) + continue + } + + // Already a leaf node, can immediately exit + { + let result = exit(node, ctx) ?? WalkAction.Continue + + switch (result.kind) { + case WalkKind.Continue: + case WalkKind.Skip: + stack[depth][1]++ // Advance to next sibling + continue + + case WalkKind.Stop: + return // Stop immediately + + case WalkKind.ReplaceStop: + replace(nodes, offset, result.nodes) + return // Stop immediately + + case WalkKind.Replace: + case WalkKind.ReplaceSkip: + replace(nodes, offset, result.nodes) + stack[depth][1] += result.nodes.length // Advance to next sibling past replacements + continue + + default: { + result satisfies never + throw new Error( + // @ts-expect-error r.kind may be invalid + `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in exit (leaf).`, + ) + } + } + } + } + + case WalkKind.Stop: + return // Stop immediately + + case WalkKind.Skip: { + let result = exit(node, ctx) ?? WalkAction.Continue + + switch (result.kind) { + case WalkKind.Continue: + case WalkKind.Skip: + stack[depth][1]++ + continue + + case WalkKind.Stop: + return // Stop immediately + + case WalkKind.Replace: + replace(nodes, offset, result.nodes) + stack[depth][1] += result.nodes.length // don't visit replacements' exits + continue + + case WalkKind.ReplaceStop: + replace(nodes, offset, result.nodes) + return // Stop immediately + + case WalkKind.ReplaceSkip: + replace(nodes, offset, result.nodes) + stack[depth][1] += result.nodes.length // don't visit replacements' exits + continue + + default: { + result satisfies never + throw new Error( + // @ts-expect-error r.kind may be invalid + `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in exit (skip).`, + ) + } + } + } + + case WalkKind.Replace: { + // Replace current node; re-visit current index (enter on first replacement) + replace(nodes, offset, result.nodes) + continue + } + + case WalkKind.ReplaceStop: { + replace(nodes, offset, result.nodes) + return // Stop immediately + } + + case WalkKind.ReplaceSkip: { + replace(nodes, offset, result.nodes) + stack[depth][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: + case WalkKind.Skip: + stack[depth][1] = index + 1 // Advance to next sibling + continue + + case WalkKind.Stop: + return // Stop immediately + + case WalkKind.Replace: { + replace(nodes, index, result.nodes) + stack[depth][1] = index + result.nodes.length // Advance to next sibling past replacements + continue + } + + case WalkKind.ReplaceStop: { + replace(nodes, index, result.nodes) + return // Stop immediately + } + + case WalkKind.ReplaceSkip: { + replace(nodes, index, result.nodes) + stack[depth][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.`, + ) + } + } + } +} From 576e894361710c3b52c14c4a789e67c206895fe0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 14 Oct 2025 19:17:34 +0200 Subject: [PATCH 3/8] remove existing `walk` implementations --- packages/tailwindcss/src/ast.ts | 154 -------------------- packages/tailwindcss/src/selector-parser.ts | 78 ---------- packages/tailwindcss/src/value-parser.ts | 142 ------------------ 3 files changed, 374 deletions(-) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 77af0b15a50c..d7a768c76093 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -184,160 +184,6 @@ export function cloneAstNode(node: T): T { } } -export const enum WalkAction { - /** Continue walking, which is the default */ - Continue, - - /** Skip visiting the children of this node */ - Skip, - - /** Stop the walk entirely */ - Stop, -} - -export function walk( - ast: AstNode[], - visit: ( - node: AstNode, - utils: { - parent: AstNode | null - replaceWith(newNode: AstNode | AstNode[]): void - context: Record - path: AstNode[] - }, - ) => void | WalkAction, - path: AstNode[] = [], - context: Record = {}, -) { - for (let i = 0; i < ast.length; i++) { - let node = ast[i] - let parent = path[path.length - 1] ?? null - - // We want context nodes to be transparent in walks. This means that - // whenever we encounter one, we immediately walk through its children and - // furthermore we also don't update the parent. - if (node.kind === 'context') { - if (walk(node.nodes, visit, path, { ...context, ...node.context }) === WalkAction.Stop) { - return WalkAction.Stop - } - continue - } - - path.push(node) - let replacedNode = false - let replacedNodeOffset = 0 - let status = - visit(node, { - parent, - context, - path, - replaceWith(newNode) { - if (replacedNode) return - replacedNode = true - - if (Array.isArray(newNode)) { - if (newNode.length === 0) { - ast.splice(i, 1) - replacedNodeOffset = 0 - } else if (newNode.length === 1) { - ast[i] = newNode[0] - replacedNodeOffset = 1 - } else { - ast.splice(i, 1, ...newNode) - replacedNodeOffset = newNode.length - } - } else { - ast[i] = newNode - replacedNodeOffset = 1 - } - }, - }) ?? WalkAction.Continue - path.pop() - - // We want to visit or skip the newly replaced node(s), which start at the - // current index (i). By decrementing the index here, the next loop will - // process this position (containing the replaced node) again. - if (replacedNode) { - if (status === WalkAction.Continue) { - i-- - } else { - i += replacedNodeOffset - 1 - } - continue - } - - // Stop the walk entirely - if (status === WalkAction.Stop) return WalkAction.Stop - - // Skip visiting the children of this node - if (status === WalkAction.Skip) continue - - if ('nodes' in node) { - path.push(node) - let result = walk(node.nodes, visit, path, context) - path.pop() - - if (result === WalkAction.Stop) { - return WalkAction.Stop - } - } - } -} - -// This is a depth-first traversal of the AST -export function walkDepth( - ast: AstNode[], - visit: ( - node: AstNode, - utils: { - parent: AstNode | null - path: AstNode[] - context: Record - replaceWith(newNode: AstNode[]): void - }, - ) => void, - path: AstNode[] = [], - context: Record = {}, -) { - for (let i = 0; i < ast.length; i++) { - let node = ast[i] - let parent = path[path.length - 1] ?? null - - if (node.kind === 'rule' || node.kind === 'at-rule') { - path.push(node) - walkDepth(node.nodes, visit, path, context) - path.pop() - } else if (node.kind === 'context') { - walkDepth(node.nodes, visit, path, { ...context, ...node.context }) - continue - } - - path.push(node) - visit(node, { - parent, - context, - path, - replaceWith(newNode) { - if (Array.isArray(newNode)) { - if (newNode.length === 0) { - ast.splice(i, 1) - } else if (newNode.length === 1) { - ast[i] = newNode[0] - } else { - ast.splice(i, 1, ...newNode) - } - } else { - ast[i] = newNode - } - - // Skip over the newly inserted nodes (being depth-first it doesn't make sense to visit them) - i += newNode.length - 1 - }, - }) - path.pop() - } -} - // Optimize the AST for printing where all the special nodes that require custom // handling are handled such that the printing is a 1-to-1 transformation. export function optimizeAst( 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/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) { From 510cc270c0a288dc3647c8cf0a10a57449e9beee Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 14 Oct 2025 19:19:13 +0200 Subject: [PATCH 4/8] use new walk --- packages/@tailwindcss-node/src/urls.ts | 3 +- .../src/codemods/css/migrate-preflight.ts | 5 +- .../migrate-automatic-var-injection.ts | 2 +- .../codemods/template/migrate-theme-to-var.ts | 15 +- .../template/migrate-variant-order.ts | 3 +- packages/tailwindcss/src/apply.ts | 10 +- packages/tailwindcss/src/ast.test.ts | 79 ++++---- packages/tailwindcss/src/ast.ts | 12 +- packages/tailwindcss/src/at-import.ts | 9 +- packages/tailwindcss/src/candidate.ts | 7 +- .../src/canonicalize-candidates.ts | 25 +-- .../src/compat/apply-compat-hooks.ts | 11 +- packages/tailwindcss/src/compat/plugin-api.ts | 11 +- packages/tailwindcss/src/compile.ts | 12 +- .../src/constant-fold-declaration.ts | 171 +++++++++--------- packages/tailwindcss/src/css-functions.ts | 9 +- packages/tailwindcss/src/index.ts | 41 ++--- .../tailwindcss/src/selector-parser.test.ts | 7 +- packages/tailwindcss/src/signatures.ts | 23 +-- .../tailwindcss/src/source-maps/source-map.ts | 3 +- packages/tailwindcss/src/utilities.ts | 107 +++++------ packages/tailwindcss/src/utils/variables.ts | 7 +- packages/tailwindcss/src/value-parser.test.ts | 7 +- packages/tailwindcss/src/variants.ts | 32 ++-- 24 files changed, 301 insertions(+), 310 deletions(-) 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 e6fc0cbeca63..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, ctx) => { + 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 - ctx.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 d9b87d513c18..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, ctx) => { + walk(ast, (node, ctx) => { if (node.kind === 'function' && node.value === 'theme') { if (node.nodes.length < 1) return @@ -241,7 +242,7 @@ function substituteFunctionsInValue( } } - ctx.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 a5be59ca52c9..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 @@ -69,8 +70,7 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) { for (let dependency of resolveApplyDependencies(node, designSystem)) { // Mark every parent in the path as having a dependency to that utility. - for (let parent of ctx.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, ctx) => { + 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) { } } - ctx.replaceWith(newNodes) + return WalkAction.Replace(newNodes) } }) } diff --git a/packages/tailwindcss/src/ast.test.ts b/packages/tailwindcss/src/ast.test.ts index 86a5e3a6f4c8..d9c6e83f6da4 100644 --- a/packages/tailwindcss/src/ast.test.ts +++ b/packages/tailwindcss/src/ast.test.ts @@ -1,18 +1,9 @@ import { expect, it } from 'vitest' -import { - atRule, - context, - decl, - optimizeAst, - styleRule, - toCss, - walk, - WalkAction, - type AstNode, -} from './ast' +import { atRule, context, decl, optimizeAst, styleRule, toCss, 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 +22,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 +39,23 @@ it('allows the placement of context nodes', () => { let blueContext let greenContext - walk(ast, (node, ctx) => { + let walkContext: Record = {} + walk(ast, (node) => { + if (node.kind === 'context') { + walkContext = { ...walkContext, ...node.context } + return + } + if (node.kind !== 'declaration') return switch (node.value) { case 'red': - redContext = ctx.context + redContext = walkContext break case 'blue': - blueContext = ctx.context + blueContext = walkContext break case 'green': - greenContext = ctx.context + greenContext = walkContext break } }) @@ -292,25 +289,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, ctx) => { + walk(ast, (node) => { if (visited.has(node)) { throw new Error('Visited node twice') } visited.add(node) - if (node.kind === 'at-rule') ctx.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 +317,20 @@ it('should only visit children once when calling `replaceWith` with multi-elemen styleRule('.bar', [decl('color', 'green')]), ] - walk(ast, (node, ctx) => { + 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') ctx.replaceWith(node.nodes) + if (node.kind === 'at-rule') return WalkAction.Replace(node.nodes) }) expect(visited).toMatchInlineSnapshot(` Set { "@media ", + "", ".foo", "color: red", ".baz", @@ -348,14 +346,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, ctx) => { + walk(ast, (node) => { visited.add(node) if (node.kind === 'at-rule') { - ctx.replaceWith(node.nodes) - return WalkAction.Skip + return WalkAction.ReplaceSkip(node.nodes) } }) @@ -413,11 +410,10 @@ it('should skip the correct number of children based on the replaced children no decl('--index', '4'), ] let visited: string[] = [] - walk(ast, (node, ctx) => { + walk(ast, (node) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - ctx.replaceWith([]) - return WalkAction.Skip + return WalkAction.ReplaceSkip([]) } }) @@ -441,11 +437,10 @@ it('should skip the correct number of children based on the replaced children no decl('--index', '4'), ] let visited: string[] = [] - walk(ast, (node, ctx) => { + walk(ast, (node) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - ctx.replaceWith([]) - return WalkAction.Continue + return WalkAction.Replace([]) } }) @@ -469,11 +464,10 @@ it('should skip the correct number of children based on the replaced children no decl('--index', '4'), ] let visited: string[] = [] - walk(ast, (node, ctx) => { + walk(ast, (node) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - ctx.replaceWith([decl('--index', '2.1')]) - return WalkAction.Skip + return WalkAction.ReplaceSkip([decl('--index', '2.1')]) } }) @@ -497,11 +491,10 @@ it('should skip the correct number of children based on the replaced children no decl('--index', '4'), ] let visited: string[] = [] - walk(ast, (node, ctx) => { + walk(ast, (node) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - ctx.replaceWith([decl('--index', '2.1')]) - return WalkAction.Continue + return WalkAction.Replace([decl('--index', '2.1')]) } }) @@ -526,11 +519,10 @@ it('should skip the correct number of children based on the replaced children no decl('--index', '4'), ] let visited: string[] = [] - walk(ast, (node, ctx) => { + walk(ast, (node) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - ctx.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 +546,10 @@ it('should skip the correct number of children based on the replaced children no decl('--index', '4'), ] let visited: string[] = [] - walk(ast, (node, ctx) => { + walk(ast, (node) => { visited.push(id(node)) if (node.kind === 'declaration' && node.value === '2') { - ctx.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 d7a768c76093..e6ed2a4ebcfe 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 } from './walk' const AT_SIGN = 0x40 @@ -488,12 +489,12 @@ export function optimizeAst( let ast = ValueParser.parse(declaration.value) let requiresPolyfill = false - ValueParser.walk(ast, (node, ctx) => { + walk(ast, (node) => { if (node.kind !== 'function' || node.value !== 'color-mix') return let containsUnresolvableVars = false let containsCurrentcolor = false - ValueParser.walk(node.nodes, (node, ctx) => { + walk(node.nodes, (node) => { if (node.kind == 'word' && node.value.toLowerCase() === 'currentcolor') { containsCurrentcolor = true requiresPolyfill = true @@ -537,7 +538,7 @@ export function optimizeAst( } } while (varNode) - ctx.replaceWith({ kind: 'word', value: inlinedColor }) + return WalkAction.Replace({ kind: 'word', value: inlinedColor } as const) }) if (containsUnresolvableVars || containsCurrentcolor) { @@ -548,7 +549,7 @@ export function optimizeAst( let firstColorValue = node.nodes.length > separatorIndex ? node.nodes[separatorIndex + 1] : null if (!firstColorValue) return - ctx.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. @@ -853,7 +854,8 @@ function findNode(ast: AstNode[], fn: (node: AstNode) => boolean): AstNode[] | n let foundPath: AstNode[] = [] walk(ast, (node, ctx) => { if (fn(node)) { - foundPath = [...ctx.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 ee2efb8a45be..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, ctx) => { + 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( })(), ) - ctx.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 5154a19b6398..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,7 +1026,7 @@ const printArbitraryValueCache = new DefaultMap((input) => { let drop = new Set() - ValueParser.walk(ast, (node, ctx) => { + walk(ast, (node, ctx) => { let parentArray = ctx.parent === null ? ast : (ctx.parent.nodes ?? []) // Handle operators (e.g.: inside of `calc(…)`) @@ -1064,10 +1065,10 @@ const printArbitraryValueCache = new DefaultMap((input) => { }) if (drop.size > 0) { - ValueParser.walk(ast, (node, ctx) => { + walk(ast, (node) => { if (drop.has(node)) { drop.delete(node) - ctx.replaceWith([]) + return WalkAction.ReplaceSkip([]) } }) } diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 6f47652271cb..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, ctx) => { + walk(ast, (node, ctx) => { if (node.kind === 'function' && node.value === 'theme') { if (node.nodes.length < 1) return @@ -503,7 +504,7 @@ function substituteFunctionsInValue( } } - ctx.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, ctx) => { + walk(parsed, (node) => { if (node.kind === 'word' && node.value === 'not') { containsNot = true - ctx.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 0eeb58aaeaef..9da4359d6dd7 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -3,6 +3,7 @@ import { styleRule, toCss, walk, WalkAction, type AstNode } from '../ast' import type { DesignSystem } from '../design-system' import type { SourceLocation } from '../source-maps/source' import { segment } from '../utils/segment' +import { WalkAction } from '../walk' import { applyConfigToTheme } from './apply-config-to-theme' import { applyKeyframesToTheme } from './apply-keyframes-to-theme' import { createCompatConfig } from './config/create-compat-config' @@ -117,9 +118,8 @@ export async function applyCompatibilityHooks({ Object.keys(options).length > 0 ? options : null, ]) - ctx.replaceWith([]) features |= Features.JsPluginCompat - return + return WalkAction.Replace([]) } // Collect paths from `@config` at-rules @@ -138,9 +138,8 @@ export async function applyCompatibilityHooks({ reference: !!ctx.context.reference, src: node.src, }) - ctx.replaceWith([]) features |= Features.JsPluginCompat - return + return WalkAction.Replace([]) } }) @@ -395,9 +394,7 @@ function upgradeToFullPluginSupport({ return WalkAction.Stop } - ctx.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 659d5180b51d..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, ctx) => { + 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 ctx.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 a749178aebf4..d57c945ea1db 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -11,8 +11,6 @@ import { rule, styleRule, toCss, - walk, - WalkAction, type AstNode, type AtRule, type Context, @@ -34,6 +32,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]+$/ @@ -177,16 +176,14 @@ async function parseCss( ) { // Any additional `@tailwind utilities` nodes can be removed if (utilitiesNode !== null) { - ctx.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 (ctx.context.reference) { - ctx.replaceWith([]) - return + return WalkAction.Replace([]) } let params = segment(node.params, ' ') @@ -303,8 +300,8 @@ async function parseCss( negated: not, }) } - ctx.replaceWith([]) - return + + return WalkAction.ReplaceSkip([]) } // Apply `@variant` at-rules @@ -354,9 +351,6 @@ async function parseCss( throw new Error('`@custom-variant` cannot be nested.') } - // Remove `@custom-variant` at-rule so it's not included in the compiled CSS - ctx.replaceWith([]) - let [name, selector] = segment(node.params, ' ') if (!IS_VALID_VARIANT_NAME.test(name)) { @@ -417,8 +411,6 @@ async function parseCss( ) }) customVariantDependencies.set(name, new Set()) - - return } // Variants without a selector, but with a body: @@ -448,9 +440,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 +455,12 @@ async function parseCss( if (param.startsWith('source(')) { let path = param.slice(7, -1) - walk(node.nodes, (child, ctx) => { + walk(node.nodes, (child) => { if (child.kind !== 'at-rule') return if (child.name === '@tailwind' && child.params === 'utilities') { child.params += ` source(${path})` - ctx.replaceWith([contextNode({ sourceBase: ctx.context.base }, [child])]) - return WalkAction.Stop + return WalkAction.ReplaceStop([contextNode({ sourceBase: context.base }, [child])]) } }) } @@ -535,8 +527,10 @@ async function parseCss( if (unknownParams.length > 0) { node.params = unknownParams.join(' ') } else if (params.length > 0) { - ctx.replaceWith(node.nodes) + return WalkAction.Replace(node.nodes) } + + return WalkAction.Continue } // Handle `@theme` @@ -589,11 +583,10 @@ async function parseCss( if (!firstThemeRule) { firstThemeRule = styleRule(':root, :host', []) firstThemeRule.src = node.src - ctx.replaceWith([firstThemeRule]) + return WalkAction.ReplaceSkip(firstThemeRule) } else { - ctx.replaceWith([]) + return WalkAction.ReplaceSkip([]) } - return WalkAction.Skip } }) @@ -685,11 +678,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, ctx) => { + walk(ast, (node) => { if (node.kind !== 'at-rule') return if (node.name === '@utility') { - ctx.replaceWith([]) + return WalkAction.Replace([]) } // The `@utility` has to be top-level, therefore we don't have to traverse diff --git a/packages/tailwindcss/src/selector-parser.test.ts b/packages/tailwindcss/src/selector-parser.test.ts index 217df49ac643..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, ctx) => { + walk(ast, (node) => { if (node.kind === 'function' && node.value === ':not') { - ctx.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/signatures.ts b/packages/tailwindcss/src/signatures.ts index 47acf21a0131..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, ctx) => { + walk(ast, (node) => { // Optimize declarations if (node.kind === 'declaration') { if (node.value === undefined || node.property === '--tw-sort') { - ctx.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') { - ctx.replaceWith(node.nodes) + return WalkAction.Replace(node.nodes) } // Remove comments else if (node.kind === 'comment') { - ctx.replaceWith([]) + return WalkAction.Replace([]) } // Remove at-rules that are not needed for the signature else if (node.kind === 'at-rule' && node.name === '@property') { - ctx.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, ctx) => { + 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 - ctx.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, ctx) => { + 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 - ctx.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 - ctx.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 cec43ff368e6..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, ctx) => { + walk(copy.nodes, (node) => { if (node.kind === 'word' && node.value === dataType) { - ctx.replaceWith({ kind: 'word', value: sentinelValue }) + return WalkAction.ReplaceSkip({ kind: 'word', value: sentinelValue } as const) } }) let underline = '^'.repeat(ValueParser.toCss([node]).length) @@ -6048,67 +6048,68 @@ export function createCssUtility(node: AtRule) { // Whether `--value(ratio)` was resolved let resolvedRatioValue = false - walk([atRule], (node, ctx) => { + 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 - ctx.replaceWith([]) - 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) { - ctx.replaceWith([]) - 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 - ctx.replaceWith([]) - 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 ac186e451f00..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, ctx) => { + walk(ast, (node) => { if (node.kind === 'function' && node.value === 'theme') { - ctx.replaceWith({ kind: 'word', value: '64rem' }) + return WalkAction.Replace({ kind: 'word', value: '64rem' } as const) } }) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 3209562559a5..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(ast, (node) => { // Replace `@slot` with rule nodes if (node.kind === 'at-rule' && node.name === '@slot') { - ctx.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, ctx) => { + 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 - ctx.replaceWith(node) features |= Features.Variants + return WalkAction.Replace(node) }) return features } From 44249248749c7305580bf28a2d77ac944ddffe20 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 14 Oct 2025 20:23:06 +0200 Subject: [PATCH 5/8] replace `walkDepth` with `walk#exit` --- packages/tailwindcss/src/intellisense.ts | 62 +++++++++++++----------- 1 file changed, 34 insertions(+), 28 deletions(-) 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 From 3629113961ef2b8767f2db222ed2ff23563db52d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 14 Oct 2025 20:16:39 +0200 Subject: [PATCH 6/8] extends `ctx` of CSS AstNode's The CSS AST has `context` nodes that you can access while walking the tree, where they build up information over time. Additionally, when looking at the `path` or the `parent` all the `context` nodes are hidden. This adds a small wrapper for the context object when walking over AstNode's and have to access the `.context`, `.parent` or `.path()` --- packages/tailwindcss/src/ast.test.ts | 26 ++++++++------- packages/tailwindcss/src/ast.ts | 32 ++++++++++++++++++- .../src/compat/apply-compat-hooks.ts | 11 ++++--- packages/tailwindcss/src/index.ts | 9 ++++-- packages/tailwindcss/src/walk.ts | 2 +- 5 files changed, 61 insertions(+), 19 deletions(-) diff --git a/packages/tailwindcss/src/ast.test.ts b/packages/tailwindcss/src/ast.test.ts index d9c6e83f6da4..63a621c6a298 100644 --- a/packages/tailwindcss/src/ast.test.ts +++ b/packages/tailwindcss/src/ast.test.ts @@ -1,5 +1,14 @@ import { expect, it } from 'vitest' -import { atRule, context, decl, optimizeAst, styleRule, toCss, type AstNode } from './ast' +import { + atRule, + context, + cssContext, + decl, + optimizeAst, + styleRule, + toCss, + type AstNode, +} from './ast' import * as CSS from './css-parser' import { buildDesignSystem } from './design-system' import { Theme } from './theme' @@ -39,23 +48,18 @@ it('allows the placement of context nodes', () => { let blueContext let greenContext - let walkContext: Record = {} - walk(ast, (node) => { - if (node.kind === 'context') { - walkContext = { ...walkContext, ...node.context } - return - } - + walk(ast, (node, _ctx) => { if (node.kind !== 'declaration') return + let ctx = cssContext(_ctx) switch (node.value) { case 'red': - redContext = walkContext + redContext = ctx.context break case 'blue': - blueContext = walkContext + blueContext = ctx.context break case 'green': - greenContext = walkContext + greenContext = ctx.context break } }) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index e6ed2a4ebcfe..02cdfb8528fe 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -6,7 +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 } from './walk' +import { walk, WalkAction, type VisitContext } from './walk' const AT_SIGN = 0x40 @@ -185,6 +185,36 @@ export function cloneAstNode(node: T): T { } } +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) + } + } + + // Once computed, we never need to compute this again + Object.defineProperty(this, 'context', { value: context }) + return context + }, + get parent() { + let parent = (this.path().pop() as Extract) ?? null + + // Once computed, we never need to compute this again + Object.defineProperty(this, 'parent', { value: parent }) + return parent + }, + path() { + return ctx.path().filter((n) => n.kind !== 'context') + }, + } +} + // Optimize the AST for printing where all the special nodes that require custom // handling are handled such that the printing is a 1-to-1 transformation. export function optimizeAst( diff --git a/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts index 9da4359d6dd7..6a9f580b6274 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -1,9 +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 { WalkAction } from '../walk' +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' @@ -51,8 +51,9 @@ export async function applyCompatibilityHooks({ src: SourceLocation | undefined }[] = [] - walk(ast, (node, ctx) => { + walk(ast, (node, _ctx) => { if (node.kind !== 'at-rule') return + let ctx = cssContext(_ctx) // Collect paths from `@plugin` at-rules if (node.name === '@plugin') { @@ -385,10 +386,12 @@ function upgradeToFullPluginSupport({ if (typeof resolvedConfig.important === 'string') { let wrappingSelector = resolvedConfig.important - walk(ast, (node, ctx) => { + 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 (ctx.parent?.kind === 'rule' && ctx.parent.selector === wrappingSelector) { return WalkAction.Stop diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index d57c945ea1db..6cf77bc81f40 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -6,6 +6,7 @@ import { comment, context, context as contextNode, + cssContext, decl, optimizeAst, rule, @@ -165,8 +166,9 @@ async function parseCss( let root = null as Root // Handle at-rules - walk(ast, (node, ctx) => { + 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. @@ -460,7 +462,9 @@ async function parseCss( if (child.name === '@tailwind' && child.params === 'utilities') { child.params += ` source(${path})` - return WalkAction.ReplaceStop([contextNode({ sourceBase: context.base }, [child])]) + return WalkAction.ReplaceStop([ + contextNode({ sourceBase: ctx.context.base }, [child]), + ]) } }) } @@ -475,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( diff --git a/packages/tailwindcss/src/walk.ts b/packages/tailwindcss/src/walk.ts index 2a10b0815895..c5bf7478672c 100644 --- a/packages/tailwindcss/src/walk.ts +++ b/packages/tailwindcss/src/walk.ts @@ -33,7 +33,7 @@ type ExitResult = Exclude, { kind: WalkKind.Skip }> type Parent = T & { nodes: T[] } -interface VisitContext { +export interface VisitContext { parent: Parent | null depth: number path: () => T[] From e864ccaa924e4c7ea5dde17b310c17328991024e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 14 Oct 2025 23:44:03 +0200 Subject: [PATCH 7/8] always inline `nodes.splice(idx, 1, ...replacements)` --- packages/tailwindcss/src/walk.ts | 50 ++++++++++++-------------------- 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/packages/tailwindcss/src/walk.ts b/packages/tailwindcss/src/walk.ts index c5bf7478672c..6a020a1e03f4 100644 --- a/packages/tailwindcss/src/walk.ts +++ b/packages/tailwindcss/src/walk.ts @@ -39,20 +39,6 @@ export interface VisitContext { path: () => T[] } -function replace(nodes: T[], index: number, replacements: T[]) { - switch (replacements.length) { - case 0: - nodes.splice(index, 1) - break - case 1: - nodes[index] = replacements[0] - break - default: - nodes.splice(index, 1, ...replacements) - break - } -} - export function walk( ast: T[], hooks: @@ -137,17 +123,17 @@ function walkEnter( // Replace current node, with new nodes. No need to change the offset // because we want to re-visit the current index, which now contains the // new nodes. - replace(nodes, offset, result.nodes) + nodes.splice(offset, 1, ...result.nodes) continue } case WalkKind.ReplaceStop: { - replace(nodes, offset, result.nodes) // Replace current node + nodes.splice(offset, 1, ...result.nodes) // Replace current node return // Stop immediately } case WalkKind.ReplaceSkip: { - replace(nodes, offset, result.nodes) // Replace current node + nodes.splice(offset, 1, ...result.nodes) // Replace current node stack[depth][1] += result.nodes.length // Advance to next sibling past replacements continue } @@ -223,13 +209,13 @@ function walkExit( return // Stop immediately case WalkKind.ReplaceStop: { - replace(nodes, offset, result.nodes) + nodes.splice(offset, 1, ...result.nodes) return // Stop immediately } case WalkKind.Replace: case WalkKind.ReplaceSkip: { - replace(nodes, offset, result.nodes) + nodes.splice(offset, 1, ...result.nodes) stack[depth][1] += result.nodes.length continue } @@ -260,13 +246,13 @@ function walkExit( return // Stop immediately case WalkKind.ReplaceStop: { - replace(nodes, index, result.nodes) + nodes.splice(index, 1, ...result.nodes) return // Stop immediately } case WalkKind.Replace: case WalkKind.ReplaceSkip: { - replace(nodes, index, result.nodes) + nodes.splice(index, 1, ...result.nodes) stack[depth][1] = index + result.nodes.length // Advance to next sibling past replacements continue } @@ -346,12 +332,12 @@ function walkEnterExit( return // Stop immediately case WalkKind.ReplaceStop: - replace(nodes, offset, result.nodes) + nodes.splice(offset, 1, ...result.nodes) return // Stop immediately case WalkKind.Replace: case WalkKind.ReplaceSkip: - replace(nodes, offset, result.nodes) + nodes.splice(offset, 1, ...result.nodes) stack[depth][1] += result.nodes.length // Advance to next sibling past replacements continue @@ -382,16 +368,16 @@ function walkEnterExit( return // Stop immediately case WalkKind.Replace: - replace(nodes, offset, result.nodes) + nodes.splice(offset, 1, ...result.nodes) stack[depth][1] += result.nodes.length // don't visit replacements' exits continue case WalkKind.ReplaceStop: - replace(nodes, offset, result.nodes) + nodes.splice(offset, 1, ...result.nodes) return // Stop immediately case WalkKind.ReplaceSkip: - replace(nodes, offset, result.nodes) + nodes.splice(offset, 1, ...result.nodes) stack[depth][1] += result.nodes.length // don't visit replacements' exits continue @@ -407,17 +393,17 @@ function walkEnterExit( case WalkKind.Replace: { // Replace current node; re-visit current index (enter on first replacement) - replace(nodes, offset, result.nodes) + nodes.splice(offset, 1, ...result.nodes) continue } case WalkKind.ReplaceStop: { - replace(nodes, offset, result.nodes) + nodes.splice(offset, 1, ...result.nodes) return // Stop immediately } case WalkKind.ReplaceSkip: { - replace(nodes, offset, result.nodes) + nodes.splice(offset, 1, ...result.nodes) stack[depth][1] += result.nodes.length // Advance to next sibling past replacements continue } @@ -448,18 +434,18 @@ function walkEnterExit( return // Stop immediately case WalkKind.Replace: { - replace(nodes, index, result.nodes) + nodes.splice(index, 1, ...result.nodes) stack[depth][1] = index + result.nodes.length // Advance to next sibling past replacements continue } case WalkKind.ReplaceStop: { - replace(nodes, index, result.nodes) + nodes.splice(index, 1, ...result.nodes) return // Stop immediately } case WalkKind.ReplaceSkip: { - replace(nodes, index, result.nodes) + nodes.splice(index, 1, ...result.nodes) stack[depth][1] = index + result.nodes.length // Advance to next sibling past replacements continue } From 76a5ea19f0ca885d4175dbdc664f95c2fb0e0b9d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 15 Oct 2025 00:16:18 +0200 Subject: [PATCH 8/8] simplify `walk` 1. Use single function with noop `enter` and `exit` functions instead of 3 separate functions. 2. Instead of repeating some logic with sub-switches, just toggle the offset and let the loop go around. 3. Cleanup some comments --- packages/tailwindcss/src/walk.ts | 313 ++----------------------------- 1 file changed, 16 insertions(+), 297 deletions(-) diff --git a/packages/tailwindcss/src/walk.ts b/packages/tailwindcss/src/walk.ts index 6a020a1e03f4..ccb384256c3f 100644 --- a/packages/tailwindcss/src/walk.ts +++ b/packages/tailwindcss/src/walk.ts @@ -44,234 +44,18 @@ export function walk( 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 - } - | { - enter: (node: T, ctx: VisitContext) => EnterResult | void - exit?: never - } - | { - enter?: never - exit: (node: T, ctx: VisitContext) => ExitResult | void + enter?: (node: T, ctx: VisitContext) => EnterResult | void + exit?: (node: T, ctx: VisitContext) => ExitResult | void }, ): void { - if (typeof hooks === 'function') walkEnter(ast, hooks) - else if (hooks.enter && hooks.exit) walkEnterExit(ast, hooks.enter, hooks.exit) - else if (hooks.enter) walkEnter(ast, hooks.enter) - else if (hooks.exit) walkExit(ast, hooks.exit) -} - -function walkEnter( - ast: T[], - enter: (node: T, ctx: VisitContext) => EnterResult | void, -) { - 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 - } - - let node = nodes[offset] - - ctx.parent = parent - ctx.depth = depth - - 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]) - } - - stack[depth][1]++ // Advance to next sibling - continue - } - - case WalkKind.Stop: - return // Stop immediately - - case WalkKind.Skip: - stack[depth][1]++ // Advance to next sibling - continue - - case WalkKind.Replace: { - // Replace current node, with new nodes. No need to change the offset - // because we want to re-visit the current index, which now contains the - // new nodes. - nodes.splice(offset, 1, ...result.nodes) - continue - } - - case WalkKind.ReplaceStop: { - nodes.splice(offset, 1, ...result.nodes) // Replace current node - return // Stop immediately - } - - case WalkKind.ReplaceSkip: { - nodes.splice(offset, 1, ...result.nodes) // Replace current node - stack[depth][1] += 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 in with an invalid value - `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in enter.`, - ) - } - } - } + if (typeof hooks === 'function') walkImplementation(ast, hooks) + else walkImplementation(ast, hooks.enter, hooks.exit) } -function walkExit( +function walkImplementation( ast: T[], - exit: (node: T, ctx: VisitContext) => ExitResult | void, -) { - 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. We need to enter the AST so we can go back up again - if (offset >= 0) { - let node = nodes[offset] - - if (node.nodes && node.nodes.length > 0) { - stack[depth][1] = ~offset // Prepare for actual exit phase - stack.push([node.nodes, 0, node as Parent]) - continue - } - - // Leaf node: run exit immediately - let result = exit(node, ctx) ?? WalkAction.Continue - - switch (result.kind) { - case WalkKind.Continue: { - stack[depth][1]++ // Advance to next sibling - continue - } - - case WalkKind.Stop: - return // Stop immediately - - case WalkKind.ReplaceStop: { - nodes.splice(offset, 1, ...result.nodes) - return // Stop immediately - } - - case WalkKind.Replace: - case WalkKind.ReplaceSkip: { - nodes.splice(offset, 1, ...result.nodes) - stack[depth][1] += result.nodes.length - continue - } - - default: { - result satisfies never - throw new Error( - // @ts-expect-error `result.kind` could still be filled in with an invalid value - `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in exit (leaf).`, - ) - } - } - } - - // Actual exit phase for nodes[~offset] - let index = ~offset - let node = nodes[index] - - let result = exit(node, ctx) ?? WalkAction.Continue - - switch (result.kind) { - case WalkKind.Continue: { - stack[depth][1] = index + 1 // Advance to next sibling - continue - } - - case WalkKind.Stop: - return // Stop immediately - - case WalkKind.ReplaceStop: { - nodes.splice(index, 1, ...result.nodes) - return // Stop immediately - } - - case WalkKind.Replace: - case WalkKind.ReplaceSkip: { - nodes.splice(index, 1, ...result.nodes) - stack[depth][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 in with an invalid value - `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in exit.`, - ) - } - } - } -} - -function walkEnterExit( - ast: T[], - enter: (node: T, ctx: VisitContext) => EnterResult | void, - exit: (node: T, ctx: VisitContext) => EnterResult | void, + 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 = { @@ -313,88 +97,24 @@ function walkEnterExit( switch (result.kind) { case WalkKind.Continue: { if (node.nodes && node.nodes.length > 0) { - stack[depth][1] = ~offset // Prepare for exit phase, same offset stack.push([node.nodes, 0, node as Parent]) - continue } - // Already a leaf node, can immediately exit - { - let result = exit(node, ctx) ?? WalkAction.Continue - - switch (result.kind) { - case WalkKind.Continue: - case WalkKind.Skip: - stack[depth][1]++ // Advance to next sibling - continue - - case WalkKind.Stop: - return // Stop immediately - - case WalkKind.ReplaceStop: - nodes.splice(offset, 1, ...result.nodes) - return // Stop immediately - - case WalkKind.Replace: - case WalkKind.ReplaceSkip: - nodes.splice(offset, 1, ...result.nodes) - stack[depth][1] += result.nodes.length // Advance to next sibling past replacements - continue - - default: { - result satisfies never - throw new Error( - // @ts-expect-error r.kind may be invalid - `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in exit (leaf).`, - ) - } - } - } + frame[1] = ~offset // Prepare for exit phase, same offset + continue } case WalkKind.Stop: return // Stop immediately case WalkKind.Skip: { - let result = exit(node, ctx) ?? WalkAction.Continue - - switch (result.kind) { - case WalkKind.Continue: - case WalkKind.Skip: - stack[depth][1]++ - continue - - case WalkKind.Stop: - return // Stop immediately - - case WalkKind.Replace: - nodes.splice(offset, 1, ...result.nodes) - stack[depth][1] += result.nodes.length // don't visit replacements' exits - continue - - case WalkKind.ReplaceStop: - nodes.splice(offset, 1, ...result.nodes) - return // Stop immediately - - case WalkKind.ReplaceSkip: - nodes.splice(offset, 1, ...result.nodes) - stack[depth][1] += result.nodes.length // don't visit replacements' exits - continue - - default: { - result satisfies never - throw new Error( - // @ts-expect-error r.kind may be invalid - `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in exit (skip).`, - ) - } - } + frame[1] = ~offset // Prepare for exit phase, same offset + continue } case WalkKind.Replace: { - // Replace current node; re-visit current index (enter on first replacement) nodes.splice(offset, 1, ...result.nodes) - continue + continue // Re-process at same offset } case WalkKind.ReplaceStop: { @@ -404,7 +124,7 @@ function walkEnterExit( case WalkKind.ReplaceSkip: { nodes.splice(offset, 1, ...result.nodes) - stack[depth][1] += result.nodes.length // Advance to next sibling past replacements + frame[1] += result.nodes.length // Advance to next sibling past replacements continue } @@ -426,8 +146,7 @@ function walkEnterExit( switch (result.kind) { case WalkKind.Continue: - case WalkKind.Skip: - stack[depth][1] = index + 1 // Advance to next sibling + frame[1] = index + 1 // Advance to next sibling continue case WalkKind.Stop: @@ -435,7 +154,7 @@ function walkEnterExit( case WalkKind.Replace: { nodes.splice(index, 1, ...result.nodes) - stack[depth][1] = index + result.nodes.length // Advance to next sibling past replacements + frame[1] = index + result.nodes.length // Advance to next sibling past replacements continue } @@ -446,7 +165,7 @@ function walkEnterExit( case WalkKind.ReplaceSkip: { nodes.splice(index, 1, ...result.nodes) - stack[depth][1] = index + result.nodes.length // Advance to next sibling past replacements + frame[1] = index + result.nodes.length // Advance to next sibling past replacements continue }