From ce72da7f6fdce4f82eda34981a7c36992da55934 Mon Sep 17 00:00:00 2001 From: intellild Date: Fri, 17 Jul 2026 21:21:10 +0800 Subject: [PATCH 1/3] refactor: extract AST options objects with an AstObject derive macro --- crates/rspack_macros/src/ast_object.rs | 154 +++++++++ crates/rspack_macros/src/lib.rs | 12 + ...t_meta_context_dependency_parser_plugin.rs | 246 ++++++++------- .../src/parser_plugin/worker_plugin.rs | 19 +- .../src/utils/ast_object.rs | 293 ++++++++++++++++++ .../rspack_plugin_javascript/src/utils/mod.rs | 1 + 6 files changed, 605 insertions(+), 120 deletions(-) create mode 100644 crates/rspack_macros/src/ast_object.rs create mode 100644 crates/rspack_plugin_javascript/src/utils/ast_object.rs diff --git a/crates/rspack_macros/src/ast_object.rs b/crates/rspack_macros/src/ast_object.rs new file mode 100644 index 000000000000..0c9b8cb9112f --- /dev/null +++ b/crates/rspack_macros/src/ast_object.rs @@ -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 { + 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 = 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 { + expr.as_object().map(Self::from_ast_object) + } + } + }) +} + +fn default_value(default_fn: Option) -> 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 +} diff --git a/crates/rspack_macros/src/lib.rs b/crates/rspack_macros/src/lib.rs index 4887d2d75bb2..99cb45fe7f03 100644 --- a/crates/rspack_macros/src/lib.rs +++ b/crates/rspack_macros/src/lib.rs @@ -1,3 +1,4 @@ +mod ast_object; mod hook; mod javascript_parser_plugin_hooks; mod merge; @@ -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() +} diff --git a/crates/rspack_plugin_javascript/src/parser_plugin/import_meta_context_dependency_parser_plugin.rs b/crates/rspack_plugin_javascript/src/parser_plugin/import_meta_context_dependency_parser_plugin.rs index 948794f188aa..f80b4fa3a4bd 100644 --- a/crates/rspack_plugin_javascript/src/parser_plugin/import_meta_context_dependency_parser_plugin.rs +++ b/crates/rspack_plugin_javascript/src/parser_plugin/import_meta_context_dependency_parser_plugin.rs @@ -4,22 +4,20 @@ use rspack_core::{ ReferencedSpecifier, escape_glob_pattern, extract_glob_base_dir, get_context, normalize_path_separators, normalize_path_separators_for_path, unescape_glob_path, }; +use rspack_macros::AstObject; use rspack_paths::{Utf8Path, Utf8PathBuf}; use rspack_regex::RspackRegex; use rspack_util::{SpanExt, identifier::relative_path_to_request, node_path::NodePath}; use sugar_path::SugarPath; use swc_atoms::Atom; -use swc_experimental_ecma_ast::{CallExpr, Expr, GetSpan, Lit, PropName}; +use swc_experimental_ecma_ast::{CallExpr, Expr}; use super::JavascriptParserPlugin; use crate::{ dependency::ImportMetaContextDependency, utils::{ + ast_object::{AstRegex, FromAstExpr}, eval::{self, BasicEvaluatedExpression}, - object_properties::{ - get_bool_by_obj_prop, get_literal_str_by_obj_prop, get_regex_by_obj_prop, - get_value_by_obj_prop, - }, }, visitors::{ JavascriptParser, clean_regexp_in_context_module, default_context_reg_exp, expr_name, @@ -27,6 +25,85 @@ use crate::{ }, }; +/// Options of `import.meta.webpackContext(request, options)`, mirroring the +/// TypeScript declaration in `packages/rspack/module.d.ts`. +#[derive(Debug, Default, AstObject)] +#[ast_object(rename_all = "camelCase")] +struct ImportMetaWebpackContextOptions { + reg_exp: Option, + include: Option, + exclude: Option, + mode: Option, + /// Absent or unrecognized means `true`. + recursive: Option, +} + +/// Options of `import.meta.glob(pattern, options)`, mirroring +/// `Rspack.ImportMetaGlobOptions` in `packages/rspack/module.d.ts`. +#[derive(Debug, Default, AstObject)] +#[ast_object(rename_all = "camelCase")] +struct ImportMetaGlobOptions { + eager: bool, + import: Option, + query: Option, + base: Option, + exhaustive: bool, +} + +#[derive(Debug)] +enum ImportMetaGlobQuery { + String(String), + Record(Vec<(String, ImportMetaGlobQueryValue)>), +} + +impl<'a> FromAstExpr<'a> for ImportMetaGlobQuery { + fn from_ast_expr(expr: &Expr<'a>) -> Option { + if let Some(query) = String::from_ast_expr(expr) { + return Some(Self::String(query)); + } + Vec::<(String, ImportMetaGlobQueryValue)>::from_ast_expr(expr).map(Self::Record) + } +} + +#[derive(Debug)] +enum ImportMetaGlobQueryValue { + String(String), + Number(f64), + Bool(bool), +} + +impl FromAstExpr<'_> for ImportMetaGlobQueryValue { + fn from_ast_expr(expr: &Expr<'_>) -> Option { + if let Some(value) = String::from_ast_expr(expr) { + return Some(Self::String(value)); + } + if let Some(value) = f64::from_ast_expr(expr) { + return Some(Self::Number(value)); + } + bool::from_ast_expr(expr).map(Self::Bool) + } +} + +impl ImportMetaGlobQuery { + fn into_query_string(self) -> String { + match self { + Self::String(query) => normalize_import_meta_glob_query(query), + Self::Record(entries) => { + let mut serializer = form_urlencoded::Serializer::new(String::new()); + for (key, value) in entries { + let value = match value { + ImportMetaGlobQueryValue::String(value) => value, + ImportMetaGlobQueryValue::Number(value) => value.to_string(), + ImportMetaGlobQueryValue::Bool(value) => value.to_string(), + }; + serializer.append_pair(&key, &value); + } + normalize_import_meta_glob_query(serializer.finish()) + } + } + } +} + fn static_glob_patterns_from_expr(expr: &Expr) -> Option> { if let Some(pattern) = static_string_from_expr(expr) { return Some(vec![pattern]); @@ -54,54 +131,6 @@ fn normalize_import_meta_glob_query(query: String) -> String { } } -fn static_import_meta_glob_query_from_expr(expr: &Expr) -> Option { - if let Some(query) = static_string_from_expr(expr) { - return Some(normalize_import_meta_glob_query(query)); - } - - let query = expr.as_object()?; - let mut serializer = form_urlencoded::Serializer::new(String::new()); - for prop in &query.props { - let kv = prop.as_prop().and_then(|prop| prop.as_key_value())?; - let key = static_import_meta_glob_query_key_from_prop_name(&kv.key)?; - let value = if let Some(value) = static_string_from_expr(&kv.value) { - value - } else { - match kv.value.as_lit()? { - Lit::Bool(bool) => bool.value.to_string(), - Lit::Num(num) => num.value.to_string(), - _ => return None, - } - }; - serializer.append_pair(&key, &value); - } - - Some(normalize_import_meta_glob_query(serializer.finish())) -} - -fn static_import_meta_glob_query_key_from_prop_name(prop_name: &PropName) -> Option { - match prop_name { - PropName::Ident(ident) => Some(ident.sym.to_string()), - PropName::Str(str) => Some(str.value.to_string_lossy().into_owned()), - PropName::Num(num) => Some(num.value.to_string()), - PropName::Computed(computed) => static_import_meta_glob_query_key_from_expr(&computed.expr), - _ => None, - } -} - -fn static_import_meta_glob_query_key_from_expr(expr: &Expr) -> Option { - if let Some(key) = static_string_from_expr(expr) { - return Some(key); - } - - match expr.as_lit()? { - Lit::Num(num) => Some(num.value.to_string()), - Lit::Bool(bool) => Some(bool.value.to_string()), - Lit::Null(_) => Some("null".to_string()), - _ => None, - } -} - fn import_meta_glob_path_parts<'a>( context: &'a str, compiler_context: &'a str, @@ -285,49 +314,41 @@ fn create_import_meta_context_dependency( } // TODO: should've used expression evaluation to handle cases like `abc${"efg"}`, etc. let context = static_string_from_expr(&dyn_imported.expr)?; - let context_options = if let Some(obj) = node.args.get(1).and_then(|arg| arg.expr.as_object()) { - let regexp = get_regex_by_obj_prop(obj, "regExp"); - let regexp_span = regexp.map(|r| r.span().into()); - let regexp = regexp.map_or_else(default_context_reg_exp, |regexp| { - RspackRegex::with_flags(regexp.exp.as_str(), regexp.flags.as_str()).expect("reg failed") - }); - let include = get_regex_by_obj_prop(obj, "include").map(|regexp| { - RspackRegex::with_flags(regexp.exp.as_str(), regexp.flags.as_str()).expect("reg failed") - }); - let exclude = get_regex_by_obj_prop(obj, "exclude").map(|regexp| { - RspackRegex::with_flags(regexp.exp.as_str(), regexp.flags.as_str()).expect("reg failed") - }); - let mode = get_literal_str_by_obj_prop(obj, "mode").map_or(ContextMode::Sync, |s| { - s.value.to_string_lossy().as_ref().into() + let options = node + .args + .get(1) + .and_then(|arg| arg.expr.as_object()) + .map(ImportMetaWebpackContextOptions::from_ast_object) + .unwrap_or_default(); + let regexp_span = options.reg_exp.as_ref().map(|regex| regex.span.into()); + let regexp = options + .reg_exp + .map_or_else(default_context_reg_exp, |regex| { + RspackRegex::with_flags(regex.exp.as_str(), regex.flags.as_str()).expect("reg failed") }); - let recursive = get_bool_by_obj_prop(obj, "recursive").is_none_or(|bool| bool.value); - let span = node.span; - ContextOptions { - pattern: clean_regexp_in_context_module(regexp, regexp_span, parser).into(), - include, - exclude, - recursive, - category: DependencyCategory::Esm, - request: context.clone(), - context, - mode, - start: span.real_lo(), - end: span.real_hi(), - ..Default::default() - } - } else { - let span = node.span; - ContextOptions { - recursive: true, - mode: ContextMode::Sync, - pattern: clean_regexp_in_context_module(default_context_reg_exp(), None, parser).into(), - category: DependencyCategory::Esm, - request: context.clone(), - context, - start: span.real_lo(), - end: span.real_hi(), - ..Default::default() - } + let include = options.include.map(|regex| { + RspackRegex::with_flags(regex.exp.as_str(), regex.flags.as_str()).expect("reg failed") + }); + let exclude = options.exclude.map(|regex| { + RspackRegex::with_flags(regex.exp.as_str(), regex.flags.as_str()).expect("reg failed") + }); + let mode = options + .mode + .map_or(ContextMode::Sync, |mode| mode.as_str().into()); + let recursive = options.recursive.is_none_or(|recursive| recursive); + let span = node.span; + let context_options = ContextOptions { + pattern: clean_regexp_in_context_module(regexp, regexp_span, parser).into(), + include, + exclude, + recursive, + category: DependencyCategory::Esm, + request: context.clone(), + context, + mode, + start: span.real_lo(), + end: span.real_hi(), + ..Default::default() }; Some(ImportMetaContextDependency::new( context_options, @@ -347,26 +368,23 @@ fn create_import_meta_glob_dependency( } let raw_glob_patterns = static_glob_patterns_from_expr(&dyn_imported.expr)?; let importer_context = get_context(parser.resource_data); - let glob_options = node.args.get(1).and_then(|arg| arg.expr.as_object()); - let mode = glob_options.map_or(ContextMode::Lazy, |obj| { - if get_bool_by_obj_prop(obj, "eager").is_some_and(|b| b.value) { - ContextMode::Sync - } else { - ContextMode::Lazy - } - }); - let glob_import = glob_options - .and_then(|obj| get_literal_str_by_obj_prop(obj, "import")) - .map(|s| s.value.to_string_lossy().into_owned()); - let glob_query = glob_options - .and_then(|obj| get_value_by_obj_prop(obj, "query")) - .and_then(static_import_meta_glob_query_from_expr) + let options = node + .args + .get(1) + .and_then(|arg| arg.expr.as_object()) + .map(ImportMetaGlobOptions::from_ast_object) .unwrap_or_default(); - let base = glob_options - .and_then(|obj| get_value_by_obj_prop(obj, "base")) - .and_then(static_string_from_expr); - let glob_exhaustive = glob_options - .is_some_and(|obj| get_bool_by_obj_prop(obj, "exhaustive").is_some_and(|b| b.value)); + let mode = if options.eager { + ContextMode::Sync + } else { + ContextMode::Lazy + }; + let glob_import = options.import; + let glob_query = options + .query + .map_or_else(String::new, ImportMetaGlobQuery::into_query_string); + let base = options.base; + let glob_exhaustive = options.exhaustive; let context = resolve_import_meta_glob_context( importer_context.as_str(), parser.compiler_options.context.as_str(), diff --git a/crates/rspack_plugin_javascript/src/parser_plugin/worker_plugin.rs b/crates/rspack_plugin_javascript/src/parser_plugin/worker_plugin.rs index 8b4bed6c98f0..1f5e09e4cb2f 100644 --- a/crates/rspack_plugin_javascript/src/parser_plugin/worker_plugin.rs +++ b/crates/rspack_plugin_javascript/src/parser_plugin/worker_plugin.rs @@ -6,6 +6,7 @@ use rspack_core::{ JavascriptParserWorkerOptions, JavascriptParserWorkerUrl, }; use rspack_hash::{RspackHash, RspackHasher}; +use rspack_macros::AstObject; use rspack_util::SpanExt; use rustc_hash::{FxHashMap, FxHashSet}; use swc_atoms::Atom; @@ -23,7 +24,6 @@ use crate::{ dependency::{CreateScriptUrlDependency, WorkerDependency}, magic_comment::try_extract_magic_comment, parser_plugin::url_plugin::is_meta_url, - utils::object_properties::get_literal_str_by_obj_prop, visitors::{JavascriptParser, TagInfoData, VariableDeclaration}, }; @@ -33,6 +33,12 @@ struct ParsedNewWorkerPath { pub value: String, } +/// Options of `new Worker(url, options)`. +#[derive(Debug, Default, AstObject)] +struct NewWorkerOptions { + name: Option, +} + #[derive(Debug)] struct ParsedNewWorkerOptions { pub range: Option<(u32, u32)>, @@ -46,14 +52,15 @@ struct ParsedNewWorkerImportOptions { } fn parse_new_worker_options(arg: &ExprOrSpread) -> ParsedNewWorkerOptions { - let obj = arg.expr.as_object(); - let name = obj - .and_then(|obj| get_literal_str_by_obj_prop(obj, "name")) - .map(|str| str.value.to_string_lossy().into()); + let options = arg + .expr + .as_object() + .map(NewWorkerOptions::from_ast_object) + .unwrap_or_default(); let span = arg.span(); ParsedNewWorkerOptions { range: Some((span.real_lo(), span.real_hi())), - name, + name: options.name, } } diff --git a/crates/rspack_plugin_javascript/src/utils/ast_object.rs b/crates/rspack_plugin_javascript/src/utils/ast_object.rs new file mode 100644 index 000000000000..83869437120a --- /dev/null +++ b/crates/rspack_plugin_javascript/src/utils/ast_object.rs @@ -0,0 +1,293 @@ +//! Lightweight typed extraction of options objects from AST object literals. +//! +//! `#[derive(AstObject)]` (from `rspack_macros`) generates a +//! `from_ast_object` constructor for plain data structs, so options objects +//! in parser plugins can be declared once and extracted from a call +//! expression's arguments instead of hand-rolling per-property lookups: +//! +//! ```ignore +//! #[derive(Debug, Default, AstObject)] +//! #[ast_object(rename_all = "camelCase")] +//! struct GlobOptions { +//! eager: bool, +//! import: Option, +//! #[ast_object(skip, default = "default_case_sensitive")] +//! case_sensitive: bool, +//! } +//! +//! let options = obj.map(GlobOptions::from_ast_object).unwrap_or_default(); +//! ``` +//! +//! This module provides the [`FromAstExpr`] trait the generated code uses to +//! extract each field value, plus implementations for the common value +//! shapes. Field types only need `FromAstExpr + Default`; an absent or +//! statically unresolvable property always falls back to the field default, +//! matching the previous hand-rolled behavior. Custom value shapes (enums, +//! nested options objects) participate by implementing [`FromAstExpr`], and +//! `Vec<(String, T)>` extracts records with unknown keys in source order. + +use swc_experimental_ecma_ast::{Expr, Lit, PropName, Span}; + +use crate::visitors::static_string_from_expr; + +/// Extract a typed value from an AST expression, if the expression is a +/// statically resolvable representation of the value. +pub trait FromAstExpr<'a>: Sized { + fn from_ast_expr(expr: &Expr<'a>) -> Option; +} + +impl FromAstExpr<'_> for bool { + fn from_ast_expr(expr: &Expr<'_>) -> Option { + match expr.as_lit()? { + Lit::Bool(bool) => Some(bool.value), + _ => None, + } + } +} + +impl FromAstExpr<'_> for String { + fn from_ast_expr(expr: &Expr<'_>) -> Option { + static_string_from_expr(expr) + } +} + +impl FromAstExpr<'_> for f64 { + fn from_ast_expr(expr: &Expr<'_>) -> Option { + match expr.as_lit()? { + Lit::Num(num) => Some(num.value), + _ => None, + } + } +} + +impl<'a, T: FromAstExpr<'a>> FromAstExpr<'a> for Option { + fn from_ast_expr(expr: &Expr<'a>) -> Option { + T::from_ast_expr(expr).map(Some) + } +} + +/// A regex literal extracted from an options object, with its source span +/// preserved for later diagnostics. +#[derive(Debug)] +pub struct AstRegex { + pub exp: String, + pub flags: String, + pub span: Span, +} + +impl FromAstExpr<'_> for AstRegex { + fn from_ast_expr(expr: &Expr<'_>) -> Option { + match expr.as_lit()? { + Lit::Regex(regex) => Some(Self { + exp: regex.exp.to_string(), + flags: regex.flags.to_string(), + span: regex.span, + }), + _ => None, + } + } +} + +/// Extract an object literal into key-value pairs in source order, for +/// options with unknown (dynamic) keys such as records. Every key must be +/// statically resolvable and every value must extract as `T`, otherwise the +/// whole extraction fails. +impl<'a, T: FromAstExpr<'a>> FromAstExpr<'a> for Vec<(String, T)> { + fn from_ast_expr(expr: &Expr<'a>) -> Option { + let obj = expr.as_object()?; + obj + .props + .iter() + .map(|prop| { + let kv = prop.as_prop().and_then(|prop| prop.as_key_value())?; + let key = static_prop_name(&kv.key)?; + let value = T::from_ast_expr(&kv.value)?; + Some((key, value)) + }) + .collect() + } +} + +/// Statically resolve an object literal property name, mirroring the key +/// handling of the previous hand-rolled options parsing. +fn static_prop_name(key: &PropName) -> Option { + match key { + PropName::Ident(ident) => Some(ident.sym.to_string()), + PropName::Str(str) => Some(str.value.to_string_lossy().into_owned()), + PropName::Num(num) => Some(num.value.to_string()), + PropName::Computed(computed) => { + if let Some(key) = static_string_from_expr(&computed.expr) { + return Some(key); + } + match computed.expr.as_lit()? { + Lit::Num(num) => Some(num.value.to_string()), + Lit::Bool(bool) => Some(bool.value.to_string()), + Lit::Null(_) => Some("null".to_string()), + _ => None, + } + } + PropName::BigInt(_) => None, + } +} + +#[cfg(test)] +mod tests { + use rspack_macros::AstObject; + use swc_experimental_allocator::Allocator; + use swc_experimental_ecma_ast::EsVersion; + use swc_experimental_ecma_parser::{ + EsSyntax, Lexer, Parser, StringSource, Syntax, unstable::Capturing, + }; + + use super::*; + + fn parse_expr<'a>(allocator: &'a Allocator, source: &'a str) -> Expr<'a> { + let lexer = Lexer::new( + allocator, + Syntax::Es(EsSyntax::default()), + EsVersion::EsNext, + StringSource::new(source), + None, + ); + let lexer = Capturing::new(lexer); + let mut parser = Parser::new_from(allocator, lexer); + parser + .parse_expr() + .expect("failed to parse test expression") + } + + fn default_true() -> bool { + true + } + + #[derive(Debug, Default, PartialEq, AstObject)] + #[ast_object(rename_all = "camelCase")] + struct TestOptions { + eager: bool, + case_sensitive: Option, + import: Option, + #[ast_object(skip, default = "default_true")] + skipped: bool, + } + + #[test] + fn extracts_camel_case_fields_and_defaults() { + let allocator = Allocator::new(); + let expr = parse_expr(&allocator, "{ eager: true, caseSensitive: false }"); + let options = TestOptions::from_ast_object(expr.as_object().unwrap()); + assert_eq!( + options, + TestOptions { + eager: true, + case_sensitive: Some(false), + import: None, + skipped: true, + } + ); + + let expr = parse_expr(&allocator, "{}"); + assert_eq!( + TestOptions::from_ast_object(expr.as_object().unwrap()), + TestOptions { + skipped: true, + ..Default::default() + } + ); + } + + #[test] + fn unrecognized_values_fall_back_to_default() { + let allocator = Allocator::new(); + let expr = parse_expr( + &allocator, + "{ eager: \"yes\", import: 1, caseSensitive: null }", + ); + assert_eq!( + TestOptions::from_ast_object(expr.as_object().unwrap()), + TestOptions { + skipped: true, + ..Default::default() + } + ); + + let expr = parse_expr(&allocator, "true"); + assert_eq!(TestOptions::from_ast_expr(&expr), None); + } + + #[test] + fn resolves_template_literals() { + let allocator = Allocator::new(); + let expr = parse_expr(&allocator, "{ import: `default` }"); + assert_eq!( + TestOptions::from_ast_object(expr.as_object().unwrap()).import, + Some("default".to_string()) + ); + } + + #[derive(Debug, Default, PartialEq, AstObject)] + struct TestOuter { + inner: Option, + } + + #[test] + fn extracts_nested_options_objects() { + let allocator = Allocator::new(); + let expr = parse_expr(&allocator, "{ inner: { eager: true } }"); + let options = TestOuter::from_ast_object(expr.as_object().unwrap()); + assert_eq!( + options.inner, + Some(TestOptions { + eager: true, + skipped: true, + ..Default::default() + }) + ); + } + + #[test] + fn extracts_records_in_source_order() { + let allocator = Allocator::new(); + let expr = parse_expr(&allocator, "{ b: '1', a: `2`, [\"c\"]: '3' }"); + let record = Vec::<(String, String)>::from_ast_expr(&expr).unwrap(); + assert_eq!( + record, + vec![ + ("b".to_string(), "1".to_string()), + ("a".to_string(), "2".to_string()), + ("c".to_string(), "3".to_string()), + ] + ); + } + + #[test] + fn record_extraction_is_all_or_nothing() { + let allocator = Allocator::new(); + let expr = parse_expr(&allocator, "{ a: '1', [Symbol()]: '2' }"); + assert_eq!(Vec::<(String, String)>::from_ast_expr(&expr), None); + } + + #[derive(Debug, Default, AstObject)] + #[ast_object(rename_all = "camelCase")] + struct TestRegexOptions { + reg_exp: Option, + } + + #[test] + fn extracts_regex_literals_with_span() { + let allocator = Allocator::new(); + let expr = parse_expr(&allocator, "{ regExp: /abc/gi }"); + let regex = TestRegexOptions::from_ast_object(expr.as_object().unwrap()) + .reg_exp + .expect("expected a regex"); + assert_eq!(regex.exp, "abc"); + assert_eq!(regex.flags, "gi"); + assert_eq!(regex.span.end - regex.span.start, "/abc/gi".len() as u32); + + let expr = parse_expr(&allocator, "{ regExp: \"nope\" }"); + assert!( + TestRegexOptions::from_ast_object(expr.as_object().unwrap()) + .reg_exp + .is_none() + ); + } +} diff --git a/crates/rspack_plugin_javascript/src/utils/mod.rs b/crates/rspack_plugin_javascript/src/utils/mod.rs index c64469cc78f8..421c8cbedf99 100644 --- a/crates/rspack_plugin_javascript/src/utils/mod.rs +++ b/crates/rspack_plugin_javascript/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod ast_object; pub mod eval; pub mod mangle_exports; pub mod object_properties; From 846f8aeef29f0ce2ed84daca82e42a722856f4ac Mon Sep 17 00:00:00 2001 From: intellild Date: Fri, 17 Jul 2026 23:53:49 +0800 Subject: [PATCH 2/3] feat: add lodash.get-like helpers for AST object literals --- .../src/utils/ast_object.rs | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/rspack_plugin_javascript/src/utils/ast_object.rs b/crates/rspack_plugin_javascript/src/utils/ast_object.rs index 83869437120a..754ed03daced 100644 --- a/crates/rspack_plugin_javascript/src/utils/ast_object.rs +++ b/crates/rspack_plugin_javascript/src/utils/ast_object.rs @@ -28,7 +28,25 @@ use swc_experimental_ecma_ast::{Expr, Lit, PropName, Span}; -use crate::visitors::static_string_from_expr; +use crate::{utils::object_properties::get_value_by_obj_prop, visitors::static_string_from_expr}; + +/// Look up a nested value in AST object literals by a key path, like +/// lodash's `get`. Returns the value expression at the path, or `None` if +/// any segment is missing or is not an object literal. An empty path returns +/// the expression itself. +pub fn get<'e>(expr: &'e Expr<'e>, path: &[&'e str]) -> Option<&'e Expr<'e>> { + let mut current = expr; + for key in path { + current = get_value_by_obj_prop(current.as_object()?, key)?; + } + Some(current) +} + +/// Look up a nested value by a key path and extract it as `T`, combining +/// [`get`] with [`FromAstExpr`]. +pub fn get_value<'e, T: FromAstExpr<'e>>(expr: &'e Expr<'e>, path: &[&'e str]) -> Option { + get(expr, path).and_then(|expr| T::from_ast_expr(expr)) +} /// Extract a typed value from an AST expression, if the expression is a /// statically resolvable representation of the value. @@ -291,3 +309,51 @@ mod tests { ); } } + +#[cfg(test)] +mod get_tests { + use swc_experimental_allocator::Allocator; + use swc_experimental_ecma_ast::EsVersion; + use swc_experimental_ecma_parser::{ + EsSyntax, Lexer, Parser, StringSource, Syntax, unstable::Capturing, + }; + + use super::*; + + fn parse_expr<'a>(allocator: &'a Allocator, source: &'a str) -> Expr<'a> { + let lexer = Lexer::new( + allocator, + Syntax::Es(EsSyntax::default()), + EsVersion::EsNext, + StringSource::new(source), + None, + ); + let lexer = Capturing::new(lexer); + let mut parser = Parser::new_from(allocator, lexer); + parser + .parse_expr() + .expect("failed to parse test expression") + } + + #[test] + fn gets_nested_values_by_key_path() { + let allocator = Allocator::new(); + let expr = parse_expr(&allocator, "{ a: { b: { c: 42 } } }"); + assert_eq!(get_value::(&expr, &["a", "b", "c"]), Some(42.0)); + assert!(get(&expr, &["a", "b"]).is_some_and(|expr| expr.as_object().is_some())); + // An empty path is the identity. + assert!(get(&expr, &[]).is_some()); + } + + #[test] + fn get_returns_none_for_missing_or_non_object_segments() { + let allocator = Allocator::new(); + let expr = parse_expr(&allocator, "{ a: { b: 1 } }"); + assert_eq!(get_value::(&expr, &["a", "x"]), None); + assert_eq!(get_value::(&expr, &["x"]), None); + // Intermediate value is not an object. + assert_eq!(get_value::(&expr, &["a", "b", "c"]), None); + // The leaf exists but does not match the requested type. + assert_eq!(get_value::(&expr, &["a", "b"]), None); + } +} From 7d1db15156a22f6d60d32cb5350e0f278fc5c7f3 Mon Sep 17 00:00:00 2001 From: intellild Date: Fri, 17 Jul 2026 23:53:59 +0800 Subject: [PATCH 3/3] feat: prune nested destructured properties of define objects --- .../src/parser_plugin/define_plugin/utils.rs | 110 ++++++++++++++++-- .../parser_plugin/define_plugin/walk_data.rs | 29 ++--- .../builtins/define-destructuring/index.js | 16 +++ .../define-destructuring/rspack.config.js | 17 +++ 4 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 tests/rspack-test/configCases/builtins/define-destructuring/index.js create mode 100644 tests/rspack-test/configCases/builtins/define-destructuring/rspack.config.js diff --git a/crates/rspack_plugin_javascript/src/parser_plugin/define_plugin/utils.rs b/crates/rspack_plugin_javascript/src/parser_plugin/define_plugin/utils.rs index f42dd2d9b4e2..4720fecfbf1e 100644 --- a/crates/rspack_plugin_javascript/src/parser_plugin/define_plugin/utils.rs +++ b/crates/rspack_plugin_javascript/src/parser_plugin/define_plugin/utils.rs @@ -48,6 +48,14 @@ pub fn gen_const_dep( } } +/// 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, @@ -79,18 +87,104 @@ pub fn code_to_string<'a>( 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, + ) -> 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() { + // 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 }"# + ); + } +} diff --git a/crates/rspack_plugin_javascript/src/parser_plugin/define_plugin/walk_data.rs b/crates/rspack_plugin_javascript/src/parser_plugin/define_plugin/walk_data.rs index 3bf7bfc32f57..b983bfe4cae9 100644 --- a/crates/rspack_plugin_javascript/src/parser_plugin/define_plugin/walk_data.rs +++ b/crates/rspack_plugin_javascript/src/parser_plugin/define_plugin/walk_data.rs @@ -54,7 +54,7 @@ type OnTypeof = dyn Fn(&DefineRecord, &mut JavascriptParser, u32 /* start */, u3 + Sync; pub struct DefineRecord { - code: Value, + code: Arc, pub on_evaluate_identifier: Option>, pub on_evaluate_typeof: Option>, pub on_expression: Option>, @@ -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, @@ -256,28 +256,21 @@ impl WalkData { original_key }; let key = Arc::::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); } @@ -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})")) }; @@ -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})")) }; diff --git a/tests/rspack-test/configCases/builtins/define-destructuring/index.js b/tests/rspack-test/configCases/builtins/define-destructuring/index.js new file mode 100644 index 000000000000..6333758b70ae --- /dev/null +++ b/tests/rspack-test/configCases/builtins/define-destructuring/index.js @@ -0,0 +1,16 @@ +const fs = require("fs"); +const path = require("path"); + +const { NODE_ENV, DEEP: { A } } = ENV; + +it("destructures nested define objects and prunes the rest", () => { + expect(NODE_ENV).toBe("production"); + expect(A).toBe(1); + + const bundle = fs.readFileSync(path.join(__dirname, "./bundle0.js"), "utf-8"); + expect(bundle).toContain('"NODE_ENV":"production"'); + expect(bundle).toContain('"A":1'); + // Split the literals so this file itself does not contain them contiguously. + expect(bundle).not.toContain("DEB" + "UG"); + expect(bundle).not.toContain('"B"' + ":2"); +}); diff --git a/tests/rspack-test/configCases/builtins/define-destructuring/rspack.config.js b/tests/rspack-test/configCases/builtins/define-destructuring/rspack.config.js new file mode 100644 index 000000000000..539d81c2e3db --- /dev/null +++ b/tests/rspack-test/configCases/builtins/define-destructuring/rspack.config.js @@ -0,0 +1,17 @@ +const { rspack } = require('@rspack/core'); + +/** @type {import("@rspack/core").Configuration} */ +module.exports = { + entry: { + main: ['./index.js'], + }, + plugins: [ + new rspack.DefinePlugin({ + ENV: { + NODE_ENV: '"production"', + DEBUG: true, + DEEP: { A: '1', B: '2' }, + }, + }), + ], +};