From c535f141cdae5c73257c8236826560554c5e7719 Mon Sep 17 00:00:00 2001 From: Kinflou Date: Wed, 2 Sep 2026 16:23:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(ts):=20protocol=20RPC=20shape=20=E2=80=94?= =?UTF-8?q?=20IR=5FHASH,=20params,=20error=20unions,=20async?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TypeScript `code` generator moves past bare method stubs toward the Rust generator's surface (no runtime to link against yet, so this is the typed layer only): - `export const IR_HASH` (bigint) per file with a protocol — the same canonical `schema_ir_hash` digest the Rust side emits, so a TS peer and a Rust peer agree. - `error` declarations -> `export interface`. - `Params` interface per function that takes arguments; the method takes one `params` object. - Discriminated-union error types from each `!` — per function (`Error`) and a per-protocol `Error` — every arm `{ code: ; name; data }`, keyed by the schema-global error ordinal. `@throws` in the method's JSDoc. A `!` on a one-way call is dropped. - Every method returns `Promise`; a one-way / `-> ()` call is `Promise`. - `u128` / `i128` / `s128` -> `bigint` (were leaking through unmapped); `KindValue::Unit` -> `void`. --- codegen/src/generator.rs | 255 ++++++++++++++++++++++++++++++-------- codegen/tests/generate.rs | 95 +++++++++++++- 2 files changed, 299 insertions(+), 51 deletions(-) diff --git a/codegen/src/generator.rs b/codegen/src/generator.rs index 6cc6637..12fa3ca 100644 --- a/codegen/src/generator.rs +++ b/codegen/src/generator.rs @@ -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 +//! `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 `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> { if req.mode == Mode::Lib { bail!("typescript lib mode is not implemented yet (de-rot G2)"); @@ -32,19 +40,38 @@ pub fn generate_typescript(req: &GenRequest) -> Result> { } 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)); } _ => {} } @@ -53,84 +80,214 @@ fn schema_source(units: &[FrozenUnit]) -> String { output } -fn generate_interface(name: &str, fields: &Vec) -> 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 { + 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) -> 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::>() - .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) -> 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, + 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, +} + +fn protocol(proto: &str, functions: &[FrozenUnit], errors: &HashMap) -> String { + let fns: Vec = 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::() + c.as_str(), + None => String::new(), + } + }) + .collect() +} diff --git a/codegen/tests/generate.rs b/codegen/tests/generate.rs index 823ba5d..03bf814 100644 --- a/codegen/tests/generate.rs +++ b/codegen/tests/generate.rs @@ -120,9 +120,100 @@ fn interface_from_protocol() { span: (0, 0), }]); + // a protocol makes the file carry the handshake digest + assert!(out.contains("export const IR_HASH = 0x")); + assert!(out.contains("n;\n")); + // a params interface per function that takes arguments + assert!(out.contains("export interface UserServiceGetUserParams {\n id: number;\n}")); + // every call is async; args arrive as one `params` object assert!(out.contains("export interface UserService {")); - assert!(out.contains("get_user(id: number): User;")); - assert!(out.contains("ping(): void;")); + assert!(out.contains(" get_user(params: UserServiceGetUserParams): Promise;")); + // a one-way / no-arg call: no params, `Promise` + assert!(out.contains(" ping(): Promise;")); +} + +#[test] +fn error_interfaces_and_discriminated_unions_from_throws() { + let out = one(vec![ + FrozenUnit::Error { + docstring: None, + parameters: vec![], + ordinal: 0, + imported_from: None, + name: "Rejected".to_string(), + message: "no".to_string(), + fields: vec![FrozenUnit::Field { + docstring: None, + parameters: vec![], + optional: false, + name: "why".to_string(), + kind_value: KindValue::Namespaced("string".to_string(), None), + span: (0, 0), + }], + }, + FrozenUnit::Protocol { + docstring: String::new(), + name: "Chat".to_string(), + parameters: vec![], + functions: vec![ + FrozenUnit::Function { + docstring: String::new(), + name: "send".to_string(), + parameters: vec![], + arguments: vec![FrozenArgument { + name: "body".to_string(), + kind: KindValue::Namespaced("string".to_string(), None), + span: (0, 0), + }], + _return: Some(KindValue::Unit), + throws: vec![0], + span: (0, 0), + }, + // one-way: a `!` here is dropped + FrozenUnit::Function { + docstring: String::new(), + name: "poke".to_string(), + parameters: vec![], + arguments: vec![], + _return: None, + throws: vec![0], + span: (0, 0), + }, + ], + span: (0, 0), + }, + ]); + + // the `error` becomes an interface + assert!(out.contains("export interface Rejected {\n why: string;\n}")); + // per-function union carries the wire ordinal, the name, and the payload + assert!(out.contains( + "export type ChatSendError =\n | { code: 0; name: \"Rejected\"; data: Rejected };" + )); + // per-protocol union, and the method advertises what it throws + assert!(out.contains( + "export type ChatError =\n | { code: 0; name: \"Rejected\"; data: Rejected };" + )); + assert!(out.contains(" /** @throws {ChatSendError} */")); + assert!(out.contains(" send(params: ChatSendParams): Promise;")); + // `-> ()` is request/response with an empty ack, not one-way + assert!(out.contains(" poke(): Promise;")); + // a `!` on a one-way call is dropped — no poke error type + assert!(!out.contains("ChatPokeError")); +} + +#[test] +fn a_schema_without_a_protocol_has_no_ir_hash() { + let out = one(vec![FrozenUnit::Enum { + docstring: None, + name: "Status".to_string(), + variants: vec![FrozenUnit::EnumVariant( + KindValue::EnumVariant("Active".to_string(), None), + (0, 0), + )], + span: (0, 0), + }]); + assert!(!out.contains("IR_HASH")); } #[test]