Skip to content
17 changes: 15 additions & 2 deletions src/ast/e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32> {
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) {
Expand Down
32 changes: 12 additions & 20 deletions src/ast/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<f64> {
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
Expand Down
48 changes: 8 additions & 40 deletions src/js_parser/fold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,32 +15,6 @@ fn e_string_init(data: &[u8]) -> E::EString {
}
}

#[inline]
fn e_string_javascript_length(s: &E::EString) -> Option<u32> {
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> {
Expand Down Expand Up @@ -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));
}
}
Expand Down Expand Up @@ -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()
{
Expand Down
14 changes: 9 additions & 5 deletions src/js_parser/visit/visit_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Comment thread
robobun marked this conversation as resolved.
Resolved
if let Some(number) = index.data.as_e_number() {
if number.value() >= 0.0
&& number.value() < (usize::MAX as f64)
Expand All @@ -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]),
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/jsc/RuntimeTranspilerCache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/react_compiler/optimization/constant_propagation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -809,7 +809,7 @@ fn push_units(out: &mut Vec<u16>, 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()));
}
}

Expand Down
6 changes: 6 additions & 0 deletions test/aot/minifying-first-saves-no-code/known-today.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export const knownToday: Record<string, string> = {
"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 "<its folder>/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",
Expand Down
85 changes: 85 additions & 0 deletions test/bundler/bundler_env.test.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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<string, string | undefined> = { ...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 });
});
49 changes: 49 additions & 0 deletions test/bundler/bundler_minify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": `
Expand Down
Loading
Loading