Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 206 additions & 49 deletions codegen/src/generator.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
//! TypeScript `code`-mode generation: frozen IR -> `.ts` source.
//!
//! - `struct` -> `export interface`
//! - `enum` -> `export enum` with string values (JSON interop)
//! - `protocol` -> `export interface` with a method per function
//! Per schema file:
//! - `struct` / `error` -> `export interface`
//! - `enum` -> `export enum` with string values (JSON interop)
//! - `protocol` -> the RPC shape: an `IR_HASH` for the connection
//! handshake, an `export interface` of `Promise`-returning methods, a
//! `<Proto><Fn>Params` interface per function that takes arguments, and
//! discriminated-union error types from each `!` (per function and a
//! per-protocol union), keyed by the schema-global error ordinal.
//!
//! `lib` mode (an npm package) is not built yet. See design/generation.md.
//! A `<Proto>Client` / dispatcher and a runtime package to link them against
//! are the next step; `lib` mode (an npm package) is not built yet. See
//! design/generation.md.

use std::collections::HashMap;
use std::path::PathBuf;

use comline_core::schema::ir::frozen::unit::FrozenUnit;
use comline_core::schema::ir::compiler::interpreted::kind_search::KindValue;
use comline_core::schema::ir::frozen::schema_ir_hash;
use comline_core::schema::ir::frozen::unit::FrozenUnit;

use eyre::{bail, Result};

use comline_codegen::{GenRequest, GeneratedFile, Mode};


