Skip to main content

swc_plugin_formatjs/
lib.rs

1//! Rust/SWC port of `babel-plugin-formatjs@10.5.41`.
2//!
3//! Build targets:
4//!   - WASI plugin (`--features plugin --no-default-features --target wasm32-wasip1`)
5//!     for @swc/core's wasm plugin host. Entry point: `process_transform`.
6//!   - Native rlib (`--features native`, default) for embedders that link
7//!     directly into a native @swc/core build. Entry point: [`formatjs`].
8//!
9//! # Hard-error policy
10//!
11//! Per consumer requirement, this plugin MUST hard-error on any construct
12//! we can't statically resolve, rather than silently skip extraction.
13//!
14//! Babel's plugin falls back on `path.evaluate()` for constant folding
15//! (string concatenation, const references, ternaries with literal arms).
16//! This Rust port does not implement scope-aware constant folding —
17//! supporting it properly would mean re-implementing a chunk of @babel.
18//! Until that lands, we fail loudly via [`fail`] so the build breaks
19//! instead of silently producing divergent output in a 90 GB monorepo.
20//!
21//! See `CLAUDE.md` for the parity bar.
22
23use serde::Deserialize;
24use swc_core::common::errors::HANDLER;
25use swc_core::common::{Span, Spanned, DUMMY_SP};
26use swc_core::ecma::ast::*;
27use swc_core::ecma::visit::{VisitMut, VisitMutWith};
28
29mod hash;
30mod whitespace;
31
32use hash::{interpolate_pattern, validate_pattern};
33use whitespace::normalize_whitespace;
34
35pub const DEFAULT_ID_INTERPOLATION_PATTERN: &str = "[sha512:contenthash:base64:6]";
36
37/// Emit a span-attached error via the SWC diagnostic handler (when
38/// available — i.e. inside the WASI plugin host) and panic to halt the
39/// transform. The diagnostic surfaces in @swc/core output; the panic
40/// guarantees execution stops, preferable to emitting and continuing
41/// to produce divergent code in a 90 GB monorepo.
42///
43/// `better_scoped_tls::ScopedKey` (which backs `HANDLER`) only exposes
44/// `with(...)` and panics if no scope is active — i.e. in native unit
45/// tests where the SWC host hasn't installed one. We sidestep that with
46/// `catch_unwind` so test-only invocations of `fail` still produce a
47/// clean panic with our error message.
48pub(crate) fn fail(span: Span, message: impl AsRef<str>) -> ! {
49    let msg = message.as_ref();
50    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
51        HANDLER.with(|h| {
52            h.struct_span_err(span, msg).emit();
53        });
54    }));
55    panic!("swc-plugin-formatjs: {}", msg);
56}
57
58/// Mirrors the option surface of `babel-plugin-formatjs@10.5.41`'s `Options`
59/// that upstream actually uses.
60///
61/// Intentionally NOT supported (upstream doesn't use them):
62///   - `overrideIdFn`, `onMsgExtracted`, `onMetaExtracted` (JS callbacks)
63#[derive(Debug, Default, Clone, Deserialize)]
64#[serde(rename_all = "camelCase", default)]
65pub struct Config {
66    pub id_interpolation_pattern: Option<String>,
67    pub remove_default_message: bool,
68    pub additional_component_names: Vec<String>,
69    pub additional_function_names: Vec<String>,
70    pub pragma: Option<String>,
71    pub extract_source_location: bool,
72    pub ast: bool,
73    pub preserve_whitespace: bool,
74}
75
76pub struct FormatJsTransform {
77    config: Config,
78    component_names: Vec<String>,
79    function_names: Vec<String>,
80}
81
82impl FormatJsTransform {
83    pub fn new(mut config: Config) -> Self {
84        // Hard-error on options we don't implement, BEFORE touching any file.
85        if config.ast {
86            fail(
87                DUMMY_SP,
88                "option `ast: true` is not supported — would require a Rust port \
89                 of @formatjs/icu-messageformat-parser",
90            );
91        }
92        if config.extract_source_location {
93            fail(
94                DUMMY_SP,
95                "option `extractSourceLocation: true` is not supported",
96            );
97        }
98        if let Some(p) = &config.pragma {
99            if !p.is_empty() {
100                fail(
101                    DUMMY_SP,
102                    format!("option `pragma: {:?}` is not supported", p),
103                );
104            }
105        }
106
107        if config.id_interpolation_pattern.is_none() {
108            config.id_interpolation_pattern =
109                Some(DEFAULT_ID_INTERPOLATION_PATTERN.to_string());
110        }
111        validate_pattern(config.id_interpolation_pattern.as_deref().unwrap());
112
113        // Mirrors `index.js`:
114        //   componentNames = additionalComponentNames + 'FormattedMessage'
115        //   functionNames  = additionalFunctionNames + 'formatMessage' + '$t' + '$formatMessage'
116        let mut function_names = config.additional_function_names.clone();
117        for fixed in ["formatMessage", "$t", "$formatMessage"] {
118            if !function_names.iter().any(|n| n == fixed) {
119                function_names.push(fixed.to_string());
120            }
121        }
122        let mut component_names = config.additional_component_names.clone();
123        if !component_names.iter().any(|n| n == "FormattedMessage") {
124            component_names.push("FormattedMessage".to_string());
125        }
126
127        Self {
128            config,
129            component_names,
130            function_names,
131        }
132    }
133}
134
135pub fn formatjs(config: Config) -> FormatJsTransform {
136    FormatJsTransform::new(config)
137}
138
139/// Convenience wrapper that returns the transform as `impl Pass`, ready to
140/// drop into `swc::Compiler::process_js_with_custom_pass` without the
141/// caller having to import `swc_core::ecma::visit::visit_mut_pass` themselves.
142///
143/// Equivalent to: `swc_core::ecma::visit::visit_mut_pass(formatjs(config))`.
144pub fn formatjs_pass(config: Config) -> impl swc_core::ecma::ast::Pass {
145    swc_core::ecma::visit::visit_mut_pass(formatjs(config))
146}
147
148impl VisitMut for FormatJsTransform {
149    fn visit_mut_call_expr(&mut self, n: &mut CallExpr) {
150        n.visit_mut_children_with(self);
151        self.handle_call_expr(n);
152    }
153
154    fn visit_mut_jsx_opening_element(&mut self, n: &mut JSXOpeningElement) {
155        n.visit_mut_children_with(self);
156        self.handle_jsx_opening(n);
157    }
158}
159
160// =====================================================================
161// Call expression handler
162// =====================================================================
163impl FormatJsTransform {
164    fn handle_call_expr(&self, n: &mut CallExpr) {
165        let Callee::Expr(callee_box) = &n.callee else { return };
166        let callee = unwrap_ts(callee_box);
167        let Some((name, _is_member)) = callee_ident_name(callee) else { return };
168
169        if name == "defineMessage" {
170            let arg = n
171                .args
172                .get_mut(0)
173                .unwrap_or_else(|| fail(n.span, "defineMessage(...) requires an argument"));
174            match unwrap_ts_mut(&mut arg.expr) {
175                Expr::Object(obj) => self.process_message_object(obj),
176                other => fail(
177                    other.span(),
178                    format!(
179                        "defineMessage(...) argument must be an object literal — got {}",
180                        expr_kind(other)
181                    ),
182                ),
183            }
184            return;
185        }
186
187        if name == "defineMessages" {
188            let arg = n
189                .args
190                .get_mut(0)
191                .unwrap_or_else(|| fail(n.span, "defineMessages(...) requires an argument"));
192            match unwrap_ts_mut(&mut arg.expr) {
193                Expr::Object(outer) => {
194                    for prop in outer.props.iter_mut() {
195                        match prop {
196                            PropOrSpread::Spread(s) => fail(
197                                s.dot3_token,
198                                "spread (`...rest`) inside defineMessages({...}) is not supported \
199                                 — expand to explicit `key: descriptor` entries",
200                            ),
201                            PropOrSpread::Prop(p) => {
202                                let kv = match p.as_mut() {
203                                    Prop::KeyValue(kv) => kv,
204                                    Prop::Shorthand(id) => fail(
205                                        id.span,
206                                        "shorthand property inside defineMessages({...}) is not \
207                                         supported — expand to `key: { id, defaultMessage, ... }`",
208                                    ),
209                                    Prop::Method(m) => fail(
210                                        m.function.span,
211                                        "method property inside defineMessages({...}) is not supported",
212                                    ),
213                                    Prop::Getter(_) | Prop::Setter(_) | Prop::Assign(_) => fail(
214                                        DUMMY_SP,
215                                        "getter/setter/assign properties inside defineMessages({...}) are not supported",
216                                    ),
217                                };
218                                match unwrap_ts_mut(&mut kv.value) {
219                                    Expr::Object(inner) => self.process_message_object(inner),
220                                    other => fail(
221                                        other.span(),
222                                        format!(
223                                            "defineMessages bag entry must be an object literal — got {}",
224                                            expr_kind(other)
225                                        ),
226                                    ),
227                                }
228                            }
229                        }
230                    }
231                }
232                other => fail(
233                    other.span(),
234                    format!(
235                        "defineMessages(...) argument must be an object literal — got {}",
236                        expr_kind(other)
237                    ),
238                ),
239            }
240            return;
241        }
242
243        if self.is_format_message_call(&name) {
244            let arg = match n.args.get_mut(0) {
245                // Calling `formatMessage()` with no args is legal at runtime
246                // (returns ''); babel's visitor early-returns when there's no
247                // ObjectExpression in arg[0]. We do the same.
248                None => return,
249                Some(a) => a,
250            };
251            match unwrap_ts_mut(&mut arg.expr) {
252                Expr::Object(obj) => self.process_message_object(obj),
253                // babel does `if (messageDescriptor && messageDescriptor.isObjectExpression())`
254                // — so non-object first args are SILENTLY skipped, not errored.
255                // Mirror that behavior so we don't break legitimate non-extraction call sites.
256                _ => {}
257            }
258        }
259    }
260
261    fn is_format_message_call(&self, name: &str) -> bool {
262        self.function_names.iter().any(|n| n == name)
263    }
264
265    fn process_message_object(&self, obj: &mut ObjectLit) {
266        let mut existing_id: Option<String> = None;
267        let mut default_message: Option<String> = None;
268        let mut description: Option<String> = None;
269        let mut has_id_prop = false;
270        let mut has_default_msg_prop = false;
271
272        for prop in &obj.props {
273            match prop {
274                PropOrSpread::Spread(s) => fail(
275                    s.dot3_token,
276                    "spread (`...rest`) inside a message descriptor is not supported — \
277                     expand to explicit `id` / `defaultMessage` / `description` properties",
278                ),
279                PropOrSpread::Prop(p) => match p.as_ref() {
280                    Prop::KeyValue(kv) => {
281                        let key = require_static_key(&kv.key);
282                        if is_descriptor_key(&key) {
283                            let val = require_static_string(&kv.value, &key);
284                            match key.as_str() {
285                                "id" => {
286                                    has_id_prop = true;
287                                    if !val.is_empty() {
288                                        existing_id = Some(val);
289                                    }
290                                }
291                                "defaultMessage" => {
292                                    has_default_msg_prop = true;
293                                    default_message = Some(val);
294                                }
295                                "description" => {
296                                    description = Some(val);
297                                }
298                                _ => unreachable!(),
299                            }
300                        }
301                        // else: unrelated property like `values` — ignored.
302                    }
303                    Prop::Shorthand(id) => {
304                        if is_descriptor_key(&id.sym) {
305                            fail(
306                                id.span,
307                                format!(
308                                    "shorthand property `{}` in a message descriptor is not \
309                                     supported — expand to `{}: <string literal>`",
310                                    id.sym, id.sym
311                                ),
312                            );
313                        }
314                        // shorthand for an unrelated key (`values` etc.) is harmless.
315                    }
316                    Prop::Method(m) => {
317                        let name = match &m.key {
318                            PropName::Ident(i) => Some(i.sym.to_string()),
319                            PropName::Str(s) => Some(s.value.to_atom_lossy().to_string()),
320                            _ => None,
321                        };
322                        if name.as_deref().is_none()
323                            || name.as_deref().map(is_descriptor_key).unwrap_or(true)
324                        {
325                            fail(
326                                m.function.span,
327                                "method property in a message descriptor is not supported",
328                            );
329                        }
330                    }
331                    Prop::Getter(g) => fail(
332                        g.span,
333                        "getter property in a message descriptor is not supported",
334                    ),
335                    Prop::Setter(s) => fail(
336                        s.span,
337                        "setter property in a message descriptor is not supported",
338                    ),
339                    Prop::Assign(a) => fail(
340                        a.span,
341                        "assignment property in a message descriptor is not supported",
342                    ),
343                },
344            }
345        }
346
347        // No descriptor-relevant keys at all → not an extraction target. Bail
348        // (matches babel: `intl.formatMessage(somethingElse)` does nothing).
349        if !has_id_prop && !has_default_msg_prop {
350            return;
351        }
352
353        // babel's `evaluateMessageDescriptor` only hashes when
354        // `!id && idInterpolationPattern && defaultMessage`. `""` is falsy in
355        // JS, so an empty defaultMessage with no id would fall through to
356        // `storeMessage`, which then throws on `!id && !defaultMessage`.
357        // Mirror that: hard-error early instead of silently hashing "".
358        if existing_id.is_none()
359            && default_message
360                .as_deref()
361                .map(str::is_empty)
362                .unwrap_or(true)
363        {
364            fail(
365                DUMMY_SP,
366                "message descriptor has neither a non-empty `id` nor a non-empty `defaultMessage` \
367                 — cannot generate an id",
368            );
369        }
370
371        let normalized_msg = default_message.as_ref().map(|m| {
372            if self.config.preserve_whitespace {
373                m.clone()
374            } else {
375                normalize_whitespace(m)
376            }
377        });
378
379        let final_id = compute_id(
380            existing_id.as_deref(),
381            normalized_msg.as_deref(),
382            description.as_deref(),
383            self.config
384                .id_interpolation_pattern
385                .as_deref()
386                .unwrap_or(DEFAULT_ID_INTERPOLATION_PATTERN),
387        );
388
389        // Mutation order mirrors call-expression.js:
390        //   1. Insert/replace `id` (before removing anything else).
391        //   2. Remove `description`.
392        //   3. Replace/remove `defaultMessage`.
393
394        if has_id_prop {
395            replace_object_value(obj, "id", string_expr(&final_id));
396        } else {
397            obj.props.insert(
398                0,
399                PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
400                    key: PropName::Ident(IdentName {
401                        span: DUMMY_SP,
402                        sym: "id".into(),
403                    }),
404                    value: Box::new(string_expr(&final_id)),
405                }))),
406            );
407        }
408
409        obj.props.retain(|p| {
410            let PropOrSpread::Prop(prop) = p else { return true };
411            let Prop::KeyValue(kv) = prop.as_ref() else { return true };
412            prop_name_as_str(&kv.key).as_deref() != Some("description")
413        });
414
415        if self.config.remove_default_message {
416            obj.props.retain(|p| {
417                let PropOrSpread::Prop(prop) = p else { return true };
418                let Prop::KeyValue(kv) = prop.as_ref() else { return true };
419                prop_name_as_str(&kv.key).as_deref() != Some("defaultMessage")
420            });
421        } else if let Some(msg) = &normalized_msg {
422            replace_object_value(obj, "defaultMessage", string_expr(msg));
423        }
424    }
425}
426
427// =====================================================================
428// JSX opening-element handler
429// =====================================================================
430impl FormatJsTransform {
431    fn handle_jsx_opening(&self, n: &mut JSXOpeningElement) {
432        let JSXElementName::Ident(name_ident) = &n.name else { return };
433        if !self
434            .component_names
435            .iter()
436            .any(|c| c.as_str() == &*name_ident.sym)
437        {
438            return;
439        }
440
441        let mut existing_id: Option<String> = None;
442        let mut default_message: Option<String> = None;
443        let mut description: Option<String> = None;
444        let mut has_id_attr = false;
445        let mut has_default_message_attr = false;
446
447        for attr in &n.attrs {
448            // Babel filters out spread attrs via `.filter(isJSXAttribute)` and
449            // processes whatever explicit attrs remain. The descriptor is
450            // assumed to come from the spread "elsewhere"; we mirror that —
451            // not an error case (would break legitimate `{...rest}` usage).
452            let JSXAttrOrSpread::JSXAttr(a) = attr else { continue };
453
454            let name_str = match &a.name {
455                JSXAttrName::Ident(i) => i.sym.to_string(),
456                // namespaced names (`xmlns:href`) can't be id/defaultMessage/description
457                JSXAttrName::JSXNamespacedName(_) => continue,
458            };
459
460            if !is_descriptor_key(&name_str) {
461                continue;
462            }
463
464            // For our 3 keys, the value MUST be a static string.
465            let val = match &a.value {
466                None => fail(
467                    a.span,
468                    format!("`{}` JSX attribute requires a value", name_str),
469                ),
470                Some(JSXAttrValue::Str(s)) => s.value.to_atom_lossy().to_string(),
471                Some(JSXAttrValue::JSXExprContainer(c)) => match &c.expr {
472                    JSXExpr::Expr(e) => static_string(e).unwrap_or_else(|| {
473                        fail(
474                            c.span,
475                            format!(
476                                "`{}` JSX attribute must be a string literal — got a {}. \
477                                 Constant folding (string concatenation, identifier references) is not \
478                                 supported by this Rust port; inline the literal or extend the plugin.",
479                                name_str,
480                                expr_kind(e)
481                            ),
482                        )
483                    }),
484                    JSXExpr::JSXEmptyExpr(_) => fail(
485                        c.span,
486                        format!("`{}` JSX attribute is empty (`{{}}`)", name_str),
487                    ),
488                },
489                Some(JSXAttrValue::JSXElement(e)) => fail(
490                    e.span,
491                    format!(
492                        "`{}` JSX attribute cannot be a JSX element",
493                        name_str
494                    ),
495                ),
496                Some(JSXAttrValue::JSXFragment(f)) => fail(
497                    f.span,
498                    format!(
499                        "`{}` JSX attribute cannot be a JSX fragment",
500                        name_str
501                    ),
502                ),
503            };
504
505            match name_str.as_str() {
506                "id" => {
507                    has_id_attr = true;
508                    if !val.is_empty() {
509                        existing_id = Some(val);
510                    }
511                }
512                "defaultMessage" => {
513                    has_default_message_attr = true;
514                    default_message = Some(val);
515                }
516                "description" => {
517                    description = Some(val);
518                }
519                _ => unreachable!(),
520            }
521        }
522
523        // Babel: `if (!descriptorPath.defaultMessage) return;` — JSX without a
524        // `defaultMessage` attribute is assumed to come from a spread and is
525        // extracted elsewhere. Match that behavior exactly.
526        if !has_default_message_attr {
527            return;
528        }
529
530        // Empty `defaultMessage=""` with no id: babel's `storeMessage` throws.
531        if existing_id.is_none() && default_message.as_deref() == Some("") {
532            fail(
533                n.span,
534                "<FormattedMessage> has empty `defaultMessage` and no `id` — cannot generate an id",
535            );
536        }
537
538        let normalized_msg = default_message.as_ref().map(|m| {
539            if self.config.preserve_whitespace {
540                m.clone()
541            } else {
542                normalize_whitespace(m)
543            }
544        });
545
546        let final_id = compute_id(
547            existing_id.as_deref(),
548            normalized_msg.as_deref(),
549            description.as_deref(),
550            self.config
551                .id_interpolation_pattern
552                .as_deref()
553                .unwrap_or(DEFAULT_ID_INTERPOLATION_PATTERN),
554        );
555
556        if !final_id.is_empty() {
557            if has_id_attr {
558                replace_jsx_attr_value(n, "id", string_jsx_value(&final_id));
559            } else {
560                n.attrs.insert(
561                    0,
562                    JSXAttrOrSpread::JSXAttr(JSXAttr {
563                        span: DUMMY_SP,
564                        name: JSXAttrName::Ident(IdentName {
565                            span: DUMMY_SP,
566                            sym: "id".into(),
567                        }),
568                        value: Some(string_jsx_value(&final_id)),
569                    }),
570                );
571            }
572        }
573
574        n.attrs.retain(|attr| {
575            let JSXAttrOrSpread::JSXAttr(a) = attr else { return true };
576            let JSXAttrName::Ident(id) = &a.name else { return true };
577            id.sym != "description"
578        });
579
580        if self.config.remove_default_message {
581            n.attrs.retain(|attr| {
582                let JSXAttrOrSpread::JSXAttr(a) = attr else { return true };
583                let JSXAttrName::Ident(id) = &a.name else { return true };
584                id.sym != "defaultMessage"
585            });
586        } else if let Some(msg) = &normalized_msg {
587            replace_jsx_attr_value(n, "defaultMessage", string_jsx_value(msg));
588        }
589    }
590}
591
592// =====================================================================
593// Shared helpers
594// =====================================================================
595
596fn is_descriptor_key(key: &str) -> bool {
597    matches!(key, "id" | "defaultMessage" | "description")
598}
599
600fn compute_id(
601    existing_id: Option<&str>,
602    default_message: Option<&str>,
603    description: Option<&str>,
604    pattern: &str,
605) -> String {
606    if let Some(id) = existing_id {
607        if !id.is_empty() {
608            return id.to_string();
609        }
610    }
611    let Some(msg) = default_message else { return String::new() };
612    if msg.is_empty() {
613        return String::new();
614    }
615    let content = match description {
616        Some(d) if !d.is_empty() => format!("{}#{}", msg, d),
617        _ => msg.to_string(),
618    };
619    interpolate_pattern(pattern, &content)
620}
621
622fn callee_ident_name(e: &Expr) -> Option<(String, bool)> {
623    match e {
624        Expr::Ident(id) => Some((id.sym.to_string(), false)),
625        Expr::Member(m) => match &m.prop {
626            MemberProp::Ident(id) => Some((id.sym.to_string(), true)),
627            // Computed `obj[expr](...)`. Babel's matcher is
628            // `property.isIdentifier({name})` — it returns true ONLY when
629            // the computed expression is a bare `Identifier` node whose
630            // `.name` matches a configured function name. String literals
631            // (`obj["formatMessage"]`) and complex expressions (`obj[fn()]`)
632            // return false, and babel SILENTLY does not extract from them
633            // (no error). Match that exactly.
634            MemberProp::Computed(c) => match &*c.expr {
635                Expr::Ident(id) => Some((id.sym.to_string(), true)),
636                _ => None,
637            },
638            MemberProp::PrivateName(_) => None,
639        },
640        _ => None,
641    }
642}
643
644fn require_static_key(name: &PropName) -> String {
645    match name {
646        PropName::Ident(id) => id.sym.to_string(),
647        PropName::Str(s) => s.value.to_atom_lossy().to_string(),
648        PropName::Computed(c) => fail(
649            c.span,
650            "computed property keys (`[expr]: value`) are not supported inside a message \
651             descriptor — use a literal key (`id` / `defaultMessage` / `description`)",
652        ),
653        PropName::Num(n) => fail(
654            n.span,
655            "numeric property keys are not supported inside a message descriptor",
656        ),
657        PropName::BigInt(b) => fail(
658            b.span,
659            "bigint property keys are not supported inside a message descriptor",
660        ),
661    }
662}
663
664fn prop_name_as_str(name: &PropName) -> Option<String> {
665    // Used only after `require_static_key` has already validated; the
666    // Computed/Num/BigInt branches won't appear here in practice.
667    match name {
668        PropName::Ident(id) => Some(id.sym.to_string()),
669        PropName::Str(s) => Some(s.value.to_atom_lossy().to_string()),
670        _ => None,
671    }
672}
673
674fn require_static_string(e: &Expr, key: &str) -> String {
675    static_string(e).unwrap_or_else(|| {
676        fail(
677            e.span(),
678            format!(
679                "`{}` in a message descriptor must be a string literal — got a {}. \
680                 Constant folding (e.g. `'a' + b`, identifier references, ternaries) is not \
681                 supported by this Rust port; inline the literal or extend the plugin.",
682                key,
683                expr_kind(e)
684            ),
685        )
686    })
687}
688
689fn unwrap_ts(e: &Expr) -> &Expr {
690    let mut cur = e;
691    loop {
692        match cur {
693            Expr::TsAs(t) => cur = &t.expr,
694            Expr::TsTypeAssertion(t) => cur = &t.expr,
695            Expr::TsNonNull(t) => cur = &t.expr,
696            Expr::TsConstAssertion(t) => cur = &t.expr,
697            Expr::TsSatisfies(t) => cur = &t.expr,
698            _ => return cur,
699        }
700    }
701}
702
703fn unwrap_ts_mut(mut e: &mut Expr) -> &mut Expr {
704    loop {
705        let is_ts = matches!(
706            e,
707            Expr::TsAs(_)
708                | Expr::TsTypeAssertion(_)
709                | Expr::TsNonNull(_)
710                | Expr::TsConstAssertion(_)
711                | Expr::TsSatisfies(_)
712        );
713        if !is_ts {
714            return e;
715        }
716        e = match e {
717            Expr::TsAs(t) => &mut *t.expr,
718            Expr::TsTypeAssertion(t) => &mut *t.expr,
719            Expr::TsNonNull(t) => &mut *t.expr,
720            Expr::TsConstAssertion(t) => &mut *t.expr,
721            Expr::TsSatisfies(t) => &mut *t.expr,
722            _ => unreachable!(),
723        };
724    }
725}
726
727fn static_string(e: &Expr) -> Option<String> {
728    let e = unwrap_ts(e);
729    match e {
730        Expr::Lit(Lit::Str(s)) => Some(s.value.to_atom_lossy().to_string()),
731        // No-substitution template literal: `foo bar` (no ${} interpolations).
732        Expr::Tpl(t) if t.exprs.is_empty() && t.quasis.len() == 1 => t.quasis[0]
733            .cooked
734            .as_ref()
735            .map(|c| c.to_atom_lossy().to_string()),
736        _ => None,
737    }
738}
739
740/// Human-friendly description of an expression's shape — used in error
741/// messages so the developer can see exactly why their code wasn't accepted.
742fn expr_kind(e: &Expr) -> &'static str {
743    match e {
744        Expr::Lit(Lit::Str(_)) => "string literal",
745        Expr::Lit(Lit::Num(_)) => "number literal",
746        Expr::Lit(Lit::Bool(_)) => "boolean literal",
747        Expr::Lit(Lit::Null(_)) => "null",
748        Expr::Lit(_) => "non-string literal",
749        Expr::Ident(_) => "identifier reference",
750        Expr::Bin(_) => "binary expression (e.g. `a + b`)",
751        Expr::Tpl(_) => "template literal with `${...}` interpolation",
752        Expr::Call(_) => "function call",
753        Expr::Member(_) => "member access (e.g. `x.y`)",
754        Expr::Cond(_) => "ternary expression",
755        Expr::Object(_) => "object literal",
756        Expr::Array(_) => "array literal",
757        Expr::Paren(_) => "parenthesised expression",
758        _ => "non-literal expression",
759    }
760}
761
762fn string_expr(s: &str) -> Expr {
763    Expr::Lit(Lit::Str(Str {
764        span: DUMMY_SP,
765        value: s.into(),
766        raw: None,
767    }))
768}
769
770fn string_jsx_value(s: &str) -> JSXAttrValue {
771    JSXAttrValue::Str(Str {
772        span: DUMMY_SP,
773        value: s.into(),
774        raw: None,
775    })
776}
777
778fn replace_object_value(obj: &mut ObjectLit, key: &str, new_value: Expr) {
779    for prop in obj.props.iter_mut() {
780        let PropOrSpread::Prop(p) = prop else { continue };
781        let Prop::KeyValue(kv) = p.as_mut() else { continue };
782        if prop_name_as_str(&kv.key).as_deref() == Some(key) {
783            kv.value = Box::new(new_value);
784            return;
785        }
786    }
787}
788
789fn replace_jsx_attr_value(n: &mut JSXOpeningElement, key: &str, new_value: JSXAttrValue) {
790    for attr in n.attrs.iter_mut() {
791        let JSXAttrOrSpread::JSXAttr(a) = attr else { continue };
792        let JSXAttrName::Ident(id) = &a.name else { continue };
793        if id.sym == key {
794            a.value = Some(new_value);
795            return;
796        }
797    }
798}
799
800// =====================================================================
801// WASI plugin entry point (only compiled with `--features plugin`).
802// =====================================================================
803#[cfg(feature = "plugin")]
804mod plugin_entry {
805    use super::*;
806    use swc_core::plugin::{metadata::TransformPluginProgramMetadata, plugin_transform};
807
808    #[plugin_transform]
809    pub fn process_transform(
810        mut program: Program,
811        metadata: TransformPluginProgramMetadata,
812    ) -> Program {
813        let raw = metadata
814            .get_transform_plugin_config()
815            .unwrap_or_else(|| "{}".to_string());
816        let config: Config = match serde_json::from_str(&raw) {
817            Ok(c) => c,
818            Err(e) => fail(
819                DUMMY_SP,
820                format!("failed to parse plugin config: {}", e),
821            ),
822        };
823        program.visit_mut_with(&mut FormatJsTransform::new(config));
824        program
825    }
826}
827
828#[cfg(not(feature = "plugin"))]
829#[allow(dead_code)]
830fn _force_program_used(_: &Program) {}