From 29530fad306fd5bf48e80c34f62b74e97fdc6086 Mon Sep 17 00:00:00 2001 From: Kinflou Date: Wed, 2 Sep 2026 05:32:54 +0800 Subject: [PATCH] feat(rust): @framing selector for a protocol's connect/serve helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A protocol can now declare its wire framing with an annotation: @framing = "jsonrpc" protocol Rpc { function now() -> u32; } The generator reads it off the frozen Protocol.parameters and, for a non-datagram pick, emits the JSON-RPC stack instead of the hard-wired datagram one: - Client wraps Client - connect() calls Client::connect_with_framing(..) - Dispatcher::serve() calls Server::with_framing(..) - the Handshake carries framing.name() rather than FRAMING_DATAGRAM - the Framing trait is added to the generated import list; FRAMING_DATAGRAM is dropped when no protocol in the file uses it Absent or unrecognised @framing keeps the datagram default, byte-for-byte (the conformance goldens are unchanged). No runtime rev bump — the runtime already ships JsonRpcFraming + the *_with_framing constructors. tests: - generate.rs: @framing = "jsonrpc" emits the JsonRpcFraming stack - compiles.rs: a second schema with a @framing = "jsonrpc" protocol is compiled against the real runtime alongside the datagram one --- codegen/src/generator.rs | 131 ++++++++++++++++++++++++++++++++++---- codegen/tests/compiles.rs | 34 +++++++++- codegen/tests/generate.rs | 42 ++++++++++++ 3 files changed, 192 insertions(+), 15 deletions(-) diff --git a/codegen/src/generator.rs b/codegen/src/generator.rs index 72f4c0c..db673b6 100644 --- a/codegen/src/generator.rs +++ b/codegen/src/generator.rs @@ -111,10 +111,23 @@ fn schema_source(units: &[FrozenUnit]) -> String { let has_protocol = units.iter().any(|u| matches!(u, FrozenUnit::Protocol { .. })); let errors = error_type_names(units); + // Which framings the protocols in this file reach for. A non-datagram pick + // pulls in the `Framing` trait (its helpers call `framing.name()`); the + // datagram default pulls in the `FRAMING_DATAGRAM` name constant. + let protocol_framings = || { + units.iter().filter_map(|u| match u { + FrozenUnit::Protocol { parameters, .. } => Some(framing_choice(parameters)), + _ => None, + }) + }; + let needs_framing_trait = protocol_framings().any(|f| f.explicit); + let needs_datagram_const = protocol_framings().any(|f| !f.explicit); + let mut output = String::new(); output.push_str("// Generated by Comline\n"); output.push_str("use serde::{Deserialize, Serialize};\n"); - if has_protocol { + if has_protocol && !needs_framing_trait { + // The common case — kept byte-for-byte for the conformance goldens. output.push_str( "use comline_runtime::client::Client;\n\ use comline_runtime::contract::{\n \ @@ -124,6 +137,34 @@ fn schema_source(units: &[FrozenUnit]) -> String { use comline_runtime::serve::Server;\n\ use comline_runtime::transport::Transport;\n", ); + } else if has_protocol { + let mut names = vec![ + "Call", + "CallError", + "Dispatch", + "Envelope", + "Framing", + "Handshake", + "Kind", + "Reply", + "RuntimeError", + "WireFormat", + ]; + if needs_datagram_const { + names.push("FRAMING_DATAGRAM"); + } + output.push_str("use comline_runtime::client::Client;\n"); + output.push_str("use comline_runtime::contract::{\n"); + for name in names { + output.push_str(" "); + output.push_str(name); + output.push_str(",\n"); + } + output.push_str( + "};\n\ + use comline_runtime::serve::Server;\n\ + use comline_runtime::transport::Transport;\n", + ); } output.push('\n'); @@ -147,8 +188,13 @@ fn schema_source(units: &[FrozenUnit]) -> String { FrozenUnit::Error { name, fields, .. } => { output.push_str(&error_struct(name, fields)); } - FrozenUnit::Protocol { name, functions, .. } => { - output.push_str(&protocol(name, functions, &errors)); + FrozenUnit::Protocol { + name, + functions, + parameters, + .. + } => { + output.push_str(&protocol(name, functions, parameters, &errors)); } _ => {} } @@ -270,7 +316,38 @@ struct FnInfo { timeout_ms: Option, } -fn protocol(proto: &str, functions: &[FrozenUnit], errors: &HashMap) -> String { +/// The `Framing` a protocol's generated `connect` / `serve` helpers wire up. +struct FramingChoice { + /// Path to the framing type, e.g. `comline_runtime::framing::JsonRpcFraming`. + ty: &'static str, + /// `true` for anything but the datagram default — those helpers pass the + /// framing explicitly (`Client::connect_with_framing` / `Server::with_framing`) + /// and the `Client` alias carries it as its third type parameter. + explicit: bool, +} + +/// Read `@framing = "..."` off the protocol (frozen into its `parameters`). +/// Absent / unrecognised ⇒ the datagram default. +fn framing_choice(parameters: &[FrozenUnit]) -> FramingChoice { + match annotation(parameters, "framing") { + Some("jsonrpc") | Some("json-rpc") | Some("jsonrpc-2.0") => FramingChoice { + ty: "comline_runtime::framing::JsonRpcFraming", + explicit: true, + }, + _ => FramingChoice { + ty: "comline_runtime::contract::DatagramFraming", + explicit: false, + }, + } +} + +fn protocol( + proto: &str, + functions: &[FrozenUnit], + parameters: &[FrozenUnit], + errors: &HashMap, +) -> String { + let framing = framing_choice(parameters); let fns: Vec = functions .iter() .filter_map(|f| match f { @@ -457,10 +534,21 @@ fn protocol(proto: &str, functions: &[FrozenUnit], errors: &HashMap " pub fn serve(self, transport: &mut T, format: W)".into(), ); l.push(" -> Result<(), RuntimeError> {".into()); - l.push( - " let hs = Handshake::new(IR_HASH, format.name(), FRAMING_DATAGRAM, 0);".into(), - ); - l.push(" Server::new(self, format).serve_handshaked(transport, hs)".into()); + if framing.explicit { + l.push(format!(" let framing = {};", framing.ty)); + l.push( + " let hs = Handshake::new(IR_HASH, format.name(), framing.name(), 0);".into(), + ); + l.push( + " Server::with_framing(self, format, framing).serve_handshaked(transport, hs)" + .into(), + ); + } else { + l.push( + " let hs = Handshake::new(IR_HASH, format.name(), FRAMING_DATAGRAM, 0);".into(), + ); + l.push(" Server::new(self, format).serve_handshaked(transport, hs)".into()); + } l.push(" }".into()); l.push("}".into()); l.push(String::new()); @@ -468,13 +556,18 @@ fn protocol(proto: &str, functions: &[FrozenUnit], errors: &HashMap s.push_str(&l.join("\n")); // 5. consumer stub — wraps a `Client` + let client_ty = if framing.explicit { + format!("Client", framing.ty) + } else { + "Client".to_string() + }; let mut l: Vec = Vec::new(); - l.push(format!("pub struct {proto}Client(pub Client);")); + l.push(format!("pub struct {proto}Client(pub {client_ty});")); l.push(String::new()); l.push(format!( "impl {proto}Client {{" )); - l.push(" pub fn new(client: Client) -> Self {".into()); + l.push(format!(" pub fn new(client: {client_ty}) -> Self {{")); l.push(" Self(client)".into()); l.push(" }".into()); l.push(String::new()); @@ -482,10 +575,20 @@ fn protocol(proto: &str, functions: &[FrozenUnit], errors: &HashMap l.push( " pub fn connect(transport: T, format: W) -> Result {".into(), ); - l.push( - " let hs = Handshake::new(IR_HASH, format.name(), FRAMING_DATAGRAM, 0);".into(), - ); - l.push(" Ok(Self(Client::connect(transport, format, hs)?))".into()); + if framing.explicit { + l.push(format!(" let framing = {};", framing.ty)); + l.push( + " let hs = Handshake::new(IR_HASH, format.name(), framing.name(), 0);".into(), + ); + l.push( + " Ok(Self(Client::connect_with_framing(transport, format, framing, hs)?))".into(), + ); + } else { + l.push( + " let hs = Handshake::new(IR_HASH, format.name(), FRAMING_DATAGRAM, 0);".into(), + ); + l.push(" Ok(Self(Client::connect(transport, format, hs)?))".into()); + } l.push(" }".into()); for (i, f) in fns.iter().enumerate() { let args = sig_args(&f.args); diff --git a/codegen/tests/compiles.rs b/codegen/tests/compiles.rs index 5e3800b..c630644 100644 --- a/codegen/tests/compiles.rs +++ b/codegen/tests/compiles.rs @@ -120,9 +120,41 @@ fn chat_schema() -> Vec { ] } +/// A protocol annotated `@framing = "jsonrpc"` — its generated `connect` / +/// `serve` helpers must compile against `comline_runtime::framing::JsonRpcFraming` +/// and the `Client` alias. +fn rpc_schema() -> Vec { + vec![FrozenUnit::Protocol { + docstring: "Rpc".into(), + parameters: vec![FrozenUnit::Property { + name: "framing".into(), + expression: Some("jsonrpc".into()), + }], + name: "Rpc".into(), + functions: vec![ + function( + "echo", + vec![arg("line", KindValue::Namespaced("string".into(), None))], + Some(KindValue::Namespaced("string".into(), None)), + vec![], + ), + function( + "tick", + vec![], + Some(KindValue::Primitive(Primitive::U32(None))), + vec![], + ), + ], + span: (0, 0), + }] +} + #[test] fn a_generated_protocol_crate_builds() { - let schemas = vec![("chat".to_string(), chat_schema())]; + let schemas = vec![ + ("chat".to_string(), chat_schema()), + ("rpc".to_string(), rpc_schema()), + ]; let req = GenRequest { mode: Mode::Lib, schemas: &schemas, diff --git a/codegen/tests/generate.rs b/codegen/tests/generate.rs index ccf2e10..82bd9b1 100644 --- a/codegen/tests/generate.rs +++ b/codegen/tests/generate.rs @@ -283,6 +283,48 @@ fn timeout_ms_annotation_emits_call_with_timeout() { assert!(src.contains(r#"self.0.call(Call::new(1, "fast"), &())?;"#)); } +#[test] +fn framing_annotation_selects_jsonrpc_for_the_connect_and_serve_helpers() { + // @framing = "jsonrpc" protocol Rpc { function now() -> u32; } + let proto = FrozenUnit::Protocol { + docstring: "Rpc".to_string(), + parameters: vec![FrozenUnit::Property { + name: "framing".to_string(), + expression: Some("jsonrpc".to_string()), + }], + name: "Rpc".to_string(), + functions: vec![FrozenUnit::Function { + docstring: String::new(), + name: "now".to_string(), + parameters: vec![], + arguments: vec![], + _return: Some(KindValue::Primitive(Primitive::U32(None))), + throws: vec![], + span: (0, 0), + }], + span: (0, 0), + }; + let schemas = vec![("rpc".to_string(), vec![proto])]; + let src = generate_rust(&code_req(&schemas)).unwrap().remove(0).contents; + + // the `Framing` trait is in scope (the helpers call `framing.name()`) + assert!(src.contains("\n Framing,\n")); + // the client wraps a `Client` pinned to the JSON-RPC framing + assert!(src.contains( + "pub struct RpcClient(pub Client);" + )); + // connect / serve pass the framing explicitly and hash its name + assert!(src.contains("let framing = comline_runtime::framing::JsonRpcFraming;")); + assert!(src.contains("let hs = Handshake::new(IR_HASH, format.name(), framing.name(), 0);")); + assert!(src.contains("Ok(Self(Client::connect_with_framing(transport, format, framing, hs)?))")); + assert!( + src.contains("Server::with_framing(self, format, framing).serve_handshaked(transport, hs)") + ); + // the datagram default never appears for an all-JSON-RPC schema + assert!(!src.contains("FRAMING_DATAGRAM")); + assert!(!src.contains("Client::connect(transport, format, hs)")); +} + #[test] fn an_all_one_way_protocol_leaves_the_dispatch_reply_param_unbound() { let proto = FrozenUnit::Protocol {