aura-lang 0.1.0

Aura configuration language: deterministic, capability-secured, schema-validated configs — embeddable library + the `aura` CLI
Documentation
//! `aura types`: generate host-language types from a manifest's `type` and
//! `enum` declarations (SPEC §7.2).
//!
//! One schema, two jobs: it validates the config *and* types the service that
//! consumes the resulting JSON. Emission works on the AST (not on values), so a
//! manifest never has to be evaluated — no I/O, no capabilities involved.
//!
//! Optional fields (D15) are emitted as **required**: evaluation always inserts
//! the default, so the JSON a consumer receives always carries the field.

use crate::error::Diagnostic;
use crate::lexer::Lexer;
use crate::parser::ast::{EnumDeclaration, SchemaDeclaration, Stmt, TypeName};
use crate::parser::Parser;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
    Rust,
    TypeScript,
    Go,
}

impl Lang {
    pub fn parse(s: &str) -> Option<Lang> {
        match s {
            "rust" | "rs" => Some(Lang::Rust),
            "ts" | "typescript" => Some(Lang::TypeScript),
            "go" | "golang" => Some(Lang::Go),
            _ => None,
        }
    }
}

/// Declarations worth emitting, in source order.
enum Decl<'a> {
    Schema(&'a SchemaDeclaration<'a>),
    Enum(&'a EnumDeclaration<'a>),
}

/// Generate types for every `type` and `enum` declared in `src`.
pub fn generate(src: &str, lang: Lang) -> Result<String, Diagnostic> {
    let tokens = Lexer::new(src, 0).tokenize()?;
    let module = Parser::new(tokens)
        .parse_module()
        .map_err(|mut ds| ds.remove(0))?;

    let decls: Vec<Decl> = module
        .stmts
        .iter()
        .filter_map(|s| match s {
            Stmt::TypeDecl(t) => Some(Decl::Schema(t)),
            Stmt::EnumDecl(e) => Some(Decl::Enum(e)),
            _ => None,
        })
        .collect();

    // An enum-typed field maps to the enum's own generated type name, which is
    // just `TypeName::Custom(name)` — no extra bookkeeping needed.
    Ok(match lang {
        Lang::Rust => rust(&decls),
        Lang::TypeScript => typescript(&decls),
        Lang::Go => go(&decls),
    })
}

// ---- name helpers ----

/// A member string turned into a type-identifier fragment: `"eu-central"` ->
/// `EuCentral`. Non-alphanumerics separate words; a leading digit is prefixed so
/// the result is always a valid identifier.
fn pascal(s: &str) -> String {
    let mut out = String::new();
    let mut upper = true;
    for c in s.chars() {
        if c.is_alphanumeric() {
            if upper {
                out.extend(c.to_uppercase());
            } else {
                out.push(c);
            }
            upper = false;
        } else {
            upper = true;
        }
    }
    if out.is_empty() {
        out.push_str("Empty");
    } else if out.starts_with(|c: char| c.is_ascii_digit()) {
        out.insert(0, 'N');
    }
    out
}

/// Go field names must be exported (capitalised): `min_version` -> `MinVersion`.
/// The wire name is preserved by the `json:` tag.
fn exported(s: &str) -> String {
    pascal(s)
}

const HEADER: &str = "Generated by `aura types` — do not edit by hand.";

// ---- Rust ----

fn rust_ty(ty: &TypeName) -> String {
    match ty {
        TypeName::String => "String".into(),
        TypeName::Int => "i64".into(),
        TypeName::Float => "f64".into(),
        TypeName::Bool => "bool".into(),
        TypeName::List => "Vec<serde_json::Value>".into(),
        TypeName::Object => "serde_json::Map<String, serde_json::Value>".into(),
        TypeName::Custom(name) => (*name).to_string(),
    }
}

