From 6bb0e8783e909272cc423af90ade0b87f79ddabe Mon Sep 17 00:00:00 2001 From: Kinflou Date: Wed, 2 Sep 2026 15:37:34 +0800 Subject: [PATCH] feat(rust): honour GenRequest.default_framing as the per-package fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A protocol's framing now resolves most-specific-first: 1. its own `@framing` annotation 2. the package `default_framing` (comline.toml, via GenRequest) 3. the datagram default `@framing = "datagram"` (or `"comline.datagram"`) is now recognised too, so a single protocol can opt back to datagram when the package default is `jsonrpc`. An unrecognised name at either level falls through to the next. Output is unchanged whenever `default_framing` is None and no protocol names `datagram` explicitly — the conformance goldens don't move. Bumps the comline-codegen pin to generation c70336d (the rev that adds GenRequest.default_framing). tests: - generate.rs: package default applies to an unannotated protocol; `@framing = "datagram"` beats a `jsonrpc` package default - compiles.rs: one crate now builds all three resolutions at once — `chat` opts out to datagram, `clock` takes the default, `rpc` names it --- codegen/Cargo.toml | 2 +- codegen/src/generator.rs | 57 +++++++++++++++++++++++---------- codegen/tests/compiles.rs | 30 ++++++++++++++++-- codegen/tests/generate.rs | 66 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 19 deletions(-) diff --git a/codegen/Cargo.toml b/codegen/Cargo.toml index 040894e..073b56b 100644 --- a/codegen/Cargo.toml +++ b/codegen/Cargo.toml @@ -9,7 +9,7 @@ license = "GPL-3.0-only" # repository structure — no crates.io publishing at this stage). Pinned to the # same generation rev `comline-typescript` uses, so a CLI build has one # `comline-codegen`. Local iteration: add a `[patch]` onto a sibling checkout. -comline-codegen = { git = "https://github.com/ComlineProject/generation", rev = "1f8c5e290eba72a578860ebb6dc33453a2f0726b" } +comline-codegen = { git = "https://github.com/ComlineProject/generation", rev = "c70336dcc9485cea2c99b0f1db3789e420deaeda" } comline-core = { git = "https://github.com/ComlineProject/core", rev = "47ac5f10a36b587d14d1b9672fba65d8f3712962" } eyre = "0.6.8" diff --git a/codegen/src/generator.rs b/codegen/src/generator.rs index db673b6..732cc40 100644 --- a/codegen/src/generator.rs +++ b/codegen/src/generator.rs @@ -23,13 +23,14 @@ const RUNTIME_GIT: &str = "https://github.com/ComlineProject/runtime"; const RUNTIME_REV: &str = "0dda42de5105e75e339bf66fa38085bd53ab9ab9"; pub fn generate_rust(req: &GenRequest) -> Result> { + let default_framing = req.default_framing.as_deref(); match req.mode { Mode::Code => Ok(req .schemas .iter() .map(|(namespace, units)| GeneratedFile { path: PathBuf::from(format!("{namespace}.rs")), - contents: schema_source(units), + contents: schema_source(units, default_framing), }) .collect()), @@ -54,7 +55,7 @@ pub fn generate_rust(req: &GenRequest) -> Result> { for (namespace, units) in req.schemas { files.push(GeneratedFile { path: PathBuf::from(format!("src/{namespace}.rs")), - contents: schema_source(units), + contents: schema_source(units, default_framing), }); } Ok(files) @@ -107,7 +108,7 @@ fn lib_rs(schemas: &[(String, Vec)]) -> String { s } -fn schema_source(units: &[FrozenUnit]) -> String { +fn schema_source(units: &[FrozenUnit], default_framing: Option<&str>) -> String { let has_protocol = units.iter().any(|u| matches!(u, FrozenUnit::Protocol { .. })); let errors = error_type_names(units); @@ -116,7 +117,9 @@ fn schema_source(units: &[FrozenUnit]) -> String { // 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)), + FrozenUnit::Protocol { parameters, .. } => { + Some(framing_choice(parameters, default_framing)) + } _ => None, }) }; @@ -194,7 +197,13 @@ fn schema_source(units: &[FrozenUnit]) -> String { parameters, .. } => { - output.push_str(&protocol(name, functions, parameters, &errors)); + output.push_str(&protocol( + name, + functions, + parameters, + default_framing, + &errors, + )); } _ => {} } @@ -326,28 +335,44 @@ struct FramingChoice { 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 { +/// The datagram default — the generated helpers use `Client::connect` / +/// `Server::new` and the `Client` alias keeps its two type parameters. +const DATAGRAM_FRAMING: FramingChoice = FramingChoice { + ty: "comline_runtime::contract::DatagramFraming", + explicit: false, +}; + +/// Map one framing name to a choice. `None` for a name this generator does not +/// know (the caller falls through to the next level). +fn framing_for(name: &str) -> Option { + match name { + "jsonrpc" | "json-rpc" | "jsonrpc-2.0" => Some(FramingChoice { ty: "comline_runtime::framing::JsonRpcFraming", explicit: true, - }, - _ => FramingChoice { - ty: "comline_runtime::contract::DatagramFraming", - explicit: false, - }, + }), + "datagram" | "comline.datagram" => Some(DATAGRAM_FRAMING), + _ => None, } } +/// Resolve a protocol's framing, most specific first: its own `@framing` +/// annotation, then the package `default_framing` (`comline.toml`), then the +/// datagram default. An unrecognised name at either level falls through. +fn framing_choice(parameters: &[FrozenUnit], default_framing: Option<&str>) -> FramingChoice { + annotation(parameters, "framing") + .and_then(framing_for) + .or_else(|| default_framing.and_then(framing_for)) + .unwrap_or(DATAGRAM_FRAMING) +} + fn protocol( proto: &str, functions: &[FrozenUnit], parameters: &[FrozenUnit], + default_framing: Option<&str>, errors: &HashMap, ) -> String { - let framing = framing_choice(parameters); + let framing = framing_choice(parameters, default_framing); let fns: Vec = functions .iter() .filter_map(|f| match f { diff --git a/codegen/tests/compiles.rs b/codegen/tests/compiles.rs index c630644..e5f16d8 100644 --- a/codegen/tests/compiles.rs +++ b/codegen/tests/compiles.rs @@ -52,7 +52,9 @@ fn function( /// A schema with a struct, an `error`, and a protocol exercising: a throwing /// call, a non-throwing call returning a list, a zero-arg call, and a -/// `KindValue::Unit` return. +/// `KindValue::Unit` return. `@framing = "datagram"` keeps it on the datagram +/// stack even though the compile test sets a `jsonrpc` package default — so the +/// datagram path (with all this machinery) still gets built. fn chat_schema() -> Vec { vec![ FrozenUnit::Struct { @@ -73,7 +75,10 @@ fn chat_schema() -> Vec { }, FrozenUnit::Protocol { docstring: "Chat".into(), - parameters: vec![], + parameters: vec![FrozenUnit::Property { + name: "framing".into(), + expression: Some("datagram".into()), + }], name: "Chat".into(), functions: vec![ function( @@ -149,11 +154,29 @@ fn rpc_schema() -> Vec { }] } +/// A plain protocol with no `@framing` — it rides the `jsonrpc` package default +/// the test sets, so the default-driven JSON-RPC output also gets compiled. +fn rpc_default_schema() -> Vec { + vec![FrozenUnit::Protocol { + docstring: "Clock".into(), + parameters: vec![], + name: "Clock".into(), + functions: vec![function( + "now", + vec![arg("tz", KindValue::Namespaced("string".into(), None))], + Some(KindValue::Primitive(Primitive::U64(None))), + vec![], + )], + span: (0, 0), + }] +} + #[test] fn a_generated_protocol_crate_builds() { let schemas = vec![ ("chat".to_string(), chat_schema()), ("rpc".to_string(), rpc_schema()), + ("clock".to_string(), rpc_default_schema()), ]; let req = GenRequest { mode: Mode::Lib, @@ -162,6 +185,9 @@ fn a_generated_protocol_crate_builds() { name: "comline-codegen-rust-compiletest".into(), version: "0.0.0".into(), }, + // `chat` opts back to datagram with `@framing = "datagram"`; `clock` + // (unannotated) takes this default; `rpc` names `jsonrpc` itself. + default_framing: Some("jsonrpc".into()), }; let files = generate_rust(&req).expect("generation"); diff --git a/codegen/tests/generate.rs b/codegen/tests/generate.rs index 82bd9b1..0b6f1d2 100644 --- a/codegen/tests/generate.rs +++ b/codegen/tests/generate.rs @@ -8,6 +8,7 @@ fn code_req(schemas: &[(String, Vec)]) -> GenRequest<'_> { mode: Mode::Code, schemas, package: PackageMeta { name: "test".into(), version: "0.1.0".into() }, + default_framing: None, } } @@ -16,6 +17,7 @@ fn lib_req(schemas: &[(String, Vec)]) -> GenRequest<'_> { mode: Mode::Lib, schemas, package: PackageMeta { name: "chat".into(), version: "0.3.0".into() }, + default_framing: None, } } @@ -325,6 +327,70 @@ fn framing_annotation_selects_jsonrpc_for_the_connect_and_serve_helpers() { assert!(!src.contains("Client::connect(transport, format, hs)")); } +/// A protocol with no `@framing` of its own, plus a plain (annotated) one for +/// contrast. +fn one_plain_protocol(name: &str) -> Vec { + vec![FrozenUnit::Protocol { + docstring: name.to_string(), + parameters: vec![], + name: name.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), + }] +} + +#[test] +fn package_default_framing_applies_when_a_protocol_has_no_annotation() { + let schemas = vec![("rpc".to_string(), one_plain_protocol("Rpc"))]; + let req = GenRequest { + default_framing: Some("jsonrpc".to_string()), + ..code_req(&schemas) + }; + let src = generate_rust(&req).unwrap().remove(0).contents; + + assert!(src.contains( + "pub struct RpcClient(pub Client);" + )); + assert!(src.contains("Server::with_framing(self, format, framing).serve_handshaked")); + assert!(!src.contains("FRAMING_DATAGRAM")); +} + +#[test] +fn an_explicit_datagram_annotation_opts_out_of_the_package_default() { + // @framing = "datagram" on the protocol, package default is jsonrpc. + let mut units = one_plain_protocol("Rpc"); + if let FrozenUnit::Protocol { parameters, .. } = &mut units[0] { + parameters.push(FrozenUnit::Property { + name: "framing".to_string(), + expression: Some("datagram".to_string()), + }); + } + let schemas = vec![("rpc".to_string(), units)]; + let with_default = GenRequest { + default_framing: Some("jsonrpc".to_string()), + ..code_req(&schemas) + }; + let src = generate_rust(&with_default).unwrap().remove(0).contents; + + // the datagram stack — the `@framing = "datagram"` opt-out beats the + // `jsonrpc` package default (the `IR_HASH` still moves: the annotation is + // part of the frozen IR) + assert!(!src.contains("JsonRpcFraming")); + assert!(!src.contains("Framing,")); // trait not imported + assert!(src.contains("pub struct RpcClient(pub Client);")); + assert!(src.contains("let hs = Handshake::new(IR_HASH, format.name(), FRAMING_DATAGRAM, 0);")); + assert!(src.contains("Server::new(self, format).serve_handshaked(transport, hs)")); + assert!(src.contains("Ok(Self(Client::connect(transport, format, hs)?))")); +} + #[test] fn an_all_one_way_protocol_leaves_the_dispatch_reply_param_unbound() { let proto = FrozenUnit::Protocol {