Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 33 additions & 17 deletions src/ast/e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1860,11 +1860,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 {
Expand Down Expand Up @@ -1927,29 +1929,33 @@ impl EString {
bytes.into_bump_slice()
}

/// 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.
Comment thread
robobun marked this conversation as resolved.
Resolved
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.flatten_rope(bump));
}
// `self.data` is arena-owned with the same lifetime as `bump`;
// StoreStr re-borrows under that contract.
Comment thread
robobun marked this conversation as resolved.
Resolved
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;
}
Expand Down Expand Up @@ -1978,6 +1984,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 {
Expand All @@ -2001,6 +2011,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)
Expand Down Expand Up @@ -2469,15 +2483,17 @@ impl Import {
self.import_record_index == u32::MAX
}

pub fn import_record_loader(&self) -> Option<crate::Loader> {
pub fn import_record_loader(&self, bump: &Bump) -> Option<crate::Loader> {
let crate::ExprData::EObject(obj) = &self.options.data else {
return None;
};
let with = Object::get(obj, b"with").or_else(|| Object::get(obj, b"assert"))?;
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) {
Expand Down
13 changes: 8 additions & 5 deletions src/ast/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2294,12 +2294,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(&current.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(&current.data);
segment = current.next.as_deref();
}
} else {
hasher.update(bytemuck::cast_slice::<u16, u8>(current.slice16()));
hasher.update(bytemuck::cast_slice::<u16, u8>(e.slice16()));
}
hasher.update(b"\x00");
}
Expand Down
1 change: 1 addition & 0 deletions src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8560,6 +8560,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
str_: &mut E::String,
loc: bun_ast::Loc,
) -> Option<js_ast::ExprData> {
str_.resolve_rope_if_needed(self.arena);
let _ = str_.to_utf8(self.arena);
let specifier = str_.data;

Expand Down
6 changes: 3 additions & 3 deletions src/js_parser/visit/visit_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1857,7 +1857,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),
Comment thread
robobun marked this conversation as resolved.
Resolved
..Default::default()
};

Expand Down Expand Up @@ -2530,9 +2530,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),
Expand Down
29 changes: 29 additions & 0 deletions test/bundler/bun-build-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,35 @@ describe("Bun.build", () => {
},
);

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 <b>{v}</b>; }`,
"a-b.tsx": `import { useState } from "react"; export function C() { const [v] = useState("a" + "b"); return <b>{v}</b>; }`,
"a-c.tsx": `import { useState } from "react"; export function C() { const [v] = useState("a" + "c"); return <b>{v}</b>; }`,
});
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];
Comment thread
robobun marked this conversation as resolved.
Resolved
};
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
Expand Down
51 changes: 51 additions & 0 deletions test/bundler/bundler_allow_unresolved.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});
});
22 changes: 22 additions & 0 deletions test/bundler/bundler_feature_flag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
26 changes: 26 additions & 0 deletions test/bundler/bundler_loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down
24 changes: 24 additions & 0 deletions test/bundler/transpiler/macro-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,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);
});

// A Response or Blob returned from a macro is inlined by its content type: JSON is parsed into an object
// literal, text becomes a string, anything else becomes a base64 data URL. The type has to be classified
// with its parameters stripped: `Response.json()` and most servers send `application/json;charset=utf-8`.
Expand Down
23 changes: 23 additions & 0 deletions test/bundler/transpiler/transpiler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3462,6 +3462,29 @@ 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("define", () => {
Expand Down
22 changes: 22 additions & 0 deletions test/js/bun/resolve/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
Loading