1use 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 static CURRENT_FILE: RefCell<Option<String>> = const { RefCell::new(None) };
41}
42
43#[cfg(feature = "plugin")]
46fn set_current_file(filename: Option<String>) {
47 CURRENT_FILE.with(|c| *c.borrow_mut() = filename);
48}
49
50pub 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
71pub(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#[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 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 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
183pub 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
204impl 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 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 _ => {}
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 }
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 }
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 if !has_id_prop && !has_default_msg_prop {
394 return;
395 }
396
397 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 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
471impl 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 let JSXAttrOrSpread::JSXAttr(a) = attr else { continue };
497
498 let name_str = match &a.name {
499 JSXAttrName::Ident(i) => i.sym.to_string(),
500 JSXAttrName::JSXNamespacedName(_) => continue,
502 };
503
504 if !is_descriptor_key(&name_str) {
505 continue;
506 }
507
508 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 if !has_default_message_attr {
571 return;
572 }
573
574 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
636fn 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 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 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 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
784fn 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#[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 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 set_current_file(None);
881
882 program
883 }
884}
885
886#[cfg(not(feature = "plugin"))]
887#[allow(dead_code)]
888fn _force_program_used(_: &Program) {}