Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/js_parser/repl_transforms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,31 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> {
B::B::BMissing(_) => self.new_expr(E::Missing {}, binding.loc),
}
}

/// An input reports its last expression, so a read of the class follows a decorator lowering.
#[cold]
#[inline(never)]
pub(crate) fn repl_report_class_declaration(
&mut self,
stmts: &mut BumpVec<'a, Stmt>,
lowered: &[Stmt],
class_name: js_ast::LocRef,
loc: bun_ast::Loc,
) {
if self.current_scope != self.module_scope
|| matches!(lowered.last(), Some(last) if matches!(last.data, StmtData::SClass(_)))
{
return;
}
self.record_usage(class_name.ref_);
stmts.push(self.s(
S::SExpr {
value: Expr::init_identifier(class_name.ref_, class_name.loc),
..Default::default()
},
loc,
));
}
}

/// Bump-allocate a single-element `G::DeclList`.
Expand Down
4 changes: 4 additions & 0 deletions src/js_parser/visit/visit_stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
if !mark_as_dead || was_export_inside_namespace {
// Lower class field syntax for browsers that don't support it
stmts.extend_from_slice(lowered);
if p.options.repl_mode {
let class_name = data.class.class_name.expect("infallible: name checked");
p.repl_report_class_declaration(stmts, lowered, class_name, stmt.loc);
}
} else {
let ref_ = data
.class
Expand Down
5 changes: 2 additions & 3 deletions src/runtime/cli/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2146,9 +2146,8 @@ impl<'a> Repl<'a> {
vm.transpiler.options.jsx.clone(),
bun_ast::Loader::Tsx,
bun_js_parser::TsconfigOptions {
// The REPL transform below supports only the legacy decorators.
experimental_decorators: true,
emit_decorator_metadata: false,
experimental_decorators: vm.transpiler.options.experimental_decorators,
emit_decorator_metadata: vm.transpiler.options.emit_decorator_metadata,
use_define_for_class_fields: vm.transpiler.options.use_define_for_class_fields,
},
);
Expand Down
186 changes: 185 additions & 1 deletion test/js/bun/repl/repl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,14 +891,188 @@ describe.concurrent("Bun REPL", () => {
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

// The tsconfig.json of this repository asks for the legacy decorators, so each of the
// sessions below runs in a directory of its own.
test("accessor fields and TC39 decorators work where no tsconfig.json asks for the legacy decorators", async () => {
using dir = tempDir("repl-tc39-decorators", {});
const { outputs, stderr, exitCode } = await runRepl(
[
"class E { accessor a = 1 }",
"new E().a",
// A TC39 decorator gets (value, context). A legacy one never gets a context.
"function dec(value, context) { console.log(arguments.length, context.kind); return value }",
"class F { @dec accessor b = 2 }",
"new F().b",
"@dec class D {}",
".exit",
],
{ cwd: String(dir) },
);
expect(outputs).toEqual(["[class E]", "1", "undefined", "2 accessor\n[class F]", "2", "2 class\n[class D]"]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("a class with a decorated field is not changed by a later one", async () => {
using dir = tempDir("repl-decorated-classes", {});
const { outputs, stderr, exitCode } = await runRepl(
[
// A field decorator: the field starts at its initial value plus `amount`.
"const add = amount => () => initial => initial + amount",
"class P { @add(100) v = 1 }",
"class Q { @add(200) v = 2 }",
"[new P().v, new Q().v]",
".exit",
],
{ cwd: String(dir) },
);
expect(outputs).toEqual(["[Function: add]", "[class P]", "[class Q]", "[ 101, 202 ]"]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test("a class with an accessor field is not changed by a later one of the same name", async () => {
using dir = tempDir("repl-accessor-classes", {});
const { outputs, stderr, exitCode } = await runRepl(
[
"class A { accessor x = 1 }",
"const a = new A(); a.x",
"class A { accessor x = 2 }",
"[a.x, new A().x]",
".exit",
],
{ cwd: String(dir) },
);
expect(outputs).toEqual(["[class A]", "1", "[class A]", "[ 1, 2 ]"]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

describe("an input gets the decorators that a TypeScript file gets in the same directory", () => {
// `calls` gets what each decorator received, and each metadata key that a class emitted.
const program = [
`const calls = []; Reflect.metadata = (key, value) => () => { calls.push(key === "design:type" ? key + " " + value.name : key) }; calls.length`,
"function dec(...args) { calls.push(args.length + ' ' + args[1]?.kind) }",
"class Q { @dec m() {} @dec when: Date }",
"@dec class D {}",
];
const calls = "calls.join(', ')";
// With emitDecoratorMetadata, the parser once lost its place after this conditional (#46270).
const conditional = "typeof (true ? (): new () => object => { class A { @dec p: Date } return A } : null)";
const file = [...program, `console.log(JSON.stringify(${calls}))`, `console.log(JSON.stringify(${conditional}))`];
const legacy = { compilerOptions: { experimentalDecorators: true } };

test.each([
// TC39 decorators: (value, context).
["no tsconfig.json", {}, "", '"2 method, 2 field, 2 class"'],
// The legacy decorators: (prototype, name, descriptor) for a member, (constructor) for a class.
["experimentalDecorators", { "tsconfig.json": legacy }, "", '"3 undefined, 3 undefined, 1 undefined"'],
// emitDecoratorMetadata implies the legacy decorators.
[
"emitDecoratorMetadata",
{ "tsconfig.json": { compilerOptions: { emitDecoratorMetadata: true } } },
"",
'"design:returntype, design:paramtypes, design:type Function, 3 undefined, design:type Date, 3 undefined, 1 undefined"',
],
// A tsconfig.json that extends another loses its flag (#30477), and one above the working
// directory is not read (#28605). Whatever a file gets there, the prompt gets.
[
"a tsconfig.json that extends another",
{ "tsconfig.json": { extends: "./base.json", ...legacy }, "base.json": { compilerOptions: {} } },
"",
undefined,
],
["a tsconfig.json above the working directory", { "tsconfig.json": legacy }, "src", undefined],
] as [string, Record<string, object>, string, string | undefined][])(
"%s",
async (_, files, subdirectory, expected) => {
using dir = tempDir("repl-decorator-tsconfig", {
...Object.fromEntries(Object.entries(files).map(([name, json]) => [name, JSON.stringify(json)])),
[subdirectory ? `${subdirectory}/program.ts` : "program.ts"]: file.join("\n"),
});
const cwd = path.join(String(dir), subdirectory);
await using proc = Bun.spawn({
cmd: [bunExe(), "program.ts"],
env: bunEnv,
cwd,
stdout: "pipe",
stderr: "pipe",
});
const [prompt, stdout, stderr, exitCode] = await Promise.all([
runRepl([...program, calls, conditional, ".exit"], { cwd }),
proc.stdout.text(),
proc.stderr.text(),
proc.exited,
]);
const asFile = stdout.trim().split(/\r?\n/);
expect({ outputs: asFile, stderr, exitCode }).toEqual({
outputs: [expected ?? expect.any(String), '"function"'],
stderr: "",
exitCode: 0,
});
expect({ outputs: prompt.outputs, stderr: prompt.stderr, exitCode: prompt.exitCode }).toEqual({
// A class declaration reports the class, whichever decorators it has.
outputs: ["0", "undefined", "[class Q]", "[class D]", ...asFile],
stderr: "",
exitCode: 0,
});
},
);
});

test("a decorator imported from a module is applied at the prompt as it is in the module", async () => {
using dir = tempDir("repl-imported-decorator", {
"logged.ts": `
export function logged(method: Function, context: ClassMethodDecoratorContext) {
return function (this: unknown, ...args: unknown[]) {
return "logged:" + String(context.name) + ":" + method.apply(this, args);
};
}
export class InModule {
@logged greet() {
return "hi";
}
}
`,
});
const url = JSON.stringify(Bun.pathToFileURL(path.join(String(dir), "logged.ts")).href);
const { outputs, stderr, exitCode } = await runRepl(
[
`import { logged, InModule } from ${url}`,
"class AtPrompt { @logged greet(): string { return 'hi' } }",
"[new InModule().greet(), new AtPrompt().greet()]",
".exit",
],
{ cwd: String(dir) },
);
expect(outputs).toEqual(["[class InModule]", "[class AtPrompt]", '[ "logged:greet:hi", "logged:greet:hi" ]']);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

test(".load and .copy lower an accessor field", async () => {
using dir = tempDir("repl-accessor-commands", {
"counter.ts": "class Counter { accessor count: number = 7 }\n",
});
const { stdout, outputs, stderr, exitCode } = await runRepl(
[".load ./counter.ts", ".copy new (class { accessor n = 7 })().n", ".exit"],
{ cwd: String(dir) },
);
expect(outputs).toEqual(["Loading ./counter.ts...\n[class Counter]", "Copied 1 characters to clipboard"]);
expect(stdout).toContain(`\x1b]52;c;${btoa("7")}\x07`);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
});

describe("-e / --eval and -p / --print", () => {
async function runReplWith(args: string[]) {
async function runReplWith(args: string[], cwd?: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "repl", ...args],
stdout: "pipe",
stderr: "pipe",
cwd,
env: { ...bunEnv, NO_COLOR: "1" },
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
Expand Down Expand Up @@ -975,6 +1149,16 @@ describe.concurrent("Bun REPL", () => {
expect(exitCode).toBe(0);
});

test("-p lowers an accessor field", async () => {
// A directory with no tsconfig.json: the one of this repository asks for the legacy decorators.
using dir = tempDir("repl-print-accessor", {});
const { stdout, stderr, exitCode } = await runReplWith(
["-p", "class E { accessor a = 1 }; new E().a"],
String(dir),
);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "1\n", stderr: "", exitCode: 0 });
});

test("-e with thrown error writes to stderr and exits with code 1", async () => {
const { stdout, stderr, exitCode } = await runReplWith(["-e", "throw new Error('boom')"]);
expect(stdout).toBe("");
Expand Down
37 changes: 37 additions & 0 deletions test/js/bun/transpiler/repl-transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,43 @@ describe("Bun.Transpiler replMode", () => {
});
});

test.each([
["TC39", {}],
["legacy", { tsconfig: { compilerOptions: { experimentalDecorators: true } } }],
] as [string, Bun.TranspilerOptions][])(
"a class declaration with %s decorators reports the class, as one without does",
async (_, options) => {
const { results } = await runSession(
[
"function dec() {}",
"function tagged(value) { return class extends value { static tag = value.name } }",
"class Plain {}",
"@tagged class Tagged {}",
"class Method { @dec m() {} }",
"class Field { @dec p = 1 }",
"class StaticField { @dec static s = 1 }",
"[Plain, Tagged, Method, Field, StaticField]",
],
{ options },
);
const [Plain, Tagged, Method, Field, StaticField] = results[7];
expect({
Plain: results[2] === Plain,
Tagged: results[3] === Tagged,
Method: results[4] === Method,
Field: results[5] === Field,
StaticField: results[6] === StaticField,
tag: Tagged.tag,
}).toEqual({ Plain: true, Tagged: true, Method: true, Field: true, StaticField: true, tag: "Tagged" });

// Only a declaration at the top level of a replMode input is followed by a read of its name.
const nested = await replTranspiler(options)("function inner() { @tagged class Local {} }");
expect(nested).not.toMatch(/^\s*Local;$/m);
const notReplMode = await replTranspiler({ ...options, replMode: false })("@tagged class Tagged {}");
expect(notReplMode).not.toMatch(/^\s*Tagged;$/m);
},
);

// Each input declares `K<n>`, a class expression with a decorated field,
// and the other names listed.
test.each([
Expand Down
Loading