fn rust(decls: &[Decl]) -> String {
    let mut out = format!("// {HEADER}\n\nuse serde::{{Deserialize, Serialize}};\n");
    for d in decls {
        match d {
            Decl::Enum(e) => {
                out.push_str(&format!(
                    "\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub enum {} {{\n",
                    e.name
                ));
                for m in &e.members {
                    out.push_str(&format!(
                        "    #[serde(rename = \"{m}\")]\n    {},\n",
                        pascal(m)
                    ));
                }
                out.push_str("}\n");
            }
            Decl::Schema(s) => {
                out.push_str(&format!(
                    "\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {} {{\n",
                    s.name
                ));
                for f in &s.fields {
                    out.push_str(&format!("    pub {}: {},\n", f.name, rust_ty(&f.ty)));
                }
                out.push_str("}\n");
            }
        }
    }
    out
}

// ---- TypeScript ----

fn ts_ty(ty: &TypeName) -> String {
    match ty {
        TypeName::String => "string".into(),
        TypeName::Int | TypeName::Float => "number".into(),
        TypeName::Bool => "boolean".into(),
        TypeName::List => "unknown[]".into(),
        TypeName::Object => "Record<string, unknown>".into(),
        TypeName::Custom(name) => (*name).to_string(),
    }
}

fn typescript(decls: &[Decl]) -> String {
    let mut out = format!("// {HEADER}\n");
    for d in decls {
        match d {
            // A string-literal union is the exact TypeScript equivalent of D18.
            Decl::Enum(e) => {
                let union = e
                    .members
                    .iter()
                    .map(|m| format!("\"{m}\""))
                    .collect::<Vec<_>>()
                    .join(" | ");
                out.push_str(&format!("\nexport type {} = {union};\n", e.name));
            }
            Decl::Schema(s) => {
                out.push_str(&format!("\nexport interface {} {{\n", s.name));
                for f in &s.fields {
                    out.push_str(&format!("  {}: {};\n", f.name, ts_ty(&f.ty)));
                }
                out.push_str("}\n");
            }
        }
    }
    out
}

// ---- Go ----

fn go_ty(ty: &TypeName) -> String {
    match ty {
        TypeName::String => "string".into(),
        TypeName::Int => "int64".into(),
        TypeName::Float => "float64".into(),
        TypeName::Bool => "bool".into(),
        TypeName::List => "[]any".into(),
        TypeName::Object => "map[string]any".into(),
        TypeName::Custom(name) => (*name).to_string(),
    }
}

/// Column-align a run of lines the way `gofmt`'s tabwriter does: every column
/// but the last is padded to the widest cell, separated by a single space. Emitting
/// gofmt-canonical output means `gofmt -l` on a generated file stays silent, so
/// checking in generated types never produces formatting churn.
fn align_go(rows: &[Vec<String>]) -> String {
    let cols = rows.iter().map(Vec::len).max().unwrap_or(0);
    let widths: Vec<usize> = (0..cols)
        .map(|i| {
            rows.iter()
                .filter(|r| i + 1 < r.len()) // only cells that are followed by another
                .map(|r| r[i].chars().count())
                .max()
                .unwrap_or(0)
        })
        .collect();
    let mut out = String::new();
    for row in rows {
        out.push('\t');
        for (i, cell) in row.iter().enumerate() {
            out.push_str(cell);
            if i + 1 < row.len() {
                let pad = widths[i].saturating_sub(cell.chars().count());
                out.push_str(&" ".repeat(pad + 1));
            }
        }
        out.push('\n');
    }
    out
}

