diff --git a/src/js_parser/lexer.rs b/src/js_parser/lexer.rs index 26f624f19c00..de5fcaeecab0 100644 --- a/src/js_parser/lexer.rs +++ b/src/js_parser/lexer.rs @@ -1978,7 +1978,6 @@ impl<'a> Lexer<'a> { let offset = self.scan_pragma( self.start + i + (text.len() - rest.len()), chunk, - CommentKind::MultiLine, PureAnnotation::Allow, ); @@ -2117,12 +2116,7 @@ impl<'a> Lexer<'a> { }; first_marker = false; let chunk = js_ast::StoreStr::new(self.remaining()); - self.current += self.scan_pragma( - pragma_trigger_pos, - chunk.slice(), - CommentKind::SingleLine, - pure, - ); + self.current += self.scan_pragma(pragma_trigger_pos, chunk.slice(), pure); continue; } _ => { @@ -2150,11 +2144,8 @@ impl<'a> Lexer<'a> { &mut self, offset_for_errors: usize, chunk: &[u8], - comment: CommentKind, pure: PureAnnotation, ) -> usize { - // A `//` comment ends at the line break, so a pragma argument stops there. - let allow_newline = comment == CommentKind::SingleLine; if pure == PureAnnotation::Allow && !self.has_pure_comment_before { if strings::has_prefix_with_word_boundary(chunk, b"__PURE__") { self.has_pure_comment_before = true; @@ -2163,61 +2154,29 @@ impl<'a> Lexer<'a> { } if strings::has_prefix_with_word_boundary(chunk, b"jsx") { - if let Some(span) = - PragmaArg::scan(self.start + offset_for_errors, b"jsx", chunk, allow_newline) - { + if let Some(span) = PragmaArg::scan(self.start + offset_for_errors, b"jsx", chunk) { self.jsx_pragma._jsx = span; - return "jsx".len() - + if span.range.len > 0 { - usize::try_from(span.range.len).expect("int cast") - } else { - 0 - }; + return "jsx".len() + usize::try_from(span.range.len).expect("int cast"); } } else if strings::has_prefix_with_word_boundary(chunk, b"jsxFrag") { - if let Some(span) = PragmaArg::scan( - self.start + offset_for_errors, - b"jsxFrag", - chunk, - allow_newline, - ) { + if let Some(span) = PragmaArg::scan(self.start + offset_for_errors, b"jsxFrag", chunk) { self.jsx_pragma._jsx_frag = span; - return "jsxFrag".len() - + if span.range.len > 0 { - usize::try_from(span.range.len).expect("int cast") - } else { - 0 - }; + return "jsxFrag".len() + usize::try_from(span.range.len).expect("int cast"); } } else if strings::has_prefix_with_word_boundary(chunk, b"jsxRuntime") { - if let Some(span) = PragmaArg::scan( - self.start + offset_for_errors, - b"jsxRuntime", - chunk, - allow_newline, - ) { + if let Some(span) = + PragmaArg::scan(self.start + offset_for_errors, b"jsxRuntime", chunk) + { self.jsx_pragma._jsx_runtime = span; - return "jsxRuntime".len() - + if span.range.len > 0 { - usize::try_from(span.range.len).expect("int cast") - } else { - 0 - }; + return "jsxRuntime".len() + usize::try_from(span.range.len).expect("int cast"); } } else if strings::has_prefix_with_word_boundary(chunk, b"jsxImportSource") { - if let Some(span) = PragmaArg::scan( - self.start + offset_for_errors, - b"jsxImportSource", - chunk, - allow_newline, - ) { + if let Some(span) = + PragmaArg::scan(self.start + offset_for_errors, b"jsxImportSource", chunk) + { self.jsx_pragma._jsx_import_source = span; return "jsxImportSource".len() - + if span.range.len > 0 { - usize::try_from(span.range.len).expect("int cast") - } else { - 0 - }; + + usize::try_from(span.range.len).expect("int cast"); } } else if chunk.len() > " sourceMappingURL=".len() && chunk.starts_with(b" sourceMappingURL=") @@ -3452,12 +3411,6 @@ pub(crate) fn latin1_identifier_continue_length_scalar(name: &[u8]) -> usize { name.len() } -#[derive(Clone, Copy, PartialEq, Eq)] -enum CommentKind { - SingleLine, - MultiLine, -} - /// Whether a `__PURE__` match at this position marks the next call. #[derive(Clone, Copy, PartialEq, Eq)] enum PureAnnotation { @@ -3468,10 +3421,6 @@ enum PureAnnotation { pub struct PragmaArg; impl PragmaArg { - pub(crate) fn is_newline(c: CodePoint) -> bool { - c == 0x0D || c == 0x0A || c == 0x2028 || c == 0x2029 - } - // These can be extremely long, so we use SIMD. /// "//# sourceMappingURL=data:/adspaoksdpkz" /// ^^^^^^^^^^^^^^^^^^ @@ -3518,12 +3467,7 @@ impl PragmaArg { PREFIX as usize + url_len // Correct total length } - pub(crate) fn scan( - offset_: usize, - pragma: &[u8], - text_: &[u8], - allow_newline: bool, - ) -> Option { + pub(crate) fn scan(offset_: usize, pragma: &[u8], text_: &[u8]) -> Option { let mut text = &text_[pragma.len()..]; let mut iter = CodepointIterator::init(text); @@ -3548,8 +3492,9 @@ impl PragmaArg { iter = CodepointIterator::init(text); let _ = iter.next(&mut cursor); + // A line break ends the value too. esbuild's `scanForPragmaArg` stops at white space only. let mut i: usize = 0; - while !is_whitespace(cursor.c) && (!allow_newline || !Self::is_newline(cursor.c)) { + while !strings::is_js_whitespace(cursor.c as u32) { i += cursor.width as usize; if i >= text.len() { break; @@ -3560,6 +3505,10 @@ impl PragmaArg { } } + if i == 0 { + return None; + } + Some(js_ast::Span { range: Range { len: i32::try_from(i).expect("int cast"), diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index ac8f5b5f8124..d9aa45ed5848 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -65,7 +65,8 @@ bun_core::declare_scope!(cache, visible); /// the cache-HIT path reinstates #33904 for them. /// Version 34: An import that a plugin `onResolve` answers is printed as it is written. /// Version 35: Long hexadecimal, octal and binary literals are rounded to the nearest double. -const EXPECTED_VERSION: u32 = 35; +/// Version 36: A JSX pragma value in a block comment ends at a line break. +const EXPECTED_VERSION: u32 = 36; /// 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/bundler/bundler_jsx.test.ts b/test/bundler/bundler_jsx.test.ts index d1ed6f089a0b..97a57538c18c 100644 --- a/test/bundler/bundler_jsx.test.ts +++ b/test/bundler/bundler_jsx.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { join } from "path"; import { BundlerTestInput, itBundled } from "./expectBundled"; const helpers = { @@ -527,6 +528,167 @@ describe("bundler", () => { }, }); + // The value of a pragma ends at the line break. So a pragma can have a line + // of its own in a block comment, and the next line can hold another pragma. + describe("pragma on its own line of a block comment", () => { + const lines = (...lines: string[]) => lines.join("\n") + "\n"; + const h = { "h.ts": lines(`export const h = (tag: string) => "h: " + tag;`, `export const F = "F";`) }; + const myRuntime = { + "my/jsx-dev-runtime.js": `export const jsxDEV = tag => "my runtime: " + tag;`, + "my/jsx-runtime.js": `export const jsx = tag => "my runtime: " + tag;`, + }; + // The first file of each case is its entry point. + const cases: Record; stdout: string }> = { + ImportSource: { + files: { + "index.jsx": lines("/**", " * @jsxImportSource ./my", " */", "console.log();"), + ...myRuntime, + }, + stdout: "my runtime: a", + }, + Factory: { + files: { + "index.tsx": lines( + "/** @jsxRuntime classic */", + "/**", + " * @jsx h", + " */", + `import { h } from "./h";`, + "console.log();", + ), + ...h, + }, + stdout: "h: a", + }, + Fragment: { + files: { + "index.tsx": lines( + "/** @jsxRuntime classic */", + "/** @jsx h */", + "/**", + " * @jsxFrag F", + " */", + `import { F, h } from "./h";`, + "console.log(<>);", + ), + ...h, + }, + stdout: "h: F", + }, + // With the first comment, a build that does not follow `@jsxRuntime classic` imports ./my and not react. + Runtime: { + files: { + "index.tsx": lines( + "/** @jsxImportSource ./my */", + "/**", + " * @jsxRuntime classic", + " * @jsx h", + " */", + `import { h } from "./h";`, + "console.log();", + ), + ...h, + ...myRuntime, + }, + stdout: "h: a", + }, + FactoryWithRuntimeFromTsconfig: { + files: { + "index.tsx": lines("/**", " * @jsx h", " */", `import { h } from "./h";`, "console.log();"), + "tsconfig.json": JSON.stringify({ compilerOptions: { jsx: "react" } }), + ...h, + }, + stdout: "h: a", + }, + }; + const inRoot = (files: Record) => + Object.fromEntries(Object.entries(files).map(([file, source]) => ["/" + file, source])); + + for (const [name, { files, stdout }] of Object.entries(cases)) { + itBundled(`jsx/PragmaOwnLine${name}`, { + files: inRoot(files), + target: "bun", + run: { stdout }, + }); + + test.concurrent(`bun run: ${name}`, async () => { + using dir = tempDir(`jsx-pragma-own-line-${name}`, files); + + await using proc = Bun.spawn({ + cmd: [bunExe(), Object.keys(files)[0]], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: out, stderr: err, exitCode }).toEqual({ stdout: stdout + "\n", stderr: "", exitCode: 0 }); + }); + } + + const localFactory = `const h = tag => "h: " + tag;`; + itBundled("jsx/PragmaOnEachLine", { + files: inRoot({ + "index.js": lines( + `import "./all-four.jsx";`, + `import "./column-0-runtime.jsx";`, + `import "./column-0-fragment.jsx";`, + ), + "all-four.jsx": lines( + "/**", + " * @jsxImportSource ./my", + " * @jsxRuntime classic", + " * @jsx h", + " * @jsxFrag F", + " */", + localFactory, + `const F = "F";`, + `console.log("all-four:", , <>);`, + ), + "column-0-runtime.jsx": lines( + "/*", + "@jsxRuntime classic", + "@jsx h", + "*/", + localFactory, + `console.log("column-0-runtime:", );`, + ), + "column-0-fragment.jsx": lines( + "/** @jsxRuntime classic */", + "/*", + "@jsx h", + "@jsxFrag F", + "*/", + localFactory, + `const F = "F";`, + `console.log("column-0-fragment:", <>);`, + ), + }), + target: "bun", + run: { stdout: lines("all-four: h: a h: F", "column-0-runtime: h: a", "column-0-fragment: h: F") }, + }); + + test("a warning about the value has the length of the value", async () => { + using dir = tempDir("jsx-pragma-own-line-warning", { + "index.jsx": lines("/**", " * @jsxRuntime bogus", " */", "export default ;"), + }); + + const { logs } = await Bun.build({ entrypoints: [join(String(dir), "index.jsx")], external: ["*"] }); + expect(logs.map(log => ({ level: log.level, message: log.message, length: log.position?.length }))).toEqual([ + { level: "warn", message: 'Unsupported JSX runtime: "bogus"', length: 5 }, + ]); + }); + + test("the pragmas in a file without JSX give no warning", async () => { + using dir = tempDir("jsx-pragma-own-line-no-jsx", { + "index.js": lines("/**", " * @jsxRuntime classic", " * @jsx jsx", " */", "export default 1;"), + }); + + const { logs } = await Bun.build({ entrypoints: [join(String(dir), "index.js")] }); + expect(logs.map(log => log.message)).toEqual([]); + }); + }); + // The classic transform reads the first member of the factory and fragment // member lists. A factory or fragment option whose text has no identifier // in it ("." or "..") must keep the list that was configured before it, diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 37cf46afb05c..8d9fdef29616 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2633,6 +2633,100 @@ console.log(
);`), ); }); + // The value of a JSX pragma ends at white space or at a line break, in a `//` comment and in a + // `/* */` comment. So a pragma can have a line of its own in a block comment. + describe("JSX pragma value ends at a line break", () => { + const jsx = new Bun.Transpiler({ + loader: "jsx", + autoImportJSX: true, + define: { "process.env.NODE_ENV": JSON.stringify("development") }, + }); + const lineBreaks = { LF: "\n", CRLF: "\r\n", CR: "\r", "U+2028": "\u2028", "U+2029": "\u2029" }; + const classic = "/** @jsxRuntime classic */\n"; + const elements = "console.log(, <>);\n"; + const factoryIsH = 'console.log(h("a", null), h(React.Fragment, null));\n'; + // Each pragma, the text before it that makes its effect visible, and the output when it is followed. + const pragmas = [ + ["@jsx h", classic, factoryIsH], + ["@jsxFrag F", classic, 'console.log(React.createElement("a", null), React.createElement(F, null));\n'], + [ + "@jsxRuntime classic", + "", + 'console.log(React.createElement("a", null), React.createElement(React.Fragment, null));\n', + ], + [ + "@jsxImportSource ./my", + "", + 'import { jsxDEV as jsxDEV_7x81h0kn, Fragment as Fragment_8vg9x3sq } from "./my/jsx-dev-runtime";\n' + + 'console.log(jsxDEV_7x81h0kn("a", {}, undefined, false, undefined, this), jsxDEV_7x81h0kn(Fragment_8vg9x3sq, {}, undefined, false, undefined, this));\n', + ], + ]; + + for (const [pragma, before, output] of pragmas) { + it(`${pragma} on its own line of a block comment`, () => { + for (const [name, lineBreak] of Object.entries(lineBreaks)) { + for (const star of [" * ", ""]) { + const comment = `/*${star && "*"}${lineBreak}${star}${pragma}${lineBreak}${star && " "}*/\n`; + expect(jsx.transformSync(before + comment + elements), `${name}, "${star}" before the pragma`).toBe(output); + } + } + }); + + it(`${pragma} in a // comment`, () => { + for (const [name, lineBreak] of Object.entries(lineBreaks)) { + expect(jsx.transformSync(`${before}// ${pragma}${lineBreak}${elements}`), name).toBe(output); + } + }); + } + + it("one pragma on each line of a block comment", () => { + for (const [name, lineBreak] of Object.entries(lineBreaks)) { + for (const star of [" * ", ""]) { + const comment = + `/*${star && "*"}${lineBreak}` + + ["@jsxRuntime classic", "@jsx h", "@jsxFrag F"].map(pragma => star + pragma + lineBreak).join("") + + `${star && " "}*/\n`; + expect(jsx.transformSync(comment + elements), `${name}, "${star}" before each pragma`).toBe( + 'console.log(h("a", null), h(F, null));\n', + ); + } + } + }); + + it("two pragmas on one line", () => { + expect(jsx.transformSync("/** @jsxRuntime classic @jsx h */\n" + elements)).toBe(factoryIsH); + }); + + it("a member expression on its own line", () => { + expect(jsx.transformSync(`${classic}/**\n * @jsx a.b\n * @jsxFrag c.d\n */\n${elements}`)).toBe( + 'console.log(a.b("a", null), a.b(c.d, null));\n', + ); + }); + + it("text on the line after the pragma name is not the value", () => { + for (const comment of ["/* @jsx\n h */", "/* @jsx \n h */", "/**\n * @jsx\n * h\n */"]) { + expect(jsx.transformSync(`${classic}/* @jsx first */\n${comment}\n${elements}`), comment).toBe( + 'console.log(first("a", null), first(React.Fragment, null));\n', + ); + } + }); + + it("a pragma without a value keeps the value of an earlier pragma", () => { + for (const [pragma, before, output] of pragmas) { + const name = pragma.slice(0, pragma.indexOf(" ")); + for (const comment of [`/* ${name} */`, `/*\n * ${name} \n */`, `// ${name} `, `// ${name}`]) { + expect(jsx.transformSync(`${before}/** ${pragma} */\n${comment}\n${elements}`), comment).toBe(output); + } + } + }); + + it("the value does not take an annotation at the start of the next line", () => { + expect(jsx.transformSync(`${classic}/* @jsx h\n@__PURE__ */ foo();\nconsole.log();\n`)).toBe( + 'console.log(h("a", null));\n', + ); + }); + }); + // Non-bundle transpile without `minify.identifiers` uses NoOpRenamer // (prints symbol.original_name verbatim), so the `generatedSymbolName` // hash suffix on the automatic JSX runtime import is the sole collision