From 351a2ffa4a3e75a50ab2f0cb1543fb7ccde09f39 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 25 Jan 2026 03:23:00 +0100 Subject: [PATCH] feat(transpiler): lower auto-accessor class fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lower `accessor` class fields into a backing private field + getter/setter pair, since JavaScriptCore does not support `accessor` natively. - `accessor x = 1` → `#x = 1; get x() { return this.#x; } set x(_) { this.#x = _; }` - `accessor #x` → `#_x; get #x() { return this.#_x; } set #x(_) { this.#_x = _; }` - `accessor [expr]` → `#a; get [_a = expr]() { return this.#a; } set [_a](_) { this.#a = _; }` Computed keys are cached in temp variables to avoid evaluating side-effecting expressions twice, matching esbuild's behavior. Co-Authored-By: Claude --- src/ast/G.zig | 3 +- src/ast/P.zig | 153 ++++++++++++++++++++- src/ast/parseProperty.zig | 15 +- src/ast/visit.zig | 25 ++++ src/ast/visitStmt.zig | 8 ++ src/js_lexer_tables.zig | 2 + src/js_printer.zig | 9 +- test/bundler/transpiler/transpiler.test.js | 71 +++++++++- 8 files changed, 276 insertions(+), 10 deletions(-) diff --git a/src/ast/G.zig b/src/ast/G.zig index 41f141ef23..134fb4df0c 100644 --- a/src/ast/G.zig +++ b/src/ast/G.zig @@ -50,7 +50,7 @@ pub const Class = struct { return false; } - if (property.kind == .normal) { + if (property.kind == .normal or property.kind == .auto_accessor) { if (flags.contains(.is_static)) { for ([2]?Expr{ property.value, property.initializer }) |val_| { if (val_) |val| { @@ -134,6 +134,7 @@ pub const Property = struct { declare, abstract, class_static_block, + auto_accessor, pub fn jsonStringify(self: @This(), writer: anytype) !void { return try writer.write(@tagName(self)); diff --git a/src/ast/P.zig b/src/ast/P.zig index 5dbcb1b03b..0dfe947c85 100644 --- a/src/ast/P.zig +++ b/src/ast/P.zig @@ -428,6 +428,10 @@ pub fn NewParser_( temp_refs_to_declare: List(TempRef) = .{}, temp_ref_count: i32 = 0, + // Temp vars for auto-accessor computed keys, collected during visitClass + // and emitted as var declarations by the caller. + auto_accessor_computed_key_refs: List(LocRef) = .{}, + // When bundling, hoisted top-level local variables declared with "var" in // nested scopes are moved up to be declared in the top-level scope instead. // The old "var" statements are turned into regular assignments instead. This @@ -4851,6 +4855,147 @@ pub fn NewParser_( stmts.append(closure) catch unreachable; } + pub fn lowerAutoAccessors(p: *P, properties: []G.Property) []G.Property { + var new_props = ListManaged(G.Property).init(p.allocator); + new_props.ensureTotalCapacity(properties.len * 3) catch unreachable; + var auto_accessor_count: u32 = 0; + + for (properties) |prop| { + if (prop.kind != .auto_accessor) { + new_props.append(prop) catch unreachable; + continue; + } + + const loc = if (prop.key) |k| k.loc else logger.Loc.Empty; + const is_static = prop.flags.contains(.is_static); + const is_computed = prop.flags.contains(.is_computed); + + // Derive backing field name from the key + const backing_name: []const u8 = blk: { + if (prop.key != null and prop.key.?.data == .e_private_identifier) { + // accessor #x -> backing field #_x + const orig_name = p.loadNameFromRef(prop.key.?.data.e_private_identifier.ref); + break :blk std.fmt.allocPrint(p.allocator, "#_{s}", .{orig_name[1..]}) catch unreachable; + } else if (!is_computed) { + if (prop.key != null and prop.key.?.data == .e_string) { + // accessor x -> backing field #x + const str = prop.key.?.data.e_string; + break :blk std.fmt.allocPrint(p.allocator, "#{s}", .{str.data}) catch unreachable; + } + } + // Computed key -> counter-based name + const offset: u8 = @intCast(@min(auto_accessor_count, 25)); + const name = std.fmt.allocPrint(p.allocator, "#{c}", .{ + @as(u8, 'a' + offset), + }) catch unreachable; + auto_accessor_count += 1; + break :blk name; + }; + + // Create backing field symbol + const backing_ref = p.newSymbol( + if (is_static) .private_static_field else .private_field, + backing_name, + ) catch unreachable; + p.recordDeclaredSymbol(backing_ref) catch unreachable; + + // For private auto-accessors, update the original symbol kind + // from .private_field to .private_get_set_pair + if (prop.key != null and prop.key.?.data == .e_private_identifier) { + const orig_ref = prop.key.?.data.e_private_identifier.ref; + p.symbols.items[orig_ref.innerIndex()].kind = + if (is_static) .private_static_get_set_pair else .private_get_set_pair; + } + + // For computed keys, cache in a temp variable so the expression + // is only evaluated once (getter uses _a = expr, setter uses _a) + var getter_key = prop.key; + var setter_key = prop.key; + if (is_computed) { + const temp_ref = p.newSymbol(.other, "_a") catch unreachable; + p.recordDeclaredSymbol(temp_ref) catch unreachable; + p.auto_accessor_computed_key_refs.append( + p.allocator, + LocRef{ .loc = loc, .ref = temp_ref }, + ) catch unreachable; + + // Getter key: _a = expr (evaluate and cache) + getter_key = p.newExpr(E.Binary{ + .op = .bin_assign, + .left = p.newExpr(E.Identifier{ .ref = temp_ref }, loc), + .right = prop.key.?, + }, loc); + // Setter key: _a (reuse cached value) + setter_key = p.newExpr(E.Identifier{ .ref = temp_ref }, loc); + } + + // 1. Backing field (kind = .normal, private symbol, with initializer) + new_props.append(.{ + .kind = .normal, + .key = p.newExpr(E.PrivateIdentifier{ .ref = backing_ref }, loc), + .initializer = prop.initializer, + .flags = Flags.Property.init(.{ .is_static = is_static }), + }) catch unreachable; + + // Helper: this.#backing expression + const this_dot_backing = p.newExpr(E.Index{ + .target = p.newExpr(E.This{}, loc), + .index = p.newExpr(E.PrivateIdentifier{ .ref = backing_ref }, loc), + }, loc); + + // 2. Getter: get x() { return this.#backing; } + new_props.append(.{ + .kind = .get, + .key = getter_key, + .value = p.newExpr(E.Function{ .func = .{ + .body = G.FnBody.initReturnExpr(p.allocator, this_dot_backing) catch unreachable, + .open_parens_loc = loc, + } }, loc), + .flags = Flags.Property.init(.{ + .is_static = is_static, + .is_method = true, + .is_computed = is_computed, + }), + }) catch unreachable; + + // 3. Setter: set x(_) { this.#backing = _; } + const setter_param_ref = p.newSymbol(.other, "_") catch unreachable; + const setter_arg = p.allocator.alloc(G.Arg, 1) catch unreachable; + setter_arg[0] = .{ + .binding = Binding.alloc(p.allocator, B.Identifier{ .ref = setter_param_ref }, loc), + }; + + const assign_expr = p.newExpr(E.Binary{ + .op = .bin_assign, + .left = p.newExpr(E.Index{ + .target = p.newExpr(E.This{}, loc), + .index = p.newExpr(E.PrivateIdentifier{ .ref = backing_ref }, loc), + }, loc), + .right = p.newExpr(E.Identifier{ .ref = setter_param_ref }, loc), + }, loc); + + const setter_body_stmts = p.allocator.alloc(Stmt, 1) catch unreachable; + setter_body_stmts[0] = p.s(S.SExpr{ .value = assign_expr }, loc); + + new_props.append(.{ + .kind = .set, + .key = setter_key, + .value = p.newExpr(E.Function{ .func = .{ + .body = .{ .loc = loc, .stmts = setter_body_stmts }, + .args = setter_arg, + .open_parens_loc = loc, + } }, loc), + .flags = Flags.Property.init(.{ + .is_static = is_static, + .is_method = true, + .is_computed = is_computed, + }), + }) catch unreachable; + } + + return new_props.items; + } + pub fn lowerClass( noalias p: *P, stmtorexpr: js_ast.StmtOrExpr, @@ -4910,9 +5055,9 @@ pub fn NewParser_( const descriptor_key = prop.key.?; const loc = descriptor_key.loc; - // TODO: when we have the `accessor` modifier, add `and !prop.flags.contains(.has_accessor_modifier)` to - // the if statement. - const descriptor_kind: Expr = if (!prop.flags.contains(.is_method)) + const descriptor_kind: Expr = if (prop.kind == .auto_accessor) + p.newExpr(E.Null{}, loc) + else if (!prop.flags.contains(.is_method)) p.newExpr(E.Undefined{}, loc) else p.newExpr(E.Null{}, loc); @@ -4929,7 +5074,7 @@ pub fn NewParser_( if (p.options.features.emit_decorator_metadata) { switch (prop.kind) { - .normal, .abstract => { + .normal, .abstract, .auto_accessor => { { // design:type var args = p.allocator.alloc(Expr, 2) catch unreachable; diff --git a/src/ast/parseProperty.zig b/src/ast/parseProperty.zig index aa7bf0e665..dce7ef7595 100644 --- a/src/ast/parseProperty.zig +++ b/src/ast/parseProperty.zig @@ -274,7 +274,7 @@ pub fn ParseProperty( const scope_index = p.scopes_in_order.items.len; if (try p.parseProperty(kind, opts, null)) |_prop| { var prop = _prop; - if (prop.kind == .normal and prop.value == null and opts.ts_decorators.len > 0) { + if ((prop.kind == .normal or prop.kind == .auto_accessor) and prop.value == null and opts.ts_decorators.len > 0) { prop.kind = .declare; return prop; } @@ -289,7 +289,7 @@ pub fn ParseProperty( opts.is_ts_abstract = true; const scope_index = p.scopes_in_order.items.len; if (try p.parseProperty(kind, opts, null)) |*prop| { - if (prop.kind == .normal and prop.value == null and opts.ts_decorators.len > 0) { + if ((prop.kind == .normal or prop.kind == .auto_accessor) and prop.value == null and opts.ts_decorators.len > 0) { var prop_ = prop.*; prop_.kind = .abstract; return prop_; @@ -299,6 +299,15 @@ pub fn ParseProperty( return null; } }, + .p_accessor => { + if (opts.is_class and !opts.is_async and !opts.is_generator and + (js_lexer.PropertyModifierKeyword.List.get(raw) orelse .p_static) == .p_accessor) + { + kind = .auto_accessor; + errors = null; + continue :restart; + } + }, .p_private, .p_protected, .p_public, .p_readonly, .p_override => { // Skip over TypeScript keywords if (opts.is_class and is_typescript_enabled and (js_lexer.PropertyModifierKeyword.List.get(raw) orelse .p_static) == keyword) { @@ -430,7 +439,7 @@ pub fn ParseProperty( // Parse a class field with an optional initial value if (opts.is_class and - kind == .normal and !opts.is_async and + (kind == .normal or kind == .auto_accessor) and !opts.is_async and !opts.is_generator and p.lexer.token != .t_open_paren and !has_type_parameters and diff --git a/src/ast/visit.zig b/src/ast/visit.zig index e3e79a0b9c..a6f3a979f6 100644 --- a/src/ast/visit.zig +++ b/src/ast/visit.zig @@ -713,6 +713,20 @@ pub fn Visit( } } } + + // Lower auto-accessors into backing field + getter + setter + { + var has_auto_accessors = false; + for (class.properties) |prop| { + if (prop.kind == .auto_accessor) { + has_auto_accessors = true; + break; + } + } + if (has_auto_accessors) { + class.properties = p.lowerAutoAccessors(class.properties); + } + } } if (p.symbols.items[shadow_ref.innerIndex()].use_count_estimate == 0) { @@ -830,6 +844,17 @@ pub fn Visit( break :list_getter &visited; }; try p.visitAndAppendStmt(list, stmt); + + // Emit var declarations for auto-accessor computed key temps + // that were generated from class expressions during this statement. + // (Class statement temps are handled in visitAndAppendStmt directly.) + // var is hoisted so placement within the statement list is fine. + for (p.auto_accessor_computed_key_refs.items) |temp| { + const decls = p.allocator.alloc(G.Decl, 1) catch unreachable; + decls[0] = .{ .binding = p.b(B.Identifier{ .ref = temp.ref.? }, temp.loc) }; + try list.append(p.s(S.Local{ .decls = G.Decl.List.fromOwnedSlice(decls) }, temp.loc)); + } + p.auto_accessor_computed_key_refs.clearRetainingCapacity(); } // Transform block-level function declarations into variable declarations diff --git a/src/ast/visitStmt.zig b/src/ast/visitStmt.zig index 44b3642711..acd28bcbee 100644 --- a/src/ast/visitStmt.zig +++ b/src/ast/visitStmt.zig @@ -587,6 +587,14 @@ pub fn VisitStmt( _ = p.visitClass(stmt.loc, &data.class, Ref.None); + // Emit var declarations for auto-accessor computed key temps + for (p.auto_accessor_computed_key_refs.items) |temp| { + const decls = p.allocator.alloc(Decl, 1) catch unreachable; + decls[0] = .{ .binding = Binding.alloc(p.allocator, B.Identifier{ .ref = temp.ref.? }, temp.loc) }; + stmts.append(p.s(S.Local{ .decls = Decl.List.fromOwnedSlice(decls) }, temp.loc)) catch unreachable; + } + p.auto_accessor_computed_key_refs.clearRetainingCapacity(); + // Remove the export flag inside a namespace const was_export_inside_namespace = data.is_export and p.enclosing_namespace_arg_ref != null; if (was_export_inside_namespace) { diff --git a/src/js_lexer_tables.zig b/src/js_lexer_tables.zig index e1d4cbc997..1d72f02113 100644 --- a/src/js_lexer_tables.zig +++ b/src/js_lexer_tables.zig @@ -214,6 +214,7 @@ pub const StrictModeReservedWordsRemap = ComptimeStringMap(string, .{ pub const PropertyModifierKeyword = enum { p_abstract, + p_accessor, p_async, p_declare, p_get, @@ -227,6 +228,7 @@ pub const PropertyModifierKeyword = enum { pub const List = ComptimeStringMap(PropertyModifierKeyword, .{ .{ "abstract", .p_abstract }, + .{ "accessor", .p_accessor }, .{ "async", .p_async }, .{ "declare", .p_declare }, .{ "get", .p_get }, diff --git a/src/js_printer.zig b/src/js_printer.zig index 0431180838..ea26c38612 100644 --- a/src/js_printer.zig +++ b/src/js_printer.zig @@ -3292,6 +3292,13 @@ fn NewPrinter( p.print("set"); p.printSpace(); }, + .auto_accessor => { + if (comptime is_json and Environment.allow_assert) + unreachable; + p.printSpaceBeforeIdentifier(); + p.print("accessor"); + p.printSpace(); + }, else => {}, } @@ -3478,7 +3485,7 @@ fn NewPrinter( }, } - if (item.kind != .normal) { + if (item.kind != .normal and item.kind != .auto_accessor) { if (comptime is_json) { bun.unreachablePanic("item.kind must be normal in json", .{}); } diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 609d0e4d89..ba631293ea 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -828,6 +828,68 @@ function foo() {} exp("let x: abstract new () => Foo", "let x"); }); + it("auto-accessor", () => { + const exp = ts.expectPrinted_; + + // Basic lowering (matching esbuild TestLowerAutoAccessors) + exp( + "class Foo { accessor x }", + "class Foo {\n #x;\n get x() {\n return this.#x;\n }\n set x(_) {\n this.#x = _;\n }\n}", + ); + exp( + "class Foo { accessor x = null }", + "class Foo {\n #x = null;\n get x() {\n return this.#x;\n }\n set x(_) {\n this.#x = _;\n }\n}", + ); + exp( + "class Foo { static accessor x }", + "class Foo {\n static #x;\n static get x() {\n return this.#x;\n }\n static set x(_) {\n this.#x = _;\n }\n}", + ); + exp( + "class Foo { static accessor x = null }", + "class Foo {\n static #x = null;\n static get x() {\n return this.#x;\n }\n static set x(_) {\n this.#x = _;\n }\n}", + ); + + // Private auto-accessors + exp( + "class Foo { accessor #x = 1 }", + "class Foo {\n #_x = 1;\n get #x() {\n return this.#_x;\n }\n set #x(_) {\n this.#_x = _;\n }\n}", + ); + + // Computed keys (expression cached in temp var to avoid double evaluation) + exp( + "class Foo { accessor [x] }", + "var _a;\n\nclass Foo {\n #a;\n get [_a = x]() {\n return this.#a;\n }\n set [_a](_) {\n this.#a = _;\n }\n}", + ); + exp( + "class Foo { accessor [x] = null }", + "var _a;\n\nclass Foo {\n #a = null;\n get [_a = x]() {\n return this.#a;\n }\n set [_a](_) {\n this.#a = _;\n }\n}", + ); + exp( + "class Foo { static accessor [x] }", + "var _a;\n\nclass Foo {\n static #a;\n static get [_a = x]() {\n return this.#a;\n }\n static set [_a](_) {\n this.#a = _;\n }\n}", + ); + exp( + "class Foo { static accessor [x] = null }", + "var _a;\n\nclass Foo {\n static #a = null;\n static get [_a = x]() {\n return this.#a;\n }\n static set [_a](_) {\n this.#a = _;\n }\n}", + ); + + // TypeScript modifiers (type stripping) + exp( + "class Foo { accessor x: number = 1 }", + "class Foo {\n #x = 1;\n get x() {\n return this.#x;\n }\n set x(_) {\n this.#x = _;\n }\n}", + ); + exp( + "class Foo { public accessor x = 1 }", + "class Foo {\n #x = 1;\n get x() {\n return this.#x;\n }\n set x(_) {\n this.#x = _;\n }\n}", + ); + exp("class Foo { declare accessor x: number }", "class Foo {\n}"); + exp("abstract class Foo { abstract accessor x: number }", "class Foo {\n}"); + + // Contextual keyword edge cases (not auto-accessor syntax) + exp("class Foo { accessor() {} }", "class Foo {\n accessor() {}\n}"); + exp("class Foo { accessor = 1 }", "class Foo {\n accessor = 1;\n}"); + }); + it("as", () => { const exp = ts.expectPrinted_; exp("x as 1 < 1", "x < 1"); @@ -3605,7 +3667,14 @@ it("does not crash with --minify-syntax and revisiting dot expressions", () => { env: bunEnv, }); - expect(stderr.toString()).toBe(""); + expect( + stderr + .toString() + .split(/\r?\n/) + .filter(s => !s.startsWith("WARNING: ASAN interferes")) + .join("\n") + .trim(), + ).toBe(""); expect(stdout.toString()).toBe("undefined\n"); expect(exitCode).toBe(0); });