mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
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:
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))
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user