feat(ast): add escape_raw parameter to template_element builders (#18121)

## Summary

- Add `escape_raw: bool` parameter to `template_element` and `template_element_with_lone_surrogates` builder methods
- When `escape_raw: true`, special characters (backticks, `${`, backslashes, carriage returns) are automatically escaped in the raw field
- Parser passes `false` (already-escaped values from source)
- Programmatic users pass `true` (runtime string values need escaping)

This is an alternative approach to #18101 - instead of escaping in codegen, we make the escaping opt-in at the AST builder level.

Closes #18101

## Test plan

- [x] Added test `template_literal_escape_when_building_ast` in codegen tests
- [x] Verified parser still works correctly with `escape_raw: false`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Boshen committed 2026-01-19 14:38:47 +00:00
1 parent f1b655b168
commit 2ef5647495
7 files changed
+187 -3

No files matched your search

@@ -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::<u8>(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))
}
}
+42 -1
View File
@@ -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");
}
@@ -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),
@@ -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() {
+1
View File
@@ -643,6 +643,7 @@ impl<'a> ParserImpl<'a> {
TemplateElementValue { raw, cooked },
tail,
lone_surrogates,
false, // escape_raw: parser provides already-escaped values from source
)
}
@@ -1155,6 +1155,7 @@ mod tests {
SPAN,
TemplateElementValue { raw: ast.atom(input), cooked: Some(ast.atom(input)) },
true,
false,
)),
ast.vec(),
);
+65 -2
View File
@@ -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::<u8>(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
}
};