diff --git a/crates/oxc_ast/src/generated/ast_builder.rs b/crates/oxc_ast/src/generated/ast_builder.rs index 3f8d9b3d8c334..904eee5bf45f4 100644 --- a/crates/oxc_ast/src/generated/ast_builder.rs +++ b/crates/oxc_ast/src/generated/ast_builder.rs @@ -17,6 +17,8 @@ use oxc_syntax::{ comment_node::CommentNodeId, reference::ReferenceId, scope::ScopeId, symbol::SymbolId, }; +use oxc_span::Atom; + use crate::{AstBuilder, ast::*}; impl<'a> AstBuilder<'a> { @@ -1788,7 +1790,16 @@ impl<'a> AstBuilder<'a> { span: Span, value: TemplateElementValue<'a>, tail: bool, + escape_raw: bool, ) -> TemplateElement<'a> { + let value = if escape_raw { + TemplateElementValue { + raw: escape_template_element_raw(value.raw.as_str(), self), + cooked: value.cooked, + } + } else { + value + }; TemplateElement { span, value, tail, lone_surrogates: Default::default() } } @@ -1806,7 +1817,16 @@ impl<'a> AstBuilder<'a> { value: TemplateElementValue<'a>, tail: bool, lone_surrogates: bool, + escape_raw: bool, ) -> TemplateElement<'a> { + let value = if escape_raw { + TemplateElementValue { + raw: escape_template_element_raw(value.raw.as_str(), self), + cooked: value.cooked, + } + } else { + value + }; TemplateElement { span, value, tail, lone_surrogates } } @@ -15134,3 +15154,58 @@ impl<'a> AstBuilder<'a> { Box::new_in(self.js_doc_unknown_type(span), self.allocator) } } + +/// Escape special characters for template element raw value. +/// +/// Escapes: backticks, `${`, backslashes, and carriage returns. +fn escape_template_element_raw<'a>(raw: &str, ast: AstBuilder<'a>) -> Atom<'a> { + let bytes = raw.as_bytes(); + let mut extra_bytes = 0usize; + for i in 0..bytes.len() { + extra_bytes += match bytes[i] { + b'\\' | b'`' | b'\r' => 1, + b'$' if bytes.get(i + 1) == Some(&b'{') => 1, + _ => 0, + }; + } + if extra_bytes == 0 { + return ast.atom(raw); + } + let len = bytes.len() + extra_bytes; + let layout = std::alloc::Layout::array::(len).unwrap(); + let ptr = ast.allocator.alloc_layout(layout); + #[expect(clippy::undocumented_unsafe_blocks)] + unsafe { + let escaped = std::slice::from_raw_parts_mut(ptr.as_ptr(), len); + let mut j = 0; + for i in 0..bytes.len() { + match bytes[i] { + b'\\' => { + *escaped.get_unchecked_mut(j) = b'\\'; + *escaped.get_unchecked_mut(j + 1) = b'\\'; + j += 2; + } + b'`' => { + *escaped.get_unchecked_mut(j) = b'\\'; + *escaped.get_unchecked_mut(j + 1) = b'`'; + j += 2; + } + b'$' if bytes.get(i + 1) == Some(&b'{') => { + *escaped.get_unchecked_mut(j) = b'\\'; + *escaped.get_unchecked_mut(j + 1) = b'$'; + j += 2; + } + b'\r' => { + *escaped.get_unchecked_mut(j) = b'\\'; + *escaped.get_unchecked_mut(j + 1) = b'r'; + j += 2; + } + b => { + *escaped.get_unchecked_mut(j) = b; + j += 1; + } + } + } + Atom::from(std::str::from_utf8_unchecked(escaped)) + } +} diff --git a/crates/oxc_codegen/tests/integration/js.rs b/crates/oxc_codegen/tests/integration/js.rs index 2c2f1030f1c7b..b829509afd3c6 100644 --- a/crates/oxc_codegen/tests/integration/js.rs +++ b/crates/oxc_codegen/tests/integration/js.rs @@ -1,4 +1,7 @@ -use oxc_codegen::{CodegenOptions, IndentChar}; +use oxc_allocator::Allocator; +use oxc_ast::AstBuilder; +use oxc_codegen::{Codegen, CodegenOptions, IndentChar}; +use oxc_span::SPAN; use crate::tester::{ test, test_minify, test_minify_same, test_options, test_same, test_same_ignore_parse_errors, @@ -691,3 +694,41 @@ fn indentation() { CodegenOptions { initial_indent: 1, ..CodegenOptions::default() }, ); } + +#[test] +fn template_literal_escape_when_building_ast() { + use oxc_ast::ast::TemplateElementValue; + + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + + // Create a template literal with special characters that need escaping: + // backtick, ${, and backslash + // Pass escape_raw: true to automatically escape the raw field + let cooked = "hello`world${foo}\\bar"; + let value = TemplateElementValue { raw: ast.atom(cooked), cooked: Some(ast.atom(cooked)) }; + let element = ast.template_element(SPAN, value, true, true); // escape_raw: true + let quasis = ast.vec1(element); + let template_literal = ast.template_literal(SPAN, quasis, ast.vec()); + + let expr = ast.expression_template_literal( + SPAN, + template_literal.quasis, + template_literal.expressions, + ); + let stmt = ast.statement_expression(SPAN, expr); + let program = ast.program( + SPAN, + oxc_span::SourceType::mjs(), + "", + ast.vec(), + None, + ast.vec(), + ast.vec1(stmt), + ); + + let result = Codegen::new().build(&program).code; + // The raw value should have been escaped by template_element with escape_raw: true + // backtick, ${, and backslash are all escaped + assert_eq!(result, "`hello\\`world\\${foo}\\\\bar`;\n"); +} diff --git a/crates/oxc_minifier/src/peephole/remove_unused_expression.rs b/crates/oxc_minifier/src/peephole/remove_unused_expression.rs index 2de02f5724da1..8f2c7473efb85 100644 --- a/crates/oxc_minifier/src/peephole/remove_unused_expression.rs +++ b/crates/oxc_minifier/src/peephole/remove_unused_expression.rs @@ -273,6 +273,7 @@ impl<'a> PeepholeOptimizations { e.span(), TemplateElementValue { raw: "".into(), cooked: Some("".into()) }, false, + false, ) }) .take(expressions.len() + 1), @@ -299,6 +300,7 @@ impl<'a> PeepholeOptimizations { temp_lit.span, TemplateElementValue { raw: "".into(), cooked: Some("".into()) }, false, + false, ) }) .take(expressions.len() + 1), diff --git a/crates/oxc_minifier/src/peephole/replace_known_methods.rs b/crates/oxc_minifier/src/peephole/replace_known_methods.rs index 3b639ac9de0e9..0d2930210ec13 100644 --- a/crates/oxc_minifier/src/peephole/replace_known_methods.rs +++ b/crates/oxc_minifier/src/peephole/replace_known_methods.rs @@ -333,6 +333,7 @@ impl<'a> PeepholeOptimizations { cooked: Some(cooked), }, false, + false, // raw is already escaped by escape_string_for_template_literal ) })); if let Some(last_quasi) = quasis.last_mut() { diff --git a/crates/oxc_parser/src/js/expression.rs b/crates/oxc_parser/src/js/expression.rs index 38a9cd09cb784..143e14dd10d48 100644 --- a/crates/oxc_parser/src/js/expression.rs +++ b/crates/oxc_parser/src/js/expression.rs @@ -643,6 +643,7 @@ impl<'a> ParserImpl<'a> { TemplateElementValue { raw, cooked }, tail, lone_surrogates, + false, // escape_raw: parser provides already-escaped values from source ) } diff --git a/crates/oxc_transformer/src/plugins/styled_components.rs b/crates/oxc_transformer/src/plugins/styled_components.rs index 20f6283850f85..322d7862ee738 100644 --- a/crates/oxc_transformer/src/plugins/styled_components.rs +++ b/crates/oxc_transformer/src/plugins/styled_components.rs @@ -1155,6 +1155,7 @@ mod tests { SPAN, TemplateElementValue { raw: ast.atom(input), cooked: Some(ast.atom(input)) }, true, + false, )), ast.vec(), ); diff --git a/tasks/ast_tools/src/generators/ast_builder.rs b/tasks/ast_tools/src/generators/ast_builder.rs index 8ebd29c6ea5b3..793e97122f77c 100644 --- a/tasks/ast_tools/src/generators/ast_builder.rs +++ b/tasks/ast_tools/src/generators/ast_builder.rs @@ -90,6 +90,9 @@ impl Generator for AstBuilderGenerator { reference::ReferenceId }; + ///@@line_break + use oxc_span::Atom; + ///@@line_break use crate::{AstBuilder, ast::*}; @@ -97,6 +100,47 @@ impl Generator for AstBuilderGenerator { impl<'a> AstBuilder<'a> { #fns } + + ///@@line_break + /// Escape special characters for template element raw value. + /// + /// Escapes: backticks, `${`, backslashes, and carriage returns. + fn escape_template_element_raw<'a>(raw: &str, ast: AstBuilder<'a>) -> Atom<'a> { + let bytes = raw.as_bytes(); + // Calculate size needed for escaped string + let mut extra_bytes = 0usize; + for i in 0..bytes.len() { + extra_bytes += match bytes[i] { + b'\\' | b'`' | b'\r' => 1, + b'$' if bytes.get(i + 1) == Some(&b'{') => 1, + _ => 0, + }; + } + if extra_bytes == 0 { + return ast.atom(raw); + } + // Allocate directly in arena + let len = bytes.len() + extra_bytes; + let layout = std::alloc::Layout::array::(len).unwrap(); + let ptr = ast.allocator.alloc_layout(layout); + // SAFETY: `ptr` points to `len` bytes of valid memory allocated by the arena. + // Input is valid UTF-8, we only escape ASCII bytes, so output is also valid UTF-8. + #[expect(clippy::undocumented_unsafe_blocks)] + unsafe { + let escaped = std::slice::from_raw_parts_mut(ptr.as_ptr(), len); + let mut j = 0; + for i in 0..bytes.len() { + match bytes[i] { + b'\\' => { *escaped.get_unchecked_mut(j) = b'\\'; *escaped.get_unchecked_mut(j + 1) = b'\\'; j += 2; } + b'`' => { *escaped.get_unchecked_mut(j) = b'\\'; *escaped.get_unchecked_mut(j + 1) = b'`'; j += 2; } + b'$' if bytes.get(i + 1) == Some(&b'{') => { *escaped.get_unchecked_mut(j) = b'\\'; *escaped.get_unchecked_mut(j + 1) = b'$'; j += 2; } + b'\r' => { *escaped.get_unchecked_mut(j) = b'\\'; *escaped.get_unchecked_mut(j + 1) = b'r'; j += 2; } + b => { *escaped.get_unchecked_mut(j) = b; j += 1; } + } + } + Atom::from(std::str::from_utf8_unchecked(escaped)) + } + } }; Output::Rust { path: output_path(AST_CRATE_PATH, "ast_builder.rs"), tokens: output } @@ -262,13 +306,32 @@ fn generate_builder_methods_for_struct_impl( let params_docs = generate_doc_comment_for_params(params); + // Special case for TemplateElement: add `escape_raw` parameter + let (extra_params, body) = if struct_name == "TemplateElement" { + let extra_params = quote! { , escape_raw: bool }; + let body = quote! { + let value = if escape_raw { + TemplateElementValue { + raw: escape_template_element_raw(value.raw.as_str(), self), + cooked: value.cooked, + } + } else { + value + }; + #struct_ident { #fields } + }; + (extra_params, body) + } else { + (quote! {}, quote! { #struct_ident { #fields } }) + }; + let method = quote! { ///@@line_break #fn_docs #params_docs #[inline] - pub fn #fn_name #generic_params (self, #fn_params) -> #struct_ty #where_clause { - #struct_ident { #fields } + pub fn #fn_name #generic_params (self, #fn_params #extra_params) -> #struct_ty #where_clause { + #body } };