diff --git a/.gitignore b/.gitignore index 87953c0..389104e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ node_modules dist coverage .claude/settings.local.json -package-lock.json \ No newline at end of file +package-lock.json +benchmark/memory-results.json \ No newline at end of file diff --git a/src/arena.test.ts b/src/arena.test.ts index fe1f6bd..ba09ed9 100644 --- a/src/arena.test.ts +++ b/src/arena.test.ts @@ -321,16 +321,49 @@ describe('CSSDataArena', () => { }) }) + describe('trim', () => { + test('should set capacity equal to count', () => { + const arena = new CSSDataArena(100) + arena.create_node(STYLESHEET, 0, 0, 1, 1) + arena.create_node(DECLARATION, 0, 0, 1, 1) + expect(arena.get_capacity()).toBe(100) + arena.trim() + expect(arena.get_capacity()).toBe(arena.get_count()) + }) + + test('should preserve all node data after trim', () => { + const arena = new CSSDataArena(100) + const n1 = arena.create_node(STYLESHEET, 10, 500, 1, 1) + const n2 = arena.create_node(DECLARATION, 20, 30, 2, 5) + arena.set_flag(n2, FLAG_IMPORTANT) + arena.trim() + + expect(arena.get_type(n1)).toBe(STYLESHEET) + expect(arena.get_start_offset(n1)).toBe(10) + expect(arena.get_type(n2)).toBe(DECLARATION) + expect(arena.get_start_offset(n2)).toBe(20) + expect(arena.has_flag(n2, FLAG_IMPORTANT)).toBe(true) + }) + + test('should be a no-op when already tight', () => { + const arena = new CSSDataArena(2) + arena.create_node(STYLESHEET, 0, 0, 1, 1) // count = 2, triggers grow to capacity 3 + arena.create_node(STYLESHEET, 0, 0, 1, 1) // count = 3 = capacity + expect(arena.get_count()).toBe(arena.get_capacity()) + arena.trim() // should not throw or corrupt + expect(arena.get_count()).toBe(arena.get_capacity()) + }) + }) + describe('real-world CSS frameworks', () => { test('should not grow for Bootstrap CSS', () => { const css = readFileSync('node_modules/bootstrap/dist/css/bootstrap.css', 'utf-8') const result = parse(css) as unknown as CSSNode + const arena = result.__get_arena() - expect(result.__get_arena().get_growth_count()).toBe(0) - const utilization = - (result.__get_arena().get_count() / result.__get_arena().get_capacity()) * 100 - expect(utilization).toBeLessThan(85) - expect(utilization).toBeGreaterThan(30) + expect(arena.get_growth_count()).toBe(0) + // parse() calls trim(), so capacity must equal count + expect(arena.get_capacity()).toBe(arena.get_count()) }) test('should not grow for Bootstrap minified CSS', () => { @@ -339,9 +372,7 @@ describe('CSSDataArena', () => { const arena = result.__get_arena() expect(arena.get_growth_count()).toBe(0) - const utilization = (arena.get_count() / arena.get_capacity()) * 100 - expect(utilization).toBeLessThan(85) - expect(utilization).toBeGreaterThan(30) + expect(arena.get_capacity()).toBe(arena.get_count()) }) test('should not grow for Tailwind CSS', () => { @@ -350,20 +381,16 @@ describe('CSSDataArena', () => { const arena = result.__get_arena() expect(arena.get_growth_count()).toBe(0) - const utilization = (arena.get_count() / arena.get_capacity()) * 100 - expect(utilization).toBeLessThan(85) - expect(utilization).toBeGreaterThan(30) + expect(arena.get_capacity()).toBe(arena.get_count()) }) test('should not grow for Tailwind minified CSS', () => { const css = readFileSync('node_modules/tailwindcss/dist/tailwind.min.css', 'utf-8') const result = parse(css) as unknown as CSSNode + const arena = result.__get_arena() - expect(result.__get_arena().get_growth_count()).toBe(0) - const utilization = - (result.__get_arena().get_count() / result.__get_arena().get_capacity()) * 100 - expect(utilization).toBeLessThan(85) - expect(utilization).toBeGreaterThan(30) + expect(arena.get_growth_count()).toBe(0) + expect(arena.get_capacity()).toBe(arena.get_count()) }) }) }) diff --git a/src/arena.ts b/src/arena.ts index 98f67e6..08fc3c1 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -116,13 +116,15 @@ export class CSSDataArena { // Growth multiplier when capacity is exceeded private static readonly GROWTH_FACTOR = 1.3 - // Estimated nodes per KB of CSS (based on real-world data) - // Increased from 270 to 325 to account for VALUE wrapper nodes - // (~20% of nodes are declarations, +1 VALUE node per declaration = +20% nodes) - private static readonly NODES_PER_KB = 325 + // Estimated nodes per KB of CSS. + // Measured across real-world files (unminified and minified): + // bootstrap.css 137 | bootstrap.min 166 | tailwind.css 157 | tailwind.min 195 | small 198 + // 210 keeps ~16% headroom above the observed ceiling of 198 nodes/KB. + private static readonly NODES_PER_KB = 210 - // Buffer to avoid frequent growth (15%) - private static readonly CAPACITY_BUFFER = 1.2 + // Safety buffer on top of NODES_PER_KB to absorb variance without a grow. + // Combined with the constant above: effective ceiling = 210 × 1.1 = 231 nodes/KB. + private static readonly CAPACITY_BUFFER = 1.1 constructor(initial_capacity: number = 1024) { this.capacity = initial_capacity @@ -350,6 +352,23 @@ export class CSSDataArena { } } + /** + * Shrink the buffer to exactly the number of live nodes, releasing wasted capacity. + * Call once after parsing is complete. Safe to call multiple times (no-op if already tight). + * + * @see https://doc.rust-lang.org/std/vec/struct.Vec.html#method.shrink_to_fit + * @see https://en.cppreference.com/w/cpp/container/vector/shrink_to_fit + */ + trim(): void { + if (this.count === this.capacity) return + let byte_count = this.count * BYTES_PER_NODE + let new_buffer = new ArrayBuffer(byte_count) + new Uint8Array(new_buffer).set(new Uint8Array(this.buffer, 0, byte_count)) + this.buffer = new_buffer + this.view = new DataView(new_buffer) + this.capacity = this.count + } + // Check if a node has any children has_children(node_index: number): boolean { return this.get_first_child(node_index) !== 0 diff --git a/src/parse.ts b/src/parse.ts index 0bb4603..5f80c8b 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -147,6 +147,9 @@ export class Parser { // Link all rules as children this.arena.append_children(stylesheet, rules) + // Release wasted pre-allocated capacity now that node count is final + this.arena.trim() + // Return wrapped node return new CSSNode(this.arena, this.source, stylesheet) as StyleSheet }