Skip to main content

intlayer_swc_plugin/
ast.rs

1//! Small, dependency-free helpers for reading and building the AST nodes the
2//! transforms care about.
3
4use base62::encode as base62_encode;
5use std::hash::{BuildHasher, BuildHasherDefault, Hasher};
6use swc_core::{
7    common::{SyntaxContext, DUMMY_SP},
8    ecma::{ast::*, atoms::Atom},
9};
10use twox_hash::XxHash64;
11
12/// Aborts the transform when the host hands over an AST node this build's
13/// `swc_ecma_ast` schema has no variant for.
14///
15/// Only reachable on a host *newer* than the schema the plugin was compiled
16/// against, and only for source code using syntax that schema predates: SWC's
17/// forward-compatible ABI deserialises such nodes into the `Unknown` variant
18/// every AST enum carries under the `swc_ast_unknown` cfg, rather than failing
19/// the whole plugin the way the older rkyv ABI did.
20///
21/// Skipping the node instead would leave a call site or a content field access
22/// un-rewritten while `replaceDictionaryEntry` has already emptied the runtime
23/// dictionary registry — a production bundle whose translations silently
24/// resolve to nothing. Failing the build is the safer of the two, and is what
25/// SWC recommends for these variants.
26#[cfg(swc_ast_unknown)]
27pub fn unsupported_ast_node(node_kind: &str) -> ! {
28    panic!(
29        "@intlayer/swc does not understand the `{node_kind}` node your bundler's \
30         SWC produced — the plugin is older than the syntax in this file. \
31         Upgrade @intlayer/swc, or set `build.optimize: false` in your Intlayer \
32         configuration to build without the transform."
33    )
34}
35
36/// Splits a namespace string at the first `.` to separate the dictionary key
37/// from an optional key prefix for nested namespaces.
38///
39/// Examples:
40/// - `"about"` -> `("about", "")`
41/// - `"about.counter"` -> `("about", "counter")`
42/// - `"about.section.title"` -> `("about", "section.title")`
43pub fn split_namespace(namespace: &str) -> (&str, &str) {
44    if let Some(dot_position) = namespace.find('.') {
45        (&namespace[..dot_position], &namespace[dot_position + 1..])
46    } else {
47        (namespace, "")
48    }
49}
50
51/// Reads a fully-static string from an expression: a string literal, or a
52/// template literal with a single quasi and no interpolations.
53pub fn read_static_string(expr: &Expr) -> Option<String> {
54    match expr {
55        Expr::Lit(Lit::Str(Str { value, .. })) => Some(value.to_string_lossy().into_owned()),
56        Expr::Tpl(Tpl { exprs, quasis, .. }) if exprs.is_empty() && quasis.len() == 1 => {
57            Some(quasis[0].raw.to_string())
58        }
59        _ => None,
60    }
61}
62
63/// Returns `true` when the object property name matches `property`.
64pub fn prop_name_matches(key: &PropName, property: &str) -> bool {
65    match key {
66        PropName::Ident(ident) => ident.sym.as_str() == property,
67        PropName::Str(string_key) => string_key.value.to_string_lossy() == property,
68        _ => false,
69    }
70}
71
72/// Reads the statically-known name of an object property key, or `None` for
73/// computed / dynamic keys.
74pub fn read_prop_name(key: &PropName) -> Option<String> {
75    match key {
76        PropName::Ident(ident) => Some(ident.sym.to_string()),
77        PropName::Str(string_key) => Some(string_key.value.to_string_lossy().into_owned()),
78        _ => None,
79    }
80}
81
82/// Reads the statically-known name of a member-expression property
83/// (`obj.name` or `obj["name"]`), or `None` for dynamic computed accesses.
84pub fn read_member_prop_name(prop: &MemberProp) -> Option<String> {
85    match prop {
86        MemberProp::Ident(ident) => Some(ident.sym.to_string()),
87        MemberProp::Computed(computed) => match &*computed.expr {
88            Expr::Lit(Lit::Str(Str { value, .. })) => Some(value.to_string_lossy().into_owned()),
89            _ => None,
90        },
91        MemberProp::PrivateName(_) => None,
92        #[cfg(swc_ast_unknown)]
93        _ => unsupported_ast_node("MemberProp"),
94    }
95}
96
97/// Overwrites the name of a statically-known member-expression property,
98/// preserving its `obj.name` / `obj["name"]` shape.
99pub fn write_member_prop_name(prop: &mut MemberProp, name: &str) {
100    match prop {
101        MemberProp::Ident(ident) => ident.sym = Atom::from(name),
102        MemberProp::Computed(computed) => {
103            *computed.expr = Expr::Lit(Lit::Str(make_str(name)));
104        }
105        MemberProp::PrivateName(_) => {}
106        #[cfg(swc_ast_unknown)]
107        _ => unsupported_ast_node("MemberProp"),
108    }
109}
110
111/// Returns `true` for a numeric index access such as `[0]` or `[1]`.
112pub fn is_numeric_index_prop(prop: &MemberProp) -> bool {
113    matches!(prop, MemberProp::Computed(computed) if matches!(&*computed.expr, Expr::Lit(Lit::Num(_))))
114}
115
116/// Strips redundant parentheses and TypeScript type assertions so the wrapped
117/// expression can be inspected directly.
118pub fn unwrap_expr(expr: &Expr) -> &Expr {
119    match expr {
120        Expr::Paren(paren) => unwrap_expr(&paren.expr),
121        Expr::TsAs(ts_as) => unwrap_expr(&ts_as.expr),
122        Expr::TsNonNull(ts_non_null) => unwrap_expr(&ts_non_null.expr),
123        Expr::TsSatisfies(ts_satisfies) => unwrap_expr(&ts_satisfies.expr),
124        other => other,
125    }
126}
127
128/// Builds a string literal node with no span or raw representation.
129pub fn make_str(value: &str) -> Str {
130    Str {
131        span: DUMMY_SP,
132        value: Atom::from(value).into(),
133        raw: None,
134    }
135}
136
137/// Builds a spanless identifier in the empty syntax context.
138pub fn make_ident(name: &str) -> Ident {
139    Ident::new(Atom::from(name), DUMMY_SP, SyntaxContext::empty())
140}
141
142/// Builds a plain (non-spread) string-literal call argument.
143pub fn make_string_arg(value: &str) -> ExprOrSpread {
144    ExprOrSpread {
145        spread: None,
146        expr: Box::new(Expr::Lit(Lit::Str(make_str(value)))),
147    }
148}
149
150/// Builds a plain (non-spread) identifier call argument.
151pub fn make_ident_arg(ident: Ident) -> ExprOrSpread {
152    ExprOrSpread {
153        spread: None,
154        expr: Box::new(Expr::Ident(ident)),
155    }
156}
157
158/// Derives a short, stable identifier from a dictionary key using
159/// xxHash64 + base62, prefixed with `_` and followed by `suffix`.
160/// Example: `"locale-switcher"` → `"_eEmT39vss4n4"` (empty suffix).
161pub fn make_hashed_ident(key: &str, suffix: &str) -> Ident {
162    let mut hasher = BuildHasherDefault::<XxHash64>::default().build_hasher();
163    hasher.write(key.as_bytes());
164    let hash = hasher.finish();
165    let mut encoded = base62_encode(hash);
166    encoded.insert(0, '_');
167    encoded.push_str(suffix);
168    make_ident(&encoded)
169}
170
171/// Reads the local name of a call's callee when it is a bare identifier.
172pub fn callee_ident_name(callee: &Callee) -> Option<&str> {
173    match callee {
174        Callee::Expr(callee_expr) => match &**callee_expr {
175            Expr::Ident(ident) => Some(ident.sym.as_ref()),
176            _ => None,
177        },
178        _ => None,
179    }
180}
181
182/// Reads the name an import specifier resolves to in its source module —
183/// the `imported` name when aliased (`{ a as b }`), otherwise the local name.
184pub fn imported_specifier_name(named: &ImportNamedSpecifier) -> String {
185    match &named.imported {
186        Some(ModuleExportName::Ident(ident)) => ident.sym.to_string(),
187        Some(ModuleExportName::Str(string_name)) => {
188            string_name.value.to_string_lossy().into_owned()
189        }
190        None => named.local.sym.to_string(),
191        #[cfg(swc_ast_unknown)]
192        Some(_) => unsupported_ast_node("ModuleExportName"),
193    }
194}