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 std::cell::RefCell;
24use std::collections::HashMap;
25
26use serde::Deserialize;
27use swc_core::common::errors::HANDLER;
28use swc_core::common::{Span, Spanned, DUMMY_SP};
29use swc_core::ecma::ast::*;
30use swc_core::ecma::atoms::Atom;
31use swc_core::ecma::visit::{VisitMut, VisitMutWith};
32
33thread_local! {
34    /// Filename of the file currently being transformed. Read by [`fail`]
35    /// so panic messages include "(path/to/file.tsx)" — critical for
36    /// diagnosing CI failures across a large monorepo where the same
37    /// pattern shows up in hundreds of locations.
38    ///
39    /// Populated by the WASI plugin entry point from
40    /// `TransformPluginMetadataContextKind::Filename`. Native callers can
41    /// set it via [`with_current_file`].
42    static CURRENT_FILE: RefCell<Option<String>> = const { RefCell::new(None) };
43}
44
45// Only used by the WASI plugin entry point; native callers go through
46// `with_current_file` (scope guard) instead.
47#[cfg(feature = "plugin")]
48fn set_current_file(filename: Option<String>) {
49    CURRENT_FILE.with(|c| *c.borrow_mut() = filename);
50}
51
52/// Scope guard for setting the current filename — useful for native
53/// callers that want filename context in error messages without managing
54/// the thread-local manually.
55pub fn with_current_file<F, R>(filename: Option<String>, f: F) -> R
56where
57    F: FnOnce() -> R,
58{
59    let prev = CURRENT_FILE.with(|c| c.borrow_mut().replace(filename.unwrap_or_default()));
60    let result = f();
61    CURRENT_FILE.with(|c| *c.borrow_mut() = prev);
62    result
63}
64
65mod hash;
66mod whitespace;
67
68use hash::{interpolate_pattern, validate_pattern};
69use whitespace::normalize_whitespace;
70
71pub const DEFAULT_ID_INTERPOLATION_PATTERN: &str = "[sha512:contenthash:base64:6]";
72
73/// Emit a span-attached error via the SWC diagnostic handler (when
74/// available — i.e. inside the WASI plugin host) and panic to halt the
75/// transform. The diagnostic surfaces in @swc/core output; the panic
76/// guarantees execution stops, preferable to emitting and continuing
77/// to produce divergent code in a 90 GB monorepo.
78///
79/// Includes the filename of the file being transformed (read from the
80/// `CURRENT_FILE` thread-local) — without it, panic messages from a
81/// large CI run are unattributable.
82///
83/// `better_scoped_tls::ScopedKey` (which backs `HANDLER`) only exposes
84/// `with(...)` and panics if no scope is active — i.e. in native unit
85/// tests where the SWC host hasn't installed one. We sidestep that with
86/// `catch_unwind` so test-only invocations of `fail` still produce a
87/// clean panic with our error message.
88pub(crate) fn fail(span: Span, message: impl AsRef<str>) -> ! {
89    let msg = message.as_ref();
90    let filename =
91        CURRENT_FILE.with(|c| c.borrow().clone()).filter(|s| !s.is_empty());
92    let with_file = match &filename {
93        Some(f) => format!("{} (in {})", msg, f),
94        None => msg.to_string(),
95    };
96    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
97        HANDLER.with(|h| {
98            h.struct_span_err(span, &with_file).emit();
99        });
100    }));
101    panic!("swc-plugin-formatjs: {}", with_file);
102}
103
104/// Mirrors the option surface of `babel-plugin-formatjs@10.5.41`'s `Options`
105/// that upstream actually uses.
106///
107/// Intentionally NOT supported (upstream doesn't use them):
108///   - `overrideIdFn`, `onMsgExtracted`, `onMetaExtracted` (JS callbacks)
109#[derive(Debug, Default, Clone, Deserialize)]
110#[serde(rename_all = "camelCase", default)]
111pub struct Config {
112    pub id_interpolation_pattern: Option<String>,
113    pub remove_default_message: bool,
114    pub additional_component_names: Vec<String>,
115    pub additional_function_names: Vec<String>,
116    pub pragma: Option<String>,
117    pub extract_source_location: bool,
118    pub ast: bool,
119    pub preserve_whitespace: bool,
120}
121
122pub struct FormatJsTransform {
123    config: Config,
124    component_names: Vec<String>,
125    function_names: Vec<String>,
126    /// Module-level `const NAME = <string-foldable>` bindings, populated
127    /// in `visit_mut_module` / `visit_mut_script` before any
128    /// formatMessage/JSX visitation. Used by the recursive string folder
129    /// to resolve bare identifier references in `id` / `defaultMessage` /
130    /// `description` values to the literal string babel's `path.evaluate()`
131    /// would fold them to.
132    ///
133    /// Function-local / block-scoped consts are intentionally NOT
134    /// collected — that would require a full scope subsystem. Hits on
135    /// function-local patterns continue to hard-error.
136    module_consts: HashMap<Atom, String>,
137}
138
139impl FormatJsTransform {
140    pub fn new(mut config: Config) -> Self {
141        // Hard-error on options we don't implement, BEFORE touching any file.
142        if config.ast {
143            fail(
144                DUMMY_SP,
145                "option `ast: true` is not supported — would require a Rust port \
146                 of @formatjs/icu-messageformat-parser",
147            );
148        }
149        if config.extract_source_location {
150            fail(
151                DUMMY_SP,
152                "option `extractSourceLocation: true` is not supported",
153            );
154        }
155        if let Some(p) = &config.pragma {
156            if !p.is_empty() {
157                fail(
158                    DUMMY_SP,
159                    format!("option `pragma: {:?}` is not supported", p),
160                );
161            }
162        }
163
164        if config.id_interpolation_pattern.is_none() {
165            config.id_interpolation_pattern =
166                Some(DEFAULT_ID_INTERPOLATION_PATTERN.to_string());
167        }
168        validate_pattern(config.id_interpolation_pattern.as_deref().unwrap());
169
170        // Mirrors `index.js`:
171        //   componentNames = additionalComponentNames + 'FormattedMessage'
172        //   functionNames  = additionalFunctionNames + 'formatMessage' + '$t' + '$formatMessage'
173        let mut function_names = config.additional_function_names.clone();
174        for fixed in ["formatMessage", "$t", "$formatMessage"] {
175            if !function_names.iter().any(|n| n == fixed) {
176                function_names.push(fixed.to_string());
177            }
178        }
179        let mut component_names = config.additional_component_names.clone();
180        if !component_names.iter().any(|n| n == "FormattedMessage") {
181            component_names.push("FormattedMessage".to_string());
182        }
183
184        Self {
185            config,
186            component_names,
187            function_names,
188            module_consts: HashMap::new(),
189        }
190    }
191}
192
193pub fn formatjs(config: Config) -> FormatJsTransform {
194    FormatJsTransform::new(config)
195}
196
197/// Convenience wrapper that returns the transform as `impl Pass`, ready to
198/// drop into `swc::Compiler::process_js_with_custom_pass` without the
199/// caller having to import `swc_core::ecma::visit::visit_mut_pass` themselves.
200///
201/// Equivalent to: `swc_core::ecma::visit::visit_mut_pass(formatjs(config))`.
202pub fn formatjs_pass(config: Config) -> impl swc_core::ecma::ast::Pass {
203    swc_core::ecma::visit::visit_mut_pass(formatjs(config))
204}
205
206impl VisitMut for FormatJsTransform {
207    fn visit_mut_module(&mut self, n: &mut Module) {
208        // Pre-pass: collect module-level `const X = <foldable>` bindings
209        // BEFORE descending into call/JSX sites so the folder can resolve
210        // identifier references when it gets there.
211        self.module_consts = collect_module_consts(&n.body);
212        n.visit_mut_children_with(self);
213    }
214
215    fn visit_mut_script(&mut self, n: &mut Script) {
216        self.module_consts = collect_script_consts(&n.body);
217        n.visit_mut_children_with(self);
218    }
219
220    fn visit_mut_call_expr(&mut self, n: &mut CallExpr) {
221        n.visit_mut_children_with(self);
222        self.handle_call_expr(n);
223    }
224
225    fn visit_mut_jsx_opening_element(&mut self, n: &mut JSXOpeningElement) {
226        n.visit_mut_children_with(self);
227        self.handle_jsx_opening(n);
228    }
229}
230
231// =====================================================================
232// Module-level const string folding (Option 1 evaluator).
233//
234// Coverage: top-level `const NAME = <foldable>` where <foldable> is any
235// combination of string literals, no-substitution template literals,
236// `+`-concat of two foldable string operands, template literals with
237// `${expr}` where every interpolated expr folds, identifier references
238// to previously-declared module-level consts, parenthesised expressions,
239// and TS wrapper expressions.
240//
241// Anything that does not fold leaves the binding out of the table — any
242// later reference to it falls through to the existing hard-error path.
243// =====================================================================
244
245fn collect_module_consts(items: &[ModuleItem]) -> HashMap<Atom, String> {
246    let mut table = HashMap::new();
247    for item in items {
248        let var_decl = match item {
249            ModuleItem::Stmt(Stmt::Decl(Decl::Var(v))) if v.kind == VarDeclKind::Const => v,
250            ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(e)) => match &e.decl {
251                Decl::Var(v) if v.kind == VarDeclKind::Const => v,
252                _ => continue,
253            },
254            _ => continue,
255        };
256        collect_consts_from_var_decl(var_decl, &mut table);
257    }
258    table
259}
260
261fn collect_script_consts(items: &[Stmt]) -> HashMap<Atom, String> {
262    let mut table = HashMap::new();
263    for item in items {
264        if let Stmt::Decl(Decl::Var(v)) = item {
265            if v.kind == VarDeclKind::Const {
266                collect_consts_from_var_decl(v, &mut table);
267            }
268        }
269    }
270    table
271}
272
273fn collect_consts_from_var_decl(v: &VarDecl, table: &mut HashMap<Atom, String>) {
274    for decl in &v.decls {
275        // Only bare-identifier `const X = <expr>`. Destructuring patterns
276        // (`const { X } = obj`, `const [X] = arr`) are out of scope for
277        // Option 1 — they'd need object/array folding.
278        let Pat::Ident(name) = &decl.name else { continue };
279        let Some(init) = &decl.init else { continue };
280        if let Some(s) = fold_string_with(init, table) {
281            table.insert(name.id.sym.clone(), s);
282        }
283    }
284}
285
286/// Recursive string folder. Mirrors the subset of babel's `path.evaluate()`
287/// that produces string-typed results from string-typed inputs only.
288///
289/// Returns `Some(folded)` when every sub-expression resolves to a string;
290/// `None` otherwise (so the caller can hard-error with a helpful message).
291fn fold_string_with(e: &Expr, consts: &HashMap<Atom, String>) -> Option<String> {
292    let e = unwrap_ts(e);
293    match e {
294        Expr::Lit(Lit::Str(s)) => Some(s.value.to_atom_lossy().to_string()),
295
296        // Template literal — two cases:
297        //   (a) no substitutions: `foo bar`        → cooked of the single quasi
298        //   (b) `${expr}` substitutions:           → interleave quasis with folded exprs
299        Expr::Tpl(t) => fold_template(t, consts),
300
301        // `'a' + 'b'` — both sides must fold to strings. Number-to-string
302        // coercion is intentionally NOT supported (JS's `String(n)`
303        // semantics — NaN, Infinity, float printing — would diverge from
304        // babel without careful handling).
305        Expr::Bin(b) if matches!(b.op, BinaryOp::Add) => {
306            let left = fold_string_with(&b.left, consts)?;
307            let right = fold_string_with(&b.right, consts)?;
308            Some(format!("{}{}", left, right))
309        }
310
311        // Identifier — look up in the module-const table.
312        Expr::Ident(id) => consts.get(&id.sym).cloned(),
313
314        // Parenthesised: unwrap.
315        Expr::Paren(p) => fold_string_with(&p.expr, consts),
316
317        _ => None,
318    }
319}
320
321fn fold_template(t: &Tpl, consts: &HashMap<Atom, String>) -> Option<String> {
322    // Invariant from the spec: `quasis.len() == exprs.len() + 1`.
323    if t.quasis.len() != t.exprs.len() + 1 {
324        return None;
325    }
326    let mut out = String::new();
327    for (i, q) in t.quasis.iter().enumerate() {
328        let cooked = q.cooked.as_ref()?;
329        out.push_str(&cooked.to_atom_lossy().to_string());
330        if i < t.exprs.len() {
331            let folded = fold_string_with(&t.exprs[i], consts)?;
332            out.push_str(&folded);
333        }
334    }
335    Some(out)
336}
337
338// =====================================================================
339// Call expression handler
340// =====================================================================
341impl FormatJsTransform {
342    fn handle_call_expr(&self, n: &mut CallExpr) {
343        let Callee::Expr(callee_box) = &n.callee else { return };
344        let callee = unwrap_ts(callee_box);
345        let Some((name, _is_member)) = callee_ident_name(callee) else { return };
346
347        if name == "defineMessage" {
348            let arg = n
349                .args
350                .get_mut(0)
351                .unwrap_or_else(|| fail(n.span, "defineMessage(...) requires an argument"));
352            match unwrap_ts_mut(&mut arg.expr) {
353                Expr::Object(obj) => self.process_message_object(obj),
354                other => fail(
355                    other.span(),
356                    format!(
357                        "defineMessage(...) argument must be an object literal — got {}",
358                        expr_kind(other)
359                    ),
360                ),
361            }
362            return;
363        }
364
365        if name == "defineMessages" {
366            let arg = n
367                .args
368                .get_mut(0)
369                .unwrap_or_else(|| fail(n.span, "defineMessages(...) requires an argument"));
370            match unwrap_ts_mut(&mut arg.expr) {
371                Expr::Object(outer) => {
372                    for prop in outer.props.iter_mut() {
373                        match prop {
374                            PropOrSpread::Spread(s) => fail(
375                                s.dot3_token,
376                                "spread (`...rest`) inside defineMessages({...}) is not supported \
377                                 — expand to explicit `key: descriptor` entries",
378                            ),
379                            PropOrSpread::Prop(p) => {
380                                let kv = match p.as_mut() {
381                                    Prop::KeyValue(kv) => kv,
382                                    Prop::Shorthand(id) => fail(
383                                        id.span,
384                                        "shorthand property inside defineMessages({...}) is not \
385                                         supported — expand to `key: { id, defaultMessage, ... }`",
386                                    ),
387                                    Prop::Method(m) => fail(
388                                        m.function.span,
389                                        "method property inside defineMessages({...}) is not supported",
390                                    ),
391                                    Prop::Getter(_) | Prop::Setter(_) | Prop::Assign(_) => fail(
392                                        DUMMY_SP,
393                                        "getter/setter/assign properties inside defineMessages({...}) are not supported",
394                                    ),
395                                };
396                                match unwrap_ts_mut(&mut kv.value) {
397                                    Expr::Object(inner) => self.process_message_object(inner),
398                                    other => fail(
399                                        other.span(),
400                                        format!(
401                                            "defineMessages bag entry must be an object literal — got {}",
402                                            expr_kind(other)
403                                        ),
404                                    ),
405                                }
406                            }
407                        }
408                    }
409                }
410                other => fail(
411                    other.span(),
412                    format!(
413                        "defineMessages(...) argument must be an object literal — got {}",
414                        expr_kind(other)
415                    ),
416                ),
417            }
418            return;
419        }
420
421        if self.is_format_message_call(&name) {
422            let arg = match n.args.get_mut(0) {
423                // Calling `formatMessage()` with no args is legal at runtime
424                // (returns ''); babel's visitor early-returns when there's no
425                // ObjectExpression in arg[0]. We do the same.
426                None => return,
427                Some(a) => a,
428            };
429            match unwrap_ts_mut(&mut arg.expr) {
430                Expr::Object(obj) => self.process_message_object(obj),
431                // babel does `if (messageDescriptor && messageDescriptor.isObjectExpression())`
432                // — so non-object first args are SILENTLY skipped, not errored.
433                // Mirror that behavior so we don't break legitimate non-extraction call sites.
434                _ => {}
435            }
436        }
437    }
438
439    fn is_format_message_call(&self, name: &str) -> bool {
440        self.function_names.iter().any(|n| n == name)
441    }
442
443    fn process_message_object(&self, obj: &mut ObjectLit) {
444        let mut existing_id: Option<String> = None;
445        let mut default_message: Option<String> = None;
446        let mut description: Option<String> = None;
447        let mut has_id_prop = false;
448        let mut has_default_msg_prop = false;
449
450        for prop in &obj.props {
451            match prop {
452                PropOrSpread::Spread(s) => fail(
453                    s.dot3_token,
454                    "spread (`...rest`) inside a message descriptor is not supported — \
455                     expand to explicit `id` / `defaultMessage` / `description` properties",
456                ),
457                PropOrSpread::Prop(p) => match p.as_ref() {
458                    Prop::KeyValue(kv) => {
459                        let key = require_static_key(&kv.key);
460                        if is_descriptor_key(&key) {
461                            let val = self.require_static_string(&kv.value, &key);
462                            match key.as_str() {
463                                "id" => {
464                                    has_id_prop = true;
465                                    if !val.is_empty() {
466                                        existing_id = Some(val);
467                                    }
468                                }
469                                "defaultMessage" => {
470                                    has_default_msg_prop = true;
471                                    default_message = Some(val);
472                                }
473                                "description" => {
474                                    description = Some(val);
475                                }
476                                _ => unreachable!(),
477                            }
478                        }
479                        // else: unrelated property like `values` — ignored.
480                    }
481                    Prop::Shorthand(id) => {
482                        if is_descriptor_key(&id.sym) {
483                            fail(
484                                id.span,
485                                format!(
486                                    "shorthand property `{}` in a message descriptor is not \
487                                     supported — expand to `{}: <string literal>`",
488                                    id.sym, id.sym
489                                ),
490                            );
491                        }
492                        // shorthand for an unrelated key (`values` etc.) is harmless.
493                    }
494                    Prop::Method(m) => {
495                        let name = match &m.key {
496                            PropName::Ident(i) => Some(i.sym.to_string()),
497                            PropName::Str(s) => Some(s.value.to_atom_lossy().to_string()),
498                            _ => None,
499                        };
500                        if name.as_deref().is_none()
501                            || name.as_deref().map(is_descriptor_key).unwrap_or(true)
502                        {
503                            fail(
504                                m.function.span,
505                                "method property in a message descriptor is not supported",
506                            );
507                        }
508                    }
509                    Prop::Getter(g) => fail(
510                        g.span,
511                        "getter property in a message descriptor is not supported",
512                    ),
513                    Prop::Setter(s) => fail(
514                        s.span,
515                        "setter property in a message descriptor is not supported",
516                    ),
517                    Prop::Assign(a) => fail(
518                        a.span,
519                        "assignment property in a message descriptor is not supported",
520                    ),
521                },
522            }
523        }
524
525        // No descriptor-relevant keys at all → not an extraction target. Bail
526        // (matches babel: `intl.formatMessage(somethingElse)` does nothing).
527        if !has_id_prop && !has_default_msg_prop {
528            return;
529        }
530
531        // babel's `evaluateMessageDescriptor` only hashes when
532        // `!id && idInterpolationPattern && defaultMessage`. `""` is falsy in
533        // JS, so an empty defaultMessage with no id would fall through to
534        // `storeMessage`, which then throws on `!id && !defaultMessage`.
535        // Mirror that: hard-error early instead of silently hashing "".
536        if existing_id.is_none()
537            && default_message
538                .as_deref()
539                .map(str::is_empty)
540                .unwrap_or(true)
541        {
542            fail(
543                DUMMY_SP,
544                "message descriptor has neither a non-empty `id` nor a non-empty `defaultMessage` \
545                 — cannot generate an id",
546            );
547        }
548
549        let normalized_msg = default_message.as_ref().map(|m| {
550            if self.config.preserve_whitespace {
551                m.clone()
552            } else {
553                normalize_whitespace(m)
554            }
555        });
556
557        let final_id = compute_id(
558            existing_id.as_deref(),
559            normalized_msg.as_deref(),
560            description.as_deref(),
561            self.config
562                .id_interpolation_pattern
563                .as_deref()
564                .unwrap_or(DEFAULT_ID_INTERPOLATION_PATTERN),
565        );
566
567        // Mutation order mirrors call-expression.js:
568        //   1. Insert/replace `id` (before removing anything else).
569        //   2. Remove `description`.
570        //   3. Replace/remove `defaultMessage`.
571
572        if has_id_prop {
573            replace_object_value(obj, "id", string_expr(&final_id));
574        } else {
575            obj.props.insert(
576                0,
577                PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
578                    key: PropName::Ident(IdentName {
579                        span: DUMMY_SP,
580                        sym: "id".into(),
581                    }),
582                    value: Box::new(string_expr(&final_id)),
583                }))),
584            );
585        }
586
587        obj.props.retain(|p| {
588            let PropOrSpread::Prop(prop) = p else { return true };
589            let Prop::KeyValue(kv) = prop.as_ref() else { return true };
590            prop_name_as_str(&kv.key).as_deref() != Some("description")
591        });
592
593        if self.config.remove_default_message {
594            obj.props.retain(|p| {
595                let PropOrSpread::Prop(prop) = p else { return true };
596                let Prop::KeyValue(kv) = prop.as_ref() else { return true };
597                prop_name_as_str(&kv.key).as_deref() != Some("defaultMessage")
598            });
599        } else if let Some(msg) = &normalized_msg {
600            replace_object_value(obj, "defaultMessage", string_expr(msg));
601        }
602    }
603}
604
605// =====================================================================
606// JSX opening-element handler
607// =====================================================================
608impl FormatJsTransform {
609    fn handle_jsx_opening(&self, n: &mut JSXOpeningElement) {
610        let JSXElementName::Ident(name_ident) = &n.name else { return };
611        if !self
612            .component_names
613            .iter()
614            .any(|c| c.as_str() == &*name_ident.sym)
615        {
616            return;
617        }
618
619        let mut existing_id: Option<String> = None;
620        let mut default_message: Option<String> = None;
621        let mut description: Option<String> = None;
622        let mut has_id_attr = false;
623        let mut has_default_message_attr = false;
624
625        for attr in &n.attrs {
626            // Babel filters out spread attrs via `.filter(isJSXAttribute)` and
627            // processes whatever explicit attrs remain. The descriptor is
628            // assumed to come from the spread "elsewhere"; we mirror that —
629            // not an error case (would break legitimate `{...rest}` usage).
630            let JSXAttrOrSpread::JSXAttr(a) = attr else { continue };
631
632            let name_str = match &a.name {
633                JSXAttrName::Ident(i) => i.sym.to_string(),
634                // namespaced names (`xmlns:href`) can't be id/defaultMessage/description
635                JSXAttrName::JSXNamespacedName(_) => continue,
636            };
637
638            if !is_descriptor_key(&name_str) {
639                continue;
640            }
641
642            // For our 3 keys, the value MUST be a static string.
643            let val = match &a.value {
644                None => fail(
645                    a.span,
646                    format!("`{}` JSX attribute requires a value", name_str),
647                ),
648                Some(JSXAttrValue::Str(s)) => s.value.to_atom_lossy().to_string(),
649                Some(JSXAttrValue::JSXExprContainer(c)) => match &c.expr {
650                    JSXExpr::Expr(e) => self.fold_string(e).unwrap_or_else(|| {
651                        fail(
652                            c.span,
653                            format!(
654                                "`{}` JSX attribute must be a string literal — got a {}. \
655                                 Module-level `const NAME = <literal>` references DO fold; \
656                                 function-local consts, function calls, member access, and \
657                                 cross-module imports do NOT. Inline the literal or hoist the \
658                                 const to module scope.",
659                                name_str,
660                                expr_kind(e)
661                            ),
662                        )
663                    }),
664                    JSXExpr::JSXEmptyExpr(_) => fail(
665                        c.span,
666                        format!("`{}` JSX attribute is empty (`{{}}`)", name_str),
667                    ),
668                },
669                Some(JSXAttrValue::JSXElement(e)) => fail(
670                    e.span,
671                    format!(
672                        "`{}` JSX attribute cannot be a JSX element",
673                        name_str
674                    ),
675                ),
676                Some(JSXAttrValue::JSXFragment(f)) => fail(
677                    f.span,
678                    format!(
679                        "`{}` JSX attribute cannot be a JSX fragment",
680                        name_str
681                    ),
682                ),
683            };
684
685            match name_str.as_str() {
686                "id" => {
687                    has_id_attr = true;
688                    if !val.is_empty() {
689                        existing_id = Some(val);
690                    }
691                }
692                "defaultMessage" => {
693                    has_default_message_attr = true;
694                    default_message = Some(val);
695                }
696                "description" => {
697                    description = Some(val);
698                }
699                _ => unreachable!(),
700            }
701        }
702
703        // Babel: `if (!descriptorPath.defaultMessage) return;` — JSX without a
704        // `defaultMessage` attribute is assumed to come from a spread and is
705        // extracted elsewhere. Match that behavior exactly.
706        if !has_default_message_attr {
707            return;
708        }
709
710        // Empty `defaultMessage=""` with no id: babel's `storeMessage` throws.
711        if existing_id.is_none() && default_message.as_deref() == Some("") {
712            fail(
713                n.span,
714                "<FormattedMessage> has empty `defaultMessage` and no `id` — cannot generate an id",
715            );
716        }
717
718        let normalized_msg = default_message.as_ref().map(|m| {
719            if self.config.preserve_whitespace {
720                m.clone()
721            } else {
722                normalize_whitespace(m)
723            }
724        });
725
726        let final_id = compute_id(
727            existing_id.as_deref(),
728            normalized_msg.as_deref(),
729            description.as_deref(),
730            self.config
731                .id_interpolation_pattern
732                .as_deref()
733                .unwrap_or(DEFAULT_ID_INTERPOLATION_PATTERN),
734        );
735
736        if !final_id.is_empty() {
737            if has_id_attr {
738                replace_jsx_attr_value(n, "id", string_jsx_value(&final_id));
739            } else {
740                n.attrs.insert(
741                    0,
742                    JSXAttrOrSpread::JSXAttr(JSXAttr {
743                        span: DUMMY_SP,
744                        name: JSXAttrName::Ident(IdentName {
745                            span: DUMMY_SP,
746                            sym: "id".into(),
747                        }),
748                        value: Some(string_jsx_value(&final_id)),
749                    }),
750                );
751            }
752        }
753
754        n.attrs.retain(|attr| {
755            let JSXAttrOrSpread::JSXAttr(a) = attr else { return true };
756            let JSXAttrName::Ident(id) = &a.name else { return true };
757            id.sym != "description"
758        });
759
760        if self.config.remove_default_message {
761            n.attrs.retain(|attr| {
762                let JSXAttrOrSpread::JSXAttr(a) = attr else { return true };
763                let JSXAttrName::Ident(id) = &a.name else { return true };
764                id.sym != "defaultMessage"
765            });
766        }
767        // NOTE: Unlike the call-expression visitor, babel's JSX visitor
768        // does NOT rewrite the `defaultMessage` attribute to the normalized
769        // string. It only touches the attribute under `removeDefaultMessage`
770        // or `ast: true` (unsupported here). For the default path the
771        // original attribute expression (`={M}`, `="raw  string"`, etc.)
772        // is left intact — runtime sees the source-form value.
773        let _ = &normalized_msg;
774    }
775}
776
777// =====================================================================
778// Shared helpers
779// =====================================================================
780
781fn is_descriptor_key(key: &str) -> bool {
782    matches!(key, "id" | "defaultMessage" | "description")
783}
784
785fn compute_id(
786    existing_id: Option<&str>,
787    default_message: Option<&str>,
788    description: Option<&str>,
789    pattern: &str,
790) -> String {
791    if let Some(id) = existing_id {
792        if !id.is_empty() {
793            return id.to_string();
794        }
795    }
796    let Some(msg) = default_message else { return String::new() };
797    if msg.is_empty() {
798        return String::new();
799    }
800    let content = match description {
801        Some(d) if !d.is_empty() => format!("{}#{}", msg, d),
802        _ => msg.to_string(),
803    };
804    interpolate_pattern(pattern, &content)
805}
806
807fn callee_ident_name(e: &Expr) -> Option<(String, bool)> {
808    match e {
809        Expr::Ident(id) => Some((id.sym.to_string(), false)),
810        Expr::Member(m) => match &m.prop {
811            MemberProp::Ident(id) => Some((id.sym.to_string(), true)),
812            // Computed `obj[expr](...)`. Babel's matcher is
813            // `property.isIdentifier({name})` — it returns true ONLY when
814            // the computed expression is a bare `Identifier` node whose
815            // `.name` matches a configured function name. String literals
816            // (`obj["formatMessage"]`) and complex expressions (`obj[fn()]`)
817            // return false, and babel SILENTLY does not extract from them
818            // (no error). Match that exactly.
819            MemberProp::Computed(c) => match &*c.expr {
820                Expr::Ident(id) => Some((id.sym.to_string(), true)),
821                _ => None,
822            },
823            MemberProp::PrivateName(_) => None,
824        },
825        _ => None,
826    }
827}
828
829fn require_static_key(name: &PropName) -> String {
830    match name {
831        PropName::Ident(id) => id.sym.to_string(),
832        PropName::Str(s) => s.value.to_atom_lossy().to_string(),
833        PropName::Computed(c) => fail(
834            c.span,
835            "computed property keys (`[expr]: value`) are not supported inside a message \
836             descriptor — use a literal key (`id` / `defaultMessage` / `description`)",
837        ),
838        PropName::Num(n) => fail(
839            n.span,
840            "numeric property keys are not supported inside a message descriptor",
841        ),
842        PropName::BigInt(b) => fail(
843            b.span,
844            "bigint property keys are not supported inside a message descriptor",
845        ),
846    }
847}
848
849fn prop_name_as_str(name: &PropName) -> Option<String> {
850    // Used only after `require_static_key` has already validated; the
851    // Computed/Num/BigInt branches won't appear here in practice.
852    match name {
853        PropName::Ident(id) => Some(id.sym.to_string()),
854        PropName::Str(s) => Some(s.value.to_atom_lossy().to_string()),
855        _ => None,
856    }
857}
858
859impl FormatJsTransform {
860    /// Fold an expression to a literal string using the module-const table.
861    /// Returns `None` if the expression can't be folded — the caller then
862    /// hard-errors with `require_static_string`.
863    fn fold_string(&self, e: &Expr) -> Option<String> {
864        fold_string_with(e, &self.module_consts)
865    }
866
867    fn require_static_string(&self, e: &Expr, key: &str) -> String {
868        self.fold_string(e).unwrap_or_else(|| {
869            fail(
870                e.span(),
871                format!(
872                    "`{}` in a message descriptor must be a string literal — got a {}. \
873                     Module-level `const NAME = <literal>` references DO fold (Option 1 \
874                     evaluator); function-local consts, function calls, member access, and \
875                     cross-module imports do NOT. Inline the literal at the call site or \
876                     hoist the const to module scope.",
877                    key,
878                    expr_kind(e)
879                ),
880            )
881        })
882    }
883}
884
885fn unwrap_ts(e: &Expr) -> &Expr {
886    let mut cur = e;
887    loop {
888        match cur {
889            Expr::TsAs(t) => cur = &t.expr,
890            Expr::TsTypeAssertion(t) => cur = &t.expr,
891            Expr::TsNonNull(t) => cur = &t.expr,
892            Expr::TsConstAssertion(t) => cur = &t.expr,
893            Expr::TsSatisfies(t) => cur = &t.expr,
894            _ => return cur,
895        }
896    }
897}
898
899fn unwrap_ts_mut(mut e: &mut Expr) -> &mut Expr {
900    loop {
901        let is_ts = matches!(
902            e,
903            Expr::TsAs(_)
904                | Expr::TsTypeAssertion(_)
905                | Expr::TsNonNull(_)
906                | Expr::TsConstAssertion(_)
907                | Expr::TsSatisfies(_)
908        );
909        if !is_ts {
910            return e;
911        }
912        e = match e {
913            Expr::TsAs(t) => &mut *t.expr,
914            Expr::TsTypeAssertion(t) => &mut *t.expr,
915            Expr::TsNonNull(t) => &mut *t.expr,
916            Expr::TsConstAssertion(t) => &mut *t.expr,
917            Expr::TsSatisfies(t) => &mut *t.expr,
918            _ => unreachable!(),
919        };
920    }
921}
922
923
924/// Human-friendly description of an expression's shape — used in error
925/// messages so the developer can see exactly why their code wasn't accepted.
926fn expr_kind(e: &Expr) -> &'static str {
927    match e {
928        Expr::Lit(Lit::Str(_)) => "string literal",
929        Expr::Lit(Lit::Num(_)) => "number literal",
930        Expr::Lit(Lit::Bool(_)) => "boolean literal",
931        Expr::Lit(Lit::Null(_)) => "null",
932        Expr::Lit(_) => "non-string literal",
933        Expr::Ident(_) => "identifier reference",
934        Expr::Bin(_) => "binary expression (e.g. `a + b`)",
935        Expr::Tpl(_) => "template literal with `${...}` interpolation",
936        Expr::Call(_) => "function call",
937        Expr::Member(_) => "member access (e.g. `x.y`)",
938        Expr::Cond(_) => "ternary expression",
939        Expr::Object(_) => "object literal",
940        Expr::Array(_) => "array literal",
941        Expr::Paren(_) => "parenthesised expression",
942        _ => "non-literal expression",
943    }
944}
945
946fn string_expr(s: &str) -> Expr {
947    Expr::Lit(Lit::Str(Str {
948        span: DUMMY_SP,
949        value: s.into(),
950        raw: None,
951    }))
952}
953
954fn string_jsx_value(s: &str) -> JSXAttrValue {
955    JSXAttrValue::Str(Str {
956        span: DUMMY_SP,
957        value: s.into(),
958        raw: None,
959    })
960}
961
962fn replace_object_value(obj: &mut ObjectLit, key: &str, new_value: Expr) {
963    for prop in obj.props.iter_mut() {
964        let PropOrSpread::Prop(p) = prop else { continue };
965        let Prop::KeyValue(kv) = p.as_mut() else { continue };
966        if prop_name_as_str(&kv.key).as_deref() == Some(key) {
967            kv.value = Box::new(new_value);
968            return;
969        }
970    }
971}
972
973fn replace_jsx_attr_value(n: &mut JSXOpeningElement, key: &str, new_value: JSXAttrValue) {
974    for attr in n.attrs.iter_mut() {
975        let JSXAttrOrSpread::JSXAttr(a) = attr else { continue };
976        let JSXAttrName::Ident(id) = &a.name else { continue };
977        if id.sym == key {
978            a.value = Some(new_value);
979            return;
980        }
981    }
982}
983
984// =====================================================================
985// WASI plugin entry point (only compiled with `--features plugin`).
986// =====================================================================
987#[cfg(feature = "plugin")]
988mod plugin_entry {
989    use super::*;
990    use swc_core::plugin::metadata::TransformPluginMetadataContextKind;
991    use swc_core::plugin::{metadata::TransformPluginProgramMetadata, plugin_transform};
992
993    #[plugin_transform]
994    pub fn process_transform(
995        mut program: Program,
996        metadata: TransformPluginProgramMetadata,
997    ) -> Program {
998        // Stash the source file path before parsing config or running the
999        // visitor so any `fail(...)` we hit can include it in the message.
1000        // The host hands us a path string here (relative to the project
1001        // root) — we just forward it.
1002        let filename = metadata.get_context(&TransformPluginMetadataContextKind::Filename);
1003        set_current_file(filename);
1004
1005        let raw = metadata
1006            .get_transform_plugin_config()
1007            .unwrap_or_else(|| "{}".to_string());
1008        let config: Config = match serde_json::from_str(&raw) {
1009            Ok(c) => c,
1010            Err(e) => fail(
1011                DUMMY_SP,
1012                format!("failed to parse plugin config: {}", e),
1013            ),
1014        };
1015        program.visit_mut_with(&mut FormatJsTransform::new(config));
1016
1017        // Clear so a subsequent invocation in the same plugin instance
1018        // (host reuses instances) can't accidentally attribute its error
1019        // to the previous file.
1020        set_current_file(None);
1021
1022        program
1023    }
1024}
1025
1026#[cfg(not(feature = "plugin"))]
1027#[allow(dead_code)]
1028fn _force_program_used(_: &Program) {}