Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions crates/oxc_ast/src/generated/ast_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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() }
}

Expand All @@ -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 }
}

Expand Down Expand Up @@ -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))
}
}
43 changes: 42 additions & 1 deletion crates/oxc_codegen/tests/integration/js.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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");
}
2 changes: 2 additions & 0 deletions crates/oxc_minifier/src/peephole/remove_unused_expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ impl<'a> PeepholeOptimizations {
e.span(),
TemplateElementValue { raw: "".into(), cooked: Some("".into()) },
false,
false,
)
})
.take(expressions.len() + 1),
Expand All @@ -299,6 +300,7 @@ impl<'a> PeepholeOptimizations {
temp_lit.span,
TemplateElementValue { raw: "".into(), cooked: Some("".into()) },
false,
false,
)
})
.take(expressions.len() + 1),
Expand Down
1 change: 1 addition & 0 deletions crates/oxc_minifier/src/peephole/replace_known_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions crates/oxc_parser/src/js/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@ impl<'a> ParserImpl<'a> {
TemplateElementValue { raw, cooked },
tail,
lone_surrogates,
false, // escape_raw: parser provides already-escaped values from source
)
}

Expand Down
1 change: 1 addition & 0 deletions crates/oxc_transformer/src/plugins/styled_components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,7 @@ mod tests {
SPAN,
TemplateElementValue { raw: ast.atom(input), cooked: Some(ast.atom(input)) },
true,
false,
)),
ast.vec(),
);
Expand Down
67 changes: 65 additions & 2 deletions tasks/ast_tools/src/generators/ast_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,57 @@ impl Generator for AstBuilderGenerator {
reference::ReferenceId
};

///@@line_break
use oxc_span::Atom;

///@@line_break
use crate::{AstBuilder, ast::*};

///@@line_break
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 }
Expand Down Expand Up @@ -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
}
};

Expand Down
Loading