fn go(decls: &[Decl]) -> String {
    let mut out = format!("// {HEADER}\n\npackage config\n");
    for d in decls {
        match d {
            // Idiomatic Go: a named string type plus typed constants.
            Decl::Enum(e) => {
                out.push_str(&format!("\ntype {} string\n\nconst (\n", e.name));
                let rows: Vec<Vec<String>> = e
                    .members
                    .iter()
                    .map(|m| {
                        vec![
                            format!("{}{}", e.name, pascal(m)),
                            e.name.to_string(),
                            format!("= \"{m}\""),
                        ]
                    })
                    .collect();
                out.push_str(&align_go(&rows));
                out.push_str(")\n");
            }
            Decl::Schema(s) => {
                out.push_str(&format!("\ntype {} struct {{\n", s.name));
                let rows: Vec<Vec<String>> = s
                    .fields
                    .iter()
                    .map(|f| {
                        vec![
                            exported(f.name),
                            go_ty(&f.ty),
                            format!("`json:\"{}\"`", f.name),
                        ]
                    })
                    .collect();
                out.push_str(&align_go(&rows));
                out.push_str("}\n");
            }
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    const SRC: &str = concat!(
        "enum Tier\n  \"frontend\"\n  \"backend\"\nend\n",
        "type Endpoint\n  host: String\n  port: Int = 443\nend\n",
        "type Service\n",
        "  name:     String\n",
        "  tier:     Tier\n",
        "  endpoint: Endpoint\n",
        "  ratio:    Float\n",
        "  enabled:  Bool\n",
        "  tags:     List\n",
        "  labels:   Object\n",
        "end\n",
    );

    #[test]
    fn rust_output() {
        let out = generate(SRC, Lang::Rust).unwrap();
        assert!(out.contains("pub enum Tier {"));
        assert!(out.contains("#[serde(rename = \"frontend\")]"));
        assert!(out.contains("    Frontend,"));
        assert!(out.contains("pub struct Service {"));
        assert!(out.contains("pub tier: Tier,"));
        assert!(out.contains("pub endpoint: Endpoint,"));
        assert!(out.contains("pub port: i64,")); // optional field is still required
        assert!(out.contains("pub tags: Vec<serde_json::Value>,"));
        assert!(out.contains("pub labels: serde_json::Map<String, serde_json::Value>,"));
    }

    #[test]
    fn typescript_output_uses_a_literal_union() {
        let out = generate(SRC, Lang::TypeScript).unwrap();
        assert!(out.contains("export type Tier = \"frontend\" | \"backend\";"));
        assert!(out.contains("export interface Service {"));
        assert!(out.contains("  tier: Tier;"));
        assert!(out.contains("  ratio: number;"));
        assert!(out.contains("  labels: Record<string, unknown>;"));
    }

    #[test]
    fn go_output_uses_typed_constants_and_json_tags() {
        let out = generate(SRC, Lang::Go).unwrap();
        assert!(out.contains("type Tier string"));
        assert!(out.contains("\tTierFrontend Tier = \"frontend\""));
        assert!(out.contains("type Service struct {"));
        // Columns are gofmt-aligned: `gofmt -l` on the output must stay silent.
        assert!(
            out.contains("\tTier     Tier           `json:\"tier\"`\n"),
            "{out}"
        );
        assert!(
            out.contains("\tLabels   map[string]any `json:\"labels\"`\n"),
            "{out}"
        );
        // Enum constants align on the type column too.
        assert!(out.contains("\tTierBackend  Tier = \"backend\"\n"), "{out}");
    }

    #[test]
    fn member_names_are_sanitised() {
        // Non-identifier members still produce valid type identifiers.
        let src = "enum Region\n  \"eu-central\"\n  \"us.east\"\n  \"2fa\"\nend\n";
        let out = generate(src, Lang::Rust).unwrap();
        assert!(out.contains("    EuCentral,"), "{out}");
        assert!(out.contains("    UsEast,"), "{out}");
        assert!(out.contains("    N2fa,"), "{out}"); // a leading digit is prefixed
                                                     // and the wire value is preserved verbatim
        assert!(out.contains("#[serde(rename = \"eu-central\")]"));
    }

    #[test]
    fn a_manifest_without_declarations_emits_only_a_header() {
        let out = generate("x: 1\n", Lang::TypeScript).unwrap();
        assert!(out.starts_with("// Generated by"));
        assert!(!out.contains("interface"));
    }

    #[test]
    fn a_broken_manifest_is_an_error_not_a_panic() {
        assert!(generate("type\n", Lang::Rust).is_err());
    }
}