Compare commits

...

2 Commits

Author SHA1 Message Date
Jarred Sumner
47f13092df fix(transpiler): avoid private name collisions in auto-accessor lowering
Generated backing field names for computed auto-accessors (#a, #b, ...)
now skip names already used by existing private fields in the class.
Private names are not subject to automatic renaming, so collisions
would produce duplicate private name errors at runtime.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-25 07:57:10 +01:00
Jarred Sumner
351a2ffa4a 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>
2026-01-25 03:49:40 +01:00
8 changed files with 299 additions and 10 deletions

View File

@@ -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));

View File

@@ -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,164 @@ 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, skipping any
// that collide with existing private names in the class
// (private names are not subject to automatic renaming).
while (true) : (auto_accessor_count += 1) {
const offset: u8 = @intCast(@min(auto_accessor_count, 25));
const candidate = std.fmt.allocPrint(p.allocator, "#{c}", .{
@as(u8, 'a' + offset),
}) catch unreachable;
var collides = false;
for (properties) |other| {
if (other.key != null and other.key.?.data == .e_private_identifier) {
const other_name = p.loadNameFromRef(other.key.?.data.e_private_identifier.ref);
if (strings.eql(candidate, other_name)) {
collides = true;
break;
}
}
}
if (!collides) {
auto_accessor_count += 1;
break :blk candidate;
}
}
unreachable;
};
// 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 +5072,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 +5091,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;

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 },

View File

@@ -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", .{});
}

View File

@@ -828,6 +828,74 @@ 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}");
// Computed backing field skips existing private names to avoid collisions
exp(
"class Foo { accessor [x]; #a = 1 }",
"var _a;\n\nclass Foo {\n #b;\n get [_a = x]() {\n return this.#b;\n }\n set [_a](_) {\n this.#b = _;\n }\n #a = 1;\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 +3673,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);
});