Skip to content
Closed
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
154 changes: 154 additions & 0 deletions crates/rspack_macros/src/ast_object.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Error, ExprPath, Fields, LitStr, Result};

/// Derives `from_ast_object` for plain data structs, extracting each field
/// from an AST object literal. See `utils/ast_object.rs` in
/// `rspack_plugin_javascript` for the runtime side.
pub fn expand_ast_object_derive(input: DeriveInput) -> Result<TokenStream> {
let name = &input.ident;

if !input.generics.params.is_empty() {
return Err(Error::new_spanned(
&input.generics,
"AstObject does not support generic structs",
));
}

let mut rename_all_camel_case = false;
for attr in &input.attrs {
if !attr.path().is_ident("ast_object") {
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename_all") {
let value: LitStr = meta.value()?.parse()?;
if value.value() == "camelCase" {
rename_all_camel_case = true;
Ok(())
} else {
Err(meta.error("only `camelCase` is supported"))
}
} else {
Err(meta.error("unsupported attribute, expected `rename_all`"))
}
})?;
}

let fields = match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(fields) => &fields.named,
fields => {
return Err(Error::new_spanned(
fields,
"AstObject requires a struct with named fields",
));
}
},
data => {
return Err(Error::new_spanned(
&input.ident,
match data {
Data::Enum(_) => "AstObject does not support enums",
Data::Union(_) => "AstObject does not support unions",
Data::Struct(_) => unreachable!(),
},
));
}
};

let mut initializers = Vec::with_capacity(fields.len());
for field in fields {
let field_ident = field.ident.as_ref().expect("named field");
let mut skip = false;
let mut rename = None;
let mut default_fn: Option<ExprPath> = None;
for attr in &field.attrs {
if !attr.path().is_ident("ast_object") {
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("skip") {
skip = true;
Ok(())
} else if meta.path.is_ident("rename") {
let value: LitStr = meta.value()?.parse()?;
rename = Some(value.value());
Ok(())
} else if meta.path.is_ident("default") {
let value: LitStr = meta.value()?.parse()?;
default_fn = Some(value.parse()?);
Ok(())
} else {
Err(meta.error("unsupported attribute, expected `skip`, `rename` or `default`"))
}
})?;
}

if skip {
let default = default_value(default_fn);
initializers.push(quote!(#field_ident: #default,));
continue;
}

let key = rename.unwrap_or_else(|| {
let ident = field_ident.to_string();
if rename_all_camel_case {
snake_to_camel_case(&ident)
} else {
ident
}
});
let ty = &field.ty;
let extract = quote! {
crate::utils::object_properties::get_value_by_obj_prop(obj, #key)
.and_then(<#ty as crate::utils::ast_object::FromAstExpr>::from_ast_expr)
};
let initializer = match default_fn {
Some(default_fn) => quote!(#extract.unwrap_or_else(#default_fn)),
None => quote!(#extract.unwrap_or_default()),
};
initializers.push(quote!(#field_ident: #initializer,));
}

Ok(quote! {
impl #name {
/// Extract the options from an AST object literal. Properties that are
/// absent or not statically resolvable fall back to the field default.
pub fn from_ast_object(obj: &::swc_experimental_ecma_ast::ObjectLit<'_>) -> Self {
Self {
#(#initializers)*
}
}
}

impl<'__ast> crate::utils::ast_object::FromAstExpr<'__ast> for #name {
fn from_ast_expr(expr: &::swc_experimental_ecma_ast::Expr<'__ast>) -> Option<Self> {
expr.as_object().map(Self::from_ast_object)
}
}
})
}

fn default_value(default_fn: Option<ExprPath>) -> TokenStream {
match default_fn {
Some(default_fn) => quote!(#default_fn()),
None => quote!(::core::default::Default::default()),
}
}

fn snake_to_camel_case(ident: &str) -> String {
let mut camel = String::with_capacity(ident.len());
let mut uppercase_next = false;
for ch in ident.chars() {
if ch == '_' {
uppercase_next = true;
} else if uppercase_next {
camel.extend(ch.to_uppercase());
uppercase_next = false;
} else {
camel.push(ch);
}
}
camel
}
12 changes: 12 additions & 0 deletions crates/rspack_macros/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod ast_object;
mod hook;
mod javascript_parser_plugin_hooks;
mod merge;
Expand Down Expand Up @@ -80,3 +81,14 @@ pub fn rspack_hash_derive(input: proc_macro::TokenStream) -> proc_macro::TokenSt
}
.into()
}

#[proc_macro_derive(AstObject, attributes(ast_object))]
pub fn ast_object_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let input = syn::parse_macro_input!(input as syn::DeriveInput);
let output = ast_object::expand_ast_object_derive(input);
match output {
syn::Result::Ok(tt) => tt,
syn::Result::Err(err) => err.to_compile_error(),
}
.into()
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@
}
}

/// Serialize a define value to its code representation. String values are
/// code fragments and are embedded verbatim; object keys are JSON-escaped.
///
/// When `obj_keys` is given (the destructured properties collected from an
/// object pattern), only those properties are emitted, recursing into nested
/// object patterns. Note that values are never parsed: code fragments are
/// spliced verbatim, which tolerates fragments that are not valid standalone
/// expressions (they may only be valid—or even invalid—where they are used).
pub fn code_to_string<'a>(
code: &'a Value,
asi_safe: Option<bool>,
Expand Down Expand Up @@ -79,18 +87,104 @@
let elements = obj
.iter()
.filter_map(|(key, value)| {
if obj_keys.is_none_or(|keys| keys.iter().any(|prop| prop.id.as_str() == key)) {
Some(format!(
"{}:{}",
json!(key),
code_to_string(value, None, None)
))
} else {
None
let matched = obj_keys.and_then(|keys| keys.iter().find(|prop| prop.id.as_str() == key));
if obj_keys.is_some() && matched.is_none() {
return None;
}
let nested_keys = matched.and_then(|prop| prop.pattern.as_ref());
Some(format!(
"{}:{}",
json!(key),
code_to_string(value, None, nested_keys)
))
})
.join(",");
wrap_ansi(Cow::Owned(format!("{{ {elements} }}")), false, asi_safe)
}
}
}

