Skip to main content

vexil_codegen_ts/
backend.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::path::PathBuf;
3
4use vexil_lang::codegen::{CodegenBackend, CodegenError};
5use vexil_lang::ir::{CompiledSchema, ResolvedType, TypeDef, TypeId};
6use vexil_lang::project::ProjectResult;
7
8/// TypeScript code-generation backend for Vexil schemas.
9///
10/// Generates TypeScript interfaces, encode/decode functions, and type-safe
11/// discriminated unions using the `@vexil-lang/runtime` package.
12#[derive(Debug, Clone, Copy)]
13pub struct TypeScriptBackend;
14
15impl CodegenBackend for TypeScriptBackend {
16    fn name(&self) -> &str {
17        "typescript"
18    }
19
20    fn file_extension(&self) -> &str {
21        "ts"
22    }
23
24    fn generate(&self, compiled: &CompiledSchema) -> Result<String, CodegenError> {
25        crate::generate(compiled).map_err(|e| CodegenError::BackendSpecific(Box::new(e)))
26    }
27
28    fn generate_project(
29        &self,
30        result: &ProjectResult,
31    ) -> Result<BTreeMap<PathBuf, String>, CodegenError> {
32        let mut files = BTreeMap::new();
33        let mut index_tree: BTreeMap<String, Vec<String>> = BTreeMap::new();
34
35        // Step 1: Build a global type_name -> TS module path map.
36        let mut global_type_map: HashMap<String, String> = HashMap::new();
37        for (ns, compiled) in &result.schemas {
38            let segments: Vec<&str> = ns.split('.').collect();
39            let ts_module = format!("./{}", segments.join("/"));
40            for &type_id in &compiled.declarations {
41                if let Some(typedef) = compiled.registry.get(type_id) {
42                    let name = crate::type_name_of(typedef);
43                    global_type_map.insert(name.to_string(), ts_module.clone());
44                }
45            }
46        }
47
48        for (ns, compiled) in &result.schemas {
49            let segments: Vec<&str> = ns.split('.').collect();
50            if segments.is_empty() {
51                continue;
52            }
53            let file_name = segments[segments.len() - 1];
54            let dir_segments = &segments[..segments.len() - 1];
55
56            // Track index.ts entries
57            for i in 0..segments.len() - 1 {
58                let parent_key = segments[..i].join("/");
59                let child = segments[i].to_string();
60                let entry = index_tree.entry(parent_key).or_default();
61                if !entry.contains(&child) {
62                    entry.push(child);
63                }
64            }
65            if segments.len() >= 2 {
66                let parent_key = dir_segments.join("/");
67                let child = file_name.to_string();
68                let entry = index_tree.entry(parent_key).or_default();
69                if !entry.contains(&child) {
70                    entry.push(child);
71                }
72            } else {
73                let entry = index_tree.entry(String::new()).or_default();
74                let child = file_name.to_string();
75                if !entry.contains(&child) {
76                    entry.push(child);
77                }
78            }
79
80            // Step 2: Build import_paths for this schema.
81            let declared_ids: HashSet<TypeId> = compiled.declarations.iter().copied().collect();
82
83            let mut import_paths: HashMap<String, String> = HashMap::new();
84            for &type_id in &compiled.declarations {
85                if let Some(typedef) = compiled.registry.get(type_id) {
86                    collect_named_ids_from_typedef(typedef, &declared_ids, |imported_id| {
87                        if let Some(imported_def) = compiled.registry.get(imported_id) {
88                            let name = crate::type_name_of(imported_def);
89                            if let Some(ts_path) = global_type_map.get(name) {
90                                import_paths.insert(name.to_string(), ts_path.clone());
91                            }
92                        }
93                    });
94                }
95            }
96
97            // Generate code with cross-file imports.
98            let imports = if import_paths.is_empty() {
99                None
100            } else {
101                Some(&import_paths)
102            };
103            let code = crate::generate_with_imports(compiled, imports)
104                .map_err(|e| CodegenError::BackendSpecific(Box::new(e)))?;
105
106            let mut file_path = PathBuf::new();
107            for seg in dir_segments {
108                file_path.push(seg);
109            }
110            file_path.push(format!("{file_name}.ts"));
111            files.insert(file_path, code);
112        }
113
114        // Generate index.ts files
115        for (dir_key, children) in &index_tree {
116            let mut index_path = PathBuf::new();
117            if !dir_key.is_empty() {
118                for seg in dir_key.split('/') {
119                    index_path.push(seg);
120                }
121            }
122            index_path.push("index.ts");
123
124            let mut content = String::from("// Code generated by vexilc. DO NOT EDIT.\n\n");
125            for child in children {
126                content.push_str(&format!("export * from './{child}';\n"));
127            }
128            files.insert(index_path, content);
129        }
130
131        Ok(files)
132    }
133}
134
135/// Collect all `ResolvedType::Named(id)` from a TypeDef where `id` is NOT in
136/// the declared set (i.e., it's an imported type).
137fn collect_named_ids_from_typedef(
138    typedef: &TypeDef,
139    declared: &HashSet<TypeId>,
140    mut on_import: impl FnMut(TypeId),
141) {
142    match typedef {
143        TypeDef::Message(msg) => {
144            for f in &msg.fields {
145                collect_named_ids_from_resolved(&f.resolved_type, declared, &mut on_import);
146            }
147        }
148        TypeDef::Union(un) => {
149            for v in &un.variants {
150                for f in &v.fields {
151                    collect_named_ids_from_resolved(&f.resolved_type, declared, &mut on_import);
152                }
153            }
154        }
155        TypeDef::Newtype(nt) => {
156            collect_named_ids_from_resolved(&nt.inner_type, declared, &mut on_import);
157        }
158        TypeDef::Config(cfg) => {
159            for f in &cfg.fields {
160                collect_named_ids_from_resolved(&f.resolved_type, declared, &mut on_import);
161            }
162        }
163        _ => {}
164    }
165}
166
167fn collect_named_ids_from_resolved(
168    ty: &ResolvedType,
169    declared: &HashSet<TypeId>,
170    on_import: &mut impl FnMut(TypeId),
171) {
172    match ty {
173        ResolvedType::Named(id) => {
174            if !declared.contains(id) {
175                on_import(*id);
176            }
177        }
178        ResolvedType::Optional(inner) | ResolvedType::Array(inner) => {
179            collect_named_ids_from_resolved(inner, declared, on_import);
180        }
181        ResolvedType::Map(k, v) | ResolvedType::Result(k, v) => {
182            collect_named_ids_from_resolved(k, declared, on_import);
183            collect_named_ids_from_resolved(v, declared, on_import);
184        }
185        _ => {}
186    }
187}