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        Language::JsonSchema => "json_schema",
85    }
86}
87
88pub fn file_extension(language: Language) -> &'static str {
89    match language {
90        Language::TypeScript => "ts",
91        Language::Python => "py",
92        Language::Go => "go",
93        Language::Swift => "swift",
94        Language::Kotlin => "kt",
95        Language::GraphQL => "graphql",
96        Language::JsonSchema => "schema.json",
97    }
98}
99
100pub fn output_dir_for_language(config: &TypewriterConfig, language: Language) -> &str {
101    match language {
102        Language::TypeScript => config.ts_output_dir(),
103        Language::Python => config.py_output_dir(),
104        Language::Go => config.go_output_dir(),
105        Language::Swift => config.swift_output_dir(),
106        Language::Kotlin => config.kotlin_output_dir(),
107        Language::GraphQL => config.graphql_output_dir(),
108        Language::JsonSchema => config.json_schema_output_dir(),
109    }
110}
111
112fn effective_targets(spec_targets: &[Language], lang_filter: &[Language]) -> Vec<Language> {
113    if lang_filter.is_empty() {
114        return spec_targets.to_vec();
115    }
116
117    spec_targets
118        .iter()
119        .copied()
120        .filter(|lang| lang_filter.contains(lang))
121        .collect()
122}
123
124fn render_single(
125    type_def: &TypeDef,
126    source_path: PathBuf,
127    zod_override: Option<bool>,
128    language: Language,
129    project_root: &Path,
130    config: &TypewriterConfig,
131    _skip_unavailable: bool,
132) -> Result<Vec<GeneratedFile>> {
133    match language {
134        Language::TypeScript => {
135            #[cfg(feature = "typescript")]
136            {
137                let mut mapper = typewriter_typescript::TypeScriptMapper::new()
138                    .with_readonly(config.ts_readonly());
139                if let Some(style) = config.ts_file_style() {
140                    mapper = mapper.with_file_style(style);
141                }
142
143                let output_dir = project_root.join(config.ts_output_dir());
144
145                let type_file = render_with_mapper(
146                    &mapper,
147                    type_def,
148                    source_path.clone(),
149                    language,
150                    output_dir.clone(),
151                );
152
153                let mut files = vec![type_file];
154
155                if zod_override.unwrap_or(config.ts_zod_enabled()) {
156                    files.push(GeneratedFile {
157                        type_name: type_def.name().to_string(),
158                        language,
159                        output_path: output_dir.join(mapper.zod_output_filename(type_def.name())),
160                        content: mapper.emit_zod_type_def(type_def),
161                        source_path,
162                    });
163                }
164
165                return Ok(files);
166            }
167            #[cfg(not(feature = "typescript"))]
168            {
169                if _skip_unavailable {
170                    return Ok(vec![]);
171                }
172                anyhow::bail!("language 'typescript' is not enabled in this build")
173            }
174        }
175        Language::Python => {
176            #[cfg(feature = "python")]
177            {
178                let mut mapper = typewriter_python::PythonMapper::new();
179                if let Some(style) = config.py_file_style() {
180                    mapper = mapper.with_file_style(style);
181                }
182                let output_dir = project_root.join(config.py_output_dir());
183                return Ok(vec![render_with_mapper(
184                    &mapper,
185                    type_def,
186                    source_path,
187                    language,
188                    output_dir,
189                )]);
190            }
191            #[cfg(not(feature = "python"))]
192            {
193                if _skip_unavailable {
194                    return Ok(vec![]);
195                }
196                anyhow::bail!("language 'python' is not enabled in this build")
197            }
198        }
199        Language::Go => {
200            #[cfg(feature = "go")]
201            {
202                let mut mapper =
203                    typewriter_go::GoMapper::new().with_package_name(config.go_package_name());
204                if let Some(style) = config.go_file_style() {
205                    mapper = mapper.with_file_style(style);
206                }
207                let output_dir = project_root.join(config.go_output_dir());
208                return Ok(vec![render_with_mapper(
209                    &mapper,
210                    type_def,
211                    source_path,
212                    language,
213                    output_dir,
214                )]);
215            }
216            #[cfg(not(feature = "go"))]
217            {
218                if _skip_unavailable {
219                    return Ok(vec![]);
220                }
221                anyhow::bail!("language 'go' is not enabled in this build")
222            }
223        }
224        Language::Swift => {
225            #[cfg(feature = "swift")]
226            {
227                let mut mapper = typewriter_swift::SwiftMapper::new();
228                if let Some(style) = config.swift_file_style() {
229                    mapper = mapper.with_file_style(style);
230                }
231                let output_dir = project_root.join(config.swift_output_dir());
232                return Ok(vec![render_with_mapper(
233                    &mapper,
234                    type_def,
235                    source_path,
236                    language,
237                    output_dir,
238                )]);
239            }
240            #[cfg(not(feature = "swift"))]
241            {
242                if _skip_unavailable {
243                    return Ok(vec![]);
244                }
245                anyhow::bail!("language 'swift' is not enabled in this build")
246            }
247        }
248        Language::Kotlin => {
249            #[cfg(feature = "kotlin")]
250            {
251                let mut mapper = typewriter_kotlin::KotlinMapper::new()
252                    .with_package_name(config.kotlin_package_name());
253                if let Some(style) = config.kotlin_file_style() {
254                    mapper = mapper.with_file_style(style);
255                }
256                let output_dir = project_root.join(config.kotlin_output_dir());
257                return Ok(vec![render_with_mapper(
258                    &mapper,
259                    type_def,
260                    source_path,
261                    language,
262                    output_dir,
263                )]);
264            }
265            #[cfg(not(feature = "kotlin"))]
266            {
267                if _skip_unavailable {
268                    return Ok(vec![]);
269                }
270                anyhow::bail!("language 'kotlin' is not enabled in this build")
271            }
272        }
273        Language::GraphQL => {
274            #[cfg(feature = "graphql")]
275            {
276                let mut mapper = typewriter_graphql::GraphQLMapper::new();
277                if let Some(style) = config.graphql_file_style() {
278                    mapper = mapper.with_file_style(style);
279                }
280                let output_dir = project_root.join(config.graphql_output_dir());
281                return Ok(vec![render_with_mapper(
282                    &mapper,
283                    type_def,
284                    source_path,
285                    language,
286                    output_dir,
287                )]);
288            }
289            #[cfg(not(feature = "graphql"))]
290            {
291                if _skip_unavailable {
292                    return Ok(vec![]);
293                }
294                anyhow::bail!("language 'graphql' is not enabled in this build")
295            }
296        }
297        Language::JsonSchema => {
298            #[cfg(feature = "json_schema")]
299            {
300                let mut mapper = typewriter_json_schema::JsonSchemaMapper::new();
301                if let Some(style) = config.json_schema_file_style() {
302                    mapper = mapper.with_file_style(style);
303                }
304                let output_dir = project_root.join(config.json_schema_output_dir());
305                return Ok(vec![render_with_mapper(
306                    &mapper,
307                    type_def,
308                    source_path,
309                    language,
310                    output_dir,
311                )]);
312            }
313            #[cfg(not(feature = "json_schema"))]
314            {
315                if _skip_unavailable {
316                    return Ok(vec![]);
317                }
318                anyhow::bail!("language 'json_schema' is not enabled in this build")
319            }
320        }
321    }
322}
323
324fn render_with_mapper<M: TypeMapper>(
325    mapper: &M,
326    type_def: &TypeDef,
327    source_path: PathBuf,
328    language: Language,
329    output_dir: PathBuf,
330) -> GeneratedFile {
331    let filename = mapper.output_filename(type_def.name());
332    let content = mapper.emit_type_def(type_def);
333
334    GeneratedFile {
335        type_name: type_def.name().to_string(),
336        language,
337        output_path: output_dir.join(filename),
338        content,
339        source_path,
340    }
341}