diff --git a/src/aot/lower/context.rs b/src/aot/lower/context.rs index 0d5c85b88a4b..c7e84ced228b 100644 --- a/src/aot/lower/context.rs +++ b/src/aot/lower/context.rs @@ -8,8 +8,7 @@ use core::cell::{OnceCell, RefCell}; use core::mem::take; use std::borrow::Cow; -use bun_core::printer::{FIRST_ASCII, LAST_ASCII, bmp_escape, surrogate_pair_escape}; -use bun_core::strings::{CodepointIterator, Cursor}; +use bun_core::printer::{RawText, write_raw_text_ascii}; use bun_sema::atom::Atom; use bun_sema::bind::{Binding, Bound, SymbolId}; use bun_sema::check::aot::{BuiltinTypeSymbols, NamedValues, Queries, QueryInputs}; @@ -56,23 +55,12 @@ use crate::statistics::{LimitId, LimitValues}; pub(crate) use self::function::FunctionLowering; pub(super) fn regexp_pattern_as_printed(written: &[u8]) -> Cow<'_, [u8]> { - let is_printed_as_written = |character: u32| (FIRST_ASCII..=LAST_ASCII).contains(&character); - if written - .iter() - .all(|&byte| is_printed_as_written(u32::from(byte))) - { + let Some(start) = RawText::RegExp.first_respelled(written) else { return Cow::Borrowed(written); - } + }; let mut printed = Vec::with_capacity(written.len() * 2); - let characters = CodepointIterator::init(written); - let mut cursor = Cursor::default(); - while characters.next(&mut cursor) { - match cursor.c as u32 { - character if is_printed_as_written(character) => printed.push(character as u8), - character @ 0..=0xFFFF => printed.extend_from_slice(&bmp_escape(character)), - character => printed.extend_from_slice(&surrogate_pair_escape(character)), - } - } + write_raw_text_ascii(written, start, RawText::RegExp, &mut printed) + .expect("a Vec takes every write"); Cow::Owned(printed) } diff --git a/src/aot/lower/quoted_text.rs b/src/aot/lower/quoted_text.rs index db268956c8f7..a7dff072bfa7 100644 --- a/src/aot/lower/quoted_text.rs +++ b/src/aot/lower/quoted_text.rs @@ -13,6 +13,7 @@ use bun_sort_by_number::SortByNumber; use rustc_hash::FxHashMap; use smallvec::SmallVec; +use super::context::regexp_pattern_as_printed; use super::expression::is_link_in_brackets; use super::{FileLowering, FunctionLowering}; use crate::ir::generated::QuotedText; @@ -787,22 +788,9 @@ impl Printer<'_, '_, '_, '_, '_> { ExprKind::Regex { .. } => { self.space_before_name(); let start = whole(self); - let characters = CodepointIterator::init( - &hir.text[hir[expression].pos as usize..hir[expression].end as usize], - ); - let mut cursor = Cursor::default(); - while characters.next(&mut cursor) { - match (u8::try_from(cursor.c), char::from_u32(cursor.c as u32)) { - (Ok(character), _) if character.is_ascii() => self.text.push(character), - (_, Some(character)) => { - for unit in character.encode_utf16(&mut [0; 2]) { - self.text - .extend_from_slice(format!("\\u{unit:04X}").as_bytes()); - } - } - (_, None) => self.gaps += 1, - } - } + let literal = &hir.text[hir[expression].pos as usize..hir[expression].end as usize]; + self.text + .extend_from_slice(®exp_pattern_as_printed(literal)); start } ExprKind::Spread(operand) => { diff --git a/src/bun_core/string/mod.rs b/src/bun_core/string/mod.rs index 45d9296b1f53..90813d6eb828 100644 --- a/src/bun_core/string/mod.rs +++ b/src/bun_core/string/mod.rs @@ -2057,7 +2057,7 @@ pub mod printer { use crate::string::immutable::{self as strings, Encoding as StrEncoding}; use crate::string::mutable_string::MutableString; - use crate::fmt::{hex_byte_lower, hex2_upper, hex4_upper}; + use crate::fmt::{hex_byte_lower, hex2_upper, hex4_upper, u64_hex_var_lower}; pub const FIRST_ASCII: u32 = 0x20; pub const LAST_ASCII: u32 = 0x7E; @@ -2089,6 +2089,118 @@ pub mod printer { /// `MutableString`, and any other `crate::io::Write` sink. pub use crate::io::Write as PrinterWriter; + /// Source text that the printer holds as it is written, backslashes included. + #[derive(Clone, Copy)] + pub enum RawText { + /// One part of a tagged template. + Template, + /// A regular expression literal, or its pattern alone. + RegExp, + } + + impl RawText { + #[inline] + fn is_respelled(self, character: u32) -> bool { + match self { + RawText::Template => character >= 0x80, + RawText::RegExp => !(FIRST_ASCII..=LAST_ASCII).contains(&character), + } + } + + /// Where `write_raw_text_ascii` first writes an escape. `None`: the text is printed as it is. + #[inline] + pub fn first_respelled(self, text: &[u8]) -> Option { + match self { + RawText::Template => strings::first_non_ascii_usize(text), + RawText::RegExp => text + .iter() + .position(|&byte| self.is_respelled(u32::from(byte))), + } + } + } + + /// Writes `text` in ASCII: an escape stands for each code point that is not printed as it is. + #[inline(always)] // Each caller names its kind, so the loop is compiled for that kind alone. + pub fn write_raw_text_ascii( + text: &[u8], + start: usize, + kind: RawText, + writer: &mut W, + ) -> crate::CrateResult<()> { + debug_assert_eq!(kind.first_respelled(text), Some(start)); + let characters = strings::CodepointIterator::init(text); + let mut cursor = strings::Cursor { + i: start as u32, + ..Default::default() + }; + let mut copied = 0; + while characters.next(&mut cursor) { + let character = cursor.c as u32; + if !kind.is_respelled(character) { + continue; + } + let at = cursor.i as usize; + if copied < at { + writer.write_all(&text[copied..at])?; + } + // A backslash is printed as it is, so only what was copied just now can end in one. + let is_behind_backslash = copied < at && text[at - 1] == b'\\'; + copied = at + usize::from(cursor.width); + if is_behind_backslash && write_behind_backslash(&text[..at], character, kind, writer)? + { + continue; + } + match (kind, character) { + (_, 0..=0xFFFF) => writer.write_all(&bmp_escape(character))?, + (RawText::Template, _) => write_braced_escape(b"\\u{", character, writer)?, + (RawText::RegExp, _) => writer.write_all(&surrogate_pair_escape(character))?, + } + } + if copied < text.len() { + writer.write_all(&text[copied..])?; + } + Ok(()) + } + + /// Where the last backslash of `ahead` escapes `character`, writes the escape without its own backslash. + #[cold] + #[inline(never)] + fn write_behind_backslash( + ahead: &[u8], + character: u32, + kind: RawText, + writer: &mut W, + ) -> crate::CrateResult { + let mut run = 0; + while run < ahead.len() && ahead[ahead.len() - 1 - run] == b'\\' { + run += 1; + } + if run % 2 == 0 { + return Ok(false); + } + match (kind, character) { + // A line continuation. No regular expression literal holds a line terminator. + (RawText::Template, 0x2028 | 0x2029) => writer.write_all(b"\n")?, + (_, 0..=0xFFFF) => writer.write_all(&bmp_escape(character)[1..])?, + (RawText::Template, _) => write_braced_escape(b"u{", character, writer)?, + (RawText::RegExp, _) => writer.write_all(&surrogate_pair_escape(character)[1..])?, + } + Ok(true) + } + + /// `\u{1f600}`, where `opening` is `\u{` or `u{`. + #[inline] + fn write_braced_escape( + opening: &[u8], + character: u32, + writer: &mut W, + ) -> crate::CrateResult<()> { + let mut digits = [0; 16]; + writer.write_all(opening)?; + writer.write_all(u64_hex_var_lower(&mut digits, u64::from(character)))?; + writer.write_all(b"}") + } + #[inline] fn can_print_without_escape(c: i32, ascii_only: bool) -> bool { if c <= LAST_ASCII as i32 { diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 4390d65d0eed..c844fe39f4b0 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -3,6 +3,7 @@ use bun_collections::VecExt; use std::io::Write as _; use bstr::BStr; +use bun_core::printer::RawText; use bun_core::strings; use crate::lexer as js_lexer; @@ -816,14 +817,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return None; }; let head = *head; - let mut is_ascii = strings::is_all_ascii(head.slice()); + let is_printed_as_written = |text: &[u8]| RawText::Template.first_respelled(text).is_none(); + let mut is_all_printed_as_written = is_printed_as_written(head.slice()); for part in template.parts() { let E::TemplateContents::Raw(tail) = &part.tail else { return None; }; - is_ascii = is_ascii && strings::is_all_ascii(tail.slice()); + is_all_printed_as_written = + is_all_printed_as_written && is_printed_as_written(tail.slice()); } - if is_ascii { + if is_all_printed_as_written { return None; } diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 067c3be8510c..9006f7dabeac 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -861,8 +861,8 @@ pub type RuntimeTranspilerCacheRef = core::ptr::NonNull 0x7F - 0..=LAST_ASCII => { - if !is_ascii { - ascii_start = cursor.i as usize; - is_ascii = true; - } - } - _ => { - if is_ascii { - self.print(&bytes[ascii_start..(cursor.i as usize)]); - is_ascii = false; - } - - match cursor.c as u32 { - c @ 0..=0xFFFF => self.print(&bmp_escape(c)[..]), - _ => { - self.print(b"\\u{"); - let _ = self.fmt(format_args!("{:x}", cursor.c)); - self.print(b"}"); - } - } - } - } } + } - if is_ascii { - self.print(&bytes[ascii_start..]); - } + #[inline(always)] + fn print_raw_text_ascii(&mut self, text: &[u8], kind: RawText) { + let Some(start) = kind.first_respelled(text) else { + return self.print(text); + }; + let mut writer = self.writer.std_writer(); + let _ = write_raw_text_ascii(text, start, kind, &mut writer); } pub(crate) fn check_stack_overflow(&self) -> crate::Result<()> { @@ -4678,38 +4645,8 @@ pub(crate) mod __gated_printer { } if IS_BUN_PLATFORM { - // Translate any non-ASCII to unicode escape sequences - let mut ascii_start: usize = 0; - let mut is_ascii = false; - let iter = CodepointIterator::init(&e.value); - let mut cursor = strings::Cursor::default(); - while iter.next(&mut cursor) { - match cursor.c as u32 { - FIRST_ASCII..=LAST_ASCII => { - if !is_ascii { - ascii_start = cursor.i as usize; - is_ascii = true; - } - } - _ => { - if is_ascii { - self.print(&e.value[ascii_start..(cursor.i as usize)]); - is_ascii = false; - } - - match cursor.c as u32 { - c @ 0..=0xFFFF => self.print(&bmp_escape(c)[..]), - c => self.print(&surrogate_pair_escape(c)[..]), - } - } - } - } - - if is_ascii { - self.print(&e.value[ascii_start..]); - } + self.print_raw_text_ascii(&e.value, RawText::RegExp); } else { - // UTF8 sequence is fine self.print(&e.value[..]); } diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index cafb4f0281bf..5f7b89a5a41f 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: A backslash ahead of a character that is printed as an escape starts that escape (#26785). +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/test/aot/ir/lower/expressions/regexp-literal.ts b/test/aot/ir/lower/expressions/regexp-literal.ts index b30ef965f64b..01d4735c21ab 100644 --- a/test/aot/ir/lower/expressions/regexp-literal.ts +++ b/test/aot/ir/lower/expressions/regexp-literal.ts @@ -3,7 +3,20 @@ // CHECK-NOT: NewRegExp // CHECK-LABEL: function {{*}}name="create" // CHECK: = NewRegExp pattern="a+" regExpFlags=[Global, IgnoreCase] +// CHECK-LABEL: function {{*}}name="escaped" +// CHECK: = NewRegExp pattern="\\u00E9+" +// CHECK: = NewRegExp pattern="[\\u00E9-\\u00FC]" +// CHECK: = NewRegExp pattern="\\\\\\u00E9" +// CHECK: = NewRegExp pattern="\\\\\\u00E9" +// CHECK: = NewRegExp pattern="\\uD83D\\uDE00" +// CHECK: = NewRegExp pattern="(?\\u00E9)" export function create() { return /a+/gi; } + +// The pattern is what `bun run` prints for the literal: an escape for each non-ASCII character, and a backslash +// ahead of the character is the backslash of that escape. +export function escaped() { + return [/\é+/, /[\é-\ü]/, /\\é/, /\\\é/, /\😀/, /(?\é)/]; +} diff --git a/test/aot/parallel/errors/a-regular-expression-is-quoted-with-the-escapes-that-bun-prints.js b/test/aot/parallel/errors/a-regular-expression-is-quoted-with-the-escapes-that-bun-prints.js new file mode 100644 index 000000000000..5e9ee8f6be21 --- /dev/null +++ b/test/aot/parallel/errors/a-regular-expression-is-quoted-with-the-escapes-that-bun-prints.js @@ -0,0 +1,8 @@ +// A message quotes a regular expression as Bun prints it: a backslash ahead of a character that is printed as an escape starts that escape, and a control character (the tab of the last but one line) has its escape. +const say = run => { try { run(); } catch (error) { console.log(JSON.stringify(error.message)); } }; +const o = {}; +say(() => o.nothing(/\é/)); +say(() => o.nothing(/\\é/, /\\\é/, /[a-\ÿ]/)); +say(() => o.nothing(/\日+/, /\😀/, /[\😀]/u)); +say(() => o.nothing(/a b/, /a\ b/)); +say(() => /\é/.nothing()); diff --git a/test/aot/parallel/regexp/a-backslash-ahead-of-a-non-ascii-character-of-a-literal.js b/test/aot/parallel/regexp/a-backslash-ahead-of-a-non-ascii-character-of-a-literal.js new file mode 100644 index 000000000000..468bb95df046 --- /dev/null +++ b/test/aot/parallel/regexp/a-backslash-ahead-of-a-non-ascii-character-of-a-literal.js @@ -0,0 +1,10 @@ +// A backslash ahead of a non-ASCII character of a literal: alone, in a class, in a range, behind other backslashes, and beside the name of a group. A tag gets the character in its string. +const subjects = ["é", "ü", "\\é", "\\u00E9", "u00E9", "日日", "😀", "\ud83d", "\ude00", "aé", "aaé", "xé", "u{é}", "k", "a"]; +const expressions = [ + /\é/, /[\é]/, /[^\é]/, /[\é-\ü]/, /\\é/, /\\\é/, /\é\é/, /^\日+$/, /^\😀$/, /[\😀]/, /^\😀+$/, + /(?<é>a)\é/, /(?a)\k\é/, /(?<=\é)a|\é/, /\x\é/, /\u{\é}/, /\k/, +]; +for (const expression of expressions) + console.log(expression.source, subjects.map(subject => JSON.stringify(subject.match(expression))).join(" ")); +const tag = strings => JSON.stringify([...strings]); +console.log(tag`\é`, tag`a\日b${1}\😀`, tag`\\é\\\é`, tag`\u{zz}\é`); diff --git a/test/aot/regexp-literals-and-interpreter.test.ts b/test/aot/regexp-literals-and-interpreter.test.ts index e37c0df1b525..048dafd37dd5 100644 --- a/test/aot/regexp-literals-and-interpreter.test.ts +++ b/test/aot/regexp-literals-and-interpreter.test.ts @@ -65,3 +65,15 @@ for (const expression of expressions) expect(withTheOption.hasFailed).toBe(false); expect(withTheOption.stdout.split("\n").length).toBe(13); }); + +// What Node prints. `bun run` prints an escape for such a character, and the compiler gives the engine the same text. +test("a backslash ahead of a non-ASCII character or a tab of a literal escapes that character", async () => { + const { withoutTheOption, withTheOption } = await compileAndRun(` +console.log(/\\é/.test("é"), /^[a-z\\é]+$/.test("a09"), "xéy".replace(/\\é/g, "-"), /^\\\\\\é$/.test("\\\\é"), /^\\😀$/.test("😀"), /a\\\tb/.test("a\\tb")); +`); + expect(withTheOption).toEqual(withoutTheOption); + expect({ stdout: withTheOption.stdout, hasFailed: withTheOption.hasFailed }).toEqual({ + stdout: "true false x-y true true true\n", + hasFailed: false, + }); +}); diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 27e4b71f848b..5071bb90769e 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -19,6 +19,103 @@ test("ascii regex with escapes", () => { expect(/^[-#!$@£%^&*()_+|~=`{}\[\]:";'<>?,.\/ ]$/).toBeInstanceOf(RegExp); }); +describe("a regexp literal", () => { + test("with a backslash ahead of a non-ASCII character matches that character", () => { + expect(/\é/.test("é")).toBe(true); + expect(/^a\日b$/.test("a日b")).toBe(true); + expect(/^\😀$/.test("😀")).toBe(true); + expect(/^[\é]$/.test("é")).toBe(true); + expect(/^[a-z\é]+$/.test("café")).toBe(true); + expect(/^[a-z\é]+$/.test("a09")).toBe(false); + expect("xéy".replace(/\é/g, "-")).toBe("x-y"); + expect(/^\\é$/.test("\\é")).toBe(true); + expect(/^\\\é$/.test("\\é")).toBe(true); + const fraction = /([⁰¹²³⁴⁵⁶⁷⁸⁹]+|[0-9]+)([\/\⁄])([₀₁₂₃₄₅₆₇₈₉]+|[0-9]+)/; + expect(Array.from("³⁄₅₂ cup of stuff".match(fraction) ?? [])).toEqual(["³⁄₅₂", "³", "⁄", "₅₂"]); + }); + + const forBun = new Bun.Transpiler({ target: "bun" }); + // What the engine makes of a literal: its name for the error, or what it finds in each subject. + const read = (assignment: string, subjects: string[]) => { + let regexp: RegExp; + try { + regexp = new Function("let x;\n" + assignment + "\nreturn x;")(); + } catch (error) { + return (error as Error).name; + } + return JSON.stringify(subjects.map(subject => regexp.exec(subject)).map(match => match && [match.index, ...match])); + }; + + // What Node finds. + test.each([ + ["a backslash ahead of a tab", "/a\\\tb/", "a\tb", [0, "a\tb"]], + ["a backslash ahead of U+007F in a class", "/[\\\x7F]/", "a\x7F", [1, "\x7F"]], + ["a backslash ahead of U+0000", "/a\\\0/", "a\0", [0, "a\0"]], + ["U+0000 in a class with the v flag", "/[\0]/v", "a\0", [1, "\0"]], + ["a backslash ahead of U+00E9 behind \\k<, and no named group", "/\\k<\\\u00E9>/", "k<\u00E9>", [0, "k<\u00E9>"]], + ])("with %s finds what it finds in Node", (_, literal, subject, found) => { + expect(read(forBun.transformSync("x = " + literal + ";"), [subject])).toBe(JSON.stringify([found])); + }); + + test("is printed for Bun with an escape for each character that is not printable ASCII", () => { + // [the literal, what is printed for Bun] + const rows = [ + ["/\u0080\\\u0080\\\\\u0080/", "/\\u0080\\u0080\\\\\\u0080/"], + ["/\uFFFF\\\uFFFF/", "/\\uFFFF\\uFFFF/"], + ["/\u{10000}\\\u{10000}/u", "/\\uD800\\uDC00\\uD800\\uDC00/u"], + ["/\u{10FFFF}\\\\\\\u{10FFFF}/", "/\\uDBFF\\uDFFF\\\\\\uDBFF\\uDFFF/"], + ["/\u00E9\x7F\t\0\\\t~ /", "/\\u00E9\\u007F\\u0009\\u0000\\u0009~ /"], + ]; + for (const target of ["browser", "node"] as const) { + const transpiler = new Bun.Transpiler({ target }); + expect(rows.map(([literal]) => transpiler.transformSync("x = " + literal + ";"))).toEqual( + rows.map(([literal]) => "x = " + literal + ";\n"), + ); + } + expect(rows.map(([literal]) => forBun.transformSync("x = " + literal + ";"))).toEqual( + rows.map(([, printed]) => "x = " + printed + ";\n"), + ); + }); + + // Each pattern is given to the engine twice: as it is written, and as it is printed for Bun. The engine must + // refuse both, or find the same with both. `X` stands for the character. V8 reads each of them as the engine does. + test("is read by the engine as it is written, whatever stands ahead of a non-ASCII character", () => { + const places = [ + ...["\\X", "X", "a\\Xb", "\\\\X", "\\\\\\X", "\\\\\\\\X", "\\X\\X", "X\\X", "\\XX"], + ...["^\\X+$", "^\\X{2}$", "\\X*?", "\\b\\X", "\\d\\X", "\t\\X", "\\\t\\X", "\\\x7F\\X"], + ...["[\\X]", "[^\\X]", "[a\\X]", "[a-\\X]", "[\\X-]", "[\\X-\\\u{10FFFF}]", "[\\X\\X]", "[\\]\\X]", "[/]\\X"], + ...["(\\X)", "(?:\\X)", "(?=\\X)", "(?<=\\X)a", "(?\\X)", "(?a)\\k\\X", "(?\\X)\\k", "\\k", "\\k", "\\\\k<\\X>"], + ...["[\\k<\\X>]", "\\(?<\\X>", "[(?<\\X>]", "\\c\\X", "\\cX", "\\x\\X", "\\u\\X", "\\u00\\X", "\\u{\\X}"], + ...["\\p{\\X}", "\\0\\X", "(a)\\1\\X"], + ]; + // With `u` or `v`, ECMAScript refuses a backslash ahead of the character: these have none. + const literals = [...places.map(place => "/" + place + "/"), "/X/u", "/^[X]+$/u", "/[^X]/v", "/(?X)\\k/u"]; + const characters = ["\u0080", "\u00E9", "\u65E5", "\uFFFF", "\u{10000}", "\u{1F600}"]; + const readAnotherWay: string[] = []; + for (const X of characters) { + const subjects = [ + ...["", "a", "u", "\\", "X", "XX", "\\X", "aX", "aXb", "aaX", "k", "k", "k", "k"], + ...["\\k", "(?", "cX", "\\cX", "xX", "uX", "u00X", "u{X}", "p{X}", "\0X", "\tX", "\x7FX", "/X", "]X"], + ...["a{1,X}", "dX", "0X", "\u{10FFFF}"], + ].map(subject => subject.replaceAll("X", X)); + // The text of the escapes, with and without a backslash ahead, and each UTF-16 code unit alone. + const units = X.split(""); + const escapes = units + .map(unit => "\\u" + unit.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")) + .join(""); + subjects.push(escapes, "\\" + escapes, escapes.slice(1), ...units); + for (const literal of literals) { + const written = "x = " + literal.replaceAll("X", X) + ";"; + if (read(forBun.transformSync(written), subjects) !== read(written, subjects)) { + readAnotherWay.push(literal + " U+" + X.codePointAt(0)!.toString(16).toUpperCase()); + } + } + } + expect(readAnotherWay).toEqual([]); + }); +}); + describe("// @bun", () => { beforeEach(() => { delete require.cache[require.resolve("./async-transpiler-entry")]; diff --git a/test/bundler/transpiler/template-literal-tags-fixture.js b/test/bundler/transpiler/template-literal-tags-fixture.js index cc149a3117e9..093714d7fe80 100644 --- a/test/bundler/transpiler/template-literal-tags-fixture.js +++ b/test/bundler/transpiler/template-literal-tags-fixture.js @@ -31,7 +31,10 @@ console.log( raw: [String.raw`😀`, String.raw`a${1}é${2}日本`, String.raw`{"a":"😀"}`], cooked: [...strings`😀\n${0}é`], ownEscapes: strings`\u{1f600}😀\xe9é`.raw, + escaped: [...strings`\é${0}a\日b\😀${1}\\é\\\é`], + escapedRaw: strings`\é${0}a\日b\😀${1}\\é\\\é`.raw, invalidEscape: [strings`\u{zz}é`[0] === undefined, strings`\u{zz}é`.raw[0]], + invalidEscapeAndEscaped: [strings`\u{zz}\é`[0] === undefined, strings`\x\é`[0] === undefined, strings`\x\é`.raw[0]], newline: strings`é é`.raw, sameSite: site() === site(), diff --git a/test/bundler/transpiler/template-literal.test.ts b/test/bundler/transpiler/template-literal.test.ts index 821008773d1d..748dc58e27bb 100644 --- a/test/bundler/transpiler/template-literal.test.ts +++ b/test/bundler/transpiler/template-literal.test.ts @@ -28,7 +28,10 @@ describe("a tag whose template has non-ASCII text", () => { raw: ["😀", "a1é2日本", '{"a":"😀"}'], cooked: ["😀\n", "é"], ownEscapes: ["\\u{1f600}😀\\xe9é"], + escaped: ["é", "a日b😀", "\\é\\é"], + escapedRaw: ["\\é", "a\\日b\\😀", "\\\\é\\\\\\é"], invalidEscape: [true, "\\u{zz}é"], + invalidEscapeAndEscaped: [true, true, "\\x\\é"], newline: ["é\né"], sameSite: true, otherSite: false, @@ -139,4 +142,46 @@ describe("a tag whose template has non-ASCII text", () => { expect(JSON.parse(stdout)).toEqual(["é\né\né"]); expect(exitCode).toBe(0); }); + + // What Node gives the tag: [strings, strings.raw]. + test.concurrent.each([ + ["a line continuation that ends in U+2028", "a\\\u2028b", [["ab"], ["a\\\u2028b"]]], + ["a line continuation that ends in U+2029", "a\\\u2029b", [["ab"], ["a\\\u2029b"]]], + ["U+2028 behind two backslashes", "a\\\\\u2028b", [["a\\\u2028b"], ["a\\\\\u2028b"]]], + ["a line continuation between $ and {", "$\\\u2028{1}", [["${1}"], ["$\\\u2028{1}"]]], + ["U+007F", "a\x7Fb", [["a\x7Fb"], ["a\x7Fb"]]], + ["U+007F behind a backslash", "a\\\x7Fb", [["a\x7Fb"], ["a\\\x7Fb"]]], + ])("gets %s as it is in Node", async (_, text, stringsAndRaw) => { + using dir = tempDir("template-literal-tags", { + "tag.js": "console.log(JSON.stringify((strings => [[...strings], strings.raw])`" + text + "`));", + }); + const { stdout, stderr, exitCode } = await run(join(String(dir), "tag.js")); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(stringsAndRaw); + expect(exitCode).toBe(0); + }); + + // `Bun.Transpiler` calls no helper, so the tag gets what is printed in `strings.raw`. + test("is printed for Bun with an escape for each non-ASCII character, and cooks to the same strings", () => { + // [the text of the template, what is printed for Bun] + const rows = [ + ["\u00E9\\\u00E9\\\\\u00E9", "\\u00E9\\u00E9\\\\\\u00E9"], + ["\u0080\uFFFF\\\uFFFF", "\\u0080\\uFFFF\\uFFFF"], + ["\u{10000}\\\u{10000}\\\\\\\u{10FFFF}", "\\u{10000}\\u{10000}\\\\\\u{10ffff}"], + ["a\\\u2028b\u2029${1}\\\u2029", "a\\\nb\\u2029${1}\\\n"], + ["\u00E9\x7F\t\0\\\x7F", "\\u00E9\x7F\t\0\\\x7F"], + ]; + const code = (text: string) => "x = tag`" + text + "`;\n"; + for (const target of ["browser", "node"] as const) { + const transpiler = new Bun.Transpiler({ target }); + expect(rows.map(([text]) => transpiler.transformSync(code(text)))).toEqual(rows.map(([text]) => code(text))); + } + const transpiler = new Bun.Transpiler({ target: "bun" }); + expect(rows.map(([text]) => transpiler.transformSync(code(text)))).toEqual( + rows.map(([, printed]) => code(printed)), + ); + const cooked = (code: string) => + new Function("tag", "let x;\n" + code + "return x;")((strings: string[]) => [...strings]); + expect(rows.map(([, printed]) => cooked(code(printed)))).toEqual(rows.map(([text]) => cooked(code(text)))); + }); });