Skip to main content

typewriter_engine/
emit.rs

1//! Render and write generated output files for target languages.
2
3use anyhow::Result;
4use std::collections::BTreeMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7use typewriter_core::config::TypewriterConfig;
8use typewriter_core::ir::{Language, TypeDef};
9use typewriter_core::mapper::TypeMapper;
10
11use crate::TypeSpec;
12
13#[derive(Debug, Clone)]
14pub struct GeneratedFile {
15    pub type_name: String,
16    pub language: Language,
17    pub output_path: PathBuf,
18    pub content: String,
19    pub source_path: PathBuf,
20}
21
22/// Render all expected generated files for a set of type specs.
23pub fn render_specs(
24    specs: &[TypeSpec],
25    project_root: &Path,
26    config: &TypewriterConfig,
27    lang_filter: &[Language],
28    skip_unavailable: bool,
29) -> Result<Vec<GeneratedFile>> {
30    let mut files = Vec::new();
31
32    for spec in specs {
33        for target in effective_targets(&spec.targets, lang_filter) {
34            let mut rendered = render_single(
35                &spec.type_def,
36                spec.source_path.clone(),
37                spec.zod_schema,
38                target,
39                project_root,
40                config,
41                skip_unavailable,
42            )?;
43            files.append(&mut rendered);
44        }
45    }
46
47    Ok(files)
48}
49
50/// Render with output path deduplication (last writer wins on path collisions).
51pub fn render_specs_deduped(
52    specs: &[TypeSpec],
53    project_root: &Path,
54    config: &TypewriterConfig,
55    lang_filter: &[Language],
56    skip_unavailable: bool,
57) -> Result<Vec<GeneratedFile>> {
58    let files = render_specs(specs, project_root, config, lang_filter, skip_unavailable)?;
59    let mut by_path = BTreeMap::new();
60    for file in files {
61        by_path.insert(file.output_path.clone(), file);
62    }
63    Ok(by_path.into_values().collect())
64}
65
66pub fn write_generated_files(files: &[GeneratedFile]) -> Result<()> {
67    for file in files {
68        if let Some(parent) = file.output_path.parent() {
69            fs::create_dir_all(parent)?;
70        }
71        fs::write(&file.output_path, &file.content)?;
72    }
73    Ok(())
74}
75
76pub fn language_label(language: Language) -> &'static str {
77    match language {
78        Language::TypeScript => "typescript",
79        Language::Python => "python",
80        Language::Go => "go",
81        Language::Swift => "swift",
82        Language::Kotlin => "kotlin",
83        Language::GraphQL => "graphql",
84    }
85}
86
87pub fn file_extension(language: Language) -> &'static str {
88    match language {
89        Language::TypeScript => "ts",
90        Language::Python => "py",
91        Language::Go => "go",
92        Language::Swift => "swift",
93        Language::Kotlin => "kt",
94        Language::GraphQL => "graphql",
95    }
96}
97
98pub fn output_dir_for_language(config: &TypewriterConfig, language: Language) -> &str {
99    match language {
100        Language::TypeScript => config.ts_output_dir(),
101        Language::Python => config.py_output_dir(),
102        Language::Go => config.go_output_dir(),
103        Language::Swift => config.swift_output_dir(),
104        Language::Kotlin => config.kotlin_output_dir(),
105        Language::GraphQL => config.graphql_output_dir(),
106    }
107}
108
109fn effective_targets(spec_targets: &[Language], lang_filter: &[Language]) -> Vec<Language> {
110    if lang_filter.is_empty() {
111        return spec_targets.to_vec();
112    }
113
114    spec_targets
115        .iter()
116        .copied()
117        .filter(|lang| lang_filter.contains(lang))
118        .collect()
119}
120
121fn render_single(
122    type_def: &TypeDef,
123    source_path: PathBuf,
124    zod_override: Option<bool>,
125    language: Language,
126    project_root: &Path,
127    config: &TypewriterConfig,
128    _skip_unavailable: bool,
129) -> Result<Vec<GeneratedFile>> {
130    match language {
131        Language::TypeScript => {
132            #[cfg(feature = "typescript")]
133            {
134                let mut mapper = typewriter_typescript::TypeScriptMapper::new()
135                    .with_readonly(config.ts_readonly());
136                if let Some(style) = config.ts_file_style() {
137                    mapper = mapper.with_file_style(style);
138                }
139
140                let output_dir = project_root.join(config.ts_output_dir());
141
142                let type_file = render_with_mapper(
143                    &mapper,
144                    type_def,
145                    source_path.clone(),
146                    language,
147                    output_dir.clone(),
148                );
149
150                let mut files = vec![type_file];
151
152                if zod_override.unwrap_or(config.ts_zod_enabled()) {
153                    files.push(GeneratedFile {
154                        type_name: type_def.name().to_string(),
155                        language,
156                        output_path: output_dir.join(mapper.zod_output_filename(type_def.name())),
157                        content: mapper.emit_zod_type_def(type_def),
158                        source_path,
159                    });
160                }
161
162                return Ok(files);
163            }
164            #[cfg(not(feature = "typescript"))]
165            {
166                if _skip_unavailable {
167                    return Ok(vec![]);
168                }
169                anyhow::bail!("language 'typescript' is not enabled in this build")
170            }
171        }
172        Language::Python => {
173            #[cfg(feature = "python")]
174            {
175                let mut mapper = typewriter_python::PythonMapper::new();
176                if let Some(style) = config.py_file_style() {
177                    mapper = mapper.with_file_style(style);
178                }
179                let output_dir = project_root.join(config.py_output_dir());
180                return Ok(vec![render_with_mapper(
181                    &mapper,
182                    type_def,
183                    source_path,
184                    language,
185                    output_dir,
186                )]);
187            }
188            #[cfg(not(feature = "python"))]
189            {
190                if _skip_unavailable {
191                    return Ok(vec![]);
192                }
193                anyhow::bail!("language 'python' is not enabled in this build")
194            }
195        }
196        Language::Go => {
197            #[cfg(feature = "go")]
198            {
199                let mut mapper =
200                    typewriter_go::GoMapper::new().with_package_name(config.go_package_name());
201                if let Some(style) = config.go_file_style() {
202                    mapper = mapper.with_file_style(style);
203                }
204                let output_dir = project_root.join(config.go_output_dir());
205                return Ok(vec![render_with_mapper(
206                    &mapper,
207                    type_def,
208                    source_path,
209                    language,
210                    output_dir,
211                )]);
212            }
213            #[cfg(not(feature = "go"))]
214            {
215                if _skip_unavailable {
216                    return Ok(vec![]);
217                }
218                anyhow::bail!("language 'go' is not enabled in this build")
219            }
220        }
221        Language::Swift => {
222            #[cfg(feature = "swift")]
223            {
224                let mut mapper = typewriter_swift::SwiftMapper::new();
225                if let Some(style) = config.swift_file_style() {
226                    mapper = mapper.with_file_style(style);
227                }
228                let output_dir = project_root.join(config.swift_output_dir());
229                return Ok(vec![render_with_mapper(
230                    &mapper,
231                    type_def,
232                    source_path,
233                    language,
234                    output_dir,
235                )]);
236            }
237            #[cfg(not(feature = "swift"))]
238            {
239                if _skip_unavailable {
240                    return Ok(vec![]);
241                }
242                anyhow::bail!("language 'swift' is not enabled in this build")
243            }
244        }
245        Language::Kotlin => {
246            #[cfg(feature = "kotlin")]
247            {
248                let mut mapper = typewriter_kotlin::KotlinMapper::new()
249                    .with_package_name(config.kotlin_package_name());
250                if let Some(style) = config.kotlin_file_style() {
251                    mapper = mapper.with_file_style(style);
252                }
253                let output_dir = project_root.join(config.kotlin_output_dir());
254                return Ok(vec![render_with_mapper(
255                    &mapper,
256                    type_def,
257                    source_path,
258                    language,
259                    output_dir,
260                )]);
261            }
262            #[cfg(not(feature = "kotlin"))]
263            {
264                if _skip_unavailable {
265                    return Ok(vec![]);
266                }
267                anyhow::bail!("language 'kotlin' is not enabled in this build")
268            }
269        }
270        Language::GraphQL => {
271            #[cfg(feature = "graphql")]
272            {
273                let mut mapper = typewriter_graphql::GraphQLMapper::new();
274                if let Some(style) = config.graphql_file_style() {
275                    mapper = mapper.with_file_style(style);
276                }
277                let output_dir = project_root.join(config.graphql_output_dir());
278                return Ok(vec![render_with_mapper(
279                    &mapper,
280                    type_def,
281                    source_path,
282                    language,
283                    output_dir,
284                )]);
285            }
286            #[cfg(not(feature = "graphql"))]
287            {
288                if _skip_unavailable {
289                    return Ok(vec![]);
290                }
291                anyhow::bail!("language 'graphql' is not enabled in this build")
292            }
293        }
294    }
295}
296
297fn render_with_mapper<M: TypeMapper>(
298    mapper: &M,
299    type_def: &TypeDef,
300    source_path: PathBuf,
301    language: Language,
302    output_dir: PathBuf,
303) -> GeneratedFile {
304    let filename = mapper.output_filename(type_def.name());
305    let content = mapper.emit_type_def(type_def);
306
307    GeneratedFile {
308        type_name: type_def.name().to_string(),
309        language,
310        output_path: output_dir.join(filename),
311        content,
312        source_path,
313    }
314}