Skip to main content

typewriter_engine/
emit.rs

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