globetrotter_typescript/
ast_swc.rs

1use globetrotter_model as model;
2use std::sync::Arc;
3use swc_core::{
4    base::{Compiler, PrintArgs},
5    common::DUMMY_SP,
6    ecma::ast,
7};
8
9#[derive(thiserror::Error, Debug)]
10pub enum Error {
11    #[error(transparent)]
12    Codegen(anyhow::Error),
13}
14
15pub trait IntoAST<T> {
16    fn into_ast(self) -> T;
17}
18
19impl IntoAST<ast::TsType> for model::ArgumentType {
20    fn into_ast(self) -> ast::TsType {
21        match self {
22            Self::Number => ast::TsType::TsKeywordType(ast::TsKeywordType {
23                span: DUMMY_SP,
24                kind: ast::TsKeywordTypeKind::TsNumberKeyword,
25            }),
26            Self::String | Self::Iso8601DateTimeString => {
27                ast::TsType::TsKeywordType(ast::TsKeywordType {
28                    span: DUMMY_SP,
29                    kind: ast::TsKeywordTypeKind::TsStringKeyword,
30                })
31            }
32            Self::Any => ast::TsType::TsKeywordType(ast::TsKeywordType {
33                span: DUMMY_SP,
34                kind: ast::TsKeywordTypeKind::TsAnyKeyword,
35            }),
36        }
37    }
38}
39
40fn emit_code(compiler: &Compiler, program: &ast::Program) -> Result<String, anyhow::Error> {
41    let printed = compiler.print(
42        program,
43        PrintArgs {
44            preamble: &crate::preamble(),
45            ..PrintArgs::default()
46        },
47    )?;
48    Ok(printed.code)
49}
50
51fn type_annotation_for_translation(translation: &model::Translation) -> ast::TsType {
52    if !translation.is_template() {
53        // "translation.key": string;
54        return model::ArgumentType::String.into_ast();
55    }
56
57    // "translation.key": (values: { readonly "member.one": string; }) => string;
58    let members = translation
59        .arguments
60        .iter()
61        .map(|(name, argument)| {
62            let key = ast::Expr::Lit(ast::Lit::Str(ast::Str {
63                span: DUMMY_SP,
64                value: name.clone().into(),
65                raw: None,
66            }));
67            ast::TsTypeElement::TsPropertySignature(ast::TsPropertySignature {
68                span: DUMMY_SP,
69                readonly: true,
70                key: Box::new(key),
71                computed: false,
72                optional: false,
73                type_ann: Some(Box::new(ast::TsTypeAnn {
74                    span: DUMMY_SP,
75                    type_ann: Box::new(argument.into_ast()),
76                })),
77            })
78        })
79        .collect::<Vec<_>>();
80
81    let values = ast::TsType::TsTypeLit(ast::TsTypeLit {
82        span: DUMMY_SP,
83        members,
84    });
85
86    let values_param = ast::TsFnParam::Ident(ast::BindingIdent {
87        id: ast::Ident::new_no_ctxt("values".into(), DUMMY_SP),
88        type_ann: Some(Box::new(ast::TsTypeAnn {
89            span: DUMMY_SP,
90            type_ann: Box::new(values),
91        })),
92    });
93
94    let return_type = ast::TsTypeAnn {
95        span: DUMMY_SP,
96        type_ann: Box::new(model::ArgumentType::String.into_ast()),
97    };
98
99    ast::TsType::TsFnOrConstructorType(ast::TsFnOrConstructorType::TsFnType(ast::TsFnType {
100        span: DUMMY_SP,
101        params: vec![values_param],
102        type_params: None,
103        type_ann: Box::new(return_type),
104    }))
105}
106
107fn type_members(
108    translations: &model::Translations,
109) -> impl Iterator<Item = ast::TsTypeElement> + use<'_> {
110    translations.0.iter().map(|(key, translation)| {
111        let type_annotation = type_annotation_for_translation(translation);
112        let key = ast::Expr::Lit(ast::Lit::Str(ast::Str {
113            span: DUMMY_SP,
114            value: key.to_string().into(),
115            raw: None,
116        }));
117        ast::TsTypeElement::TsPropertySignature(ast::TsPropertySignature {
118            span: DUMMY_SP,
119            readonly: true,
120            key: Box::new(key),
121            computed: false,
122            optional: false,
123            type_ann: Some(Box::new(ast::TsTypeAnn {
124                span: DUMMY_SP,
125                type_ann: Box::new(type_annotation),
126            })),
127        })
128    })
129}
130
131/// Generate a TypeScript `Translations` type for the given model.
132///
133/// # Errors
134///
135/// Returns an error if SWC fails to emit code for the constructed AST.
136pub fn generate_translations_type_export(
137    translations: &model::Translations,
138) -> Result<String, Error> {
139    let members: Vec<_> = type_members(translations).collect();
140
141    let program = ast::Program::Module(ast::Module {
142        span: DUMMY_SP,
143        body: vec![ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportDecl(
144            ast::ExportDecl {
145                span: DUMMY_SP,
146                decl: ast::Decl::TsTypeAlias(Box::new(ast::TsTypeAliasDecl {
147                    span: DUMMY_SP,
148                    declare: false,
149                    id: ast::Ident::new_no_ctxt("Translations".into(), DUMMY_SP),
150                    type_params: None,
151                    type_ann: Box::new(ast::TsType::TsTypeLit(ast::TsTypeLit {
152                        span: DUMMY_SP,
153                        members,
154                    })),
155                })),
156            },
157        ))],
158        shebang: None,
159    });
160
161    let cm: swc_core::common::sync::Lrc<swc_core::common::SourceMap> =
162        swc_core::common::sync::Lrc::default();
163    let compiler = Compiler::new(cm);
164
165    emit_code(&compiler, &program).map_err(Error::Codegen)
166}
167
168#[cfg(test)]
169mod tests {
170    use color_eyre::eyre;
171    use globetrotter_model::{self as model, diagnostics::Spanned};
172    use indoc::indoc;
173    use similar_asserts::assert_eq as sim_assert_eq;
174    use std::sync::Arc;
175    use swc_core::{
176        base::{Compiler, PrintArgs},
177        common::DUMMY_SP,
178        ecma::ast,
179    };
180
181    trait IntoEyre {
182        fn into_eyre(self) -> eyre::Report;
183    }
184
185    impl IntoEyre for anyhow::Error {
186        fn into_eyre(self) -> eyre::Report {
187            eyre::eyre!(Box::new(self))
188        }
189    }
190
191    fn parse(
192        compiler: &Compiler,
193        fm: Arc<swc_core::common::SourceFile>,
194    ) -> eyre::Result<ast::Program> {
195        let emitter_writer = swc_core::common::errors::EmitterWriter::new(
196            Box::new(std::io::stderr()),
197            Some(compiler.cm.clone()),
198            true,
199            false,
200        );
201        let handler =
202            swc_core::common::errors::Handler::with_emitter(true, false, Box::new(emitter_writer));
203
204        let program = compiler
205            .parse_js(
206                fm,
207                &handler,
208                swc_core::ecma::ast::EsVersion::Es2022,
209                swc_core::ecma::parser::Syntax::Typescript(
210                    swc_core::ecma::parser::TsSyntax::default(),
211                ),
212                swc_core::base::config::IsModule::Unknown,
213                Some(compiler.comments()),
214            )
215            .map_err(IntoEyre::into_eyre)?;
216        Ok(program)
217    }
218
219    #[test]
220    fn generate_type() -> eyre::Result<()> {
221        crate::tests::init();
222
223        let translations = model::Translations(
224            [
225                (
226                    Spanned::dummy("test.one".to_string()),
227                    model::Translation {
228                        language: [(
229                            model::Language::En,
230                            Spanned::dummy("test.one in en".to_string()),
231                        )]
232                        .into_iter()
233                        .collect(),
234                        arguments: [].into_iter().collect(),
235                        file_id: 0,
236                    },
237                ),
238                (
239                    Spanned::dummy("test.two".to_string()),
240                    model::Translation {
241                        language: [(
242                            model::Language::En,
243                            Spanned::dummy("test.two in en".to_string()),
244                        )]
245                        .into_iter()
246                        .collect(),
247                        arguments: [
248                            ("arg-one".to_string(), model::ArgumentType::String),
249                            ("ArgTwo".to_string(), model::ArgumentType::Number),
250                            ("Arg_Three".to_string(), model::ArgumentType::Any),
251                        ]
252                        .into_iter()
253                        .collect(),
254                        file_id: 0,
255                    },
256                ),
257            ]
258            .into_iter()
259            .collect(),
260        );
261        let have = super::generate_translations_type_export(&translations)?;
262        let want = indoc::indoc! {r#"
263            export type Translations = {
264                readonly "test.one": string;
265                readonly "test.two": (values: {
266                    readonly "arg-one": string;
267                    readonly "ArgTwo": number;
268                    readonly "Arg_Three": any;
269                }) => string;
270            };
271        "# };
272        let want = format!("{}{}", crate::preamble(), want);
273        sim_assert_eq!(have: have, want: want);
274        Ok(())
275    }
276
277    #[test]
278    fn parse_reference_interface() -> eyre::Result<()> {
279        crate::tests::init();
280
281        let source_code = indoc! {r#"
282            export type Translations = {
283                test: string;
284                "other.value": (values: {
285                    a: number;
286                    b: string;
287                }) => string;
288            };
289        "#};
290
291        let cm: swc_core::common::sync::Lrc<swc_core::common::SourceMap> =
292            swc_core::common::sync::Lrc::default();
293        let fm = cm.new_source_file(
294            swc_core::common::FileName::Custom("./test.ts".to_string()).into(),
295            source_code.to_string(),
296        );
297        let ts_compiler = Compiler::new(cm);
298
299        let program = parse(&ts_compiler, fm)?;
300        dbg!(&program);
301
302        let have = super::emit_code(&ts_compiler, &program).map_err(IntoEyre::into_eyre)?;
303        println!("{have}");
304
305        let want = format!("{}{}", crate::preamble(), source_code);
306        sim_assert_eq!(have: have, want: want);
307        Ok(())
308    }
309}