diff --git a/src/ast/e.rs b/src/ast/e.rs index e00df5b0e7fc..776730186f62 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -1903,16 +1903,29 @@ impl EString { pub fn order(&self, other: &EString) -> Ordering { debug_assert!(self.is_utf8() == other.is_utf8()); if self.is_utf8() { - strings::order(&self.data, &other.data) + strings::order_utf16(&self.data, &other.data) } else { strings::order_t(self.slice16(), other.slice16()) } } + /// Whether this string, or a later part of its rope, is 8-bit text that is not ASCII. + #[inline(never)] + pub fn has_non_ascii_bytes(&self) -> bool { + let mut part = Some(self); + while let Some(segment) = part { + if segment.is_utf8() && !strings::is_all_ascii(&segment.data) { + return true; + } + part = segment.next.as_deref(); + } + false + } + pub fn javascript_length(&self) -> Option { if self.rope_len > 0 { // We only support ascii ropes for now - return Some(self.rope_len); + return (!self.has_non_ascii_bytes()).then_some(self.rope_len); } if self.is_utf8() { if !strings::is_all_ascii(&self.data) { diff --git a/src/ast/expr.rs b/src/ast/expr.rs index b09320d2a227..813be36b6693 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -2491,30 +2491,12 @@ impl Data { match self { Data::ENull(_) => Some(0.0), Data::EUndefined(_) => Some(f64::NAN), - Data::EString(str) => { - if str.next.is_some() { - return None; - } - if !str.is_utf8() { - return None; - } - // +'1' => 1 - Some(string_to_equivalent_number_value(str.slice8())) - } + Data::EString(str) => string_to_number(str), Data::EBoolean(b) | Data::EBranchBoolean(b) => Some(if b.value { 1.0 } else { 0.0 }), Data::ENumber(n) => Some(n.value()), Data::EInlinedEnum(inlined) => match &inlined.value.data { Data::ENumber(num) => Some(num.value()), - Data::EString(str) => { - if str.next.is_some() { - return None; - } - if !str.is_utf8() { - return None; - } - // +'1' => 1 - Some(string_to_equivalent_number_value(str.slice8())) - } + Data::EString(str) => string_to_number(str), _ => None, }, _ => None, @@ -2820,6 +2802,16 @@ pub use data::Store; // Helpers // ─────────────────────────────────────────────────────────────────────────── +/// `Number(str)` for a flat 8-bit string. +fn string_to_number(str: &E::String) -> Option { + if str.next.is_some() || !str.is_utf8() { + return None; + } + // `Number()` trims white space that is not ASCII too. + let text = bun_core::strings::trim_js_whitespace(str.slice8()); + Some(string_to_equivalent_number_value(text)) +} + /// `Number(str)`. pub fn string_to_equivalent_number_value(str: &[u8]) -> f64 { // +"" -> 0 diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index f317a01fe8ad..b7a8a4fad08b 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -7,11 +7,6 @@ use crate::parser::{self as js_parser, IdentifierOpts, RelocateVars, RelocateVar use bun_ast::ast_result::CommonJSNamedExport; use bun_ast::{self as js_ast, Binding, E, Expr, Flags, G, LocRef, S}; -// ── local EString shims ──────────────────────────────────────────────────── -// E.rs currently carries two `impl EString` blocks (live + round-C draft) with -// overlapping inherent methods, so calls like `EString::init`/`javascript_length` -// /`eql_bytes` are E0034-ambiguous from here. These thin wrappers go through -// public fields directly and are removed once E.rs is deduped. #[inline] fn e_string_init(data: &[u8]) -> E::EString { E::EString { @@ -20,32 +15,6 @@ fn e_string_init(data: &[u8]) -> E::EString { } } -#[inline] -fn e_string_javascript_length(s: &E::EString) -> Option { - if s.rope_len > 0 { - // We only support ascii ropes for now - return Some(s.rope_len); - } - if !s.is_utf16 { - if !s.data.iter().all(|&b| b < 128) { - return None; - } - return Some(s.data.len() as u32); - } - // UTF-16: `data.len()` stores the u16 element count (see EString::init_utf16). - Some(s.data.len() as u32) -} - -#[inline] -fn e_string_eql_bytes(s: &E::EString, other: &[u8]) -> bool { - if !s.is_utf16 { - s.data == other - } else { - let s16 = s.slice16(); - s16.len() == other.len() && s16.iter().zip(other).all(|(&c, &b)| c == b as u16) - } -} - // File-split mixin pattern: a direct `impl P` block. impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { @@ -547,8 +516,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O js_ast::ExprData::EString(str_) => { if p.options.features.minify_syntax { // minify "long-string".length to 11 - if name == b"length" { - if let Some(len) = e_string_javascript_length(&str_) { + if name == b"length" + && identifier_opts.assign_target() == js_ast::AssignTarget::None + && !identifier_opts.is_delete_target() + { + if let Some(len) = str_.javascript_length() { return Some(p.new_expr(E::Number::new(len as f64), loc)); } } @@ -576,14 +548,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O { let prop: &G::Property = &obj.properties.slice()[0]; if let (Some(value), Some(key)) = (prop.value, prop.key) { + // The lexer makes a key that is not ASCII UTF-16. This rule leaves such a key. if prop.flags.len() == 0 - && matches!(key.data, js_ast::ExprData::EString(_)) - && e_string_eql_bytes( - &key.data - .e_string() - .expect("infallible: variant checked"), - name, - ) + && (key.data.e_string()) + .is_some_and(|key| key.is_utf8() && key.eql_bytes(name)) && name != b"__proto__" && value.can_be_inlined_from_property_access() { diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 4390d65d0eed..c81f5299a95a 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1130,8 +1130,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let target = e_.target.unwrap_inlined(); let index = e_.index.unwrap_inlined(); - // `[x][0] = v` writes into the temporary, not `x`. - if p.options.features.minify_syntax && in_.assign_target == js_ast::AssignTarget::None { + // `[x][0] = v` writes into the temporary, not `x`, and `delete [x][0]` deletes from it. + if p.options.features.minify_syntax + && in_.assign_target == js_ast::AssignTarget::None + && !is_delete_target + { if let Some(number) = index.data.as_e_number() { if number.value() >= 0.0 && number.value() < (usize::MAX as f64) @@ -1146,8 +1149,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .e_number() .expect("infallible: variant checked") .to_usize(); - debug_assert!(strings::is_all_ascii(&literal)); - if num < literal.len() { + // A byte is a character only behind text that is ASCII. + if num < literal.len() && strings::is_all_ascii(&literal[..=num]) { *e = p.new_expr( E::String { data: E::Str::new(&literal[num..num + 1]), @@ -2577,7 +2580,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let float = n.value(); if float % 1.0 == 0.0 && float < (str_.len() as f64) && float >= 0.0 { let char_ = str_[float as usize]; - if char_ < 0x80 { + // A byte is a character only behind text that is ASCII. + if strings::is_all_ascii(&str_[..=float as usize]) { *e = p.new_expr(E::Number::new(f64::from(char_)), expr.loc); return; } diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index cafb4f0281bf..41fa82c4bd3f 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: `"ab".length`, `"ab"[0]` and `[x][0]` stay under a write or a `delete`. `.length`, `[n]`, `.charCodeAt(n)`, `<` and unary `+` do not read 8-bit text that is not ASCII by byte, and `{k: v}.k` does not match a UTF-16 key by byte. +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/react_compiler/optimization/constant_propagation.rs b/src/react_compiler/optimization/constant_propagation.rs index 9008fc8993b2..8f21a6cfe959 100644 --- a/src/react_compiler/optimization/constant_propagation.rs +++ b/src/react_compiler/optimization/constant_propagation.rs @@ -809,7 +809,7 @@ fn push_units(out: &mut Vec, s: &bun_ast::E::EString) { if s.is_utf16 { out.extend_from_slice(s.slice16()); } else { - out.extend(s.slice8().iter().map(|&b| b as u16)); + out.extend(bun_core::strings::wtf8_to_utf16(s.slice8())); } } diff --git a/test/aot/minifying-first-saves-no-code/known-today.ts b/test/aot/minifying-first-saves-no-code/known-today.ts index 73dd93abf2be..80c60714dfd5 100644 --- a/test/aot/minifying-first-saves-no-code/known-today.ts +++ b/test/aot/minifying-first-saves-no-code/known-today.ts @@ -34,6 +34,12 @@ export const knownToday: Record = { "written: the build: it may load any file of the executable, so nothing beside it is compiled as it ", "bundler_minify: minify/StringAdditionFolding": "each file: code bytes 659 -> 625, operations 22 -> 20; bundled: code bytes 659 -> 625, operations 22 -> 20", + "bundler_minify: minify/StringFoldsOfNonAsciiDefineText": + "each file: code bytes 662 -> 243, operations 19 -> 10, run-time operations 337 -> 332; bundled: code bytes 662 -> 243, operations 19 -> 10, run-time operations 337 -> 332", + "bundler_minify: minify/StringFoldsOfNonAsciiDefineTextWithoutMinify": + "each file: code bytes 333 -> 149, operations 8 -> 5, run-time operations 329 -> 327; bundled: code bytes 333 -> 149, operations 8 -> 5, run-time operations 329 -> 327", + "bundler_minify: minify/StringFoldsOfNonAsciiFileName": + 'written: "/$bunfs/root/c000/dép.cjs 117 47\\n\\nexit 0", bun run "/c000/dép.cjs 112 47\\n\\nexit 0"; bundled: code bytes 548 -> 165, operations 20 -> 9, run-time operations 332 -> 329', "bundler_minify: minify/TypeOfRequire": "each file: code bytes 190 -> 110, operations 11 -> 7; bundled: code bytes 190 -> 110, operations 11 -> 7", "bundler_minify: minify/UnusedInCommaExpression": "each file: code bytes 390 -> 362; bundled: code bytes 390 -> 362", diff --git a/test/bundler/bundler_env.test.ts b/test/bundler/bundler_env.test.ts index 32932889f5d3..a573a7f5f14e 100644 --- a/test/bundler/bundler_env.test.ts +++ b/test/bundler/bundler_env.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; import { itBundled } from "./expectBundled"; for (let backend of ["api", "cli"] as const) { @@ -28,6 +29,38 @@ for (let backend of ["api", "cli"] as const) { }, }); + // An inlined value is 8-bit text. No fold reads one byte of it as one character where the text is not ASCII, so + // each of these reads gives what it gives at run time. The path still joins with a literal at build time. + if (backend === "cli") + itBundled("env/inline non-ascii", { + env: { + INLINE_NON_ASCII: "é😀", + INLINE_LS_NUMBER: "\u2028 12 ", + INLINE_DIR: "./dér", + }, + backend: backend, + dotenv: "inline", + minifySyntax: true, + files: { + "/a.js": ` + const v = process.env.INLINE_NON_ASCII; + const m = require(process.env.INLINE_DIR + "/m.js"); + console.log(JSON.stringify([v, process.env.INLINE_NON_ASCII[0], process.env.INLINE_NON_ASCII?.[1], \`\${process.env.INLINE_NON_ASCII}\`.length, process.env.INLINE_NON_ASCII === "é😀", +process.env.INLINE_LS_NUMBER, ~process.env.INLINE_LS_NUMBER, m])); + `, + "/dér/m.js": `module.exports = "from m";`, + }, + onAfterBundle(api) { + expect(api.readFile("/out.js")).toContain("from m"); + }, + run: { + env: { + INLINE_NON_ASCII: "the run-time value, which inlining should have replaced", + INLINE_LS_NUMBER: "0", + }, + stdout: '["é😀","é","\\ud83d",3,true,12,-13,"from m"]', + }, + }); + // A variable from the process environment (not a .env file) is inlined at build time. It has to be one this // process started with (the api backend builds in-process), spelled as the environment spells it (on Windows the // inlined name is case-sensitive even though process.env is not: `Path`, not `PATH`). @@ -167,3 +200,55 @@ test("Bun.build inlines the env values from when it was called", async () => { expect(stderr).toBe(""); expect(exitCode).toBe(0); }); + +// A `.env` file is read as bytes, so an inlined value can be 8-bit text that is not UTF-8. A fold reads such bytes as +// `process.env` gives them at run time: the start of a sequence that does not go on is one U+FFFD. +test("bun build --env folds values that are not UTF-8 as process.env reads them", async () => { + using dir = tempDir("bun-build-env-bytes", { + ".env": Buffer.concat( + [ + ["PUB_A=a", 0xff], + ["PUB_B=a", 0xef, 0xbf, 0xbe], + ["PUB_X=Andr", 0xe9, "s"], + ["PUB_Y=Andr", 0xe9, "a"], + ["PUB_C=a", 0xc3, "z"], + ["PUB_N=", 0xa0, "1"], + ].flatMap(line => + [...line, "\n"].map(part => (typeof part === "string" ? Buffer.from(part) : Buffer.from([part]))), + ), + ), + "entry.mjs": /* js */ ` + console.log(JSON.stringify([ + process.env.PUB_A < process.env.PUB_B, process.env.PUB_A >= process.env.PUB_B, + process.env.PUB_X > process.env.PUB_Y, process.env.PUB_X <= process.env.PUB_Y, + process.env.PUB_C > "ab", process.env.PUB_C < "ab", + +process.env.PUB_N, -process.env.PUB_N, ~process.env.PUB_N, + ])); + `, + }); + // A value that the test process inherits would win over the one in `.env`. + const env: Record = { ...bunEnv }; + for (const name of Object.keys(env)) if (/^PUB_/i.test(name)) delete env[name]; + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "--env", "PUB_*", "--minify-syntax", "entry.mjs", "--outfile", "out/out.mjs"], + env, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [buildStderr, buildExitCode] = await Promise.all([build.stderr.text(), build.exited]); + expect({ stderr: buildStderr, exitCode: buildExitCode }).toEqual({ stderr: "", exitCode: 0 }); + expect(await Bun.file(join(String(dir), "out", "out.mjs")).text()).not.toContain("process.env.PUB_"); + + // The source reads `.env` at run time. The bundle runs in a folder that has none. + const run = async (cwd: string, file: string) => { + await using proc = Bun.spawn({ cmd: [bunExe(), file], env, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr, exitCode }; + }; + const expected = { stdout: "[true,false,true,false,true,false,null,null,-1]", stderr: "", exitCode: 0 }; + expect({ + source: await run(String(dir), "entry.mjs"), + bundle: await run(join(String(dir), "out"), "out.mjs"), + }).toEqual({ source: expected, bundle: expected }); +}); diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 37f69ce16a66..4fdfdb98df7a 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1059,6 +1059,55 @@ describe("bundler", () => { '+"æ"', ], }); + // A define makes 8-bit text. No fold reads one byte of it as one character + // where the text is not ASCII, so each read prints what node prints. + itBundled("minify/StringFoldsOfNonAsciiDefineText", { + files: { + "/entry.js": /* js */ ` + console.log(("x" + TEXT).length, (TEXT + "x")[3], (TEXT + "x").charCodeAt(3), JSON.stringify(TEXT[1]), \`x\${TEXT}\`.length); + console.log(("x" + HIGH) < ("x" + LOW), HIGH < LOW, +\`1\${SPACE}\`, -(SPACE + "1"), TEXT.charCodeAt(3)); + `, + }, + define: { TEXT: '"a\u00e9b"', HIGH: '"\u{1F600}"', LOW: '"\uffee"', SPACE: '"\u00a0"' }, + minifySyntax: true, + run: { stdout: '4 x 120 "\u00e9" 4\ntrue true 1 -1 NaN' }, + }); + // `.charCodeAt()` and the unary operators fold with no minify option. + itBundled("minify/StringFoldsOfNonAsciiDefineTextWithoutMinify", { + files: { + "/entry.js": /* js */ ` + console.log(TEXT.charCodeAt(3), TEXT.charCodeAt(0), +SPACE1, -SPACE1, TEXT.charCodeAt(3) === 98 ? "byte" : "unit"); + `, + }, + define: { TEXT: '"a\u00e9b"', SPACE1: '"\u00a01"' }, + run: { stdout: "NaN 97 1 -1 unit" }, + }); + // An inlined file name is 8-bit text too: "d\u00e9p.cjs"[3] is ".", and byte 3 is "p". + itBundled("minify/StringFoldsOfNonAsciiFileName", { + files: { + "/entry.js": /* js */ ` + require("./d\u00e9p.cjs"); + `, + "/d\u00e9p.cjs": /* js */ ` + console.log(module.id, module.id.charCodeAt(3), module.id.charCodeAt(0)); + `, + }, + run: { stdout: "d\u00e9p.cjs 46 100" }, + }); + // In a CommonJS bundle `import.meta.dir` is the text of the build directory, which is 8-bit too. + itBundled("minify/StringFoldsOfNonAsciiBuildDirectory", { + files: { + "/d\u00e9r/entry.js": /* js */ ` + const base = \`\${import.meta.dir}/assets/\`; + const strip = p => (p.startsWith(base) ? p.slice(base.length) : p); + console.log(strip(base + "logo.svg"), (import.meta.dir + "/x").length === import.meta.dir.length + 2); + `, + }, + entryPoints: ["/d\u00e9r/entry.js"], + format: "cjs", + minifySyntax: true, + run: { stdout: "logo.svg true" }, + }); itBundled("minify/ImportMetaHotTreeShaking", { files: { "/entry.ts": ` diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index ff34a7a9f3e4..16d9e913debf 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -781,6 +781,34 @@ describe("bundler", () => { }); } + // A define makes 8-bit text. The React Compiler reads such text by UTF-16 + // code unit where it propagates a length or a join with a UTF-16 literal. + for (const minifySyntax of [false, true] as const) { + itBundled(`react-compiler/NonAsciiDefineTextJoinedToOtherText-syntax=${minifySyntax}`, { + files: { + "/entry.tsx": /* tsx */ ` + enum K { S = "x" + TEXT } + function Lengths() { return {[\`x\${TEXT}\`.length, ("x" + TEXT).length, TEXT.length, K.S.length]}; } + function Joins() { return {[TEXT + "\u00e9", "\u00e9" + TEXT, (TEXT + "\u00e9").length]}; } + console.log(JSON.stringify([Lengths().props.children, Joins().props.children])); + `, + "/node_modules/react/compiler-runtime.js": ` + exports.c = function (n) { return new Array(n).fill(Symbol.for("react.memo_cache_sentinel")); }; + `, + "/node_modules/react/index.js": `exports.createElement = () => null;`, + "/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => ({ type, props });`, + "/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => ({ type, props });`, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + }, + define: { TEXT: '"a\u00e9b"' }, + reactCompiler: true, + target: "browser", + backend: "api", + minifySyntax, + run: { stdout: '[[4,4,3,4],["a\u00e9b\u00e9","\u00e9a\u00e9b",4]]' }, + }); + } + // A user's local `jsx` / `jsxs` / `jsxDEV` / `Fragment` binding in the // component body scope must not capture the automatic JSX runtime import // when the React Compiler rewrites the component. diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 4724a1bd2a31..ed18d9d5c073 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -257,6 +257,75 @@ describe("Bun.Transpiler", () => { ]); expect(exitCode).toBe(0); }); + it("keeps a read of a string under a write or a delete", () => { + ts.expectPrintedMin_('"abc".length = 1', '"abc".length = 1'); + ts.expectPrintedMin_('"abc".length++', '"abc".length++'); + ts.expectPrintedMin_('delete "abc".length', 'delete "abc".length'); + ts.expectPrintedMin_('delete "abc"[0]', 'delete "abc"[0]'); + ts.expectPrintedMin_("delete [y][0]", "delete [y][0]"); + ts.expectPrintedMin_('x = "abc".length', "x = 3"); + ts.expectPrintedMin_('x = "abc"[0]', 'x = "a"'); + }); + // "\u00c3\u00a9" is two characters, and their code units are the UTF-8 bytes of "\u00e9". + it("does not match the key of a literal with one property byte for code unit", () => { + ts.expectPrintedMin_('x = ({ "\u00c3\u00a9": 1 }).\u00e9', 'x = { "\u00c3\u00a9": 1 }.\u00e9'); + ts.expectPrintedMin_('x = ({ "\u00e9": 1 }).\u00e9', 'x = { "\u00e9": 1 }.\u00e9'); + ts.expectPrintedMin_('x = ({ "\\u0061": 1 }).a', "x = 1"); + }); + it("runs a write and a delete on a string or list literal as node does", async () => { + const src = ` + function attempt(fn) { try { return fn(); } catch (e) { return e.constructor.name; } } + function kind() { return this === undefined ? "undefined" : typeof this; } + console.log( + attempt(() => { "use strict"; return ("abc".length = 1); }), + attempt(() => { "use strict"; return delete "abc".length; }), + attempt(() => { "use strict"; return delete "abc"[0]; }), + attempt(() => delete [1][0]), + ({ "\u00c3\u00a9": 1 }).\u00e9, + ({ "\u00e9": () => 1 }).\u00e9.name === "\u00e9", + ({ "\u00e9": kind }).\u00e9\`x\`, + ); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + 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: "TypeError TypeError TypeError true undefined true object\n", + stderr: "", + exitCode: 0, + }); + }); + // A const string is inlined at each use, so a write to a member of it reaches the fold as a literal. + it("loads a file that writes to a member of a const string", async () => { + const src = ` + const label = "ready"; + function neverCalled() { label.length = 0; label.length++; delete label[0]; } + function attempt(fn) { try { return fn(); } catch (e) { return e.constructor.name; } } + console.log( + label, + attempt(() => { "use strict"; label.length = 1; }), + attempt(() => { "use strict"; return delete label.length; }), + attempt(() => { "use strict"; return delete label[0]; }), + attempt(() => { "use strict"; label.length++; }), + ); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + 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: "ready TypeError TypeError TypeError TypeError\n", + stderr: "", + exitCode: 0, + }); + }); it("bails out on optional-chain index into enum", () => { const pre = "enum Foo { A }\nenum Bar { 'a-b' = 1 }\n"; const lastLine = out => out.trimEnd().split("\n").at(-1); @@ -348,6 +417,130 @@ describe("Bun.Transpiler", () => { }); }); + // A define makes 8-bit text. A byte of it is a character only where the text is ASCII. + describe("8-bit text that is not ASCII", () => { + const values = { A: "ab", T: "a\u00e9b", H: "\u{1F600}", L: "\uffee", N: "\u00a01", Z: "" }; + const define = Object.fromEntries(Object.entries(values).map(([name, value]) => [name, JSON.stringify(value)])); + const transpiler = new Bun.Transpiler({ minify: { syntax: true }, define }); + const fold = source => transpiler.transformSync(source).trimEnd(); + + it("keeps a fold that would read it by byte", () => { + expect(fold('x = ("x" + T).length')).toBe('x = "xa\u00e9b".length;'); + expect(fold('x = (T + "x")[1]')).toBe('x = "a\u00e9bx"[1];'); + expect(fold("x = T[3]")).toBe('x = "a\u00e9b"[3];'); + expect(fold("x = T.charCodeAt(3)")).toBe('x = "a\u00e9b".charCodeAt(3);'); + expect(fold('x = (T + "x").charCodeAt(3)')).toBe('x = "a\u00e9bx".charCodeAt(3);'); + }); + // Code units order "\u{1F600}" before "\uffee", and UTF-8 bytes order it after. + it("orders it by UTF-16 code unit", () => { + expect(fold('x = [H < L, H >= L, L > H, T <= T + "x", H + "a" < H + "b"]')).toBe("x = [!0, !1, !0, !0, !0];"); + // Half of a surrogate pair is three bytes of WTF-8 in a define. + const halves = { ...define, X: '"\\ud800"', Y: '"\\udc00"' }; + const withHalves = new Bun.Transpiler({ minify: { syntax: true }, define: halves }); + expect(withHalves.transformSync("x = [X < Y, X >= Y, X + Y < L, +X];")).toBe("x = [!0, !1, !0, NaN];\n"); + }); + // A no-break space is white space, which `Number()` trims. + it("trims its white space for a number", () => { + expect(fold("x = [+N, -N, ~N, +T, +H]")).toBe("x = [1, -1, -2, NaN, NaN];"); + }); + it("folds where the text that is read is ASCII", () => { + expect(fold('x = [("x" + "yz").length, ("ab" + "cd")[1], "abc".charCodeAt(1), "a" < "b", +"1"]')).toBe( + 'x = [3, "b", 98, !0, 1];', + ); + expect(fold('x = [T[0], T.charCodeAt(0), T < "b", ("x" + T)[0]]')).toBe('x = ["a", 97, !0, "x"];'); + }); + // `.charCodeAt()` and the unary operators fold with no minify option. + it("keeps the folds that run without a minify option", () => { + const plain = new Bun.Transpiler({ define }); + expect(plain.transformSync("x = [T.charCodeAt(3), T.charCodeAt(0), +N, -N];")).toBe( + 'x = ["a\u00e9b".charCodeAt(3), 97, 1, -1];\n', + ); + }); + it("folds a unary operator on an enum member", () => { + const typescript = new Bun.Transpiler({ loader: "ts", minify: { syntax: true }, define }); + expect( + typescript.transformSync("enum E { S = N as any }\nx = [+E.S, -E.S, ~E.S];").trimEnd().split("\n").at(-1), + ).toBe("x = [1, -1, -2];"); + }); + // Every operand alone and every pair, joined by `+` and by a template, under every consumer. + it("gives what the engine gives for each generated read", () => { + const operands = [...Object.keys(values), '"x"', "7"]; + const expressions = [ + ...operands.map(operand => `(${operand})`), + ...operands.flatMap(left => operands.map(right => `(${left} + ${right})`)), + ...operands.flatMap(left => operands.map(right => "`${" + left + "}${" + right + "}`")), + ]; + const consumers = [ + e => `${e}.length`, + ...[0, 1, 3, 4].map(index => e => `${e}[${index}]`), + ...[0, 1, 3, 4].map(index => e => `${e}.charCodeAt?.(${index})`), + ...["<", ">", "<=", ">="].flatMap(operator => ["T", "H", "L"].map(other => e => `${e} ${operator} ${other}`)), + e => `+${e}`, + e => `-${e}`, + e => `~${e}`, + e => `${e} === "xa\u00e9b"`, + ]; + const rows = expressions.flatMap(expression => consumers.map(consumer => consumer(expression))); + const source = rows + .map((row, index) => `try { o[${index}] = s(${row}); } catch (e) { o[${index}] = "!" + e.constructor.name; }`) + .join("\n"); + const show = value => (typeof value === "number" ? String(value) : JSON.stringify(value)); + const run = code => { + const results = []; + new Function("o", "s", ...Object.keys(values), code)(results, show, ...Object.values(values)); + return results; + }; + const expected = run(source); + const actual = run(transpiler.transformSync(source)); + expect( + rows.flatMap((row, index) => (actual[index] === expected[index] ? [] : [`${row}: ${actual[index]}`])), + ).toEqual([]); + expect(rows.length).toBeGreaterThan(1000); + }); + // A macro that returns a JSON `Response` gives the same 8-bit text with no flag. + it("gives what node gives for the text of a JSON `Response` of a macro", async () => { + using dir = tempDir("transpiler-macro-json-text", { + "m.ts": `export function v() { return Response.json(${JSON.stringify(values)}); }`, + "index.mjs": ` + import { v } from "./m.ts" with { type: "macro" }; + const { T, H, L, N } = v(); + console.log(JSON.stringify([("x" + T).length, T[1], (T + "x")[3], T.charCodeAt(3), H < L, +N, v().T[1], ("x" + v().T).length])); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim().split("\n").at(-1), stderr, exitCode }).toEqual({ + stdout: '[4,"\u00e9","x",null,true,1,"\u00e9",4]', + stderr: "", + exitCode: 0, + }); + }); + it("gives what node gives in `bun run`", async () => { + const src = ` + enum E { S = N as any } + console.log(("x" + T).length, T[3], T.charCodeAt(3), H < L, +N, (T + "x")[1] === "\\u00e9", +E.S, -E.S, ~E.S); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), ...Object.entries(define).map(([name, value]) => `--define=${name}=${value}`), "-e", src], + 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: "4 undefined NaN true 1 true 1 -1 -2\n", + stderr: "", + exitCode: 0, + }); + }); + }); + describe("TypeScript", () => { it("import Foo = Baz.Bar", () => { ts.expectPrinted_("import Foo = Baz.Bar;\nexport default Foo;", "const Foo = Baz.Bar;\nexport default Foo");