#[cfg(test)]
mod tests {
use rspack_core::DependencyRange;
use rspack_util::fx_hash::FxIndexSet;
use serde_json::json;
use swc_atoms::Atom;

use super::*;
use crate::visitors::DestructuringAssignmentProperty;

fn keys(
props: impl IntoIterator<Item = DestructuringAssignmentProperty>,
) -> DestructuringAssignmentProperties {
DestructuringAssignmentProperties::new(FxIndexSet::from_iter(props))
}

fn prop(id: &str) -> DestructuringAssignmentProperty {
DestructuringAssignmentProperty {
range: DependencyRange::default(),
id: Atom::from(id),
pattern: None,
shorthand: true,
}
}

fn prop_nested(
id: &str,
pattern: DestructuringAssignmentProperties,
) -> DestructuringAssignmentProperty {
DestructuringAssignmentProperty {
pattern: Some(pattern),
..prop(id)
}
}

#[test]
fn filters_top_level_keys() {
let value = json!({ "a": 1, "b": 2, "c": 3 });
assert_eq!(
code_to_string(&value, None, Some(&keys([prop("a"), prop("c")]))),
r#"{ "a":1,"c":3 }"#
);
}

#[test]
fn filters_nested_object_patterns() {
let value = json!({ "env": { "NODE_ENV": "\"production\"", "DEBUG": true }, "other": 1 });
assert_eq!(
code_to_string(
&value,
None,
Some(&keys([prop_nested("env", keys([prop("NODE_ENV")]))]))
),
r#"{ "env":{ "NODE_ENV":"production" } }"#
);
}

#[test]
fn keeps_arrays_whole_even_with_nested_patterns() {
let value = json!({ "arr": [1, 2, 3], "other": 1 });
assert_eq!(
code_to_string(
&value,
None,
Some(&keys([prop_nested("arr", keys([prop("0")]))]))
),
r#"{ "arr":[1,2,3] }"#
);
}

#[test]
fn prunes_unparseable_fragments_verbatim() {

Check warning on line 178 in crates/rspack_plugin_javascript/src/parser_plugin/define_plugin/utils.rs

View workflow job for this annotation

GitHub Actions / Lint and format

"unparseable" should be "unparsable".
// Unused properties may contain fragments that are not valid standalone
// expressions; they must be pruned without ever being parsed.
let value = json!({
"used": 1,
"unused": "(() => throw new Error('unused property was rendered'))()",
});
assert_eq!(
code_to_string(&value, None, Some(&keys([prop("used")]))),
r#"{ "used":1 }"#
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ type OnTypeof = dyn Fn(&DefineRecord, &mut JavascriptParser, u32 /* start */, u3
+ Sync;

pub struct DefineRecord {
code: Value,
code: Arc<str>,
pub on_evaluate_identifier: Option<Box<OnEvaluateIdentifier>>,
pub on_evaluate_typeof: Option<Box<OnEvaluateTypeof>>,
pub on_expression: Option<Box<OnExpression>>,
Expand Down Expand Up @@ -89,9 +89,9 @@ impl std::fmt::Debug for DefineRecord {
}

impl DefineRecord {
fn from_code(code: Value) -> DefineRecord {
fn from_code(code: &Value) -> DefineRecord {
Self {
code,
code: code_to_string(code, None, None).into_owned().into(),
on_evaluate_identifier: None,
on_evaluate_typeof: None,
on_expression: None,
Expand Down Expand Up @@ -256,28 +256,21 @@ impl WalkData {
original_key
};
let key = Arc::<str>::from(key);
let mut define_record = DefineRecord::from_code(code.clone());
let mut define_record = DefineRecord::from_code(code);
if !is_typeof {
walk_data.can_rename.insert(key.clone(), None);
define_record = define_record
.with_on_evaluate_identifier(Box::new(move |record, parser, _ident, start, end| {
parser
.evaluate(
code_to_string(&record.code, None, None).into_owned(),
"DefinePlugin",
)
.evaluate(record.code.to_string(), "DefinePlugin")
.map(|mut evaluated| {
evaluated.set_range(start, end);
evaluated
})
}))
.with_on_expression(Box::new(
move |record, parser, span, start, end, for_name| {
let code = code_to_string(
&record.code,
Some(!parser.is_asi_position(span.start)),
None,
);
move |record, parser, _span, start, end, for_name| {
let code = Cow::Borrowed(record.code.as_ref());
for dep in gen_const_dep(parser, code, for_name, start, end) {
parser.add_presentational_dependency(dep);
}
Expand All @@ -289,9 +282,9 @@ impl WalkData {

define_record = define_record
.with_on_evaluate_typeof(Box::new(move |record, parser, start, end| {
let code = code_to_string(&record.code, None, None);
let code = record.code.as_ref();
let typeof_code = if is_typeof {
code
Cow::Borrowed(code)
} else {
Cow::Owned(format!("typeof ({code})"))
};
Expand All @@ -303,9 +296,9 @@ impl WalkData {
})
}))
.with_on_typeof(Box::new(move |record, parser, start, end| {
let code = code_to_string(&record.code, None, None);
let code = record.code.as_ref();
let typeof_code = if is_typeof {
code
Cow::Borrowed(code)
} else {
Cow::Owned(format!("typeof ({code})"))
};
Expand Down
Loading
Loading