diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 1c20cf221d97..7ea11a8b59ac 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2495,6 +2495,11 @@ pub mod parse_worker { let mut opts = ParserOptions::init( crate::transpiler::to_parser_jsx_pragma(task.jsx.clone()), loader, + js_parser::TsconfigOptions { + experimental_decorators: task.experimental_decorators, + emit_decorator_metadata: task.emit_decorator_metadata, + use_define_for_class_fields: task.use_define_for_class_fields, + }, ); opts.bundle = true; opts.warn_about_unbundled_modules = false; @@ -2539,13 +2544,6 @@ pub mod parse_worker { opts.features.minify_identifiers = topts.minify_identifiers; opts.features.minify_keep_names = topts.keep_names; opts.features.minify_whitespace = topts.minify_whitespace; - opts.use_define_for_class_fields = task.use_define_for_class_fields; - opts.features.emit_decorator_metadata = task.emit_decorator_metadata; - // emitDecoratorMetadata implies legacy/experimental decorators, as it only - // makes sense with TypeScript's legacy decorator system (reflect-metadata). - // TC39 standard decorators have their own metadata mechanism. - opts.features.standard_decorators = !loader.is_typescript() - || !(task.experimental_decorators || task.emit_decorator_metadata); opts.features.unwrap_commonjs_packages = topts.unwrap_commonjs_packages; opts.features.no_macros = topts.no_macros; // Modeled as diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 201b32587eeb..3a9a1a027bf8 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -7614,6 +7614,7 @@ pub mod bv2_impl { let mut js_parser_options = bun_js_parser::ParserOptions::init( self.transpiler_for_target(target).options.jsx.clone(), Loader::Html, + bun_js_parser::TsconfigOptions::NONE, ); js_parser_options.bundle = true; diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index c5ef7f1b8fd3..04c35d4ba25a 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -1490,12 +1490,11 @@ impl<'a> Transpiler<'a> { is_entry_point: false, }; - opts.features.emit_decorator_metadata = this_parse.emit_decorator_metadata; - // emitDecoratorMetadata implies legacy/experimental decorators, as it only - // makes sense with TypeScript's legacy decorator system (reflect-metadata). - // TC39 standard decorators have their own metadata mechanism. - opts.features.standard_decorators = !loader.is_typescript() - || !(this_parse.experimental_decorators || this_parse.emit_decorator_metadata); + opts.set_tsconfig(js_ast::TsconfigOptions { + experimental_decorators: this_parse.experimental_decorators, + emit_decorator_metadata: this_parse.emit_decorator_metadata, + use_define_for_class_fields: this_parse.use_define_for_class_fields, + }); opts.features.allow_runtime = self.options.allow_runtime; opts.features.set_breakpoint_on_first_line = this_parse.set_breakpoint_on_first_line; diff --git a/src/js_parser/lib.rs b/src/js_parser/lib.rs index e8ad22dd9038..36f471e8b1f3 100644 --- a/src/js_parser/lib.rs +++ b/src/js_parser/lib.rs @@ -27,7 +27,7 @@ pub mod typescript; pub mod visit; pub use p::P; -pub use parse::parse_entry::{Options as ParserOptions, Parser}; +pub use parse::parse_entry::{Options as ParserOptions, Parser, TsconfigOptions}; // Full impl lives in *_jsc; this stub re-exposes the JSC-free constants and a // placeholder `MacroContext` so lower-tier crates (bundler, transpiler) that diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index d1cf892e5f49..3b0bdeddeb99 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -118,7 +118,13 @@ pub fn parse_only_text( let mut ast_memory_allocator = bun_ast::ASTMemoryAllocator::borrowing(&arena); let _ast_scope = ast_memory_allocator.enter(); let source = bun_ast::Source::init_path_string(&b"chunk.js"[..], text); - let mut options = Options::init(Default::default(), bun_ast::Loader::Js); + let mut options = Options::init( + Default::default(), + bun_ast::Loader::Js, + TsconfigOptions::NONE, + ); + // JavaScriptCore has no decorator or auto-accessor grammar. + options.features.standard_decorators = false; options.features.no_macros = true; options.features.top_level_await = top_level_await; options.jsc_builtin_syntax = jsc_builtin_syntax; @@ -198,6 +204,23 @@ pub struct Options<'a> { pub is_entry_point: bool, } +/// The parser options that a tsconfig.json controls, besides JSX. +#[derive(Clone, Copy)] +pub struct TsconfigOptions { + pub experimental_decorators: bool, + pub emit_decorator_metadata: bool, + pub use_define_for_class_fields: bool, +} + +impl TsconfigOptions { + /// No tsconfig.json governs this parse. + pub const NONE: TsconfigOptions = TsconfigOptions { + experimental_decorators: false, + emit_decorator_metadata: false, + use_define_for_class_fields: true, + }; +} + impl<'a> Default for Options<'a> { fn default() -> Self { // `macro_context` is `None`; caller must set @@ -362,7 +385,20 @@ impl<'a> Options<'a> { self.bundle } - pub fn init(jsx: options::JSX::Pragma, loader: options::Loader) -> Options<'static> { + /// `emitDecoratorMetadata` implies the legacy decorators: TypeScript emits it only for them. + #[inline] + pub fn set_tsconfig(&mut self, tsconfig: TsconfigOptions) { + self.use_define_for_class_fields = tsconfig.use_define_for_class_fields; + self.features.emit_decorator_metadata = tsconfig.emit_decorator_metadata; + self.features.standard_decorators = + !self.ts || !(tsconfig.experimental_decorators || tsconfig.emit_decorator_metadata); + } + + pub fn init( + jsx: options::JSX::Pragma, + loader: options::Loader, + tsconfig: TsconfigOptions, + ) -> Options<'static> { // `macro_context` is `None` // (see field comment); caller overwrites before use. let mut opts = Options { @@ -372,7 +408,7 @@ impl<'a> Options<'a> { ignore_dce_annotations: false, keep_directives: false, preserve_unused_imports_ts: false, - use_define_for_class_fields: true, + use_define_for_class_fields: tsconfig.use_define_for_class_fields, suppress_warnings_about_weird_code: true, features: RuntimeFeatures::default(), tree_shaking: false, @@ -397,6 +433,7 @@ impl<'a> Options<'a> { is_entry_point: false, }; opts.jsx.parse = loader.is_jsx(); + opts.set_tsconfig(tsconfig); opts } } diff --git a/src/js_parser/parse/parse_skip_typescript.rs b/src/js_parser/parse/parse_skip_typescript.rs index ef1b8d8d917e..6b80878ccb3c 100644 --- a/src/js_parser/parse/parse_skip_typescript.rs +++ b/src/js_parser/parse/parse_skip_typescript.rs @@ -634,11 +634,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O TsIdentKind::Normal => { if GET_METADATA { let ident = self.lexer.identifier; - let find_result = self.find_symbol(bun_ast::Loc::EMPTY, ident)?; + // The scan pass binds no symbols: it marks the name as used. + let ref_ = if Self::TRACK_SYMBOL_USAGE_DURING_PARSE_PASS { + self.store_name_in_ref(ident) + } else { + self.find_symbol(bun_ast::Loc::EMPTY, ident)?.r#ref + }; **result .as_mut() .expect("infallible: GET_METADATA implies Some") = - Metadata::MIdentifier(find_result.r#ref); + Metadata::MIdentifier(ref_); } self.lexer.next()?; @@ -904,23 +909,31 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let r = result .as_deref_mut() .expect("infallible: GET_METADATA implies Some"); - match r { - Metadata::MIdentifier(id_ref) => { - let id_ref = *id_ref; - let mut dot: Vec = Vec::with_capacity(2); - dot.push(id_ref); - let find_result = self.find_symbol(bun_ast::Loc::EMPTY, ident)?; - dot.push(find_result.r#ref); - *r = Metadata::MDot(dot); + if Self::TRACK_SYMBOL_USAGE_DURING_PARSE_PASS { + // The scan pass prints no metadata, so it builds no `MDot`. + if matches!(r, Metadata::MIdentifier(_)) { + self.store_name_in_ref(ident); } - Metadata::MDot(dot) => { - if self.lexer.is_identifier_or_keyword() { + } else { + match r { + Metadata::MIdentifier(id_ref) => { + let id_ref = *id_ref; + let mut dot: Vec = Vec::with_capacity(2); + dot.push(id_ref); let find_result = self.find_symbol(bun_ast::Loc::EMPTY, ident)?; dot.push(find_result.r#ref); + *r = Metadata::MDot(dot); + } + Metadata::MDot(dot) => { + if self.lexer.is_identifier_or_keyword() { + let find_result = + self.find_symbol(bun_ast::Loc::EMPTY, ident)?; + dot.push(find_result.r#ref); + } } + _ => {} } - _ => {} } } diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index b37f40c8f55a..cbac92942bcf 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -146,7 +146,7 @@ pub mod options { pub import_source: Cow<'static, [u8]>, } } -pub use crate::parse::parse_entry::{Options as ParserOptions, Parser}; +pub use crate::parse::parse_entry::{Options as ParserOptions, Parser, TsconfigOptions}; pub use crate::renamer; pub use crate::scan::scan_side_effects::SideEffects; diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index 5e01edc3914a..f4b6491349d7 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -1555,12 +1555,23 @@ impl JSTranspiler { let _ast_scope = ast_memory_allocator.enter(); let source = bun_ast::Source::init_path_string(loader.stdin_name(), code); - let jsx = match self.config.get().tsconfig.as_deref() { + let tsconfig = self.config.get().tsconfig.as_deref(); + let jsx = match tsconfig { Some(ts) => ts.merge_jsx(self.transpiler.get().options.jsx.clone()), None => self.transpiler.get().options.jsx.clone(), }; - let mut opts = bun_js_parser::ParserOptions::init(jsx, loader); + let mut opts = bun_js_parser::ParserOptions::init( + jsx, + loader, + bun_js_parser::TsconfigOptions { + experimental_decorators: tsconfig.is_some_and(|ts| ts.experimental_decorators), + emit_decorator_metadata: tsconfig.is_some_and(|ts| ts.emit_decorator_metadata), + use_define_for_class_fields: tsconfig + .and_then(|ts| ts.use_define_for_class_fields) + .unwrap_or(true), + }, + ); // SAFETY: see `transpiler_mut`. The `&mut Transpiler` is reborrowed // disjointly for `macro_context` (stored in `opts`) and `options.define` // (raw-addr read) below; both end when `opts` is consumed by `scan()`. diff --git a/src/runtime/cli/pm_diff_normalize.rs b/src/runtime/cli/pm_diff_normalize.rs index c79093ac0f14..22deb56d3592 100644 --- a/src/runtime/cli/pm_diff_normalize.rs +++ b/src/runtime/cli/pm_diff_normalize.rs @@ -303,7 +303,15 @@ fn parse_js<'a>( loader: bun_ast::Loader, options: Options, ) -> Option>> { - let mut opts = bun_js_parser::ParserOptions::init(Default::default(), loader); + let mut opts = bun_js_parser::ParserOptions::init( + Default::default(), + loader, + // No tsconfig comes with a package file: TypeScript gets the legacy decorators. + bun_js_parser::TsconfigOptions { + experimental_decorators: true, + ..bun_js_parser::TsconfigOptions::NONE + }, + ); // Print what is there: no dead-code removal, no macro execution, no import trimming. opts.features.dead_code_elimination = options.dce; opts.features.no_macros = true; @@ -314,7 +322,6 @@ fn parse_js<'a>( opts.keep_directives = true; // Accept the same syntax as the runtime. opts.features.top_level_await = true; - opts.features.standard_decorators = !loader.is_typescript(); opts.transform_only = true; opts.suppress_warnings_about_weird_code = true; let define: &'a bun_js_parser::Define = arena.alloc(bun_js_parser::Define::default()); diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 4ff19836ca6e..335c0ddc3f07 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -2145,6 +2145,12 @@ impl<'a> Repl<'a> { let mut opts = bun_js_parser::ParserOptions::init( 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, + use_define_for_class_fields: vm.transpiler.options.use_define_for_class_fields, + }, ); opts.repl_mode = true; opts.features.dead_code_elimination = false; // REPL needs all code diff --git a/src/runtime/test_runner/snapshot.rs b/src/runtime/test_runner/snapshot.rs index b8af22c773e9..190c0e12bdbc 100644 --- a/src/runtime/test_runner/snapshot.rs +++ b/src/runtime/test_runner/snapshot.rs @@ -230,8 +230,11 @@ impl Snapshots { // SAFETY: VM is thread-local singleton installed before any test runs; lives for the // duration of the runner. Per `VirtualMachine::get` doc, callers form a short-lived borrow. let vm = VirtualMachine::get().as_mut(); - let opts = - js_parser::ParserOptions::init(vm.transpiler.options.jsx.clone(), bun_ast::Loader::Js); + let opts = js_parser::ParserOptions::init( + vm.transpiler.options.jsx.clone(), + bun_ast::Loader::Js, + js_parser::TsconfigOptions::NONE, + ); // Thread a per-call arena — js_parser is bump-allocated. let arena = bun_alloc::Arena::new(); let mut temp_log = bun_ast::Log::init(); @@ -551,10 +554,13 @@ impl Snapshots { } lexer.next()?; // `ParserOptions` isn't `Clone`; rebuild per-iteration. - let opts = js_parser::ParserOptions::init( + let mut opts = js_parser::ParserOptions::init( vm.transpiler.options.jsx.clone(), bun_ast::Loader::Js, + js_parser::TsconfigOptions::NONE, ); + // The arguments are parsed as TypeScript, with the legacy decorators. + opts.features.standard_decorators = false; // `P::init` takes an out-param // since 9a98701c980c — `P` is ~5 KiB and the previous // `let p = P::init(..)?` shape forced 2-3 by-value moves. diff --git a/src/sourcemap_jsc/CodeCoverage.rs b/src/sourcemap_jsc/CodeCoverage.rs index c67135b5fff6..ff085383843d 100644 --- a/src/sourcemap_jsc/CodeCoverage.rs +++ b/src/sourcemap_jsc/CodeCoverage.rs @@ -860,7 +860,13 @@ fn print_for_coverage(path: &[u8], text: &[u8]) -> Option);`), expect(imports.filter(({ path }) => path === "react")).toHaveLength(1); expect(imports).toHaveLength(2); }); + + describe("decorators and accessor fields", () => { + // The import paths a call returns, or every error it throws. + const outcome = call => { + try { + return call().map(({ path }) => path); + } catch (e) { + return (e.errors ?? [e]).map(({ message, position }) => ({ + message, + line: position?.line, + column: position?.column, + })); + } + }; + + const shapes = { + "accessor field": `class C { accessor x = 1 }`, + "static accessor field": `class C { static accessor x = 1 }`, + "decorated accessor field": `class C { @d accessor x = 1 }`, + "class decorator": `@d class C {}`, + "member decorator": `class C { @d m() {} }`, + "decorator after export": `export @d class C {}`, + "decorator before export": `@d export class C {}`, + "class expression decorator": `(@d class {});`, + "parenthesized decorator": `@(d) class C {}`, + "decorated private field": `class C { @d #p = 1 }`, + "decorator that only the legacy grammar accepts": `@d()() class C {}`, + }; + const outcomes = scan => + Object.fromEntries( + Object.entries(shapes).map(([name, source]) => [name, outcome(() => scan(`import "./a";\n${source}`))]), + ); + const compilerOptions = { + "no tsconfig": undefined, + "experimentalDecorators": { experimentalDecorators: true }, + "emitDecoratorMetadata": { emitDecoratorMetadata: true }, + "experimentalDecorators and emitDecoratorMetadata": { + experimentalDecorators: true, + emitDecoratorMetadata: true, + }, + }; + + describe.each(["js", "jsx", "ts", "tsx"])("with the %s loader", loader => { + it.each(Object.keys(compilerOptions))("scans what scan() scans, %s", name => { + const options = compilerOptions[name]; + const tsconfigs = options + ? [{ compilerOptions: options }, JSON.stringify({ compilerOptions: options })] + : [undefined]; + for (const tsconfig of tsconfigs) { + for (const trimUnusedImports of [false, true]) { + const transpiler = new Bun.Transpiler({ loader, tsconfig, trimUnusedImports }); + const expected = outcomes(source => transpiler.scan(source).imports); + expect(outcomes(source => transpiler.scanImports(source))).toEqual(expected); + + // The loader of the call selects the grammar, not the loader of the instance. + const other = new Bun.Transpiler({ + loader: loader.startsWith("ts") ? "js" : "ts", + tsconfig, + trimUnusedImports, + }); + expect(outcomes(source => other.scanImports(source, loader))).toEqual(expected); + } + } + }); + + it("accepts every shape of the TC39 grammar", () => { + const { "decorator that only the legacy grammar accepts": legacyOnly, ...standard } = outcomes(source => + new Bun.Transpiler({ loader }).scanImports(source), + ); + expect(standard).toEqual(Object.fromEntries(Object.keys(standard).map(name => [name, ["./a"]]))); + expect(legacyOnly[0].message).toBe('Expected "class" but found "("'); + }); + }); + + it("uses the legacy grammar for TypeScript when the tsconfig sets experimentalDecorators", () => { + const source = `import "./a";\n@d()() class C {}`; + const legacy = new Bun.Transpiler({ + loader: "ts", + tsconfig: { compilerOptions: { experimentalDecorators: true } }, + }); + expect(legacy.scanImports(source)).toEqual([{ kind: "import-statement", path: "./a" }]); + expect(outcome(() => legacy.scanImports(source, "js"))).toEqual( + outcome(() => legacy.scan(source, "js").imports), + ); + + const standard = new Bun.Transpiler({ loader: "ts" }); + expect(() => standard.scanImports(source)).toThrow("Failed to scan imports"); + expect(standard.scanImports(`import "./b";\nclass C { accessor x = 1 }`)).toEqual([ + { kind: "import-statement", path: "./b" }, + ]); + }); + + // With emitDecoratorMetadata, the type of a decorated member is a value at run time. + describe.each(["ts", "tsx"])("metadata types with the %s loader", loader => { + const paths = imports => imports.map(({ path }) => path); + const both = (transpiler, source) => ({ + scanImports: paths(transpiler.scanImports(source)), + scan: paths(transpiler.scan(source).imports), + }); + const metadata = new Bun.Transpiler({ + loader, + trimUnusedImports: true, + tsconfig: { compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }, + }); + + it.each([ + ["a parameter type", `class C { @d m(a: Foo) {} }`], + ["an array type", `class C { @d m(a: Foo[]) {} }`], + ["a union type", `class C { @d m(a: Foo | undefined) {} }`], + ["a return type", `class C { @d m(): Foo { return null } }`], + ["a property type", `class C { @d p: Foo; }`], + ["a constructor parameter of a decorated class", `@d class C { constructor(a: Foo) {} }`], + ["the type of a decorated parameter", `class C { m(@d a: Foo) {} }`], + ["a member of a class expression", `const K = class { @d m(a: Foo) {} };`], + ])("keeps the import of %s", (_, source) => { + const kept = ["./foo", "./d"]; + expect(both(metadata, `import { Foo } from "./foo"; import { d } from "./d";\n${source}`)).toEqual({ + scanImports: kept, + scan: kept, + }); + }); + + it("keeps default and aliased imports", () => { + const kept = ["./default", "./aliased", "./d"]; + const source = ` + import Default from "./default"; + import { Foo as Aliased } from "./aliased"; + import { d } from "./d"; + class C { @d m(a: Default, b: Aliased) {} } + `; + expect(both(metadata, source)).toEqual({ scanImports: kept, scan: kept }); + }); + + it("keeps a namespace import", () => { + const source = ` + import * as ns from "./namespace"; + import { d } from "./d"; + class C { @d m(a: ns.Foo, b: ns.inner.Bar) {} } + `; + expect(paths(metadata.scanImports(source))).toEqual(["./namespace", "./d"]); + }); + + it.each([ + [ + "a type argument", + `import { Foo } from "./foo"; import { d } from "./d";\nclass C { @d m(a: Array) {} }`, + ], + ["an import type", `import type { Foo } from "./foo"; import { d } from "./d";\nclass C { @d m(a: Foo) {} }`], + [ + "an undecorated member", + `import { Foo } from "./foo"; import { d } from "./d";\nclass C { m(a: Foo) {} } d;`, + ], + ])("drops the import of %s", (_, source) => { + expect(both(metadata, source)).toEqual({ scanImports: ["./d"], scan: ["./d"] }); + }); + + it("drops the import without emitDecoratorMetadata", () => { + const source = `import { Foo } from "./foo"; import { d } from "./d";\nclass C { @d m(a: Foo) {} }`; + for (const compilerOptions of [undefined, { experimentalDecorators: true }]) { + const transpiler = new Bun.Transpiler({ + loader, + trimUnusedImports: true, + tsconfig: compilerOptions && { compilerOptions }, + }); + expect(both(transpiler, source)).toEqual({ scanImports: ["./d"], scan: ["./d"] }); + } + }); + }); + }); }); const parsed = (code, trim = true, autoExport = false, transpiler_ = transpiler) => { diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 6f3ecb50cd83..93f76e12a973 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -838,6 +838,20 @@ describe.concurrent("Bun REPL", () => { expect(stderr).toBe(""); expect(exitCode).toBe(0); }); + + test.each([true, false])("class fields follow useDefineForClassFields: %p of the tsconfig.json", async define => { + using dir = tempDir("repl-class-fields", { + "tsconfig.json": JSON.stringify({ compilerOptions: { useDefineForClassFields: define } }), + }); + // A field with no initializer is an own property only when the class defines its fields. + const { outputs, stderr, exitCode } = await runRepl( + ["class A { x: number }", "Object.hasOwn(new A(), 'x')", ".exit"], + { cwd: String(dir) }, + ); + expect(outputs).toEqual(["[class A]", String(define)]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); }); describe("-e / --eval and -p / --print", () => {