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