Skip to main content

oag_core/engine/
mod.rs

1pub mod bundled;
2pub mod context;
3pub mod pack;
4pub mod resolve;
5pub mod type_map;
6
7use minijinja::Environment;
8
9use crate::config::{GeneratorConfig, OutputLayout, SplitBy};
10use crate::ir::{IrSpec, group_operations};
11use crate::{GenerateOutput, GeneratedFile, GeneratorError, normalize_generated};
12
13use self::pack::{FilterConfig, TemplatePack};
14
15/// Generate code files from an IR spec using a template pack.
16///
17/// Returns a [`GenerateOutput`] with source files (always overwritten) and
18/// scaffold files (write-once by default).
19pub fn generate(
20    ir: &IrSpec,
21    config: &GeneratorConfig,
22    pack: &TemplatePack,
23) -> Result<GenerateOutput, GeneratorError> {
24    let tm = &pack.manifest.type_map;
25    let field_casing = &pack.manifest.pack.field_casing;
26    let operation_casing = &pack.manifest.pack.operation_casing;
27
28    // Build universal context
29    let ctx = context::build_context(ir, tm, field_casing, operation_casing);
30
31    // Build minijinja environment
32    let mut env = Environment::new();
33    env.set_trim_blocks(true);
34
35    // Register templates
36    for (name, content) in &pack.templates {
37        env.add_template(name, content)
38            .map_err(|e| GeneratorError::Render(format!("template '{name}': {e}")))?;
39    }
40
41    // Register filters
42    for (name, filter_config) in &pack.manifest.filters {
43        register_filter(&mut env, name, filter_config);
44    }
45
46    // Build render context with config values included
47    let scaffold_ctx = config
48        .scaffold
49        .as_ref()
50        .map(minijinja::Value::from_serialize)
51        .unwrap_or(minijinja::Value::UNDEFINED);
52
53    let source_dir = &config.source_dir;
54    let no_jsdoc = config.no_jsdoc.unwrap_or(false);
55
56    let config_ctx = minijinja::context! {
57        scaffold => scaffold_ctx,
58        source_dir => source_dir,
59        no_jsdoc => no_jsdoc,
60        base_url => config.base_url.clone(),
61    };
62
63    // Merge: base context first, then config overlay
64    let render_ctx = minijinja::context! {
65        ..ctx,
66        ..config_ctx,
67    };
68
69    let mut source_files = match config.layout {
70        OutputLayout::Bundled => render_bundled(&env, pack, &render_ctx, source_dir)?,
71        OutputLayout::Modular => render_modular(&env, pack, &render_ctx, source_dir)?,
72        OutputLayout::Split => {
73            let split_by = config.split_by.unwrap_or(SplitBy::Tag);
74            render_split(&env, pack, &render_ctx, source_dir, ir, no_jsdoc, split_by)?
75        }
76    };
77
78    let mut scaffold_files = render_scaffold(&env, pack, &render_ctx, source_dir)?;
79
80    // Normalize whitespace
81    for file in &mut source_files {
82        file.content = normalize_generated(&file.content);
83    }
84    for file in &mut scaffold_files {
85        file.content = normalize_generated(&file.content);
86    }
87
88    Ok(GenerateOutput {
89        source_files,
90        scaffold_files,
91    })
92}
93
94fn register_filter(env: &mut Environment, name: &str, config: &FilterConfig) {
95    let replace = config.replace.clone();
96    let with = config.with.clone();
97    env.add_filter(name.to_string(), move |value: String| -> String {
98        value.replace(&replace, &with)
99    });
100}
101
102fn render_modular(
103    env: &Environment,
104    pack: &TemplatePack,
105    ctx: &minijinja::Value,
106    source_dir: &str,
107) -> Result<Vec<GeneratedFile>, GeneratorError> {
108    let layout =
109        pack.manifest.layouts.modular.as_ref().ok_or_else(|| {
110            GeneratorError::Other("pack has no modular layout defined".to_string())
111        })?;
112
113    let mut files = Vec::new();
114    for file_def in &layout.files {
115        let path = file_def.path.replace("{source_dir}", source_dir);
116        let path = normalize_path(&path);
117
118        // Check `when` condition if present
119        if let Some(ref when) = file_def.when
120            && !eval_condition(env, when, ctx)
121        {
122            continue;
123        }
124
125        // Some templates are static (not jinja), render them as-is
126        let content = if env.get_template(&file_def.template).is_ok() {
127            let tmpl = env.get_template(&file_def.template).unwrap();
128            tmpl.render(ctx)
129                .map_err(|e| GeneratorError::Render(format!("{}: {e}", file_def.template)))?
130        } else {
131            // Template not found — it might be a static file
132            pack.templates
133                .get(&file_def.template)
134                .cloned()
135                .unwrap_or_default()
136        };
137
138        files.push(GeneratedFile { path, content });
139    }
140
141    Ok(files)
142}
143
144fn render_bundled(
145    env: &Environment,
146    pack: &TemplatePack,
147    ctx: &minijinja::Value,
148    source_dir: &str,
149) -> Result<Vec<GeneratedFile>, GeneratorError> {
150    let layout =
151        pack.manifest.layouts.bundled.as_ref().ok_or_else(|| {
152            GeneratorError::Other("pack has no bundled layout defined".to_string())
153        })?;
154
155    let mut rendered_sections = Vec::new();
156    for section in &layout.sections {
157        let tmpl = env
158            .get_template(&section.template)
159            .map_err(|e| GeneratorError::Render(format!("{}: {e}", section.template)))?;
160        let content = tmpl
161            .render(ctx)
162            .map_err(|e| GeneratorError::Render(format!("{}: {e}", section.template)))?;
163        rendered_sections.push((section.label.as_str(), content));
164    }
165
166    let bundled_content = bundled::bundle_sections(layout, rendered_sections);
167    let path = layout.output_path.replace("{source_dir}", source_dir);
168    let path = normalize_path(&path);
169
170    Ok(vec![GeneratedFile {
171        path,
172        content: bundled_content,
173    }])
174}
175
176fn render_split(
177    env: &Environment,
178    pack: &TemplatePack,
179    ctx: &minijinja::Value,
180    source_dir: &str,
181    ir: &IrSpec,
182    _no_jsdoc: bool,
183    split_by: SplitBy,
184) -> Result<Vec<GeneratedFile>, GeneratorError> {
185    let layout = pack
186        .manifest
187        .layouts
188        .split
189        .as_ref()
190        .ok_or_else(|| GeneratorError::Other("pack has no split layout defined".to_string()))?;
191
192    let mut files = Vec::new();
193
194    // Render shared files
195    for file_def in &layout.shared_files {
196        let path = file_def.path.replace("{source_dir}", source_dir);
197        let path = normalize_path(&path);
198        let tmpl = env
199            .get_template(&file_def.template)
200            .map_err(|e| GeneratorError::Render(format!("{}: {e}", file_def.template)))?;
201        let content = tmpl
202            .render(ctx)
203            .map_err(|e| GeneratorError::Render(format!("{}: {e}", file_def.template)))?;
204        files.push(GeneratedFile { path, content });
205    }
206
207    // Also render client.ts (the full client is always needed for split)
208    // This is included in shared_files by the pack manifest
209
210    // Group operations and create per-group files
211    let groups = group_operations(ir, split_by);
212    let mut group_names = Vec::new();
213
214    for group in &groups {
215        let group_file_name =
216            normalize_path(&format!("{}/{}.ts", source_dir, group.name.snake_case));
217
218        // Build a simple re-export file for the group
219        let op_names: Vec<&str> = group
220            .operation_indices
221            .iter()
222            .map(|&i| ir.operations[i].name.camel_case.as_str())
223            .collect();
224
225        let mut lines = Vec::new();
226        lines.push("// Auto-generated by oag — do not edit".to_string());
227        lines.push(format!("// Operations group: {}", group.name.original));
228        lines.push(String::new());
229        lines.push("// This group contains the following operations:".to_string());
230        for name in &op_names {
231            lines.push(format!("//   - {name}"));
232        }
233        lines.push(String::new());
234        lines.push("// Import the client and call the relevant methods:".to_string());
235        lines.push("// import { ApiClient } from \"./client\";".to_string());
236        lines.push(String::new());
237        lines.push("export { ApiClient } from \"./client\";".to_string());
238        lines.push("export * from \"./types\";".to_string());
239
240        group_names.push(group.name.snake_case.clone());
241        files.push(GeneratedFile {
242            path: group_file_name,
243            content: lines.join("\n") + "\n",
244        });
245    }
246
247    // Split index
248    let mut index_lines = vec![
249        "// Auto-generated by oag — do not edit".to_string(),
250        "export * from \"./types\";".to_string(),
251        "export * from \"./guards\";".to_string(),
252        "export { ApiClient, type ClientConfig, type RequestOptions } from \"./client\";"
253            .to_string(),
254        "export { streamSse, SSEError, type SSEOptions } from \"./sse\";".to_string(),
255    ];
256    for name in &group_names {
257        index_lines.push(format!("export * from \"./{name}\";"));
258    }
259    let index_path = normalize_path(&format!("{}/index.ts", source_dir));
260    files.push(GeneratedFile {
261        path: index_path,
262        content: index_lines.join("\n") + "\n",
263    });
264
265    Ok(files)
266}
267
268fn render_scaffold(
269    env: &Environment,
270    pack: &TemplatePack,
271    ctx: &minijinja::Value,
272    source_dir: &str,
273) -> Result<Vec<GeneratedFile>, GeneratorError> {
274    // Only render scaffold files if scaffold config is present
275    let has_scaffold = ctx
276        .get_attr("scaffold")
277        .ok()
278        .is_some_and(|v| !v.is_undefined());
279    if !has_scaffold {
280        return Ok(Vec::new());
281    }
282
283    let mut files = Vec::new();
284
285    // Scaffold files
286    for file_def in &pack.manifest.scaffold.files {
287        if let Some(ref when) = file_def.when
288            && !eval_condition(env, when, ctx)
289        {
290            continue;
291        }
292        let path = file_def.path.replace("{source_dir}", source_dir);
293        let path = normalize_path(&path);
294
295        let content = if let Ok(tmpl) = env.get_template(&file_def.template) {
296            tmpl.render(ctx)
297                .map_err(|e| GeneratorError::Render(format!("{}: {e}", file_def.template)))?
298        } else {
299            pack.templates
300                .get(&file_def.template)
301                .cloned()
302                .unwrap_or_default()
303        };
304
305        files.push(GeneratedFile { path, content });
306    }
307
308    // Test files
309    for file_def in &pack.manifest.scaffold.test_files {
310        if let Some(ref when) = file_def.when
311            && !eval_condition(env, when, ctx)
312        {
313            continue;
314        }
315        let path = file_def.path.replace("{source_dir}", source_dir);
316        let path = normalize_path(&path);
317
318        let content = if let Ok(tmpl) = env.get_template(&file_def.template) {
319            tmpl.render(ctx)
320                .map_err(|e| GeneratorError::Render(format!("{}: {e}", file_def.template)))?
321        } else {
322            pack.templates
323                .get(&file_def.template)
324                .cloned()
325                .unwrap_or_default()
326        };
327
328        files.push(GeneratedFile { path, content });
329    }
330
331    Ok(files)
332}
333
334/// Evaluate a condition string as a minijinja expression.
335fn eval_condition(env: &Environment, condition: &str, ctx: &minijinja::Value) -> bool {
336    // Create a temporary template that just evaluates the expression
337    let expr_template = format!("{{% if {condition} %}}true{{% endif %}}");
338    let mut temp_env = env.clone();
339    if temp_env
340        .add_template("__condition__", &expr_template)
341        .is_err()
342    {
343        return false;
344    }
345    let Ok(tmpl) = temp_env.get_template("__condition__") else {
346        return false;
347    };
348    tmpl.render(ctx).ok().is_some_and(|s| s.trim() == "true")
349}
350
351/// Normalize a file path: remove leading `./` and double `/`.
352fn normalize_path(path: &str) -> String {
353    let path = path.replace("//", "/");
354    let path = path.strip_prefix("./").unwrap_or(&path);
355    if let Some(stripped) = path.strip_prefix('/') {
356        stripped.to_string()
357    } else {
358        path.to_string()
359    }
360}