diff --git a/src/ast/e.rs b/src/ast/e.rs index bcdd9490294d..6f096b14d6c8 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -1859,11 +1859,13 @@ impl EString { } pub fn eql_bytes(&self, other: &[u8]) -> bool { - if self.is_utf8() { - strings::eql_long(&self.data, other, true) - } else { - strings::utf16_eql_string(self.slice16(), other) + if !self.is_utf8() { + return strings::utf16_eql_string(self.slice16(), other); + } + if self.next.is_none() { + return strings::eql_long(&self.data, other, true); } + self.eql8_rope(other) } pub fn eql_comptime(&self, value: &'static [u8]) -> bool { @@ -1894,44 +1896,53 @@ impl EString { true } + /// Copies every segment of a rope into one arena slice. + fn join_rope<'b>(&self, bump: &'b Bump) -> &'b [u8] { + debug_assert!(self.next.is_some() && self.is_utf8()); + let mut bytes = bun_alloc::ArenaVec::::with_capacity_in(self.rope_len as usize, bump); + let mut segment: Option<&EString> = Some(self); + while let Some(part) = segment { + bytes.extend_from_slice(&part.data); + segment = part.next.as_deref(); + } + bytes.into_bump_slice() + } + pub fn resolve_rope_if_needed(&mut self, bump: &Bump) { if self.next.is_none() || !self.is_utf8() { return; } - let mut bytes = bun_alloc::ArenaVec::::with_capacity_in(self.rope_len as usize, bump); - bytes.extend_from_slice(&self.data); - let mut str_ = self.next; - while let Some(part) = str_ { - bytes.extend_from_slice(&part.get().data); - str_ = part.get().next; - } - self.data = Str::new(bytes.into_bump_slice()); + self.data = Str::new(self.join_rope(bump)); self.next = None; } - /// Return UTF-8 bytes, transcoding if UTF-16. - /// The transcode allocates via the global arena then copies into `bump`. + /// Return UTF-8 bytes, transcoding if UTF-16 and joining a rope. + /// A rope is joined into `bump` on every call; `slice` stores the joined + /// bytes back into the node instead. pub fn string<'b>(&self, bump: &'b Bump) -> Result<&'b [u8], AllocError> { - if self.is_utf8() { - // `self.data` is arena-owned with the same lifetime as `bump`; - // StoreStr re-borrows under that contract. - Ok(self.data.slice()) - } else { + if !self.is_utf8() { let v = strings::to_utf8_alloc(self.slice16()); - Ok(bump.alloc_slice_copy(&v)) + return Ok(bump.alloc_slice_copy(&v)); } + if self.next.is_some() { + return Ok(self.join_rope(bump)); + } + // `self.data` is arena-owned with the same lifetime as `bump`; + // StoreStr re-borrows under that contract. + Ok(self.data.slice()) } pub(crate) fn string_cloned<'b>(&self, bump: &'b Bump) -> Result<&'b [u8], AllocError> { - if self.is_utf8() { + if self.is_utf8() && self.next.is_none() { Ok(bump.alloc_slice_copy(&self.data)) } else { - let v = strings::to_utf8_alloc(self.slice16()); - Ok(bump.alloc_slice_copy(&v)) + // `string` already returns a fresh copy for these. + self.string(bump) } } pub fn hash(&self) -> u64 { + debug_assert!(self.next.is_none(), "hash() on an unresolved rope"); if self.is_blank() { return 0; } @@ -1960,6 +1971,10 @@ impl EString { #[inline] pub fn order(&self, other: &EString) -> Ordering { debug_assert!(self.is_utf8() == other.is_utf8()); + debug_assert!( + self.next.is_none() && other.next.is_none(), + "order() on an unresolved rope" + ); if self.is_utf8() { strings::order(&self.data, &other.data) } else { @@ -1983,6 +1998,10 @@ impl EString { // `eql`, split by operand type. pub fn eql_string(&self, other: &EString) -> bool { + debug_assert!( + self.next.is_none() && other.next.is_none(), + "eql_string() on an unresolved rope" + ); if self.is_utf8() { if other.is_utf8() { strings::eql_long(&self.data, &other.data, true) @@ -2438,7 +2457,7 @@ impl Import { self.import_record_index == u32::MAX } - pub fn import_record_loader(&self) -> Option { + pub fn import_record_loader(&self, bump: &Bump) -> Option { let crate::ExprData::EObject(obj) = &self.options.data else { return None; }; @@ -2446,7 +2465,9 @@ impl Import { let crate::ExprData::EObject(with_obj) = &with.data else { return None; }; - let str_ = Object::get(with_obj, b"type")?.data.as_e_string()?; + let mut str_ = Object::get(with_obj, b"type")?.data.as_e_string()?; + // import() options are always constant-folded, so this may be a rope. + str_.resolve_rope_if_needed(bump); if !str_.is_utf16 { if let Some(loader) = crate::Loader::from_string(&str_.data) { diff --git a/src/ast/expr.rs b/src/ast/expr.rs index e61f8fa11a02..3fda6395ddb4 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -2304,12 +2304,15 @@ impl Data { hasher.update(&e.value); } Data::EString(e) => { - // Only the *first* rope segment is hashed. - let current: &E::String = e; - if current.is_utf8() { - hasher.update(¤t.data); + if e.is_utf8() { + // Rope segments hash back to back, so "a" + "b" hashes like "ab". + let mut segment: Option<&E::String> = Some(e.get()); + while let Some(current) = segment { + hasher.update(¤t.data); + segment = current.next.as_deref(); + } } else { - hasher.update(bytemuck::cast_slice::(current.slice16())); + hasher.update(bytemuck::cast_slice::(e.slice16())); } hasher.update(b"\x00"); } diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index e14684e6a2f2..8679d1739a5e 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -7541,6 +7541,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O str_: &mut E::String, loc: bun_ast::Loc, ) -> Option { + str_.resolve_rope_if_needed(self.arena); let _ = str_.to_utf8(self.arena); let specifier = str_.data; diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 260a937e154b..79f62efed6dc 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1003,6 +1003,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let unwrapped = e_.index.unwrap_inlined(); if let Some(mut s) = unwrapped.data.e_string() { if !s.is_utf16 { + s.resolve_rope_if_needed(p.arena); // "a['b' + '']" => "a.b" // "enum A { B = 'b' }; a[A.B]" => "a.b" if p.options.features.minify_syntax && s.is_identifier(p.arena) { @@ -1827,7 +1828,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ), import_options: e_.options, loc: e_.expr.loc, - import_loader: e_.import_record_loader(), + import_loader: e_.import_record_loader(p.arena), ..Default::default() }; @@ -2380,9 +2381,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } // Check if the feature flag is enabled - // Use the underlying string data directly without allocation. // Feature flag names should be ASCII identifiers, so UTF-16 is unexpected. - let flag_string = arg.data.e_string().expect("infallible: variant checked"); + let mut flag_string = arg.data.e_string().expect("infallible: variant checked"); + flag_string.resolve_rope_if_needed(p.arena); if flag_string.is_utf16 { p.log().add_error( Some(p.source), diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 8be40eb1b8ae..c62336588a90 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -71,6 +71,35 @@ describe("Bun.build", () => { expect(await bunRun(build.outputs[0].path)).toSpawn("world"); }); + test("reactFastRefresh signature hashes the whole folded string literal", async () => { + // minify.syntax folds "a" + "b" into a rope; the hook signature must cover + // every segment, otherwise "a" + "b" and "a" + "c" get the same signature. + using dir = tempDir("bun-build-api-refresh-sig", { + "ab.tsx": `import { useState } from "react"; export function C() { const [v] = useState("ab"); return {v}; }`, + "a-b.tsx": `import { useState } from "react"; export function C() { const [v] = useState("a" + "b"); return {v}; }`, + "a-c.tsx": `import { useState } from "react"; export function C() { const [v] = useState("a" + "c"); return {v}; }`, + }); + const signatureOf = async (file: string) => { + const build = await Bun.build({ + entrypoints: [join(String(dir), file)], + reactFastRefresh: true, + minify: { syntax: true }, + external: ["react"], + }); + const output = await build.outputs[0].text(); + const match = output.match(/_s\w*\(C, "([^"]+)"\)/); + if (!match) throw new Error(`no refresh signature in ${file}:\n${output}`); + return match[1]; + }; + const [ab, aPlusB, aPlusC] = await Promise.all([ + signatureOf("ab.tsx"), + signatureOf("a-b.tsx"), + signatureOf("a-c.tsx"), + ]); + expect(aPlusB).toBe(ab); + expect(aPlusC).not.toBe(ab); + }); + test("passing undefined doesnt segfault", () => { try { // @ts-ignore diff --git a/test/bundler/bundler_allow_unresolved.test.ts b/test/bundler/bundler_allow_unresolved.test.ts index 9d8ea92207fa..1fd6859c60a8 100644 --- a/test/bundler/bundler_allow_unresolved.test.ts +++ b/test/bundler/bundler_allow_unresolved.test.ts @@ -226,4 +226,55 @@ describe("bundler", () => { backend: "cli", allowUnresolved: ["./locales/*.json"], }); + + // 17-20. Constant folding turns the template head/tail into a rope of string + // segments. The shape must include every segment ("./locales/*.json"), not + // just the first one ("./loc*.json"), or a matching pattern is rejected. + itBundled("allow-unresolved/RopeHeadFromStringAddition", { + files: { + "/entry.js": /* js */ ` + export function load(x) { + return import("./loc" + \`ales/\${x}.json\`); + } + `, + }, + outdir: "/out", + allowUnresolved: ["./locales/*.json"], + }); + + itBundled("allow-unresolved/RopeHeadFromTemplateFolding", { + files: { + "/entry.js": /* js */ ` + export function load(x) { + return import(\`./loc\${"ales"}/\${x}.json\`); + } + `, + }, + outdir: "/out", + allowUnresolved: ["./locales/*.json"], + }); + + itBundled("allow-unresolved/RopeTailFromStringAddition", { + files: { + "/entry.js": /* js */ ` + export function load(x) { + return import(\`./locales/\${x}\` + ".json"); + } + `, + }, + outdir: "/out", + allowUnresolved: ["./locales/*.json"], + }); + + itBundled("allow-unresolved/RequireResolveRopeHead", { + files: { + "/entry.js": /* js */ ` + export function load(x) { + return require.resolve("./loc" + \`ales/\${x}.json\`); + } + `, + }, + outdir: "/out", + allowUnresolved: ["./locales/*.json"], + }); }); diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 7fe388d3a623..14d84049ce31 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2913,6 +2913,36 @@ describe("bundler", () => { }, run: { stdout: "true 1" }, }); + // Enum initializers are always constant-folded, so K.X inlines as a rope + // ("fo" -> "o"). Without minifySyntax the index visitor used to rewrite the + // access with only the first segment: `fo` instead of `foo`. + itBundled("edgecase/FoldedEnumStringIndexOnNamespaceImport", { + files: { + "/entry.ts": /* ts */ ` + import * as ns from "./ns.ts"; + enum K { X = "fo" + "o" } + console.log(ns[K.X]); + `, + "/ns.ts": /* ts */ ` + export const foo = "yes"; + export const fo = "no"; + `, + }, + run: { stdout: "yes" }, + }); + itBundled("edgecase/FoldedEnumStringIndexAsCommonJSExportName", { + files: { + "/entry.ts": /* ts */ ` + import * as k from "./k.ts"; + console.log(JSON.stringify(Object.keys(k).sort()), k.foo); + `, + "/k.ts": /* ts */ ` + enum K { X = "fo" + "o" } + exports[K.X] = "value"; + `, + }, + run: { stdout: '["foo"] value' }, + }); // The bundler rewrites bare `require`/`require.main`/`require.resolve` to an // ERequireCallTarget / ERequireMain / ERequireResolveCallTarget that prints // as `__require` / `__require.main` / `__require.resolve`. diff --git a/test/bundler/bundler_feature_flag.test.ts b/test/bundler/bundler_feature_flag.test.ts index 327763902a63..933d0145a9c8 100644 --- a/test/bundler/bundler_feature_flag.test.ts +++ b/test/bundler/bundler_feature_flag.test.ts @@ -88,6 +88,28 @@ if (feature("DISABLED_FEATURE")) { }, }); + // minifySyntax folds "ENABLED_" + "FEATURE" before feature() sees it; the + // lookup must use the whole folded name, not its first segment. + itBundled(`feature_flag/${backend}/FoldedFlagName`, { + backend, + files: { + "/a.js": ` +import { feature } from "bun:bundle"; +if (feature("ENABLED_" + "FEATURE")) { + console.log("this should be kept"); +} else { + console.log("this should be removed"); +} +`, + }, + features: ["ENABLED_FEATURE"], + minifySyntax: true, + onAfterBundle(api) { + api.expectFile("out.js").toInclude("this should be kept"); + api.expectFile("out.js").not.toInclude("this should be removed"); + }, + }); + itBundled(`feature_flag/${backend}/ImportRemoved`, { backend, files: { diff --git a/test/bundler/bundler_loader.test.ts b/test/bundler/bundler_loader.test.ts index e7a7d02051d5..0dfccea4f6f5 100644 --- a/test/bundler/bundler_loader.test.ts +++ b/test/bundler/bundler_loader.test.ts @@ -151,6 +151,32 @@ describe("bundler", async () => { }); } + // import() options are visited with constant folding forced on, so these + // attribute strings reach the loader lookup as ropes; the lookup must see the + // whole string ("json"), not just its first segment ("js" is a real loader). + itBundled("bun/loader-dynamic-import-attribute-folded-type", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + const mod = await import('./hello.notjson', { with: { type: "js" + "on" } }); + console.write(JSON.stringify(mod.default)); + `, + "/hello.notjson": JSON.stringify({ hello: "world" }), + }, + run: { stdout: '{"hello":"world"}' }, + }); + itBundled("bun/loader-dynamic-import-attribute-folded-keys", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + const mod = await import('./hello.notjson', { ["wi" + "th"]: { ["ty" + "pe"]: "json" } }); + console.write(JSON.stringify(mod.default)); + `, + "/hello.notjson": JSON.stringify({ hello: "world" }), + }, + run: { stdout: '{"hello":"world"}' }, + }); + itBundled("bun/loader-text-file", { target: "bun", outfile: "", diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index 50bac3b40ede..6751c9195c1e 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -182,6 +182,30 @@ test("object destructuring of a macro result keeps every bound property regardle expect(exitCode).toBe(0); }); +test("object destructuring of a macro result matches a computed key built from folded string literals", async () => { + // ["a" + "b"] is folded into a rope; matching it against the macro result must use the whole + // string, or the `ab` property is dropped from the inlined object. + using dir = tempDir("macro-destructure-folded-key", { + "m.ts": `export function m() {\n return { ab: 1, c: 2 };\n}\n`, + "index.ts": [ + `import { m } from "./m.ts" with { type: "macro" };`, + `const { ["a" + "b"]: x, c } = m();`, + `console.log(JSON.stringify([x, c]));`, + ``, + ].join("\n"), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "index.ts"], + 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({ lastLine: stdout.trim().split("\n").pop(), stderr }).toEqual({ lastLine: "[1,2]", stderr: "" }); + expect(exitCode).toBe(0); +}); + describe("--no-macros", () => { const files = { "macro.ts": ` diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index a9a0d382aaed..f068e1b4ae4b 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3038,6 +3038,43 @@ console.log(resolve.length) `export const foo = require.resolve("my-module")`, ); }); + + it("require.resolve() keeps every segment of a folded string concatenation", () => { + // "./fo" + "o" is folded into a rope; the import record must hold the whole string, not its head. + expectPrinted_( + `export const foo = require.resolve('./fo' + 'o')`, + `export const foo = require.resolve("./foo")`, + ); + expectPrinted_( + `export const foo = require.resolve('./a' + '/b' + '/c')`, + `export const foo = require.resolve("./a/b/c")`, + ); + expectPrinted_( + `export const foo = require.resolve(x ? './fo' + 'o' : './ba' + 'r')`, + `export const foo = x ? require.resolve("./foo") : require.resolve("./bar")`, + ); + // require() and import() of the same argument already behaved this way. + expectPrinted_(`export const foo = require('./fo' + 'o')`, `export const foo = require("./foo")`); + expectPrinted_(`export const foo = import('./fo' + 'o')`, `export const foo = import("./foo")`); + expect(transpiler.scan(`require.resolve("./fo" + "o"); import("./ba" + "r");`).imports).toEqual([ + { kind: "require-resolve", path: "./foo" }, + { kind: "dynamic-import", path: "./bar" }, + ]); + }); + + it("a folded string index into an enum selects the member named by the whole string", () => { + // Arguments of require()/require.resolve()/import() are folded even without minification, + // so E["fo" + "o"] is looked up with a rope; it used to select E.fo. + const out = transpiler.transformSync(` + enum E { fo = "./fo", foo = "./foo" } + export const a = require(E["fo" + "o"]); + export const b = require.resolve(E["fo" + "o"]); + export const c = import(E["fo" + "o"]); + `); + expect(out).toContain(`export const a = require("./foo" /* foo */)`); + expect(out).toContain(`export const b = require.resolve("./foo" /* foo */)`); + expect(out).toContain(`export const c = import("./foo" /* foo */)`); + }); }); it("define", () => { diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 554b7d8e1046..8eb6ad131678 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -259,6 +259,28 @@ it("file url in require.resolve resolves", async () => { expect(stdout.toString("utf8")).toBe(`${dir}${sep}index.js\n`); }); +it("require.resolve of a folded string concatenation resolves the whole specifier", async () => { + // "./fo" + "o" is constant-folded by the transpiler; it used to record only "./fo". + await using dir = tempDir("require-resolve-folded", { + "foo.js": "module.exports = 'foo';", + "bar.js": "module.exports = 'bar';", + "test.js": ` + console.log(require.resolve("./fo" + "o")); + console.log(require.resolve(process.argv.length > 100 ? "./fo" + "o" : "./ba" + "r")); + console.log(require("./fo" + "o")); + `, + }); + + const { exitCode, stdout, stderr } = Bun.spawnSync({ + cmd: [bunExe(), `${dir}/test.js`], + env: bunEnv, + cwd: String(dir), + }); + expect(stderr.toString("utf8")).toBe(""); + expect(stdout.toString("utf8")).toBe(`${dir}${sep}foo.js\n${dir}${sep}bar.js\nfoo\n`); + expect(exitCode).toBe(0); +}); + it("file url with special characters in require resolves", async () => { const filename = "🅱️ndex.js"; await using dir = tempDir("file url", {