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
71 changes: 46 additions & 25 deletions src/ast/e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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::<u8>::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::<u8>::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.
Comment thread
robobun marked this conversation as 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.join_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.
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 @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -2438,15 +2457,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 @@ -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(&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 @@ -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<js_ast::ExprData> {
str_.resolve_rope_if_needed(self.arena);
let _ = str_.to_utf8(self.arena);
let specifier = str_.data;

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

Expand Down Expand Up @@ -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),
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 @@ -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 <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];
};
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"],
});
});
30 changes: 30 additions & 0 deletions test/bundler/bundler_edgecase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
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
Loading
Loading