1use serde::Deserialize;
24use swc_core::common::errors::HANDLER;
25use swc_core::common::{Span, Spanned, DUMMY_SP};
26use swc_core::ecma::ast::*;
27use swc_core::ecma::visit::{VisitMut, VisitMutWith};
28
29mod hash;
30mod whitespace;
31
32use hash::{interpolate_pattern, validate_pattern};
33use whitespace::normalize_whitespace;
34
35pub const DEFAULT_ID_INTERPOLATION_PATTERN: &str = "[sha512:contenthash:base64:6]";
36
37pub(crate) fn fail(span: Span, message: impl AsRef<str>) -> ! {
49 let msg = message.as_ref();
50 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
51 HANDLER.with(|h| {
52 h.struct_span_err(span, msg).emit();
53 });
54 }));
55 panic!("swc-plugin-formatjs: {}", msg);
56}
57
58#[derive(Debug, Default, Clone, Deserialize)]
64#[serde(rename_all = "camelCase", default)]
65pub struct Config {
66 pub id_interpolation_pattern: Option<String>,
67 pub remove_default_message: bool,
68 pub additional_component_names: Vec<String>,
69 pub additional_function_names: Vec<String>,
70 pub pragma: Option<String>,
71 pub extract_source_location: bool,
72 pub ast: bool,
73 pub preserve_whitespace: bool,
74}
75
76pub struct FormatJsTransform {
77 config: Config,
78 component_names: Vec<String>,
79 function_names: Vec<String>,
80}
81
82impl FormatJsTransform {
83 pub fn new(mut config: Config) -> Self {
84 if config.ast {
86 fail(
87 DUMMY_SP,
88 "option `ast: true` is not supported — would require a Rust port \
89 of @formatjs/icu-messageformat-parser",
90 );
91 }
92 if config.extract_source_location {
93 fail(
94 DUMMY_SP,
95 "option `extractSourceLocation: true` is not supported",
96 );
97 }
98 if let Some(p) = &config.pragma {
99 if !p.is_empty() {
100 fail(
101 DUMMY_SP,
102 format!("option `pragma: {:?}` is not supported", p),
103 );
104 }
105 }
106
107 if config.id_interpolation_pattern.is_none() {
108 config.id_interpolation_pattern =
109 Some(DEFAULT_ID_INTERPOLATION_PATTERN.to_string());
110 }
111 validate_pattern(config.id_interpolation_pattern.as_deref().unwrap());
112
113 let mut function_names = config.additional_function_names.clone();
117 for fixed in ["formatMessage", "$t", "$formatMessage"] {
118 if !function_names.iter().any(|n| n == fixed) {
119 function_names.push(fixed.to_string());
120 }
121 }
122 let mut component_names = config.additional_component_names.clone();
123 if !component_names.iter().any(|n| n == "FormattedMessage") {
124 component_names.push("FormattedMessage".to_string());
125 }
126
127 Self {
128 config,
129 component_names,
130 function_names,
131 }
132 }
133}
134
135pub fn formatjs(config: Config) -> FormatJsTransform {
136 FormatJsTransform::new(config)
137}
138
139pub fn formatjs_pass(config: Config) -> impl swc_core::ecma::ast::Pass {
145 swc_core::ecma::visit::visit_mut_pass(formatjs(config))
146}
147
148impl VisitMut for FormatJsTransform {
149 fn visit_mut_call_expr(&mut self, n: &mut CallExpr) {
150 n.visit_mut_children_with(self);
151 self.handle_call_expr(n);
152 }
153
154 fn visit_mut_jsx_opening_element(&mut self, n: &mut JSXOpeningElement) {
155 n.visit_mut_children_with(self);
156 self.handle_jsx_opening(n);
157 }
158}
159
160impl FormatJsTransform {
164 fn handle_call_expr(&self, n: &mut CallExpr) {
165 let Callee::Expr(callee_box) = &n.callee else { return };
166 let callee = unwrap_ts(callee_box);
167 let Some((name, _is_member)) = callee_ident_name(callee) else { return };
168
169 if name == "defineMessage" {
170 let arg = n
171 .args
172 .get_mut(0)
173 .unwrap_or_else(|| fail(n.span, "defineMessage(...) requires an argument"));
174 match unwrap_ts_mut(&mut arg.expr) {
175 Expr::Object(obj) => self.process_message_object(obj),
176 other => fail(
177 other.span(),
178 format!(
179 "defineMessage(...) argument must be an object literal — got {}",
180 expr_kind(other)
181 ),
182 ),
183 }
184 return;
185 }
186
187 if name == "defineMessages" {
188 let arg = n
189 .args
190 .get_mut(0)
191 .unwrap_or_else(|| fail(n.span, "defineMessages(...) requires an argument"));
192 match unwrap_ts_mut(&mut arg.expr) {
193 Expr::Object(outer) => {
194 for prop in outer.props.iter_mut() {
195 match prop {
196 PropOrSpread::Spread(s) => fail(
197 s.dot3_token,
198 "spread (`...rest`) inside defineMessages({...}) is not supported \
199 — expand to explicit `key: descriptor` entries",
200 ),
201 PropOrSpread::Prop(p) => {
202 let kv = match p.as_mut() {
203 Prop::KeyValue(kv) => kv,
204 Prop::Shorthand(id) => fail(
205 id.span,
206 "shorthand property inside defineMessages({...}) is not \
207 supported — expand to `key: { id, defaultMessage, ... }`",
208 ),
209 Prop::Method(m) => fail(
210 m.function.span,
211 "method property inside defineMessages({...}) is not supported",
212 ),
213 Prop::Getter(_) | Prop::Setter(_) | Prop::Assign(_) => fail(
214 DUMMY_SP,
215 "getter/setter/assign properties inside defineMessages({...}) are not supported",
216 ),
217 };
218 match unwrap_ts_mut(&mut kv.value) {
219 Expr::Object(inner) => self.process_message_object(inner),
220 other => fail(
221 other.span(),
222 format!(
223 "defineMessages bag entry must be an object literal — got {}",
224 expr_kind(other)
225 ),
226 ),
227 }
228 }
229 }
230 }
231 }
232 other => fail(
233 other.span(),
234 format!(
235 "defineMessages(...) argument must be an object literal — got {}",
236 expr_kind(other)
237 ),
238 ),
239 }
240 return;
241 }
242
243 if self.is_format_message_call(&name) {
244 let arg = match n.args.get_mut(0) {
245 None => return,
249 Some(a) => a,
250 };
251 match unwrap_ts_mut(&mut arg.expr) {
252 Expr::Object(obj) => self.process_message_object(obj),
253 _ => {}
257 }
258 }
259 }
260
261 fn is_format_message_call(&self, name: &str) -> bool {
262 self.function_names.iter().any(|n| n == name)
263 }
264
265 fn process_message_object(&self, obj: &mut ObjectLit) {
266 let mut existing_id: Option<String> = None;
267 let mut default_message: Option<String> = None;
268 let mut description: Option<String> = None;
269 let mut has_id_prop = false;
270 let mut has_default_msg_prop = false;
271
272 for prop in &obj.props {
273 match prop {
274 PropOrSpread::Spread(s) => fail(
275 s.dot3_token,
276 "spread (`...rest`) inside a message descriptor is not supported — \
277 expand to explicit `id` / `defaultMessage` / `description` properties",
278 ),
279 PropOrSpread::Prop(p) => match p.as_ref() {
280 Prop::KeyValue(kv) => {
281 let key = require_static_key(&kv.key);
282 if is_descriptor_key(&key) {
283 let val = require_static_string(&kv.value, &key);
284 match key.as_str() {
285 "id" => {
286 has_id_prop = true;
287 if !val.is_empty() {
288 existing_id = Some(val);
289 }
290 }
291 "defaultMessage" => {
292 has_default_msg_prop = true;
293 default_message = Some(val);
294 }
295 "description" => {
296 description = Some(val);
297 }
298 _ => unreachable!(),
299 }
300 }
301 }
303 Prop::Shorthand(id) => {
304 if is_descriptor_key(&id.sym) {
305 fail(
306 id.span,
307 format!(
308 "shorthand property `{}` in a message descriptor is not \
309 supported — expand to `{}: <string literal>`",
310 id.sym, id.sym
311 ),
312 );
313 }
314 }
316 Prop::Method(m) => {
317 let name = match &m.key {
318 PropName::Ident(i) => Some(i.sym.to_string()),
319 PropName::Str(s) => Some(s.value.to_atom_lossy().to_string()),
320 _ => None,
321 };
322 if name.as_deref().is_none()
323 || name.as_deref().map(is_descriptor_key).unwrap_or(true)
324 {
325 fail(
326 m.function.span,
327 "method property in a message descriptor is not supported",
328 );
329 }
330 }
331 Prop::Getter(g) => fail(
332 g.span,
333 "getter property in a message descriptor is not supported",
334 ),
335 Prop::Setter(s) => fail(
336 s.span,
337 "setter property in a message descriptor is not supported",
338 ),
339 Prop::Assign(a) => fail(
340 a.span,
341 "assignment property in a message descriptor is not supported",
342 ),
343 },
344 }
345 }
346
347 if !has_id_prop && !has_default_msg_prop {
350 return;
351 }
352
353 if existing_id.is_none()
359 && default_message
360 .as_deref()
361 .map(str::is_empty)
362 .unwrap_or(true)
363 {
364 fail(
365 DUMMY_SP,
366 "message descriptor has neither a non-empty `id` nor a non-empty `defaultMessage` \
367 — cannot generate an id",
368 );
369 }
370
371 let normalized_msg = default_message.as_ref().map(|m| {
372 if self.config.preserve_whitespace {
373 m.clone()
374 } else {
375 normalize_whitespace(m)
376 }
377 });
378
379 let final_id = compute_id(
380 existing_id.as_deref(),
381 normalized_msg.as_deref(),
382 description.as_deref(),
383 self.config
384 .id_interpolation_pattern
385 .as_deref()
386 .unwrap_or(DEFAULT_ID_INTERPOLATION_PATTERN),
387 );
388
389 if has_id_prop {
395 replace_object_value(obj, "id", string_expr(&final_id));
396 } else {
397 obj.props.insert(
398 0,
399 PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
400 key: PropName::Ident(IdentName {
401 span: DUMMY_SP,
402 sym: "id".into(),
403 }),
404 value: Box::new(string_expr(&final_id)),
405 }))),
406 );
407 }
408
409 obj.props.retain(|p| {
410 let PropOrSpread::Prop(prop) = p else { return true };
411 let Prop::KeyValue(kv) = prop.as_ref() else { return true };
412 prop_name_as_str(&kv.key).as_deref() != Some("description")
413 });
414
415 if self.config.remove_default_message {
416 obj.props.retain(|p| {
417 let PropOrSpread::Prop(prop) = p else { return true };
418 let Prop::KeyValue(kv) = prop.as_ref() else { return true };
419 prop_name_as_str(&kv.key).as_deref() != Some("defaultMessage")
420 });
421 } else if let Some(msg) = &normalized_msg {
422 replace_object_value(obj, "defaultMessage", string_expr(msg));
423 }
424 }
425}
426
427impl FormatJsTransform {
431 fn handle_jsx_opening(&self, n: &mut JSXOpeningElement) {
432 let JSXElementName::Ident(name_ident) = &n.name else { return };
433 if !self
434 .component_names
435 .iter()
436 .any(|c| c.as_str() == &*name_ident.sym)
437 {
438 return;
439 }
440
441 let mut existing_id: Option<String> = None;
442 let mut default_message: Option<String> = None;
443 let mut description: Option<String> = None;
444 let mut has_id_attr = false;
445 let mut has_default_message_attr = false;
446
447 for attr in &n.attrs {
448 let JSXAttrOrSpread::JSXAttr(a) = attr else { continue };
453
454 let name_str = match &a.name {
455 JSXAttrName::Ident(i) => i.sym.to_string(),
456 JSXAttrName::JSXNamespacedName(_) => continue,
458 };
459
460 if !is_descriptor_key(&name_str) {
461 continue;
462 }
463
464 let val = match &a.value {
466 None => fail(
467 a.span,
468 format!("`{}` JSX attribute requires a value", name_str),
469 ),
470 Some(JSXAttrValue::Str(s)) => s.value.to_atom_lossy().to_string(),
471 Some(JSXAttrValue::JSXExprContainer(c)) => match &c.expr {
472 JSXExpr::Expr(e) => static_string(e).unwrap_or_else(|| {
473 fail(
474 c.span,
475 format!(
476 "`{}` JSX attribute must be a string literal — got a {}. \
477 Constant folding (string concatenation, identifier references) is not \
478 supported by this Rust port; inline the literal or extend the plugin.",
479 name_str,
480 expr_kind(e)
481 ),
482 )
483 }),
484 JSXExpr::JSXEmptyExpr(_) => fail(
485 c.span,
486 format!("`{}` JSX attribute is empty (`{{}}`)", name_str),
487 ),
488 },
489 Some(JSXAttrValue::JSXElement(e)) => fail(
490 e.span,
491 format!(
492 "`{}` JSX attribute cannot be a JSX element",
493 name_str
494 ),
495 ),
496 Some(JSXAttrValue::JSXFragment(f)) => fail(
497 f.span,
498 format!(
499 "`{}` JSX attribute cannot be a JSX fragment",
500 name_str
501 ),
502 ),
503 };
504
505 match name_str.as_str() {
506 "id" => {
507 has_id_attr = true;
508 if !val.is_empty() {
509 existing_id = Some(val);
510 }
511 }
512 "defaultMessage" => {
513 has_default_message_attr = true;
514 default_message = Some(val);
515 }
516 "description" => {
517 description = Some(val);
518 }
519 _ => unreachable!(),
520 }
521 }
522
523 if !has_default_message_attr {
527 return;
528 }
529
530 if existing_id.is_none() && default_message.as_deref() == Some("") {
532 fail(
533 n.span,
534 "<FormattedMessage> has empty `defaultMessage` and no `id` — cannot generate an id",
535 );
536 }
537
538 let normalized_msg = default_message.as_ref().map(|m| {
539 if self.config.preserve_whitespace {
540 m.clone()
541 } else {
542 normalize_whitespace(m)
543 }
544 });
545
546 let final_id = compute_id(
547 existing_id.as_deref(),
548 normalized_msg.as_deref(),
549 description.as_deref(),
550 self.config
551 .id_interpolation_pattern
552 .as_deref()
553 .unwrap_or(DEFAULT_ID_INTERPOLATION_PATTERN),
554 );
555
556 if !final_id.is_empty() {
557 if has_id_attr {
558 replace_jsx_attr_value(n, "id", string_jsx_value(&final_id));
559 } else {
560 n.attrs.insert(
561 0,
562 JSXAttrOrSpread::JSXAttr(JSXAttr {
563 span: DUMMY_SP,
564 name: JSXAttrName::Ident(IdentName {
565 span: DUMMY_SP,
566 sym: "id".into(),
567 }),
568 value: Some(string_jsx_value(&final_id)),
569 }),
570 );
571 }
572 }
573
574 n.attrs.retain(|attr| {
575 let JSXAttrOrSpread::JSXAttr(a) = attr else { return true };
576 let JSXAttrName::Ident(id) = &a.name else { return true };
577 id.sym != "description"
578 });
579
580 if self.config.remove_default_message {
581 n.attrs.retain(|attr| {
582 let JSXAttrOrSpread::JSXAttr(a) = attr else { return true };
583 let JSXAttrName::Ident(id) = &a.name else { return true };
584 id.sym != "defaultMessage"
585 });
586 } else if let Some(msg) = &normalized_msg {
587 replace_jsx_attr_value(n, "defaultMessage", string_jsx_value(msg));
588 }
589 }
590}
591
592fn is_descriptor_key(key: &str) -> bool {
597 matches!(key, "id" | "defaultMessage" | "description")
598}
599
600fn compute_id(
601 existing_id: Option<&str>,
602 default_message: Option<&str>,
603 description: Option<&str>,
604 pattern: &str,
605) -> String {
606 if let Some(id) = existing_id {
607 if !id.is_empty() {
608 return id.to_string();
609 }
610 }
611 let Some(msg) = default_message else { return String::new() };
612 if msg.is_empty() {
613 return String::new();
614 }
615 let content = match description {
616 Some(d) if !d.is_empty() => format!("{}#{}", msg, d),
617 _ => msg.to_string(),
618 };
619 interpolate_pattern(pattern, &content)
620}
621
622fn callee_ident_name(e: &Expr) -> Option<(String, bool)> {
623 match e {
624 Expr::Ident(id) => Some((id.sym.to_string(), false)),
625 Expr::Member(m) => match &m.prop {
626 MemberProp::Ident(id) => Some((id.sym.to_string(), true)),
627 MemberProp::Computed(c) => fail(
631 c.span,
632 "computed member callee (`obj[expr](...)`) is not supported when matching \
633 formatMessage-like functions — use `obj.formatMessage(...)` or add the \
634 dynamic name to `additionalFunctionNames`",
635 ),
636 MemberProp::PrivateName(_) => None,
637 },
638 _ => None,
639 }
640}
641
642fn require_static_key(name: &PropName) -> String {
643 match name {
644 PropName::Ident(id) => id.sym.to_string(),
645 PropName::Str(s) => s.value.to_atom_lossy().to_string(),
646 PropName::Computed(c) => fail(
647 c.span,
648 "computed property keys (`[expr]: value`) are not supported inside a message \
649 descriptor — use a literal key (`id` / `defaultMessage` / `description`)",
650 ),
651 PropName::Num(n) => fail(
652 n.span,
653 "numeric property keys are not supported inside a message descriptor",
654 ),
655 PropName::BigInt(b) => fail(
656 b.span,
657 "bigint property keys are not supported inside a message descriptor",
658 ),
659 }
660}
661
662fn prop_name_as_str(name: &PropName) -> Option<String> {
663 match name {
666 PropName::Ident(id) => Some(id.sym.to_string()),
667 PropName::Str(s) => Some(s.value.to_atom_lossy().to_string()),
668 _ => None,
669 }
670}
671
672fn require_static_string(e: &Expr, key: &str) -> String {
673 static_string(e).unwrap_or_else(|| {
674 fail(
675 e.span(),
676 format!(
677 "`{}` in a message descriptor must be a string literal — got a {}. \
678 Constant folding (e.g. `'a' + b`, identifier references, ternaries) is not \
679 supported by this Rust port; inline the literal or extend the plugin.",
680 key,
681 expr_kind(e)
682 ),
683 )
684 })
685}
686
687fn unwrap_ts(e: &Expr) -> &Expr {
688 let mut cur = e;
689 loop {
690 match cur {
691 Expr::TsAs(t) => cur = &t.expr,
692 Expr::TsTypeAssertion(t) => cur = &t.expr,
693 Expr::TsNonNull(t) => cur = &t.expr,
694 Expr::TsConstAssertion(t) => cur = &t.expr,
695 Expr::TsSatisfies(t) => cur = &t.expr,
696 _ => return cur,
697 }
698 }
699}
700
701fn unwrap_ts_mut(mut e: &mut Expr) -> &mut Expr {
702 loop {
703 let is_ts = matches!(
704 e,
705 Expr::TsAs(_)
706 | Expr::TsTypeAssertion(_)
707 | Expr::TsNonNull(_)
708 | Expr::TsConstAssertion(_)
709 | Expr::TsSatisfies(_)
710 );
711 if !is_ts {
712 return e;
713 }
714 e = match e {
715 Expr::TsAs(t) => &mut *t.expr,
716 Expr::TsTypeAssertion(t) => &mut *t.expr,
717 Expr::TsNonNull(t) => &mut *t.expr,
718 Expr::TsConstAssertion(t) => &mut *t.expr,
719 Expr::TsSatisfies(t) => &mut *t.expr,
720 _ => unreachable!(),
721 };
722 }
723}
724
725fn static_string(e: &Expr) -> Option<String> {
726 let e = unwrap_ts(e);
727 match e {
728 Expr::Lit(Lit::Str(s)) => Some(s.value.to_atom_lossy().to_string()),
729 Expr::Tpl(t) if t.exprs.is_empty() && t.quasis.len() == 1 => t.quasis[0]
731 .cooked
732 .as_ref()
733 .map(|c| c.to_atom_lossy().to_string()),
734 _ => None,
735 }
736}
737
738fn expr_kind(e: &Expr) -> &'static str {
741 match e {
742 Expr::Lit(Lit::Str(_)) => "string literal",
743 Expr::Lit(Lit::Num(_)) => "number literal",
744 Expr::Lit(Lit::Bool(_)) => "boolean literal",
745 Expr::Lit(Lit::Null(_)) => "null",
746 Expr::Lit(_) => "non-string literal",
747 Expr::Ident(_) => "identifier reference",
748 Expr::Bin(_) => "binary expression (e.g. `a + b`)",
749 Expr::Tpl(_) => "template literal with `${...}` interpolation",
750 Expr::Call(_) => "function call",
751 Expr::Member(_) => "member access (e.g. `x.y`)",
752 Expr::Cond(_) => "ternary expression",
753 Expr::Object(_) => "object literal",
754 Expr::Array(_) => "array literal",
755 Expr::Paren(_) => "parenthesised expression",
756 _ => "non-literal expression",
757 }
758}
759
760fn string_expr(s: &str) -> Expr {
761 Expr::Lit(Lit::Str(Str {
762 span: DUMMY_SP,
763 value: s.into(),
764 raw: None,
765 }))
766}
767
768fn string_jsx_value(s: &str) -> JSXAttrValue {
769 JSXAttrValue::Str(Str {
770 span: DUMMY_SP,
771 value: s.into(),
772 raw: None,
773 })
774}
775
776fn replace_object_value(obj: &mut ObjectLit, key: &str, new_value: Expr) {
777 for prop in obj.props.iter_mut() {
778 let PropOrSpread::Prop(p) = prop else { continue };
779 let Prop::KeyValue(kv) = p.as_mut() else { continue };
780 if prop_name_as_str(&kv.key).as_deref() == Some(key) {
781 kv.value = Box::new(new_value);
782 return;
783 }
784 }
785}
786
787fn replace_jsx_attr_value(n: &mut JSXOpeningElement, key: &str, new_value: JSXAttrValue) {
788 for attr in n.attrs.iter_mut() {
789 let JSXAttrOrSpread::JSXAttr(a) = attr else { continue };
790 let JSXAttrName::Ident(id) = &a.name else { continue };
791 if id.sym == key {
792 a.value = Some(new_value);
793 return;
794 }
795 }
796}
797
798#[cfg(feature = "plugin")]
802mod plugin_entry {
803 use super::*;
804 use swc_core::plugin::{metadata::TransformPluginProgramMetadata, plugin_transform};
805
806 #[plugin_transform]
807 pub fn process_transform(
808 mut program: Program,
809 metadata: TransformPluginProgramMetadata,
810 ) -> Program {
811 let raw = metadata
812 .get_transform_plugin_config()
813 .unwrap_or_else(|| "{}".to_string());
814 let config: Config = match serde_json::from_str(&raw) {
815 Ok(c) => c,
816 Err(e) => fail(
817 DUMMY_SP,
818 format!("failed to parse plugin config: {}", e),
819 ),
820 };
821 program.visit_mut_with(&mut FormatJsTransform::new(config));
822 program
823 }
824}
825
826#[cfg(not(feature = "plugin"))]
827#[allow(dead_code)]
828fn _force_program_used(_: &Program) {}