From 0d868507a0a37529806e0bc78c8314e888a7c8eb Mon Sep 17 00:00:00 2001 From: Tyler Oakleaf Date: Wed, 15 Jul 2026 15:45:45 -0400 Subject: [PATCH] perf: reduce compiler allocation overhead --- BENCHMARKS.md | 55 ++++--- README.md | 20 ++- less/anonymous.go | 34 ++--- less/combinator.go | 22 +-- less/declaration.go | 27 ++-- less/dimension.go | 23 ++- less/element.go | 9 +- less/expression.go | 21 ++- less/index.go | 167 +++++++++++---------- less/keyword.go | 14 +- less/node.go | 38 ++--- less/node_pools.go | 5 + less/parser.go | 59 +++----- less/parser_input.go | 205 ++++++++++++++++++++++---- less/parser_input_test.go | 89 +++++++++-- less/public_compile_benchmark_test.go | 38 +++++ less/ruleset.go | 26 ++-- less/selector.go | 11 +- less/unit.go | 4 +- less/value.go | 10 +- less/visitor.go | 124 +++++++++------- package.json | 2 + 22 files changed, 631 insertions(+), 372 deletions(-) create mode 100644 less/public_compile_benchmark_test.go diff --git a/BENCHMARKS.md b/BENCHMARKS.md index c6bebb3ec..83bfd6ea3 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -59,9 +59,9 @@ Go warm benchmarked: 212, Go cold benchmarked: 212 ├─────────────────────────────────────────────────────────────────────────────┤ │ │ JavaScript │ Go │ Difference │ ├────────────────────┼──────────────┼──────────────┼──────────────────────────┤ -│ Per File (avg) │ 880.46µs │ 712.86µs │ Similar (~19%) │ -│ Per File (median) │ 511.25µs │ 553.33µs │ Similar (~8.2%) │ -│ All Files (total) │ 185.78ms │ 151.13ms │ Similar (~18.7%) │ +│ Per File (avg) │ 799.19µs │ 505.55µs │ Go 1.6x faster │ +│ Per File (median) │ 483.42µs │ 385.98µs │ Go 1.3x faster │ +│ All Files (total) │ 168.63ms │ 107.18ms │ Go 1.6x faster │ └────────────────────┴──────────────┴──────────────┴──────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ @@ -69,29 +69,31 @@ Go warm benchmarked: 212, Go cold benchmarked: 212 ├─────────────────────────────────────────────────────────────────────────────┤ │ │ JavaScript │ Go │ Difference │ ├────────────────────┼──────────────┼──────────────┼──────────────────────────┤ -│ Per File (avg) │ 457.09µs │ 667.49µs │ Go 1.5x slower │ -│ Per File (median) │ 288.08µs │ 497.13µs │ Go 1.7x slower │ -│ All Files (total) │ 96.90ms │ 141.51ms │ Go 1.5x slower │ +│ Per File (avg) │ 402.75µs │ 476.68µs │ Similar (~18.4%) │ +│ Per File (median) │ 272.10µs │ 363.09µs │ Go 1.3x slower │ +│ All Files (total) │ 85.38ms │ 101.06ms │ Similar (~18.4%) │ └────────────────────┴──────────────┴──────────────┴──────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ MEMORY & ALLOCATIONS (Go only, averaged per file) │ ├─────────────────────────────────────────────────────────────────────────────┤ -│ Memory per file: 0.34 MB │ -│ Allocations per file: 6,451 allocations │ +│ Memory per file: 0.30 MB │ +│ Allocations per file: 5,408 allocations │ └─────────────────────────────────────────────────────────────────────────────┘ 🔥 WARM PERFORMANCE (primary comparison metric): - 🐌 Go is 1.5x slower than JavaScript (warm) + ⚖️ Warm performance is similar (within 20%) 🥶 COLD START PERFORMANCE: - ⚖️ Cold-start performance is SIMILAR (within 20%) + 🚀 Go is 1.6x faster than JavaScript (cold start) 📈 WARMUP EFFECT: - JavaScript: 48.1% faster after warmup - Go: 6.4% faster after warmup + JavaScript: 49.6% faster after warmup + Go: 5.7% faster after warmup ``` +The sample above was measured on July 15, 2026 on an Apple M1 Max with Go 1.24.5 and Node.js 24.4.1. Benchmark results are machine-dependent; compare changes on the same idle system. + ### Run Individual Benchmarks ```bash @@ -124,6 +126,8 @@ pnpm bench:go:suite | `pnpm bench:go` | Per-file warm benchmarks (with 5 warmup runs) | | `pnpm bench:go:cold` | Per-file cold-start benchmarks (no warmup) | | `pnpm bench:go:suite` | Suite mode: All files sequentially, 30x with warmup | +| `pnpm bench:go:suite:gc200` | Suite mode with a throughput-oriented Go GC target | +| `pnpm bench:go:public` | Main exported `Compile` API | ## Benchmark Methodologies @@ -131,7 +135,7 @@ pnpm bench:go:suite **Simulates actual CLI/build tool usage** - **What it does**: Runs 30 independent processes, each compiling all files once - **JavaScript**: 30 separate `node` processes -- **Go**: 30 independent benchmark iterations (fresh factory each time) +- **Go**: 30 separate executions of a precompiled Go benchmark binary - **Use case**: Measuring real-world build tool performance - **Process behavior**: Each iteration = fresh process start → compile all 212 benchmarked files → exit - **No warmup**: Each build is independent, like real CLI usage @@ -147,6 +151,18 @@ Build 30: Start process → [file1, file2, ..., file212] → Exit **Why this matters**: Real-world CLI tools don't benefit from JIT warmup or in-process caching. Each build starts fresh. +### Go GC Throughput Tuning + +The suite allocates about 65 MB while compiling 212 files, but most objects die quickly. On the measured system, Go's default `GOGC=100` ran roughly 553 GC cycles during a 15-build benchmark. Increasing the heap-growth target produced the following same-process results: + +| Setting | Median suite time | GC cycles | Maximum observed heap goal | +|---------|------------------:|----------:|---------------------------:| +| `GOGC=100` | 112.07ms | 553 | 10 MB | +| `GOGC=200` | 96.78ms | 193 | 15 MB | +| `GOGC=400` | 91.34ms | 76 | 27 MB | + +The warmed Node.js suite median was 102.57ms in the same session. `GOGC=200` is therefore the recommended starting point for throughput-oriented services. This is a process-wide Go runtime setting, so less.go deliberately does not change it inside the library. Re-measure under the application's actual memory limit and concurrency. + ### Per-File Mode (Good for Optimization Work) **Measures JIT optimization potential** - **What it does**: Compiles each file 30 times individually (5 warmup + 25 measured) @@ -316,9 +332,8 @@ The comparison script (`pnpm bench:compare`) runs both warm and cold-start bench - JavaScript's JIT needs warmup to reach peak performance, Go doesn't - Both metrics matter: warm for long-running processes, cold for CLI tools -**Q: Why is Go currently 2.1x slower (warm)?** -**A: Primarily allocations (~10,300 per file) and reflection usage.** The port has been significantly optimized but still has room for improvement. Recent optimizations have reduced allocations by ~78% and improved speed by ~4x. See detailed analysis: -- 📄 [`.claude/benchmarks/PERFORMANCE_ANALYSIS.md`](./.claude/benchmarks/PERFORMANCE_ANALYSIS.md) +**Q: Why does warmed V8 still win the isolated per-file benchmark?** +**A: Allocation and garbage-collection work now dominate the remaining gap.** The current suite allocates about 5,400 objects per file. The aggregate same-process gap is smaller than the isolated-file gap, and a measured `GOGC=200` setting closes it when the application can accept a modestly larger heap target. **Q: How can I find the bottlenecks?** **A: Use profiling:** @@ -329,13 +344,7 @@ pnpm bench:profile This will show CPU hot spots, memory allocations, and allocation hotspots. **Q: Is this performance acceptable?** -**A: Yes, and improving rapidly!** Recent optimizations (#229-#233) have achieved: -- ✅ **78% reduction** in memory allocations (47k → 10.3k per file) -- ✅ **4x performance improvement** (8.1x slower → 2.1x slower warm) -- ✅ **Cold-start parity** with JavaScript (actually slightly faster!) -- ✅ **80+ tests passing** with identical CSS output - -With continued targeted optimization, Go can match or exceed JavaScript warm performance while maintaining its cold-start advantage. +**A: Yes.** The fresh-process build benchmark is 1.8x faster than Less.js, cold per-file compilation is 1.6x faster, and the peak-JIT isolated-file gap is down to 18%. For long-lived throughput-oriented processes, `GOGC=200` moved the aggregate Go suite slightly ahead of the warmed Node suite in local testing. ## Contributing diff --git a/README.md b/README.md index 7fb252fed..7d03e62e6 100644 --- a/README.md +++ b/README.md @@ -121,23 +121,27 @@ result, err := less.Compile(source, &less.CompileOptions{ ## Performance -less.go provides native binary performance without requiring a JavaScript runtime. Current benchmarks cover 212 integration files and use Less.js v4.4.2 as the comparison point: +less.go provides native binary performance without requiring a JavaScript runtime. Current benchmarks cover 212 integration files and use Less.js v4.4.2 as the comparison point. These results were measured on an Apple M1 Max with Go 1.24.5 and Node.js 24.4.1: | Metric | Less.js | less.go | Difference | |--------|---------|---------|------------| -| Cold per-file average | 880.46µs | 712.86µs | Similar, Go ~19% faster | -| Warm per-file average | 457.09µs | 667.49µs | Go ~1.46x slower | -| Warm all-files total | 96.90ms | 141.51ms | Go ~1.46x slower | -| Memory per file | - | 0.34 MB | ~6,451 allocations | +| Fresh-process full build | 207.08ms | 117.46ms | Go 1.8x faster | +| Cold per-file average | 799.19µs | 505.55µs | Go 1.6x faster | +| Warm per-file average | 402.75µs | 476.68µs | Go 1.18x slower | +| Memory per file | - | 0.30 MB | ~5,408 allocations | -- **Cold-start remains competitive** - No JavaScript runtime required for core functionality -- **Warm Less.js JIT is still faster** - `pnpm bench:compare` is the parity gate for optimizer work -- **Native binary** - No JavaScript runtime needed for core functionality +- **CLI/build workloads are faster in Go** - The fresh-process 212-file workload is 1.8x faster than Less.js +- **Peak-JIT microbenchmarks are close** - V8 retains an 18% lead when each file is compiled repeatedly in isolation +- **GC tuning closes the aggregate warm gap** - `GOGC=200` reduced the same-process Go suite from about 112ms to 97ms in local testing, slightly ahead of the 103ms warmed Node suite, at the cost of a larger heap target + +`GOGC` is process-wide, so the library does not change it automatically. Throughput-oriented services can start with `GOGC=200`, measure their own memory envelope, and consider `GOGC=400` only when latency matters more than memory. Run benchmarks yourself: ```bash pnpm bench:compare # Warm/cold per-file comparison across 212 files pnpm bench:compare:suite # Realistic full-suite comparison +pnpm bench:go:public # Main exported Compile API +pnpm bench:go:suite:gc200 # Suite with the throughput-oriented GC setting ``` ## Features diff --git a/less/anonymous.go b/less/anonymous.go index 717a621c9..1e298cbe0 100644 --- a/less/anonymous.go +++ b/less/anonymous.go @@ -7,27 +7,27 @@ import ( type Anonymous struct { *Node - Value any - Index int - FileInfo map[string]any - MapLines bool + nodeStorage Node + Value any + Index int + FileInfo map[string]any + MapLines bool RulesetLike bool - AllowRoot bool + AllowRoot bool } func NewAnonymous(value any, index int, fileInfo map[string]any, mapLines bool, rulesetLike bool, visibilityInfo map[string]any) *Anonymous { - node := NewNode() + anon := &Anonymous{} + node := initEmbeddedNode(&anon.nodeStorage) node.TypeIndex = GetTypeIndexForNodeType("Anonymous") - anon := &Anonymous{ - Node: node, - Value: value, - Index: index, - FileInfo: fileInfo, - MapLines: mapLines, - RulesetLike: rulesetLike, - AllowRoot: true, - } + anon.Node = node + anon.Value = value + anon.Index = index + anon.FileInfo = fileInfo + anon.MapLines = mapLines + anon.RulesetLike = rulesetLike + anon.AllowRoot = true if visibilityInfo != nil { if blocks, ok := visibilityInfo["visibilityBlocks"].(int); ok { anon.VisibilityBlocks = &blocks @@ -75,7 +75,7 @@ func (a *Anonymous) Compare(other any) any { return 0 } } - + return nil } @@ -185,4 +185,4 @@ func (a *Anonymous) CopyVisibilityInfo(info map[string]any) { if visible, ok := info["nodeVisible"].(bool); ok { a.NodeVisible = &visible } -} \ No newline at end of file +} diff --git a/less/combinator.go b/less/combinator.go index 0ad4ed0fb..815d99cb9 100644 --- a/less/combinator.go +++ b/less/combinator.go @@ -8,14 +8,14 @@ var NoSpaceCombinators = map[string]bool{ type Combinator struct { *Node - Value string + nodeStorage Node + Value string EmptyOrWhitespace bool } func NewCombinator(value string) *Combinator { - c := &Combinator{ - Node: NewNode(), - } + c := &Combinator{} + c.Node = initEmbeddedNode(&c.nodeStorage) if value == " " { c.Value = " " @@ -35,7 +35,7 @@ func trimWhitespace(s string) string { if s == "" { return s } - + runes := []rune(s) start := 0 end := len(runes) - 1 @@ -47,20 +47,20 @@ func trimWhitespace(s string) string { for end >= start && isJSWhitespace(runes[end]) { end-- } - + if start > end { return "" } - + return string(runes[start : end+1]) } func isJSWhitespace(r rune) bool { switch r { case '\t', '\n', '\v', '\f', '\r', ' ', // ASCII whitespace - 0x00A0, // NO-BREAK SPACE - 0x1680, // OGHAM SPACE MARK - 0x180E, // MONGOLIAN VOWEL SEPARATOR + 0x00A0, // NO-BREAK SPACE + 0x1680, // OGHAM SPACE MARK + 0x180E, // MONGOLIAN VOWEL SEPARATOR 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200A, // Various spaces 0x2028, // LINE SEPARATOR 0x2029, // PARAGRAPH SEPARATOR @@ -96,4 +96,4 @@ func (c *Combinator) GenCSS(context any, output *CSSOutput) { spaceOrEmpty = " " } output.Add(spaceOrEmpty+c.Value+spaceOrEmpty, nil, nil) -} \ No newline at end of file +} diff --git a/less/declaration.go b/less/declaration.go index 6581ef4f9..ccd40ee4f 100644 --- a/less/declaration.go +++ b/less/declaration.go @@ -8,20 +8,20 @@ import ( type Declaration struct { *Node - name any - Value *Value - important string - merge any // Can be bool or string ('+' for comma merge) - inline bool - variable bool + nodeStorage Node + name any + Value *Value + important string + merge any // Can be bool or string ('+' for comma merge) + inline bool + variable bool } -// Uses sync.Pool to reuse Declaration objects. func NewDeclaration(name any, value any, important any, merge any, index int, fileInfo map[string]any, inline bool, variable any) (*Declaration, error) { - node := NewNode() + d := &Declaration{} + node := initEmbeddedNode(&d.nodeStorage) node.TypeIndex = GetTypeIndexForNodeType("Declaration") - d := GetDeclarationFromPool() d.Node = node // Intern property names when they are strings (most common case) if nameStr, ok := name.(string); ok { @@ -63,7 +63,7 @@ func NewDeclaration(name any, value any, important any, merge any, index int, fi // Has GetType method, likely a node isNode = true } - + if isNode { // Value is already a Node, wrap it in Value([node]) newValue, err := NewValue([]any{value}) @@ -451,7 +451,7 @@ func (d *Declaration) GenCSS(context any, output *CSSOutput) { default: errMsg = fmt.Sprintf("%v", e) } - + // Create an error with index and filename similar to JavaScript filename := "" if d.FileInfo() != nil { @@ -459,7 +459,7 @@ func (d *Declaration) GenCSS(context any, output *CSSOutput) { filename = f } } - + // Re-panic with enhanced error message panic(fmt.Errorf("%s (index: %d, filename: %s)", errMsg, d.GetIndex(), filename)) } @@ -497,7 +497,6 @@ func (d *Declaration) GenCSS(context any, output *CSSOutput) { } } - func isLastRule(context any) bool { if ctx, ok := context.(map[string]any); ok { if lastRule, ok := ctx["lastRule"].(bool); ok { @@ -691,4 +690,4 @@ func (d *Declaration) valueToCSS(context any) string { // GenCSSSourceMap implements the SourceMapNode interface func (d *Declaration) GenCSSSourceMap(context map[string]any, output *SourceMapOutput) { d.genCSSSourceMapImpl(context, output) -} \ No newline at end of file +} diff --git a/less/dimension.go b/less/dimension.go index 78f7f60ee..00b65c143 100644 --- a/less/dimension.go +++ b/less/dimension.go @@ -11,8 +11,9 @@ import ( type Dimension struct { *Node - Value float64 - Unit *Unit + nodeStorage Node + Value float64 + Unit *Unit } func NewDimension(value any, unit any) (*Dimension, error) { @@ -58,11 +59,8 @@ func NewDimension(value any, unit any) (*Dimension, error) { } } - d := &Dimension{ - Node: NewNode(), - Value: v, - Unit: u, - } + d := &Dimension{Value: v, Unit: u} + d.Node = initEmbeddedNode(&d.nodeStorage) d.SetParent(d.Unit, d.Node) return d, nil } @@ -72,11 +70,8 @@ func NewDimensionFrom(value float64, unit *Unit) *Dimension { if math.IsNaN(value) { return nil } - d := &Dimension{ - Node: NewNode(), - Value: value, - Unit: unit, - } + d := &Dimension{Value: value, Unit: unit} + d.Node = initEmbeddedNode(&d.nodeStorage) d.SetParent(d.Unit, d.Node) return d } @@ -308,7 +303,7 @@ func (d *Dimension) Compare(other any) *int { } func (d *Dimension) Unify() *Dimension { - conv := map[string]any{ "length": "px", "duration": "s", "angle": "rad" } + conv := map[string]any{"length": "px", "duration": "s", "angle": "rad"} return d.ConvertTo(conv) } @@ -369,4 +364,4 @@ func (d *Dimension) ConvertTo(conversions any) *Dimension { unit.Cancel() return NewDimensionFrom(value, unit) -} \ No newline at end of file +} diff --git a/less/element.go b/less/element.go index e9b01c38c..bae232372 100644 --- a/less/element.go +++ b/less/element.go @@ -7,6 +7,7 @@ import ( type Element struct { *Node + nodeStorage Node Combinator *Combinator Value any IsVariable bool @@ -75,8 +76,8 @@ func NewElement(combinator any, value any, isVariable bool, index int, currentFi val = v } - e := GetElementFromPool() - e.Node = NewNode() + e := &Element{} + e.Node = initEmbeddedNode(&e.nodeStorage) e.Combinator = comb e.Value = val e.IsVariable = isVariable @@ -200,7 +201,7 @@ func (e *Element) Eval(context any) (any, error) { } break } - endUnwrap: +endUnwrap: // Handle potential nil Node // OPTIMIZATION: Only allocate maps when e.Node is nil. @@ -316,4 +317,4 @@ func (e *Element) ToCSS(context any) string { builder.WriteString(valueCSS) return builder.String() -} \ No newline at end of file +} diff --git a/less/expression.go b/less/expression.go index 2092a68db..09b91902f 100644 --- a/less/expression.go +++ b/less/expression.go @@ -8,10 +8,11 @@ import ( type Expression struct { *Node - Value []any - NoSpacing bool - Parens bool - ParensInOp bool + nodeStorage Node + Value []any + NoSpacing bool + Parens bool + ParensInOp bool } func NewExpression(value []any, noSpacing bool) (*Expression, error) { @@ -19,11 +20,11 @@ func NewExpression(value []any, noSpacing bool) (*Expression, error) { return nil, fmt.Errorf("Expression requires an array parameter") } - e := GetExpressionFromPool() - e.Node = NewNode() + e := &Expression{} + e.Node = initEmbeddedNode(&e.nodeStorage) e.NoSpacing = noSpacing - // Copy value to pooled slice + // Keep a private copy because evaluation may replace elements in place. if cap(e.Value) < len(value) { e.Value = make([]any, len(value)) } else { @@ -124,11 +125,10 @@ func (e *Expression) Eval(context any) (any, error) { newValues[i] = nil continue } - - + newValues[i] = SafeEval(val, context) } - + expr, _ := NewExpression(newValues, e.NoSpacing) returnValue = expr } else if len(e.Value) == 1 { @@ -337,4 +337,3 @@ func (e *Expression) GetType() string { func (e *Expression) GetValue() []any { return e.Value } - \ No newline at end of file diff --git a/less/index.go b/less/index.go index 0134eaf37..2fbd78b8c 100644 --- a/less/index.go +++ b/less/index.go @@ -6,6 +6,7 @@ import ( "runtime" "strconv" "strings" + "sync" ) func Factory(environment map[string]any, fileManagers []any) map[string]any { @@ -201,6 +202,8 @@ func createParseTree(sourceMapBuilder any) any { } func createRender(env any, parseTree any, importManager any) func(string, ...any) any { + functionsObj := createFunctions(env) + return func(input string, args ...any) (result any) { defer func() { if r := recover(); r != nil { @@ -318,8 +321,6 @@ func createRender(env any, parseTree any, importManager any) func(string, ...any parseTreeFactory := DefaultParseTreeFactory(nil) parseTreeInstance := parseTreeFactory.NewParseTree(root, imports) - functionsObj := createFunctions(env) - toCSSOptions := &ToCSSOptions{ Compress: false, StrictUnits: false, @@ -551,90 +552,98 @@ func createParser() any { return map[string]any{"type": "Parser"} } +var ( + builtInFunctionsOnce sync.Once + builtInFunctionsRegistry *Registry +) + func createFunctions(env any) any { - registry := DefaultRegistry.Inherit() - - RegisterTestFunctions(registry) - - listFunctions := GetWrappedListFunctions() - for name, fn := range listFunctions { - switch name { - case "_SELF": - if selfFn, ok := fn.(func(any) any); ok { - registry.Add(name, &FlexibleFunctionDef{ - name: name, - minArgs: 1, - maxArgs: 1, - variadic: false, - fn: selfFn, - needsEval: true, - }) - } - case "~": - if spaceFn, ok := fn.(func(...any) any); ok { - registry.Add(name, &FlexibleFunctionDef{ - name: name, - minArgs: 0, - maxArgs: -1, - variadic: true, - fn: spaceFn, - needsEval: true, - }) - } - case "range": - if rangeFn, ok := fn.(func(any, any, any) *Expression); ok { - // Wrap the function to match the expected signature - registry.Add(name, &FlexibleFunctionDef{ - name: name, - minArgs: 1, - maxArgs: 3, - variadic: false, - fn: func(args ...any) any { - var start, end, step any - if len(args) > 0 { - start = args[0] - } - if len(args) > 1 { - end = args[1] - } - if len(args) > 2 { - step = args[2] - } - return rangeFn(start, end, step) - }, - needsEval: true, - }) - } - case "each": - // Register the EachFunctionDef which supports context - registry.Add(name, &EachFunctionDef{ - name: name, - }) - default: - // Try as 2-argument function (extract, length) - if functionImpl, ok := fn.(func(any, any) any); ok { - registry.Add(name, &SimpleFunctionDef{ + builtInFunctionsOnce.Do(func() { + registry := DefaultRegistry.Inherit() + + RegisterTestFunctions(registry) + + listFunctions := GetWrappedListFunctions() + for name, fn := range listFunctions { + switch name { + case "_SELF": + if selfFn, ok := fn.(func(any) any); ok { + registry.Add(name, &FlexibleFunctionDef{ + name: name, + minArgs: 1, + maxArgs: 1, + variadic: false, + fn: selfFn, + needsEval: true, + }) + } + case "~": + if spaceFn, ok := fn.(func(...any) any); ok { + registry.Add(name, &FlexibleFunctionDef{ + name: name, + minArgs: 0, + maxArgs: -1, + variadic: true, + fn: spaceFn, + needsEval: true, + }) + } + case "range": + if rangeFn, ok := fn.(func(any, any, any) *Expression); ok { + // Wrap the function to match the expected signature + registry.Add(name, &FlexibleFunctionDef{ + name: name, + minArgs: 1, + maxArgs: 3, + variadic: false, + fn: func(args ...any) any { + var start, end, step any + if len(args) > 0 { + start = args[0] + } + if len(args) > 1 { + end = args[1] + } + if len(args) > 2 { + step = args[2] + } + return rangeFn(start, end, step) + }, + needsEval: true, + }) + } + case "each": + // Register the EachFunctionDef which supports context + registry.Add(name, &EachFunctionDef{ name: name, - fn: functionImpl, }) + default: + // Try as 2-argument function (extract, length) + if functionImpl, ok := fn.(func(any, any) any); ok { + registry.Add(name, &SimpleFunctionDef{ + name: name, + fn: functionImpl, + }) + } } } - } - - registry.AddMultiple(GetWrappedStringFunctions()) - registry.AddMultiple(GetWrappedMathFunctions()) - registry.AddMultiple(GetWrappedNumberFunctions()) - registry.AddMultiple(GetWrappedBooleanFunctions()) - registry.AddMultiple(GetWrappedSvgFunctions()) - registry.AddMultiple(GetWrappedStyleFunctions()) - registry.AddMultiple(GetWrappedDataURIFunctions()) - registry.AddMultiple(GetWrappedColorBlendingFunctions()) - registry.AddMultiple(GetWrappedColorFunctions()) - registry.AddMultiple(GetWrappedTypesFunctions()) - registry.Add("default", &DefaultFunctionDefinition{}) + registry.AddMultiple(GetWrappedStringFunctions()) + registry.AddMultiple(GetWrappedMathFunctions()) + registry.AddMultiple(GetWrappedNumberFunctions()) + registry.AddMultiple(GetWrappedBooleanFunctions()) + registry.AddMultiple(GetWrappedSvgFunctions()) + registry.AddMultiple(GetWrappedStyleFunctions()) + registry.AddMultiple(GetWrappedDataURIFunctions()) + registry.AddMultiple(GetWrappedColorBlendingFunctions()) + registry.AddMultiple(GetWrappedColorFunctions()) + registry.AddMultiple(GetWrappedTypesFunctions()) + + registry.Add("default", &DefaultFunctionDefinition{}) + builtInFunctionsRegistry = registry + }) - return &DefaultFunctions{registry: registry} + return &DefaultFunctions{registry: builtInFunctionsRegistry.Inherit()} } func createContexts() any { diff --git a/less/keyword.go b/less/keyword.go index 254a431c8..3dcaf2d80 100644 --- a/less/keyword.go +++ b/less/keyword.go @@ -7,17 +7,15 @@ import ( type Keyword struct { *Node - value string - type_ string + nodeStorage Node + value string + type_ string } func NewKeyword(value string) *Keyword { internedValue := Intern(value) - k := &Keyword{ - Node: NewNode(), - value: internedValue, - type_: "Keyword", - } + k := &Keyword{value: internedValue, type_: "Keyword"} + k.Node = initEmbeddedNode(&k.nodeStorage) k.Value = internedValue return k } @@ -75,4 +73,4 @@ func (k *Keyword) Eval(context any) (any, error) { var ( KeywordTrue = NewKeyword("true") KeywordFalse = NewKeyword("false") -) \ No newline at end of file +) diff --git a/less/node.go b/less/node.go index 9005a17bf..9784317ea 100644 --- a/less/node.go +++ b/less/node.go @@ -8,17 +8,18 @@ import ( // Node represents a base node in the Less AST type Node struct { - Parent *Node + Parent *Node VisibilityBlocks *int - NodeVisible *bool - RootNode *Node - Parsed any - Value any - Index int - fileInfo map[string]any - Parens bool - ParensInOp bool - TypeIndex int // Index for visitor pattern lookup + NodeVisible *bool + RootNode *Node + Parsed any + Value any + Index int + fileInfo map[string]any + Parens bool + ParensInOp bool + embedded bool + TypeIndex int // Index for visitor pattern lookup } // GenCSSSourceMap implements SourceMapNode interface for Node @@ -27,10 +28,13 @@ func (n *Node) GenCSSSourceMap(context map[string]any, output *SourceMapOutput) } // NewNode creates a new Node instance. -// OPTIMIZATION: Uses sync.Pool to reuse Node objects and reduce GC pressure. -// Call ReleaseNode when the Node is no longer needed to return it to the pool. func NewNode() *Node { - return GetNodeFromPool() + return &Node{} +} + +func initEmbeddedNode(node *Node) *Node { + node.embedded = true + return node } // SetParent sets the parent for one or more nodes @@ -380,12 +384,12 @@ func NumericCompareStrings(a, b string) int { // Try to parse as numbers first aNum, aErr := strconv.ParseFloat(a, 64) bNum, bErr := strconv.ParseFloat(b, 64) - + if aErr == nil && bErr == nil { // Both are numbers return NumericCompare(aNum, bNum) } - + // Fall back to string comparison if a < b { return -1 @@ -457,7 +461,7 @@ func (n *Node) ClearVisibilityBlocks() { func (n *Node) VisibilityInfo() map[string]any { return map[string]any{ "visibilityBlocks": n.VisibilityBlocks, - "nodeVisible": n.NodeVisible, + "nodeVisible": n.NodeVisible, } } @@ -472,4 +476,4 @@ func (n *Node) CopyVisibilityInfo(info map[string]any) { if visible, ok := info["nodeVisible"].(*bool); ok { n.NodeVisible = visible } -} \ No newline at end of file +} diff --git a/less/node_pools.go b/less/node_pools.go index cea815460..0073a0e8b 100644 --- a/less/node_pools.go +++ b/less/node_pools.go @@ -67,6 +67,7 @@ func ReleaseNode(n *Node) { if n == nil { return } + embedded := n.embedded n.Parent = nil n.VisibilityBlocks = nil n.NodeVisible = nil @@ -78,6 +79,10 @@ func ReleaseNode(n *Node) { n.Parens = false n.ParensInOp = false n.TypeIndex = 0 + if embedded { + n.embedded = true + return + } nodePool.Put(n) } diff --git a/less/parser.go b/less/parser.go index 7e21da861..962cec7fb 100644 --- a/less/parser.go +++ b/less/parser.go @@ -1579,9 +1579,11 @@ func (e *EntityParsers) Keyword() any { k := e.parsers.parser.parserInput.Char('%') if k == nil { - k = e.parsers.parser.parserInput.Re(reEntityName) + if entityName := e.parsers.parser.parserInput.matchEntityName(); entityName != "" { + k = entityName + } if tracerEnabled { - tracer.TraceMode("Keyword: after regex", fmt.Sprintf("matched=%v, value=%v", k != nil, k), e.parsers.parser) + tracer.TraceMode("Keyword: after entity name", fmt.Sprintf("matched=%v, value=%v", k != nil, k), e.parsers.parser) } } @@ -1692,24 +1694,17 @@ func (e *EntityParsers) Color() any { func (e *EntityParsers) ColorKeyword() any { e.parsers.parser.parserInput.Save() // Note: autoCommentAbsorb access would need to be implemented in ParserInput - k := e.parsers.parser.parserInput.Re(reIdentifier) + k := e.parsers.parser.parserInput.matchIdentifier() - if k == nil { + if k == "" { e.parsers.parser.parserInput.Forget() return nil } e.parsers.parser.parserInput.Restore("") - var kStr string - if matches, ok := k.([]string); ok { - kStr = matches[0] - } else if match, ok := k.(string); ok { - kStr = match - } - - color := FromKeyword(kStr) + color := FromKeyword(k) if color != nil { - e.parsers.parser.parserInput.Str(kStr) + e.parsers.parser.parserInput.Str(k) return color } return nil @@ -1736,15 +1731,8 @@ func (e *EntityParsers) Dimension() any { // UnicodeDescriptor parses unicode descriptors - U+0?? or U+00A1-00A9 func (e *EntityParsers) UnicodeDescriptor() any { - ud := e.parsers.parser.parserInput.Re(reUnicodeRange) - if ud != nil { - var udStr string - if matches, ok := ud.([]string); ok { - udStr = matches[0] - } else if match, ok := ud.(string); ok { - udStr = match - } - return NewUnicodeDescriptor(udStr) + if ud := e.parsers.parser.parserInput.matchUnicodeRange(); ud != "" { + return NewUnicodeDescriptor(ud) } return nil } @@ -3425,35 +3413,22 @@ func (e *EntityParsers) Call() any { return nil } - e.parsers.parser.parserInput.Save() - if tracer.IsEnabled() { - tracer.TraceSaveRestore("Save", "Call", e.parsers.parser) - } - - nameMatch := e.parsers.parser.parserInput.Re(reFunctionName) + name, nameLength := e.parsers.parser.parserInput.PeekFunctionName() if tracer.IsEnabled() { - tracer.TraceRegex("Call", "^([\\w-]+|%|~|progid:[\\w.]+)\\(", nameMatch != nil, nameMatch) + tracer.TraceRegex("Call", "^([\\w-]+|%|~|progid:[\\w.]+)\\(", nameLength != 0, name) } - if nameMatch == nil { - e.parsers.parser.parserInput.Forget() + if nameLength == 0 { if tracer.IsEnabled() { - tracer.TraceSaveRestore("Forget", "Call", e.parsers.parser) tracer.TraceResult("Call", nil, "no function name matched") } return nil } - // Extract the function name from the captured group - if matches, ok := nameMatch.([]string); ok && len(matches) > 1 { - name = matches[1] - } else { - e.parsers.parser.parserInput.Forget() - if tracer.IsEnabled() { - tracer.TraceSaveRestore("Forget", "Call", e.parsers.parser) - tracer.TraceResult("Call", nil, "failed to extract function name") - } - return nil + e.parsers.parser.parserInput.Save() + if tracer.IsEnabled() { + tracer.TraceSaveRestore("Save", "Call", e.parsers.parser) } + e.parsers.parser.parserInput.advance(nameLength) function = e.CustomFuncCall(name) if funcMap, ok := function.(map[string]any); ok { diff --git a/less/parser_input.go b/less/parser_input.go index 3f4ee7e86..9233b22a0 100644 --- a/less/parser_input.go +++ b/less/parser_input.go @@ -2,22 +2,24 @@ package less_go import ( "regexp" + "strings" + "unicode/utf8" ) // ParserInput represents the parser input state type ParserInput struct { - input string - i int - j int - current string - currentPos int - furthest int + input string + i int + j int + current string + currentPos int + furthest int furthestPossibleErrorMessage string - chunks []string - saveStack []saveState - autoCommentAbsorb bool - commentStore []inputComment - finished bool + chunks []string + saveStack []saveState + autoCommentAbsorb bool + commentStore []inputComment + finished bool } type saveState struct { @@ -29,8 +31,8 @@ type saveState struct { // inputComment represents a parsed comment in the input type inputComment struct { - index int - text string + index int + text string isLineComment bool } @@ -71,10 +73,10 @@ func NewParserInput() *ParserInput { func (p *ParserInput) skipWhitespace(length int) bool { oldi := p.i - curr := oldi - p.currentPos // Calculate current position within the chunk *before* advancing + curr := oldi - p.currentPos // Calculate current position within the chunk *before* advancing endIndex := oldi + len(p.current) - curr // Calculate end index relative to *start* of skipWhitespace - mem := oldi + length // Target index after initial length advancement - p.i = mem // Advance p.i by initial length + mem := oldi + length // Target index after initial length advancement + p.i = mem // Advance p.i by initial length inp := p.input var c byte @@ -153,7 +155,7 @@ func (p *ParserInput) skipWhitespace(length int) bool { // Sync current field after advancing p.syncCurrent() - + return p.i > oldi } @@ -207,17 +209,162 @@ func (p *ParserInput) Re(tok *regexp.Regexp) any { if match == nil { return nil } - matchLen := len(match[0]) p.i += matchLen p.skipWhitespace(0) - p.syncCurrent() // Sync current field + p.syncCurrent() // Sync current field if len(match) == 1 { return match[0] } return match } +// PeekFunctionName recognizes the function-name prefix consumed by Call without +// invoking the regular-expression engine. It returns the name and the byte +// length including the opening parenthesis, but does not advance the parser. +func (p *ParserInput) PeekFunctionName() (string, int) { + if p.i >= len(p.input) { + return "", 0 + } + + input := p.input[p.i:] + if len(input) >= 2 && (input[0] == '%' || input[0] == '~') && input[1] == '(' { + return input[:1], 2 + } + + if strings.HasPrefix(input, "progid:") { + end := len("progid:") + for end < len(input) && (isASCIIWord(input[end]) || input[end] == '.') { + end++ + } + if end > len("progid:") && end < len(input) && input[end] == '(' { + return input[:end], end + 1 + } + } + + end := 0 + for end < len(input) && (isASCIIWord(input[end]) || input[end] == '-') { + end++ + } + if end > 0 && end < len(input) && input[end] == '(' { + return input[:end], end + 1 + } + return "", 0 +} + +func isASCIIWord(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' +} + +func isASCIIHex(c byte) bool { + return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' +} + +func (p *ParserInput) consumeMatch(length int) string { + start := p.i + match := p.input[start : start+length] + p.advance(length) + return match +} + +// matchEntityName recognizes the same token as reEntityName without creating +// regexp match state. CSS escapes consume either 1-6 hex digits plus an +// optional space, or one non-hex rune. +func (p *ParserInput) matchEntityName() string { + if p.i >= len(p.input) { + return "" + } + + input := p.input[p.i:] + end := 0 + if input[0] == '[' { + end++ + } + tokenStart := end + + for end < len(input) { + c := input[end] + if isASCIIWord(c) || c == '-' { + end++ + continue + } + if c != '\\' || end+1 >= len(input) { + break + } + + escapeStart := end + 1 + if isASCIIHex(input[escapeStart]) { + end = escapeStart + 1 + for end < len(input) && end-escapeStart < 6 && isASCIIHex(input[end]) { + end++ + } + if end < len(input) && input[end] == ' ' { + end++ + } + continue + } + + _, size := utf8.DecodeRuneInString(input[escapeStart:]) + end = escapeStart + size + } + + if end == tokenStart { + return "" + } + if end < len(input) && input[end] == ']' { + end++ + } + return p.consumeMatch(end) +} + +func (p *ParserInput) matchIdentifier() string { + if p.i >= len(p.input) { + return "" + } + input := p.input[p.i:] + if !(input[0] == '_' || input[0] == '-' || input[0] >= 'a' && input[0] <= 'z' || input[0] >= 'A' && input[0] <= 'Z') { + return "" + } + + end := 1 + for end < len(input) && (isASCIIWord(input[end]) || input[end] == '-') { + end++ + } + if end < 2 { + return "" + } + return p.consumeMatch(end) +} + +func (p *ParserInput) matchUnicodeRange() string { + input := p.input[p.i:] + if len(input) < 3 || input[0] != 'U' || input[1] != '+' { + return "" + } + + end := 2 + for end < len(input) && (isASCIIHex(input[end]) || input[end] == '?') { + end++ + } + if end == 2 { + return "" + } + + if end+1 < len(input) && input[end] == '-' && (isASCIIHex(input[end+1]) || input[end+1] == '?') { + end += 2 + for end < len(input) && (isASCIIHex(input[end]) || input[end] == '?') { + end++ + } + } + return p.consumeMatch(end) +} + +func (p *ParserInput) advance(length int) { + p.i += length + p.skipWhitespace(0) + p.syncCurrent() +} + func (p *ParserInput) Char(tok byte) any { if char, ok := SafeStringIndex(p.input, p.i); !ok || char != tok { return nil @@ -256,7 +403,7 @@ func (p *ParserInput) Str(tok string) any { p.i += tokLength p.skipWhitespace(0) - p.syncCurrent() // Sync current field + p.syncCurrent() // Sync current field return tok } @@ -493,11 +640,11 @@ func (p *ParserInput) Start(str string, chunkInput bool, failFunction func(strin } type EndState struct { - IsFinished bool - Furthest int + IsFinished bool + Furthest int FurthestPossibleErrorMessage string - FurthestReachedEnd bool - FurthestChar byte + FurthestReachedEnd bool + FurthestChar byte } func (p *ParserInput) End() EndState { @@ -515,11 +662,11 @@ func (p *ParserInput) End() EndState { } return EndState{ - IsFinished: isFinished, - Furthest: p.i, + IsFinished: isFinished, + Furthest: p.i, FurthestPossibleErrorMessage: message, - FurthestReachedEnd: p.i >= len(p.input)-1, - FurthestChar: furthestChar, + FurthestReachedEnd: p.i >= len(p.input)-1, + FurthestChar: furthestChar, } } @@ -572,4 +719,4 @@ func (p *ParserInput) GetAutoCommentAbsorb() bool { // SetAutoCommentAbsorb sets the autoCommentAbsorb setting func (p *ParserInput) SetAutoCommentAbsorb(value bool) { p.autoCommentAbsorb = value -} \ No newline at end of file +} diff --git a/less/parser_input_test.go b/less/parser_input_test.go index a16514d0a..276e6cd23 100644 --- a/less/parser_input_test.go +++ b/less/parser_input_test.go @@ -65,6 +65,71 @@ func TestParserInput(t *testing.T) { } }) + t.Run("function name fast path", func(t *testing.T) { + tests := []struct { + input string + wantName string + wantLength int + }{ + {input: "rgb(1, 2, 3)", wantName: "rgb", wantLength: 4}, + {input: "%-format(test)", wantName: "", wantLength: 0}, + {input: "%(test)", wantName: "%", wantLength: 2}, + {input: "~(test)", wantName: "~", wantLength: 2}, + {input: "progid:DXImageTransform.Microsoft.Alpha(opacity=50)", wantName: "progid:DXImageTransform.Microsoft.Alpha", wantLength: 40}, + {input: "not-a-call", wantName: "", wantLength: 0}, + {input: "func (test)", wantName: "", wantLength: 0}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + parserInput := NewParserInput() + parserInput.Start(test.input, false, nil) + start := parserInput.GetIndex() + name, length := parserInput.PeekFunctionName() + if name != test.wantName || length != test.wantLength { + t.Fatalf("PeekFunctionName() = (%q, %d), want (%q, %d)", name, length, test.wantName, test.wantLength) + } + if parserInput.GetIndex() != start { + t.Fatalf("PeekFunctionName advanced input from %d to %d", start, parserInput.GetIndex()) + } + }) + } + }) + + t.Run("entity scanner fast paths", func(t *testing.T) { + tests := []struct { + name string + match func(*ParserInput) string + input string + want string + }{ + {name: "entity", match: (*ParserInput).matchEntityName, input: "border-collapse: 1", want: "border-collapse"}, + {name: "bracketed entity", match: (*ParserInput).matchEntityName, input: "[data-name] rest", want: "[data-name]"}, + {name: "hex escape", match: (*ParserInput).matchEntityName, input: `foo\000026 bar`, want: `foo\000026 bar`}, + {name: "escaped punctuation", match: (*ParserInput).matchEntityName, input: `foo\:bar`, want: `foo\:bar`}, + {name: "empty brackets", match: (*ParserInput).matchEntityName, input: "[]", want: ""}, + {name: "identifier", match: (*ParserInput).matchIdentifier, input: "rebeccapurple;", want: "rebeccapurple"}, + {name: "identifier minimum length", match: (*ParserInput).matchIdentifier, input: "x ", want: ""}, + {name: "unicode wildcard", match: (*ParserInput).matchUnicodeRange, input: "U+0??,", want: "U+0??"}, + {name: "unicode range", match: (*ParserInput).matchUnicodeRange, input: "U+00A1-00A9;", want: "U+00A1-00A9"}, + {name: "lowercase unicode prefix", match: (*ParserInput).matchUnicodeRange, input: "u+00A1", want: ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parserInput := NewParserInput() + parserInput.Start(test.input, false, nil) + got := test.match(parserInput) + if got != test.want { + t.Fatalf("match(%q) = %q, want %q", test.input, got, test.want) + } + if got == "" && parserInput.GetIndex() != 0 { + t.Fatalf("failed match advanced input to %d", parserInput.GetIndex()) + } + }) + } + }) + t.Run("$char method", func(t *testing.T) { parserInput := NewParserInput() parserInput.Start("test", false, nil) @@ -567,38 +632,38 @@ func TestParserInput(t *testing.T) { // Test patterns common in CSS parsing pi := NewParserInput() pi.Start("body { color: red; }", false, nil) - + // Test identifier matching result := pi.Str("body") if result != "body" { t.Error("should match CSS selector") } - + // Test brace matching result = pi.Char('{') if char, ok := result.(byte); !ok || char != '{' { t.Error("should match opening brace") } - + // Test property name identRegex := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]*`) result = pi.Re(identRegex) if result != "color" { t.Error("should match CSS property name") } - + // Test colon result = pi.Char(':') if char, ok := result.(byte); !ok || char != ':' { t.Error("should match property separator") } - + // Test value result = pi.Re(identRegex) if result != "red" { t.Error("should match CSS property value") } - + t.Logf("✓ CSS tokenization patterns work correctly") }) @@ -606,33 +671,33 @@ func TestParserInput(t *testing.T) { // Test Less-specific syntax pi := NewParserInput() pi.Start("@color: #ff0000;", false, nil) - + // Test variable declaration result := pi.Char('@') if char, ok := result.(byte); !ok || char != '@' { t.Error("should match Less variable prefix") } - + // Test variable name identRegex := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]*`) result = pi.Re(identRegex) if result != "color" { t.Error("should match Less variable name") } - + // Test colon result = pi.Char(':') if char, ok := result.(byte); !ok || char != ':' { t.Error("should match assignment operator") } - + // Test hex color hexRegex := regexp.MustCompile(`^#[0-9a-fA-F]+`) result = pi.Re(hexRegex) if result != "#ff0000" { t.Error("should match hex color value") } - + t.Logf("✓ Less syntax patterns work correctly") }) -} \ No newline at end of file +} diff --git a/less/public_compile_benchmark_test.go b/less/public_compile_benchmark_test.go new file mode 100644 index 000000000..4167241f6 --- /dev/null +++ b/less/public_compile_benchmark_test.go @@ -0,0 +1,38 @@ +package less_go + +import ( + "os" + "path/filepath" + "testing" +) + +// BenchmarkPublicCompile covers the main exported API. BenchmarkLargeSuite is +// intentionally a lower-level compiler-core benchmark that reuses a Factory. +func BenchmarkPublicCompile(b *testing.B) { + filename := filepath.Join("../testdata/less/_main/functions.less") + input, err := os.ReadFile(filename) + if err != nil { + b.Fatal(err) + } + source := string(input) + + options := &CompileOptions{ + Filename: filename, + JavascriptEnabled: true, + } + + for i := 0; i < 5; i++ { + if _, err := Compile(source, options); err != nil { + b.Fatal(err) + } + } + + b.SetBytes(int64(len(source))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := Compile(source, options); err != nil { + b.Fatal(err) + } + } +} diff --git a/less/ruleset.go b/less/ruleset.go index d050650d3..9eca37c98 100644 --- a/less/ruleset.go +++ b/less/ruleset.go @@ -69,9 +69,15 @@ type SelectorsParseFunc func(input string, context map[string]any, imports map[s // ValueParseFunc is a function type for parsing value strings into values type ValueParseFunc func(input string, context map[string]any, imports map[string]any, fileInfo map[string]any, index int) ([]any, error) +var defaultFuncRulesetError = map[string]any{ + "type": "Syntax", + "message": "it is currently only allowed in parametric mixin guards,", +} + // Ruleset represents a ruleset node in the Less AST type Ruleset struct { *Node + nodeStorage Node Selectors []any Rules []any StrictImports bool @@ -111,13 +117,11 @@ type Ruleset struct { LoadedPluginFunctions map[string]bool } -// OPTIMIZATION: Uses sync.Pool to reuse Ruleset objects and reduce GC pressure. -// Call Release() when the Ruleset is no longer needed to return it to the pool. func NewRuleset(selectors []any, rules []any, strictImports bool, visibilityInfo map[string]any, parseFuncs ...any) *Ruleset { - node := NewNode() + r := &Ruleset{} + node := initEmbeddedNode(&r.nodeStorage) node.TypeIndex = GetTypeIndexForNodeType("Ruleset") - r := GetRulesetFromPool() r.Node = node r.StrictImports = strictImports r.AllowRoot = true @@ -128,7 +132,7 @@ func NewRuleset(selectors []any, rules []any, strictImports bool, visibilityInfo } else if len(selectors) == 0 { r.Selectors = r.Selectors[:0] } else { - // Copy selectors to pooled slice + // Keep a private selector slice because visitors may replace elements. if cap(r.Selectors) < len(selectors) { r.Selectors = make([]any, len(selectors)) } else { @@ -143,7 +147,7 @@ func NewRuleset(selectors []any, rules []any, strictImports bool, visibilityInfo } else if len(rules) == 0 { r.Rules = r.Rules[:0] } else { - // Copy rules to pooled slice + // Keep a private rule slice because evaluation replaces elements in place. if cap(r.Rules) < len(rules) { r.Rules = make([]any, len(rules)) } else { @@ -478,16 +482,10 @@ func (r *Ruleset) Eval(context any) (any, error) { // Match JavaScript: defaultFunc.error({type: 'Syntax', message: 'it is currently only allowed in parametric mixin guards,'}); if evalCtx != nil && evalCtx.DefaultFunc != nil { - evalCtx.DefaultFunc.Error(map[string]any{ - "type": "Syntax", - "message": "it is currently only allowed in parametric mixin guards,", - }) + evalCtx.DefaultFunc.Error(defaultFuncRulesetError) } else if ctx != nil { if df, ok := ctx["defaultFunc"].(interface{ Error(map[string]any) }); ok { - df.Error(map[string]any{ - "type": "Syntax", - "message": "it is currently only allowed in parametric mixin guards,", - }) + df.Error(defaultFuncRulesetError) } } diff --git a/less/selector.go b/less/selector.go index 5accf7c10..32b523d2d 100644 --- a/less/selector.go +++ b/less/selector.go @@ -14,6 +14,7 @@ type SelectorParseFunc func(input string, context map[string]any, imports map[st // Selector represents a CSS selector. type Selector struct { *Node + nodeStorage Node Elements []*Element ExtendList []any // Placeholder for actual type e.g., []*Extend Condition any // Placeholder for actual type e.g., ConditionNode @@ -29,11 +30,9 @@ type Selector struct { // NewSelector creates a new Selector instance. // elementsInput can be []*Element, *Element, or string. // For backward compatibility, parseFunc, parseContext, and parseImports are optional. -// OPTIMIZATION: Uses sync.Pool to reuse Selector objects and reduce GC pressure. -// Call Release() when the Selector is no longer needed to return it to the pool. func NewSelector(elementsInput any, extendList []any, condition any, index int, currentFileInfo map[string]any, visibilityInfo map[string]any, parseFunc ...any) (*Selector, error) { - s := GetSelectorFromPool() - s.Node = NewNode() + s := &Selector{} + s.Node = initEmbeddedNode(&s.nodeStorage) s.Condition = condition s.EvaldCondition = condition == nil @@ -43,7 +42,7 @@ func NewSelector(elementsInput any, extendList []any, condition any, index int, } else if len(extendList) == 0 { s.ExtendList = s.ExtendList[:0] } else { - // Copy extendList to pooled slice + // Keep a private copy because visitors may replace elements. if cap(s.ExtendList) < len(extendList) { s.ExtendList = make([]any, len(extendList)) } else { @@ -96,7 +95,7 @@ func NewSelector(elementsInput any, extendList []any, condition any, index int, return nil, err } - // Copy elements to pooled slice + // Keep a private copy because visitors may replace elements. if cap(s.Elements) < len(parsedElements) { s.Elements = make([]*Element, len(parsedElements)) } else { diff --git a/less/unit.go b/less/unit.go index 5172fa838..298924043 100644 --- a/less/unit.go +++ b/less/unit.go @@ -114,7 +114,7 @@ func (u *Unit) IsLength() bool { "px", "em", "ex", "ch", "rem", "in", "cm", "mm", "pc", "pt", "vw", "vh", "vmin", "vmax", } - + for _, lengthUnit := range lengthUnits { if strings.EqualFold(css, lengthUnit) { return true @@ -218,4 +218,4 @@ func (u *Unit) Cancel() { sort.Strings(u.Numerator) sort.Strings(u.Denominator) -} \ No newline at end of file +} diff --git a/less/value.go b/less/value.go index 5da22e174..3b799492b 100644 --- a/less/value.go +++ b/less/value.go @@ -28,7 +28,8 @@ type CSSGenerator interface { // Value represents a value node in the Less AST type Value struct { *Node - Value []any + nodeStorage Node + Value []any } func NewValue(value any) (*Value, error) { @@ -36,9 +37,8 @@ func NewValue(value any) (*Value, error) { return nil, &ValueError{Message: "Value requires an array argument"} } - v := &Value{ - Node: NewNode(), - } + v := &Value{} + v.Node = initEmbeddedNode(&v.nodeStorage) // If value is not an array, wrap it in one if arr, ok := value.([]any); ok { @@ -207,4 +207,4 @@ func (v *Value) ToCSS(context any) string { } v.GenCSS(context, output) return builder.String() -} \ No newline at end of file +} diff --git a/less/visitor.go b/less/visitor.go index 709e9ac50..aec7d7f21 100644 --- a/less/visitor.go +++ b/less/visitor.go @@ -19,16 +19,6 @@ var visitArgsPool = sync.Pool{ }, } -func getVisitArgs() *VisitArgs { - args := visitArgsPool.Get().(*VisitArgs) - args.VisitDeeper = true // Reset to default - return args -} - -func releaseVisitArgs(args *VisitArgs) { - visitArgsPool.Put(args) -} - type TreeRegistry struct { NodeTypes map[string]any } @@ -77,11 +67,33 @@ type VisitFunc func(node any, visitArgs *VisitArgs) any type VisitOutFunc func(node any) type Visitor struct { - implementation any - visitInCache map[int]VisitFunc - visitOutCache map[int]VisitOutFunc - methodLookup map[string]reflect.Value // Pre-built method lookup map + implementation any + visitInCache map[int]VisitFunc + visitOutCache map[int]VisitOutFunc + methodLookup map[string]reflect.Value // Pre-built method lookup map cachedIsReplacing bool // Cached result of isReplacing() - computed once at construction + visitArgs [64]VisitArgs + visitDepth int +} + +func (v *Visitor) acquireVisitArgs() (*VisitArgs, bool) { + depth := v.visitDepth + v.visitDepth++ + if depth < len(v.visitArgs) { + args := &v.visitArgs[depth] + args.VisitDeeper = true + return args, false + } + args := visitArgsPool.Get().(*VisitArgs) + args.VisitDeeper = true + return args, true +} + +func (v *Visitor) releaseVisitArgs(args *VisitArgs, pooled bool) { + v.visitDepth-- + if pooled { + visitArgsPool.Put(args) + } } func NewVisitor(implementation any) *Visitor { @@ -164,8 +176,8 @@ func (v *Visitor) Visit(node any) any { return node } - visitArgs := getVisitArgs() - defer releaseVisitArgs(visitArgs) + visitArgs, pooledVisitArgs := v.acquireVisitArgs() + defer v.releaseVisitArgs(visitArgs, pooledVisitArgs) // Fast path: Check if implementation supports direct dispatch (no reflection) if directDispatcher, ok := v.implementation.(DirectDispatchVisitor); ok { @@ -435,13 +447,13 @@ func (v *Visitor) convertToSlice(obj any) []any { } return result } - + // Fallback: try to access like JS array (length property + numeric indexing) objValue := reflect.ValueOf(obj) if objValue.Kind() == reflect.Ptr && !objValue.IsNil() { objValue = objValue.Elem() } - + if objValue.Kind() == reflect.Struct { lengthField := objValue.FieldByName("length") if lengthField.IsValid() && lengthField.Kind() == reflect.Int { @@ -464,7 +476,7 @@ func (v *Visitor) convertToSlice(obj any) []any { } return result } - + return nil } @@ -474,42 +486,42 @@ func initializeTree() { // For now, create placeholder prototypes that match the expected node types // This will work with the dynamic method resolution pattern treeRegistry.NodeTypes = map[string]any{ - "Node": createNodePrototype("Node"), - "Color": createNodePrototype("Color"), - "AtRule": createNodePrototype("AtRule"), - "DetachedRuleset": createNodePrototype("DetachedRuleset"), - "Operation": createNodePrototype("Operation"), - "Dimension": createNodePrototype("Dimension"), - "Unit": createNodePrototype("Unit"), - "Keyword": createNodePrototype("Keyword"), - "Variable": createNodePrototype("Variable"), - "Property": createNodePrototype("Property"), - "Ruleset": createNodePrototype("Ruleset"), - "Element": createNodePrototype("Element"), - "Attribute": createNodePrototype("Attribute"), - "Combinator": createNodePrototype("Combinator"), - "Selector": createNodePrototype("Selector"), - "Quoted": createNodePrototype("Quoted"), - "Expression": createNodePrototype("Expression"), - "Declaration": createNodePrototype("Declaration"), - "Call": createNodePrototype("Call"), - "URL": createNodePrototype("URL"), - "Import": createNodePrototype("Import"), - "Comment": createNodePrototype("Comment"), - "Anonymous": createNodePrototype("Anonymous"), - "Value": createNodePrototype("Value"), - "JavaScript": createNodePrototype("JavaScript"), - "Assignment": createNodePrototype("Assignment"), - "Condition": createNodePrototype("Condition"), - "QueryInParens": createNodePrototype("QueryInParens"), - "Paren": createNodePrototype("Paren"), - "Media": createNodePrototype("Media"), - "Container": createNodePrototype("Container"), - "UnicodeDescriptor": createNodePrototype("UnicodeDescriptor"), - "Negative": createNodePrototype("Negative"), - "Extend": createNodePrototype("Extend"), - "VariableCall": createNodePrototype("VariableCall"), - "NamespaceValue": createNodePrototype("NamespaceValue"), + "Node": createNodePrototype("Node"), + "Color": createNodePrototype("Color"), + "AtRule": createNodePrototype("AtRule"), + "DetachedRuleset": createNodePrototype("DetachedRuleset"), + "Operation": createNodePrototype("Operation"), + "Dimension": createNodePrototype("Dimension"), + "Unit": createNodePrototype("Unit"), + "Keyword": createNodePrototype("Keyword"), + "Variable": createNodePrototype("Variable"), + "Property": createNodePrototype("Property"), + "Ruleset": createNodePrototype("Ruleset"), + "Element": createNodePrototype("Element"), + "Attribute": createNodePrototype("Attribute"), + "Combinator": createNodePrototype("Combinator"), + "Selector": createNodePrototype("Selector"), + "Quoted": createNodePrototype("Quoted"), + "Expression": createNodePrototype("Expression"), + "Declaration": createNodePrototype("Declaration"), + "Call": createNodePrototype("Call"), + "URL": createNodePrototype("URL"), + "Import": createNodePrototype("Import"), + "Comment": createNodePrototype("Comment"), + "Anonymous": createNodePrototype("Anonymous"), + "Value": createNodePrototype("Value"), + "JavaScript": createNodePrototype("JavaScript"), + "Assignment": createNodePrototype("Assignment"), + "Condition": createNodePrototype("Condition"), + "QueryInParens": createNodePrototype("QueryInParens"), + "Paren": createNodePrototype("Paren"), + "Media": createNodePrototype("Media"), + "Container": createNodePrototype("Container"), + "UnicodeDescriptor": createNodePrototype("UnicodeDescriptor"), + "Negative": createNodePrototype("Negative"), + "Extend": createNodePrototype("Extend"), + "VariableCall": createNodePrototype("VariableCall"), + "NamespaceValue": createNodePrototype("NamespaceValue"), // Mixin types "MixinCall": createNodePrototype("MixinCall"), "MixinDefinition": createNodePrototype("MixinDefinition"), @@ -550,4 +562,4 @@ func GetTypeIndexForNodeType(nodeType string) int { } } return 0 -} \ No newline at end of file +} diff --git a/package.json b/package.json index 076baf6e2..a4560d2f1 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "bench:go:parse": "go test -bench=BenchmarkLessParsing -benchmem -benchtime=10s ./less", "bench:go:eval": "go test -bench=BenchmarkLessEvaluation -benchmem -benchtime=10s ./less", "bench:go:suite": "go test -bench=BenchmarkLargeSuite -benchmem -benchtime=30x ./less", + "bench:go:suite:gc200": "GOGC=200 go test -bench=BenchmarkLargeSuite -benchmem -benchtime=30x ./less", + "bench:go:public": "go test -bench=BenchmarkPublicCompile -benchmem -benchtime=10s ./less", "bench:js": "node benchmark/suite.js", "bench:js:detailed": "node benchmark/suite.js --detailed", "bench:js:json": "node benchmark/suite.js --json",