pub fn generate_typescript(req: &GenRequest) -> Result<Vec<GeneratedFile>> {
if req.mode == Mode::Lib {
bail!("typescript lib mode is not implemented yet (de-rot G2)");
Expand All @@ -32,19 +40,38 @@ pub fn generate_typescript(req: &GenRequest) -> Result<Vec<GeneratedFile>> {
}

fn schema_source(units: &[FrozenUnit]) -> String {
let has_protocol = units
.iter()
.any(|u| matches!(u, FrozenUnit::Protocol { .. }));
let errors = error_type_names(units);

let mut output = String::new();
output.push_str("// Generated by Comline\n\n");

if has_protocol {
output.push_str(&format!(
"// Canonical digest of the frozen IR this file was generated from — the\n\
// two ends of a connection must agree on it.\n\
export const IR_HASH = {:#018x}n;\n\n",
schema_ir_hash(units)
));
}

for unit in units {
match unit {
FrozenUnit::Struct { name, fields, .. } => {
output.push_str(&generate_interface(name, fields));
output.push_str(&interface(name, fields));
}
FrozenUnit::Error { name, fields, .. } => {
output.push_str(&interface(name, fields));
}
FrozenUnit::Enum { name, variants, .. } => {
output.push_str(&generate_enum(name, variants));
output.push_str(&string_enum(name, variants));
}
FrozenUnit::Protocol { name, functions, .. } => {
output.push_str(&generate_protocol(name, functions));
FrozenUnit::Protocol {
name, functions, ..
} => {
output.push_str(&protocol(name, functions, &errors));
}
_ => {}
}
Expand All @@ -53,84 +80,214 @@ fn schema_source(units: &[FrozenUnit]) -> String {
output
}

fn generate_interface(name: &str, fields: &Vec<FrozenUnit>) -> String {
let mut s = format!("export interface {} {{\n", name);
/// Schema-global error ordinal -> the `error` interface's name.
fn error_type_names(units: &[FrozenUnit]) -> HashMap<u16, String> {
units
.iter()
.filter_map(|u| match u {
FrozenUnit::Error { ordinal, name, .. } => Some((*ordinal, name.clone())),
_ => None,
})
.collect()
}

// ── data types ─────────────────────────────────────────────────────────────

fn interface(name: &str, fields: &[FrozenUnit]) -> String {
let mut s = format!("export interface {name} {{\n");
for field in fields {
if let FrozenUnit::Field { name, kind_value, optional, .. } = field {
if let FrozenUnit::Field {
name,
kind_value,
optional,
..
} = field
{
let opt = if *optional { "?" } else { "" };
s.push_str(&format!(" {}{}: {};\n", name, opt, map_kind_to_ts_type(kind_value)));
s.push_str(&format!(" {name}{opt}: {};\n", ts_type(kind_value)));
}
}

s.push_str("}\n\n");
s
}

fn generate_protocol(name: &str, functions: &Vec<FrozenUnit>) -> String {
let mut s = format!("export interface {} {{\n", name);

for func in functions {
if let FrozenUnit::Function { name, arguments, _return, .. } = func {
let args = arguments
.iter()
.map(|arg| format!("{}: {}", arg.name, map_kind_to_ts_type(&arg.kind)))
.collect::<Vec<_>>()
.join(", ");

let ret = match _return {
Some(r) => map_kind_to_ts_type(r),
None => "void".to_string(),
fn string_enum(name: &str, variants: &[FrozenUnit]) -> String {
let mut s = format!("export enum {name} {{\n");
for variant in variants {
if let FrozenUnit::EnumVariant(kv, _) = variant {
let v = match kv {
KindValue::EnumVariant(n, _) | KindValue::Namespaced(n, _) => n.clone(),
_ => "Unknown".to_string(),
};

s.push_str(&format!(" {}({}): {};\n", name, args, ret));
s.push_str(&format!(" {v} = \"{v}\",\n"));
}
}

s.push_str("}\n\n");
s
}

fn generate_enum(name: &str, variants: &Vec<FrozenUnit>) -> String {
let mut s = format!("export enum {} {{\n", name);
// ── protocol ───────────────────────────────────────────────────────────────

for variant in variants {
if let FrozenUnit::EnumVariant(kv, _) = variant {
let variant_name = match kv {
KindValue::EnumVariant(n, _) => n.clone(),
KindValue::Namespaced(n, _) => n.clone(),
_ => "Unknown".to_string(),
};
s.push_str(&format!(" {} = \"{}\",\n", variant_name, variant_name));
struct FnInfo {
name: String,
params_ty: Option<String>,
args: Vec<(String, String)>,
/// `Promise<...>` payload — `void` for a one-way call or a `()` return.
ret: String,
/// `(ordinal, error interface name)` for each `!` on this function. Empty
/// for a one-way call (a `!` there has nowhere to go).
throws: Vec<(u16, String)>,
err_ty: Option<String>,
}

fn protocol(proto: &str, functions: &[FrozenUnit], errors: &HashMap<u16, String>) -> String {
let fns: Vec<FnInfo> = functions
.iter()
.filter_map(|f| match f {
FrozenUnit::Function {
name,
arguments,
_return,
throws,
..
} => {
let one_way = _return.is_none();
let args: Vec<(String, String)> = arguments
.iter()
.map(|a| (a.name.clone(), ts_type(&a.kind)))
.collect();
let params_ty = if args.is_empty() {
None
} else {
Some(format!("{proto}{}Params", pascal(name)))
};
let ret = match _return {
None | Some(KindValue::Unit) => "void".to_string(),
Some(kv) => ts_type(kv),
};
let throws: Vec<(u16, String)> = if one_way {
Vec::new()
} else {
throws
.iter()
.map(|o| {
(
*o,
errors
.get(o)
.cloned()
.unwrap_or_else(|| format!("UnknownError{o}")),
)
})
.collect()
};
let err_ty = (!throws.is_empty()).then(|| format!("{proto}{}Error", pascal(name)));
Some(FnInfo {
name: name.clone(),
params_ty,
args,
ret,
throws,
err_ty,
})
}
_ => None,
})
.collect();

let mut s = String::new();

// 1. a params interface per function that takes arguments
for f in &fns {
if let Some(ty) = &f.params_ty {
s.push_str(&format!("export interface {ty} {{\n"));
for (name, tsty) in &f.args {
s.push_str(&format!(" {name}: {tsty};\n"));
}
s.push_str("}\n\n");
}
}

// 2. a discriminated-union error type per throwing function, each arm
// carrying the wire ordinal, the name, and the typed payload.
for f in &fns {
if let Some(err_ty) = &f.err_ty {
s.push_str(&format!("export type {err_ty} ={};\n\n", union_arms(&f.throws)));
}
}

// 3. the per-protocol union of every error it can raise
let mut all: Vec<(u16, String)> = fns.iter().flat_map(|f| f.throws.clone()).collect();
all.sort_by_key(|(o, _)| *o);
all.dedup_by_key(|(o, _)| *o);
if !all.is_empty() {
s.push_str(&format!("export type {proto}Error ={};\n\n", union_arms(&all)));
}

// 4. the protocol interface — every call is async
s.push_str(&format!("export interface {proto} {{\n"));
for f in &fns {
let params = match &f.params_ty {
Some(ty) => format!("params: {ty}"),
None => String::new(),
};
if let Some(err_ty) = &f.err_ty {
s.push_str(&format!(" /** @throws {{{err_ty}}} */\n"));
}
s.push_str(&format!(" {}({params}): Promise<{}>;\n", f.name, f.ret));
}
s.push_str("}\n\n");

s
}

fn map_kind_to_ts_type(kind: &KindValue) -> String {
/// `\n | { code: 0; name: "Rejected"; data: Rejected }` per `(ordinal, name)`.
fn union_arms(throws: &[(u16, String)]) -> String {
throws
.iter()
.map(|(ord, name)| format!("\n | {{ code: {ord}; name: \"{name}\"; data: {name} }}"))
.collect()
}

// ── type mapping ───────────────────────────────────────────────────────────

fn ts_type(kind: &KindValue) -> String {
match kind {
KindValue::Primitive(p) => map_str_type(p.name()),
KindValue::Namespaced(name, _) => map_str_type(name),
KindValue::EnumVariant(name, _) => name.clone(),
KindValue::Unit => "void".to_string(),
_ => "unknown".to_string(),
}
}

fn map_str_type(s: &str) -> String {
if s.ends_with("[]") {
let inner = &s[..s.len() - 2];
if let Some(inner) = s.strip_suffix("[]") {
return format!("{}[]", map_str_type(inner));
}
match s {
"string" | "str" => "string".to_string(),
"bool" => "boolean".to_string(),
// TypeScript has one numeric type; every IDL integer / float width maps to it.
"float" | "int" | "u8" | "u16" | "u32" | "u64" | "s8" | "s16" | "s32" | "s64" => {
"number".to_string()
}
// 128-bit integers overflow `number`'s 2^53 exact range — use `bigint`.
"u128" | "i128" | "s128" => "bigint".to_string(),
// TypeScript has one plain numeric type; the narrower widths map to it.
"float" | "double" | "int" | "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64"
| "s8" | "s16" | "s32" | "s64" => "number".to_string(),
// A named type from the schema (another struct / enum) — pass through.
other => other.to_string(),
}
}

/// `get_user` -> `GetUser`, for deriving a type name from a function name.
fn pascal(s: &str) -> String {
s.split('_')
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect()
}
Loading
Loading