diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 7af9d48c2bda..28d1cb244f8e 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -152,6 +152,15 @@ impl<'a> ImportRecordList<'a> { } } +/// `import X = A.B` in TypeScript, kept from the parse pass for [`P::bind_ts_import_equals_aliases`]. +pub(crate) struct TsImportEqualsAlias<'a> { + pub(crate) alias: Ref, + pub(crate) scope: js_ast::StoreRef, + pub(crate) target: Expr, + pub(crate) is_export: bool, + pub(crate) next: Option<&'a TsImportEqualsAlias<'a>>, +} + /// See [`P::parser_snapshot`]. pub(crate) struct ParserSnapshot<'a> { lexer: js_lexer::LexerSnapshot<'a>, @@ -700,6 +709,8 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) ts_namespace_scopes: Vec>, /// Companion list (sibling scopes share a map, so the scope list can't free it). pub(crate) ts_namespace_member_maps: Vec>, + /// Every `import X = A.B` of the file, newest first. + pub(crate) ts_import_equals_aliases: Option<&'a TsImportEqualsAlias<'a>>, /// When visiting expressions, namespace metadata is associated with the most /// recently visited node. If namespace metadata is present, "tsNamespaceTarget" /// will be set to the most recently visited node (as a way to mark that this @@ -3399,6 +3410,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.hoist_symbols(self.module_scope_ref()); + if TYPESCRIPT && self.ts_import_equals_aliases.is_some() { + self.bind_ts_import_equals_aliases(); + } + let mut generated_symbols_count: u32 = 3; if self.options.features.react_fast_refresh { @@ -4779,6 +4794,104 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(()) } + /// Gives each `import X = A.B` that names an enum or a namespace of this file the members of what it names. + #[cold] + #[inline(never)] + fn bind_ts_import_equals_aliases(&mut self) { + #[derive(Clone, Copy, PartialEq, Eq)] + enum State { + Waiting, + InProgress, + Done, + } + let mut records: Vec<&'a TsImportEqualsAlias<'a>> = Vec::new(); + let mut next = self.ts_import_equals_aliases; + while let Some(record) = next { + records.push(record); + next = record.next; + } + records.reverse(); + let mut states = vec![State::Waiting; records.len()]; + // Only an alias that names an alias written behind it needs this. + let mut index_of_alias: Option> = None; + let mut in_progress: Vec = Vec::new(); + let mut names: Vec<&[u8]> = Vec::new(); + for first in 0..records.len() as u32 { + if states[first as usize] != State::Waiting { + continue; + } + in_progress.push(first); + 'record: while let Some(&index) = in_progress.last() { + let record = records[index as usize]; + states[index as usize] = State::InProgress; + names.clear(); + let mut root = record.target; + while let js_ast::ExprData::EDot(dot) = root.data { + names.push(dot.name.slice()); + root = dot.target; + } + // The bare name of an `export import` of a namespace has no binding to print. + let is_skipped = record.is_export + && (record.scope.ts_namespace.is_some() + || self.is_export_to_eliminate(record.alias)); + let mut members = None; + if let js_ast::ExprData::EIdentifier(first_name) = root.data + && !is_skipped + { + let first_name = self.load_name_from_ref(first_name.ref_); + use crate::scan::scan_symbols::{TsNameBinding, find_ts_name_binding}; + members = match find_ts_name_binding(record.scope, first_name) { + TsNameBinding::Members(members) => Some(members), + TsNameBinding::Nothing => None, + TsNameBinding::Symbol(bound) => { + match self.ref_to_ts_namespace_member.get(&bound) { + Some(&js_ast::ts::Data::Namespace(members)) => Some(members), + Some(_) => None, + None => { + let is_constant = self.symbols[bound.inner_index() as usize] + .kind + == js_ast::symbol::Kind::Constant; + if is_constant { + let index_of_alias = + index_of_alias.get_or_insert_with(|| { + let mut indexes = HashMap::default(); + for (index, record) in records.iter().enumerate() { + indexes.insert(record.alias, index as u32); + } + indexes + }); + if let Some(&other) = index_of_alias.get(&bound) + && states[other as usize] == State::Waiting + { + in_progress.push(other); + continue 'record; + } + } + None + } + } + } + }; + } + for name in names.iter().rev() { + members = members.and_then(|members| { + let members: &js_ast::TSNamespaceMemberMap = &members; + match members.get(name).map(|member| &member.data) { + Some(&js_ast::ts::Data::Namespace(members)) => Some(members), + _ => None, + } + }); + } + if let Some(members) = members { + self.ref_to_ts_namespace_member + .insert(record.alias, js_ast::ts::Data::Namespace(members)); + } + states[index as usize] = State::Done; + in_progress.pop(); + } + } + } + // Generate a TypeScript namespace object for this namespace's scope. If this // namespace is another block that is to be merged with an existing namespace, // use that earlier namespace's object instead. @@ -6683,7 +6796,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loop { match ¤t.data { js_ast::ExprData::EIdentifier(id) => { - self.ignore_usage(id.ref_); + // An `import X = A.B` alias keeps its use, so that its statement stays. + let is_alias = TYPESCRIPT + && self.symbols[id.ref_.inner_index() as usize].kind + == js_ast::symbol::Kind::Constant; + if !is_alias { + self.ignore_usage(id.ref_); + } } js_ast::ExprData::EDot(dot) => { current = dot.target; @@ -8334,6 +8453,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.scopes_in_order_for_enum.pop(); } + debug_assert!( + self.ts_import_equals_aliases.is_none_or(|newest| { + (newest.alias.inner_index() as usize) < snapshot.symbols_len + }) + ); + // Enum and namespace symbols also key `ref_to_ts_namespace_member`; a symbol // that later reuses the index must not inherit their namespace data. if self.symbols.len() > snapshot.symbols_len { @@ -9882,6 +10007,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ref_to_ts_namespace_member: Default::default(), ts_namespace_scopes: Vec::new(), ts_namespace_member_maps: Vec::new(), + ts_import_equals_aliases: None, ts_namespace: RecentlyVisitedTSNamespace { expr: null_expr_data(), map: None, diff --git a/src/js_parser/parse/parse_typescript.rs b/src/js_parser/parse/parse_typescript.rs index eb4d49f92c69..c9982fb8579e 100644 --- a/src/js_parser/parse/parse_typescript.rs +++ b/src/js_parser/parse/parse_typescript.rs @@ -226,6 +226,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loc: name_loc, ref_: Ref::NONE, }; + let aliases_before = p.ts_import_equals_aliases; let scope_index = p.push_scope_for_parse_pass(ScopeKind::Entry, loc)?; p.current_scope_mut().ts_namespace = Some(ts_namespace); @@ -399,6 +400,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O || opts.is_typescript_declare { p.pop_and_discard_scope(scope_index); + p.ts_import_equals_aliases = aliases_before; if opts.scope.is_module() { p.local_type_names.put(name_text, true)?; } @@ -503,7 +505,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut value = target; p.lexer.expect(T::TIdentifier)?; - if name == b"require" && p.lexer.token == T::TOpenParen { + let is_require = name == b"require" && p.lexer.token == T::TOpenParen; + if is_require { // "import ns = require('x')" p.lexer.next()?; let path_estr = p.lexer.to_e_string()?; @@ -557,6 +560,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let ref_ = p .declare_symbol(SymbolKind::Constant, default_name_loc, default_name) .expect("unreachable"); + if !is_require { + p.ts_import_equals_aliases = Some(p.arena.alloc(crate::p::TsImportEqualsAlias { + alias: ref_, + scope: p.current_scope, + target: value, + is_export: opts.is_export, + next: p.ts_import_equals_aliases, + })); + } let binding = p.b(B::Identifier { r#ref: ref_ }, default_name_loc); let decls = G::DeclList::init_one(G::Decl { binding, diff --git a/src/js_parser/scan/scan_symbols.rs b/src/js_parser/scan/scan_symbols.rs index 4b5573dcf3cc..23b1dc90ea05 100644 --- a/src/js_parser/scan/scan_symbols.rs +++ b/src/js_parser/scan/scan_symbols.rs @@ -4,6 +4,37 @@ use crate::parser::FindSymbolResult; use bun_ast as js_ast; use bun_ast::{Ref, Scope}; +/// What a name is bound to, for [`P::bind_ts_import_equals_aliases`]. +pub(crate) enum TsNameBinding { + Symbol(Ref), + Members(js_ast::StoreRef), + Nothing, +} + +/// The scope walk of `find_symbol_with_record_usage`, without a use and without a new symbol. +pub(crate) fn find_ts_name_binding(scope: js_ast::StoreRef, name: &[u8]) -> TsNameBinding { + let hash = Scope::get_member_hash(name); + let mut current = Some(scope); + while let Some(scope) = current { + if let Some(member) = scope.get_member_with_hash(name, hash) { + return TsNameBinding::Symbol(member.ref_); + } + if let Some(ts_namespace) = scope.ts_namespace { + let exported: &js_ast::TSNamespaceMemberMap = &ts_namespace.exported_members; + if let Some(member) = exported.get(name) { + if member.data.is_enum() == ts_namespace.is_enum_scope { + return match member.data { + js_ast::ts::Data::Namespace(members) => TsNameBinding::Members(members), + _ => TsNameBinding::Nothing, + }; + } + } + } + current = scope.parent; + } + TsNameBinding::Nothing +} + impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { pub(crate) fn find_symbol( &mut self, diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 264fdd74b3c7..02a421e296ae 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1779,7 +1779,8 @@ pub(crate) mod __gated_printer { } // "**" can't contain certain unary expressions Op::Code::BinPow => { - match &e.left.data { + // An inlined enum member prints as its value, which can be a negative number. + match e.left.unwrap_inlined().data { ExprData::EUnary(left) => { if Op::Code::unary_assign_target(left.op) == js_ast::AssignTarget::None { @@ -1795,6 +1796,10 @@ pub(crate) mod __gated_printer { v.left_level = Level::Call; } } + // A member of an enum of another module prints as its value too. + _ if self.prints_as_imported_enum_value(&e.left) => { + v.left_level = Level::Call; + } _ => {} } } @@ -6749,6 +6754,28 @@ pub(crate) mod __gated_printer { )) } + fn prints_as_imported_enum_value(&self, expr: &Expr) -> bool { + match &expr.data { + ExprData::EDot(e) => { + e.optional_chain.is_none() + && self + .try_to_get_imported_enum_value(e.target, &e.name) + .is_some() + } + ExprData::EIndex(e) => { + e.optional_chain.is_none() + && e.index.data.as_e_string().is_some_and(|name| { + let name = name.flattened(self.bump); + name.is_utf8() + && self + .try_to_get_imported_enum_value(e.target, name.slice8()) + .is_some() + }) + } + _ => false, + } + } + pub(crate) fn try_to_get_imported_enum_value( &self, target: Expr, diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index cafb4f0281bf..5e8b9c76fab7 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -85,7 +85,8 @@ bun_core::declare_scope!(cache, visible); /// Version 50: The body of a class with lowered class decorators names the class by the /// binding that holds what the decorators return. /// Version 51: A `bun:` specifier that names no builtin is printed as it is written. -const EXPECTED_VERSION: u32 = 51; +/// Version 52: A read through an `import X = A.B` alias of an enum or a namespace of the file folds. +const EXPECTED_VERSION: u32 = 52; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/src/sema/check/aot/enum_constants.rs b/src/sema/check/aot/enum_constants.rs index 6e9dd2a8c15a..9cd16b565388 100644 --- a/src/sema/check/aot/enum_constants.rs +++ b/src/sema/check/aot/enum_constants.rs @@ -1,11 +1,14 @@ use super::answers::{EnumConstant, GaveUp}; use super::queries::Queries; use crate::atom::{Atom, known}; -use crate::bind::{Binding, Decl, SymFlags}; +use crate::bind::{Binding, Decl, SymFlags, SymbolId}; use crate::check::Checker; use crate::check::errors_enums_names::is_ambient_enum; use crate::fold::is_exact_power; -use crate::hir::{self, BinOp, EnumMemberId, ExprFlags, ExprId, Kind, MemberKey, Node}; +use crate::hir::{ + self, BinOp, EnumMemberId, ExprFlags, ExprId, Flags, ImportEqualsId, ImportEqualsTarget, Kind, + MemberKey, Node, +}; use crate::program::{FileId, Sym}; use crate::types::EnumValue; use smallvec::SmallVec; @@ -49,8 +52,8 @@ struct PathMember { /// What one part of a namespace exports is in scope in its other parts too, where no declaration /// of it stands around the name. -fn is_declared_around(hir: &hir::File<'_>, declarations: &[Decl], name: ExprId) -> bool { - let mut around = hir.parent(hir.node(name)); +fn is_declared_around(hir: &hir::File<'_>, declarations: &[Decl], name: Node) -> bool { + let mut around = hir.parent(name); while around.is_some() { let mut declarations = declarations.iter(); if declarations.any(|&declaration| hir.parent(hir.node(declaration)) == around) { @@ -273,16 +276,68 @@ impl Checker<'_, '_> { end.member = files.canonical(files.resolve_alias_as(link, SymFlags::VALUE)?); } end.is_path_declared_here &= match bound.binding(first) { - Binding::Symbol(symbol) => { - let symbol = bound.symbol(symbol); - !symbol.flags.intersects(SymFlags::ALIAS) - && is_declared_around(hir, symbol.decls, first) - } + Binding::Symbol(symbol) => self.is_first_name_followed(file, symbol, hir.node(first)), Binding::Arguments(_) | Binding::CommonJs(_) | Binding::Free => false, }; Some(end) } + /// Bun's transpiler gives `import X = A.B` the members of the enum or namespace of the file that `A.B` names. + fn is_first_name_followed(&self, file: FileId, mut symbol: SymbolId, mut name: Node) -> bool { + let (hir, bound) = (self.hir(file), self.bound(file)); + let mut is_behind_alias = false; + for _ in 0..=hir.import_equals.len() { + let bound_symbol = bound.symbol(symbol); + if !bound_symbol.flags.intersects(SymFlags::ALIAS) { + let parent = bound_symbol.parent; + // An alias also reaches what another part of its namespace exports. + let is_export_of_part = + is_behind_alias && parent.is_some() && parent != bound.file_symbol; + return is_export_of_part || is_declared_around(hir, bound_symbol.decls, name); + } + let [Decl::ImportEquals(import)] = *bound_symbol.decls else { + return false; + }; + let Some(root) = self.find_root_of_followed_alias(file, import) else { + return false; + }; + (symbol, name, is_behind_alias) = (root, hir.node(import), true); + } + false + } + + fn find_root_of_followed_alias( + &self, + file: FileId, + import: ImportEqualsId, + ) -> Option { + let (hir, bound, files) = (self.hir(file), self.bound(file), self.files()); + let alias = hir[import]; + let ImportEqualsTarget::Entity(names) = alias.target else { + return None; + }; + let is_export = alias.flags.contains(Flags::EXPORT); + if is_export && hir.kind(hir.parent(hir.node(import))) == Kind::ModuleBlock { + return None; + } + let Binding::Symbol(root) = bound.entity_root_binding(import) else { + return None; + }; + let mut holder = files.resolve_alias_as(files.sym(file, root), SymFlags::VALUE)?; + for name in hir.texts(names).skip(1) { + holder = files.canonical(holder); + if !files.flags(holder).intersects(SymFlags::NAMESPACE) { + return None; + } + let link = files.namespace_member(holder, name)?; + if files.flags(link).intersects(SymFlags::ALIAS) { + return None; + } + holder = files.resolve_alias_as(link, SymFlags::VALUE)?; + } + Some(root) + } + fn is_global_number(&self, symbol: Sym) -> bool { let files = self.files(); [known::NaN, known::Infinity] diff --git a/test/aot/checker-queries/narrowing.programs b/test/aot/checker-queries/narrowing.programs index f3d9900292ce..07f6767c6a74 100644 --- a/test/aot/checker-queries/narrowing.programs +++ b/test/aot/checker-queries/narrowing.programs @@ -2529,6 +2529,74 @@ export {}; //// written-type/enum-access-again.ts // Passes on what enum-access-other.ts exports. export * from "./enum-access-other"; +//// written-type/enum-access-import-equals.queries.txt +# An `import X = A.B` alias of an enum or a namespace of the file is followed like the name itself. It is not followed where it is exported from a namespace, where it names a member, an import or `require`, or behind a nearer binding of its name. +enum-access-import-equals.ts:12 enum `X.Q` = 3 +enum-access-import-equals.ts:12 enum member `P = X.Q` = 3 +enum-access-import-equals.ts:12 enum member `R` = 4 +enum-access-import-equals.ts:13 enum `X.Q` = 3 +enum-access-import-equals.ts:14 enum `X["Q"]` = 3 +enum-access-import-equals.ts:15 enum `AZ.Q` = 5 +enum-access-import-equals.ts:16 enum `N.Z.Q` = 5 +enum-access-import-equals.ts:17 enum `W.Q` = 5 +enum-access-import-equals.ts:25 enum `X.Q` = 9 +enum-access-import-equals.ts:31 enum `Sibling.Q` = 9 +enum-access-import-equals.ts:39 enum `X.Q` = 3 +enum-access-import-equals.ts:39 enum `Behind.Q` = 3 +enum-access-import-equals.ts:41 enum `Behind.Q` = 3 +enum-access-import-equals.ts:44 enum `FromHere.Q` = 3 + +absent: enum-access-import-equals.ts:18 enum `X?.Q` = 3 +absent: enum-access-import-equals.ts:19 enum `FromModule.X` = 7 +absent: enum-access-import-equals.ts:27 enum `Exported.Q` = 9 +absent: enum-access-import-equals.ts:32 enum `Exported.Q` = 9 +absent: enum-access-import-equals.ts:34 enum `M.Exported.Q` = 9 +absent: enum-access-import-equals.ts:36 enum `X.Q` = 3 +//// written-type/enum-access-import-equals.ts +// An `import X = A.B` alias of an enum or a namespace of the file is followed like the name itself. It is not followed where it is exported from a namespace, where it names a member, an import or `require`, or behind a nearer binding of its name. +import { F } from "./enum-access-other"; +enum Z { Q = 3 } +import X = Z; +namespace A { export enum Z { Q = 5 } } +import AZ = A.Z; +import N = A; +import W = AZ; +import FromModule = F; +import Member = Z.Q; +import Path = require("node:path"); +enum Counts { P = X.Q, R } +X.Q; +X["Q"]; +AZ.Q; +N.Z.Q; +W.Q; +X?.Q; +FromModule.X; +Member; +Path.sep; +namespace M { + export enum Z { Q = 9 } + import X = Z; + X.Q; + export import Exported = Z; + Exported.Q; +} +namespace M { + import Sibling = Z; + Sibling.Q; + Exported.Q; +} +M.Exported.Q; +function read(X: { Q: number }) { + return X.Q; +} +function later() { + return [X.Q, Behind.Q]; +} +Behind.Q; +import Behind = Z; +export import FromHere = Z; +FromHere.Q; //// written-type/enum-access-other.ts // An enum in another checked file, imported by enum-access.ts. export enum F { X = 7 } diff --git a/test/aot/parallel/enums/const-enum-replaced-by-values-path-read/main.ts b/test/aot/parallel/enums/const-enum-replaced-by-values-path-read/main.ts index 65cae0dbf83f..80112b31b397 100644 --- a/test/aot/parallel/enums/const-enum-replaced-by-values-path-read/main.ts +++ b/test/aot/parallel/enums/const-enum-replaced-by-values-path-read/main.ts @@ -1,4 +1,4 @@ -// A `const enum` that is read through a namespace of another file, through an `import =` alias or from another part of its namespace. An executable puts the value in place of every read, as TypeScript's compiler does when it compiles the files together. `bun run` reads the object in these places, so it sees what the program stores there. +// A `const enum` that is read through a namespace of another file, through an `import =` alias or from another part of its namespace. An executable puts the value in place of every read, as TypeScript's compiler does when it compiles the files together. Of these, `bun run` does so only through an alias of an enum of this file. In the other places it reads the object, so it sees what the program stores there. import * as all from "./other"; import { Outer, store } from "./other"; import Alias = Outer.Fixed; diff --git a/test/aot/parallel/enums/enum-read-through-import-equals-alias.ts b/test/aot/parallel/enums/enum-read-through-import-equals-alias.ts new file mode 100644 index 000000000000..f3eead25bda6 --- /dev/null +++ b/test/aot/parallel/enums/enum-read-through-import-equals-alias.ts @@ -0,0 +1,90 @@ +// `import X = A.B` names an enum or a namespace of the file. Plain Bun puts the value in place of a member that is read through the alias, so the next member of an enum counts on and a later store to the enum object is not seen. It reads the object where a namespace exports the alias, where another binding has its name, behind `?.`, and in a top-level enum that reads into a namespace. +function change(object: unknown) { + const held = object as Record; + for (const name of Object.keys(held)) { + if (typeof held[name] !== "object" && typeof held[name] !== "function") held[name] = "changed"; + } +} + +enum Z { + Q = 3, + Text = "a", +} +import X = Z; +enum FromAlias { + P = X.Q, + R, +} +namespace A { + export enum Z { + Q = 5, + } +} +import AZ = A.Z; +import N = A; +import W = AZ; +namespace B { + export enum FromNamespace { + P = AZ.Q, + R, + } + export enum FromNamespaceAlias { + P = N.Z.Q, + R, + } + export enum FromAliasOfAlias { + P = W.Q, + R, + } +} +enum TopLevel { + P = AZ.Q, + R = 1, +} +namespace M { + export enum Z { + Q = 9, + } + import Inner = Z; + export import Exported = Z; + export enum Own { + P = Inner.Q, + R, + } + export enum ThroughExported { + P = Exported.Q, + R = 1, + } + export const read = () => [Inner.Q, Exported.Q]; +} +namespace M { + import Sibling = Z; + export enum FromSibling { + P = Sibling.Q, + R, + } + export const readSibling = () => [Sibling.Q, Exported.Q]; +} +const reads = () => [X.Q, X["Q"], X.Text, AZ.Q, N.Z.Q, W.Q, M.Exported.Q, X?.Q, Behind.Q]; +function shadowed(X: { Q: unknown }) { + return X.Q; +} +import Behind = Z; + +console.log( + "enums", + JSON.stringify([ + FromAlias, + B.FromNamespace, + B.FromNamespaceAlias, + B.FromAliasOfAlias, + TopLevel, + M.Own, + M.ThroughExported, + M.FromSibling, + ]), +); +const readAll = () => JSON.stringify([reads(), M.read(), M.readSibling(), shadowed({ Q: "parameter" })]); +console.log("before", readAll()); +for (const object of [Z, A.Z, M.Z]) change(object); +console.log("after", readAll()); diff --git a/test/bundler/esbuild/ts.test.ts b/test/bundler/esbuild/ts.test.ts index 4581e85549ec..01c7d1572f0e 100644 --- a/test/bundler/esbuild/ts.test.ts +++ b/test/bundler/esbuild/ts.test.ts @@ -787,6 +787,26 @@ describe("bundler", () => { treeShaking: true, external: ["pkg"], }); + itBundled("ts/ImportEqualsEnumAlias", { + files: { + "/entry.ts": /* ts */ ` + import { Y } from "./enums"; + console.log(Y.P, Y.R, Y.P ** 2, Y["P"] ** 2); + `, + "/enums.ts": /* ts */ ` + enum Z { N = -3 } + import X = Z; + export enum Y { P = X.N, R } + `, + }, + onAfterBundle(api) { + // The member read through the alias is a constant, so the importer gets the values. + api.expectFile("/out.js").toContain("console.log(-3 /* P */, -2 /* R */, (-3) /* P */ ** 2, (-3) /* P */ ** 2);"); + }, + run: { + stdout: "-3 -2 9 9", + }, + }); itBundled("ts/MinifiedBundleES6", { files: { "/entry.ts": /* ts */ ` diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 4724a1bd2a31..e639c45a2782 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -1943,6 +1943,231 @@ class Test extends Bar { ts.expectPrinted_("export import Foo = Baz.Bar;", "export const Foo = Baz.Bar"); }); + describe("an `import X = A.B` alias of an enum or a namespace of the file", () => { + // Runs the output and returns the value of `result`. + const run = (code, result) => new Function(`${ts.parsed(code)};\nreturn ${result};`)(); + const A = "namespace A { export enum Z { Q = 3 } }\n"; + const counted = { P: 3, R: 4, 3: "P", 4: "R" }; + // The values are those of tsc. + const leftToRunTime = [ + ["a top-level enum reads an alias of an enum in a namespace", `${A}import X = A.Z; enum Y { P = X.Q, R }`, "Y"], + ["a top-level enum reads an alias of a namespace", `${A}import N = A; enum Y { P = N.Z.Q, R }`, "Y"], + ["a top-level enum reads an alias of an alias", `${A}import X = A.Z; import W = X; enum Y { P = W.Q, R }`, "Y"], + ["an alias of a member", "enum Z { Q = 3 } import X = Z.Q; enum Y { P = X, R }", "Y"], + [ + "`export import` in a namespace, read in its block", + "namespace M { export enum Z { Q = 3 } export import X = Z; export enum Y { P = X.Q, R } }", + "M.Y", + ], + [ + "`export import` in a namespace, read in a sibling block", + "namespace M { export enum Z { Q = 3 } export import X = Z; } namespace M { export enum Y { P = X.Q, R } }", + "M.Y", + ], + [ + "`export import` in a namespace, read by the whole path", + "namespace M { export enum Z { Q = 3 } export import X = Z; } namespace N { export enum Y { P = M.X.Q, R } }", + "N.Y", + ], + [ + "an alias of an `export import` in a namespace", + "namespace M { export enum Z { Q = 3 } export import X = Z; } import W = M.X; namespace N { export enum Y { P = W.Q, R } }", + "N.Y", + ], + ]; + + it.each([ + ["a top-level enum", "enum Z { Q = 3 } import X = Z; enum Y { P = X.Q, R }", "Y"], + ["a const enum", "const enum Z { Q = 3 } import X = Z; enum Y { P = X.Q, R }", "Y"], + [ + "an enum of its namespace block", + "namespace M { export enum Z { Q = 3 } import X = Z; export enum Y { P = X.Q, R } }", + "M.Y", + ], + [ + "an enum of a sibling namespace block", + "namespace M { export enum Z { Q = 3 } } namespace M { import X = Z; export enum Y { P = X.Q, R } }", + "M.Y", + ], + ["an enum, written behind the enum that reads it", "enum Z { Q = 3 } enum Y { P = X.Q, R } import X = Z;", "Y"], + ["an enum, read with brackets", "enum Z { Q = 3 } import X = Z; enum Y { P = X['Q'], R }", "Y"], + [ + "an enum, read in a function", + "enum Z { Q = 3 } import X = Z; function f() { enum Y { P = X.Q, R } return Y }", + "f()", + ], + [ + "an enum in a namespace, read in a later namespace", + `${A}import X = A.Z; namespace B { export enum Y { P = X.Q, R } }`, + "B.Y", + ], + [ + "a namespace, read in a later namespace", + `${A}import N = A; namespace B { export enum Y { P = N.Z.Q, R } }`, + "B.Y", + ], + [ + "an alias, read in a later namespace", + `${A}import X = A.Z; import W = X; namespace B { export enum Y { P = W.Q, R } }`, + "B.Y", + ], + [ + "an enum in a namespace, read in a function", + `${A}import X = A.Z; function f() { enum Y { P = X.Q, R } return Y }`, + "f()", + ], + ["an enum, read by a const enum", "enum Z { Q = 3 } import X = Z; const enum Y { P = X.Q, R }", "Y"], + ])("the next member counts on from a member read through an alias of %s", (_, code, result) => { + expect(run(code, result)).toEqual(counted); + }); + + it.each([ + [ + "a merged enum", + "enum Z { Q = 3 } enum Z { S = 7 } import X = Z; enum Y { P = X.S, R }", + { P: 7, R: 8, 7: "P", 8: "R" }, + ], + [ + "the inner of two aliases of one name", + "enum Z { Q = 3 } enum O { Q = 10 } import X = Z; namespace M { import X = O; export enum Y { P = X.Q, R } } const Y = M.Y;", + { P: 10, R: 11, 10: "P", 11: "R" }, + ], + [ + "the enum that the scope of the alias binds", + "enum Z { Q = 3 } namespace M { export enum Z { Q = 9 } import X = Z; export enum Y { P = X.Q, R } } const Y = M.Y;", + { P: 9, R: 10, 9: "P", 10: "R" }, + ], + [ + "an enum, with arithmetic", + "enum Z { Q = 3 } import X = Z; enum Y { P = X.Q + 1, R, S = -X.Q, T }", + { P: 4, R: 5, S: -3, T: -2, 4: "P", 5: "R", "-3": "S", "-2": "T" }, + ], + [ + "an enum, in the second block of the reading enum", + "enum Z { Q = 3 } import X = Z; enum Y { A = 1 } enum Y { P = X.Q, R }", + { A: 1, P: 3, R: 4, 1: "A", 3: "P", 4: "R" }, + ], + [ + "an enum with a string member, which gets no reverse key", + "enum Z { Q = 'a' } import X = Z; enum Y { P = X.Q, R = 1, S }", + { P: "a", R: 1, S: 2, 1: "R", 2: "S" }, + ], + ])("a member read through an alias of %s is a constant", (_, code, Y) => { + expect(run(code, "Y")).toEqual(Y); + }); + + it.each(leftToRunTime)("%s: the read stays, as on main", (_, code, result) => { + const Y = run(code, result); + expect({ P: Y.P, R: Y.R }).toEqual({ P: 3, R: undefined }); + }); + it.todo.each(leftToRunTime)("%s: the next member counts on", (_, code, result) => { + expect(run(code, result)).toEqual(counted); + }); + + // tsc prints `var` for an alias statement, so its output does not throw and counts on. + const behind = `${A}import W = X; import X = A.Z; namespace B { export enum Y { P = W.Q, R } }`; + it("an alias of an alias that is written behind it reads that alias too early, as on main", () => { + expect(() => run(behind, "B.Y")).toThrow(ReferenceError); + }); + it.todo("an alias of an alias that is written behind it: the next member counts on", () => { + expect(run(behind, "B.Y")).toEqual(counted); + }); + + it.each([ + ["a plain const", "enum Z { Q = 3 } const X = Z; enum Y { P = X.Q, R }", "Y", 3], + [ + "a nearer binding with the name of the alias", + "enum Z { Q = 3 } import X = Z; function f(X: any) { enum Y { P = X.Q, R } return Y }", + "f({ Q: 7 })", + 7, + ], + ["an optional chain", "enum Z { Q = 3 } import X = Z; enum Y { P = X?.Q, R }", "Y", 3], + ["a member that is no constant", "let n = 3; enum Z { Q = n } import X = Z; enum Y { P = X.Q, R }", "Y", 3], + ])("%s is no constant", (_, code, result, P) => { + const Y = run(code, result); + expect({ P: Y.P, R: Y.R }).toEqual({ P, R: undefined }); + }); + + it("a read outside an enum folds, and the alias statement stays", () => { + expect( + ts.parsed("enum Z { Q = 3, S = 's' }\nimport X = Z;\nconsole.log(X.Q, X['Q'], X.S, X, X?.Q, typeof X)"), + ).toBe( + `var Z; +((Z) => { + Z[Z["Q"] = 3] = "Q"; + Z["S"] = "s"; +})(Z ||= {}); +const X = Z; +console.log(3 /* Q */, 3 /* Q */, "s" /* S */, X, X?.Q, typeof X)`, + ); + }); + + it("a read in a namespace folds through an alias of the module scope", () => { + const out = ts.parsed( + `${A}import X = A.Z; import N = A;\nnamespace B { export const seen = [X.Q, N.Z.Q, N.Z['Q']] }`, + ); + expect(out.slice(out.indexOf("const X"))).toBe( + `const X = A.Z; +const N = A; +var B; +((B) => { + B.seen = [3 /* Q */, 3 /* Q */, 3 /* Q */]; +})(B ||= {})`, + ); + }); + + it("an exported alias of the module folds and stays exported", () => { + expect(ts.parsed("enum Z { Q = 3 }\nexport import X = Z;\nexport const x = X.Q")).toEndWith( + "export const X = Z;\nexport const x = 3 /* Q */", + ); + }); + + it("a write through the alias and a delete keep the read of the alias", () => { + const out = ts.parsed("enum Z { Q = 3 }\nimport X = Z;\n(X as any).Q = 4;\ndelete (X as any).Q;\nX.Q++"); + expect(out.slice(out.indexOf("const X"))).toBe("const X = Z;\nX.Q = 4;\ndelete X.Q;\nX.Q++"); + }); + + it("an alias of what is no enum or namespace of the file prints as on main", () => { + expect(ts.parsed("import X = require('x');\nconsole.log(X.Q)")).toBe( + 'const X = require("x");\nconsole.log(X.Q)', + ); + expect(ts.parsed("import { Z } from 'z';\nimport X = Z;\nconsole.log(X.Q)")).toBe( + 'import { Z } from "z";\nconst X = Z;\nconsole.log(X.Q)', + ); + expect(ts.parsed("import X = Baz.Bar;\nconsole.log(X.Q)")).toBe("const X = Baz.Bar;\nconsole.log(X.Q)"); + expect(ts.parsed("const Z = { Q: 3 };\nimport X = Z;\nconsole.log(X.Q)")).toBe( + "const Z = { Q: 3 };\nconst X = Z;\nconsole.log(X.Q)", + ); + }); + + it("a negative member read through an alias keeps its sign left of **", () => { + const code = "enum Z { N = -3 }\nimport X = Z;\nconst f = (n: number) => X.N ** n;"; + expect(ts.parsed(code)).toEndWith("const f = (n) => (-3) /* N */ ** n"); + expect(run(code, "f(2)")).toBe(9); + }); + + it("the enum counts on when bun runs the file", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + "enum Z { Q = 3 }\nimport X = Z;\nenum Y { P = X.Q, R, S }\nconsole.log(JSON.stringify(Y), X === Z);", + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: '{"3":"P","4":"R","5":"S","P":3,"R":4,"S":5} true\n', + stderr: "", + exitCode: 0, + }); + }); + }); + it("re-declaring an import binding that is kept in the output is an error", () => { const err = ts.expectParseError;