Skip to main content

aura_lang/codegen/
mod.rs

1//! `aura types`: generate host-language types from a manifest's `type` and
2//! `enum` declarations (SPEC §7.2).
3//!
4//! One schema, two jobs: it validates the config *and* types the service that
5//! consumes the resulting JSON. Emission works on the AST (not on values), so a
6//! manifest never has to be evaluated — no I/O, no capabilities involved.
7//!
8//! Optional fields (D15) are emitted as **required**: evaluation always inserts
9//! the default, so the JSON a consumer receives always carries the field.
10
11use crate::error::Diagnostic;
12use crate::lexer::Lexer;
13use crate::parser::ast::{EnumDeclaration, SchemaDeclaration, Stmt, TypeName};
14use crate::parser::Parser;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Lang {
18    Rust,
19    TypeScript,
20    Go,
21}
22
23impl Lang {
24    pub fn parse(s: &str) -> Option<Lang> {
25        match s {
26            "rust" | "rs" => Some(Lang::Rust),
27            "ts" | "typescript" => Some(Lang::TypeScript),
28            "go" | "golang" => Some(Lang::Go),
29            _ => None,
30        }
31    }
32}
33
34/// Declarations worth emitting, in source order.
35enum Decl<'a> {
36    Schema(&'a SchemaDeclaration<'a>),
37    Enum(&'a EnumDeclaration<'a>),
38}
39
40/// Generate types for every `type` and `enum` declared in `src`.
41pub fn generate(src: &str, lang: Lang) -> Result<String, Diagnostic> {
42    let tokens = Lexer::new(src, 0).tokenize()?;
43    let module = Parser::new(tokens)
44        .parse_module()
45        .map_err(|mut ds| ds.remove(0))?;
46
47    let decls: Vec<Decl> = module
48        .stmts
49        .iter()
50        .filter_map(|s| match s {
51            Stmt::TypeDecl(t) => Some(Decl::Schema(t)),
52            Stmt::EnumDecl(e) => Some(Decl::Enum(e)),
53            _ => None,
54        })
55        .collect();
56
57    // An enum-typed field maps to the enum's own generated type name, which is
58    // just `TypeName::Custom(name)` — no extra bookkeeping needed.
59    Ok(match lang {
60        Lang::Rust => rust(&decls),
61        Lang::TypeScript => typescript(&decls),
62        Lang::Go => go(&decls),
63    })
64}
65
66// ---- name helpers ----
67
68/// A member string turned into a type-identifier fragment: `"eu-central"` ->
69/// `EuCentral`. Non-alphanumerics separate words; a leading digit is prefixed so
70/// the result is always a valid identifier.
71fn pascal(s: &str) -> String {
72    let mut out = String::new();
73    let mut upper = true;
74    for c in s.chars() {
75        if c.is_alphanumeric() {
76            if upper {
77                out.extend(c.to_uppercase());
78            } else {
79                out.push(c);
80            }
81            upper = false;
82        } else {
83            upper = true;
84        }
85    }
86    if out.is_empty() {
87        out.push_str("Empty");
88    } else if out.starts_with(|c: char| c.is_ascii_digit()) {
89        out.insert(0, 'N');
90    }
91    out
92}
93
94/// Go field names must be exported (capitalised): `min_version` -> `MinVersion`.
95/// The wire name is preserved by the `json:` tag.
96fn exported(s: &str) -> String {
97    pascal(s)
98}
99
100const HEADER: &str = "Generated by `aura types` — do not edit by hand.";
101
102// ---- Rust ----
103
104fn rust_ty(ty: &TypeName) -> String {
105    match ty {
106        TypeName::String => "String".into(),
107        TypeName::Int => "i64".into(),
108        TypeName::Float => "f64".into(),
109        TypeName::Bool => "bool".into(),
110        TypeName::List => "Vec<serde_json::Value>".into(),
111        TypeName::Object => "serde_json::Map<String, serde_json::Value>".into(),
112        TypeName::Custom(name) => (*name).to_string(),
113    }
114}
115
116fn rust(decls: &[Decl]) -> String {
117    let mut out = format!("// {HEADER}\n\nuse serde::{{Deserialize, Serialize}};\n");
118    for d in decls {
119        match d {
120            Decl::Enum(e) => {
121                out.push_str(&format!(
122                    "\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub enum {} {{\n",
123                    e.name
124                ));
125                for m in &e.members {
126                    out.push_str(&format!(
127                        "    #[serde(rename = \"{m}\")]\n    {},\n",
128                        pascal(m)
129                    ));
130                }
131                out.push_str("}\n");
132            }
133            Decl::Schema(s) => {
134                out.push_str(&format!(
135                    "\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {} {{\n",
136                    s.name
137                ));
138                for f in &s.fields {
139                    out.push_str(&format!("    pub {}: {},\n", f.name, rust_ty(&f.ty)));
140                }
141                out.push_str("}\n");
142            }
143        }
144    }
145    out
146}
147
148// ---- TypeScript ----
149
150fn ts_ty(ty: &TypeName) -> String {
151    match ty {
152        TypeName::String => "string".into(),
153        TypeName::Int | TypeName::Float => "number".into(),
154        TypeName::Bool => "boolean".into(),
155        TypeName::List => "unknown[]".into(),
156        TypeName::Object => "Record<string, unknown>".into(),
157        TypeName::Custom(name) => (*name).to_string(),
158    }
159}
160
161fn typescript(decls: &[Decl]) -> String {
162    let mut out = format!("// {HEADER}\n");
163    for d in decls {
164        match d {
165            // A string-literal union is the exact TypeScript equivalent of D18.
166            Decl::Enum(e) => {
167                let union = e
168                    .members
169                    .iter()
170                    .map(|m| format!("\"{m}\""))
171                    .collect::<Vec<_>>()
172                    .join(" | ");
173                out.push_str(&format!("\nexport type {} = {union};\n", e.name));
174            }
175            Decl::Schema(s) => {
176                out.push_str(&format!("\nexport interface {} {{\n", s.name));
177                for f in &s.fields {
178                    out.push_str(&format!("  {}: {};\n", f.name, ts_ty(&f.ty)));
179                }
180                out.push_str("}\n");
181            }
182        }
183    }
184    out
185}
186
187// ---- Go ----
188
189fn go_ty(ty: &TypeName) -> String {
190    match ty {
191        TypeName::String => "string".into(),
192        TypeName::Int => "int64".into(),
193        TypeName::Float => "float64".into(),
194        TypeName::Bool => "bool".into(),
195        TypeName::List => "[]any".into(),
196        TypeName::Object => "map[string]any".into(),
197        TypeName::Custom(name) => (*name).to_string(),
198    }
199}
200
201/// Column-align a run of lines the way `gofmt`'s tabwriter does: every column
202/// but the last is padded to the widest cell, separated by a single space. Emitting
203/// gofmt-canonical output means `gofmt -l` on a generated file stays silent, so
204/// checking in generated types never produces formatting churn.
205fn align_go(rows: &[Vec<String>]) -> String {
206    let cols = rows.iter().map(Vec::len).max().unwrap_or(0);
207    let widths: Vec<usize> = (0..cols)
208        .map(|i| {
209            rows.iter()
210                .filter(|r| i + 1 < r.len()) // only cells that are followed by another
211                .map(|r| r[i].chars().count())
212                .max()
213                .unwrap_or(0)
214        })
215        .collect();
216    let mut out = String::new();
217    for row in rows {
218        out.push('\t');
219        for (i, cell) in row.iter().enumerate() {
220            out.push_str(cell);
221            if i + 1 < row.len() {
222                let pad = widths[i].saturating_sub(cell.chars().count());
223                out.push_str(&" ".repeat(pad + 1));
224            }
225        }
226        out.push('\n');
227    }
228    out
229}
230
231fn go(decls: &[Decl]) -> String {
232    let mut out = format!("// {HEADER}\n\npackage config\n");
233    for d in decls {
234        match d {
235            // Idiomatic Go: a named string type plus typed constants.
236            Decl::Enum(e) => {
237                out.push_str(&format!("\ntype {} string\n\nconst (\n", e.name));
238                let rows: Vec<Vec<String>> = e
239                    .members
240                    .iter()
241                    .map(|m| {
242                        vec![
243                            format!("{}{}", e.name, pascal(m)),
244                            e.name.to_string(),
245                            format!("= \"{m}\""),
246                        ]
247                    })
248                    .collect();
249                out.push_str(&align_go(&rows));
250                out.push_str(")\n");
251            }
252            Decl::Schema(s) => {
253                out.push_str(&format!("\ntype {} struct {{\n", s.name));
254                let rows: Vec<Vec<String>> = s
255                    .fields
256                    .iter()
257                    .map(|f| {
258                        vec![
259                            exported(f.name),
260                            go_ty(&f.ty),
261                            format!("`json:\"{}\"`", f.name),
262                        ]
263                    })
264                    .collect();
265                out.push_str(&align_go(&rows));
266                out.push_str("}\n");
267            }
268        }
269    }
270    out
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    const SRC: &str = concat!(
278        "enum Tier\n  \"frontend\"\n  \"backend\"\nend\n",
279        "type Endpoint\n  host: String\n  port: Int = 443\nend\n",
280        "type Service\n",
281        "  name:     String\n",
282        "  tier:     Tier\n",
283        "  endpoint: Endpoint\n",
284        "  ratio:    Float\n",
285        "  enabled:  Bool\n",
286        "  tags:     List\n",
287        "  labels:   Object\n",
288        "end\n",
289    );
290
291    #[test]
292    fn rust_output() {
293        let out = generate(SRC, Lang::Rust).unwrap();
294        assert!(out.contains("pub enum Tier {"));
295        assert!(out.contains("#[serde(rename = \"frontend\")]"));
296        assert!(out.contains("    Frontend,"));
297        assert!(out.contains("pub struct Service {"));
298        assert!(out.contains("pub tier: Tier,"));
299        assert!(out.contains("pub endpoint: Endpoint,"));
300        assert!(out.contains("pub port: i64,")); // optional field is still required
301        assert!(out.contains("pub tags: Vec<serde_json::Value>,"));
302        assert!(out.contains("pub labels: serde_json::Map<String, serde_json::Value>,"));
303    }
304
305    #[test]
306    fn typescript_output_uses_a_literal_union() {
307        let out = generate(SRC, Lang::TypeScript).unwrap();
308        assert!(out.contains("export type Tier = \"frontend\" | \"backend\";"));
309        assert!(out.contains("export interface Service {"));
310        assert!(out.contains("  tier: Tier;"));
311        assert!(out.contains("  ratio: number;"));
312        assert!(out.contains("  labels: Record<string, unknown>;"));
313    }
314
315    #[test]
316    fn go_output_uses_typed_constants_and_json_tags() {
317        let out = generate(SRC, Lang::Go).unwrap();
318        assert!(out.contains("type Tier string"));
319        assert!(out.contains("\tTierFrontend Tier = \"frontend\""));
320        assert!(out.contains("type Service struct {"));
321        // Columns are gofmt-aligned: `gofmt -l` on the output must stay silent.
322        assert!(
323            out.contains("\tTier     Tier           `json:\"tier\"`\n"),
324            "{out}"
325        );
326        assert!(
327            out.contains("\tLabels   map[string]any `json:\"labels\"`\n"),
328            "{out}"
329        );
330        // Enum constants align on the type column too.
331        assert!(out.contains("\tTierBackend  Tier = \"backend\"\n"), "{out}");
332    }
333
334    #[test]
335    fn member_names_are_sanitised() {
336        // Non-identifier members still produce valid type identifiers.
337        let src = "enum Region\n  \"eu-central\"\n  \"us.east\"\n  \"2fa\"\nend\n";
338        let out = generate(src, Lang::Rust).unwrap();
339        assert!(out.contains("    EuCentral,"), "{out}");
340        assert!(out.contains("    UsEast,"), "{out}");
341        assert!(out.contains("    N2fa,"), "{out}"); // a leading digit is prefixed
342                                                     // and the wire value is preserved verbatim
343        assert!(out.contains("#[serde(rename = \"eu-central\")]"));
344    }
345
346    #[test]
347    fn a_manifest_without_declarations_emits_only_a_header() {
348        let out = generate("x: 1\n", Lang::TypeScript).unwrap();
349        assert!(out.starts_with("// Generated by"));
350        assert!(!out.contains("interface"));
351    }
352
353    #[test]
354    fn a_broken_manifest_is_an_error_not_a_panic() {
355        assert!(generate("type\n", Lang::Rust).is_err());
356    }
357}