mirror of
https://github.com/oven-sh/bun
synced 2026-02-15 13:22:07 +00:00
feat(transpiler): lower auto-accessor class fields
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
|
||||
153
src/ast/P.zig
153
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
@@ -828,6 +828,68 @@ function foo() {}
|
||||
exp("let x: abstract new <T>() => Foo<T>", "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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user