Skip to main content

wesley_emit_codec/
lib.rs

1//! Language-neutral LE-binary codec plan.
2//!
3//! This crate lowers a Wesley L1 IR into a small, target-agnostic description of
4//! *what* the codec does — which values are scalars, enums, nested structs,
5//! options, or lists, and in what field order. The Rust and TypeScript emitters
6//! consume this one plan and only decide *how* to spell it in their language, so
7//! the structural logic (nullable → option, list → length-prefixed, scalar →
8//! primitive, named type → sub-codec) lives in exactly one place and the two
9//! backends cannot drift on the wire.
10//!
11//! Names in the plan are the **source** GraphQL names; each backend applies its
12//! own casing (`rust_field_name`, `pascalCase`, …).
13
14#![forbid(unsafe_code)]
15#![deny(missing_docs)]
16
17use std::collections::BTreeSet;
18
19use wesley_core::{OperationType, SchemaOperation, TypeKind, TypeReference, WesleyIR};
20
21/// A primitive wire scalar.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ScalarKind {
24    /// `Boolean` — a single tagged byte.
25    Bool,
26    /// `Int` — `i32` little-endian.
27    Int,
28    /// `Float` — `f32` little-endian.
29    Float,
30    /// `String` / `ID` — `u32` length-prefixed UTF-8.
31    String,
32}
33
34/// How to read or write a single value. Recurses for options and lists; a
35/// [`CodecOp::Named`] defers to another [`CodecDef`]'s codec by source name.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum CodecOp {
38    /// A primitive scalar.
39    Scalar(ScalarKind),
40    /// A reference to another emitted type (enum or struct), by source name.
41    Named(String),
42    /// A nullable value: a presence tag, then the inner value when present.
43    Option(Box<CodecOp>),
44    /// A list: a `u32` element count, then each element in turn.
45    List(Box<CodecOp>),
46}
47
48/// One field of a struct, in wire (declaration) order.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct FieldPlan {
51    /// The source field/argument name (backends apply their own casing).
52    pub name: String,
53    /// The codec for this field's value.
54    pub op: CodecOp,
55}
56
57/// Which SDL declaration a struct codec came from. Carried so a backend can
58/// label the declaration in its own idiom; it does not affect the wire format.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum StructKind {
61    /// A GraphQL `input` object.
62    Input,
63    /// A GraphQL output `type`.
64    Object,
65    /// A GraphQL `interface`.
66    Interface,
67}
68
69/// A top-level codec, emitted as an `encode_*` / `decode_*` pair.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum CodecDef {
72    /// An enum: encoded as the zero-based variant ordinal (`u32` LE).
73    Enum {
74        /// Source type name.
75        name: String,
76        /// Variant names in declaration (ordinal) order.
77        variants: Vec<String>,
78    },
79    /// A struct (input object, output object, or interface): its fields in order.
80    Struct {
81        /// Source type name.
82        name: String,
83        /// Which SDL declaration it came from.
84        kind: StructKind,
85        /// Fields in wire order.
86        fields: Vec<FieldPlan>,
87    },
88    /// An operation's variables. The struct is named differently per language
89    /// (TypeScript `<field>Vars`, Rust `<Operation><Field>Request`), so the plan
90    /// carries the raw operation identity and lets each backend name it.
91    Operation {
92        /// The operation kind.
93        operation_type: OperationType,
94        /// The root field name.
95        field_name: String,
96        /// The operation arguments, in wire order.
97        fields: Vec<FieldPlan>,
98    },
99}
100
101/// Lower a Wesley L1 IR and its operations into the codec plan.
102///
103/// Enums, input objects, and output objects each become a [`CodecDef`];
104/// operation root types (Query / Mutation / Subscription) are skipped as data
105/// types but their variables become [`CodecDef::Operation`].
106#[must_use]
107pub fn plan(ir: &WesleyIR, operations: &[SchemaOperation]) -> Vec<CodecDef> {
108    let root_type_names: BTreeSet<&str> = operations
109        .iter()
110        .map(|operation| operation.root_type_name.as_str())
111        .collect();
112
113    let mut defs = Vec::new();
114    for type_def in &ir.types {
115        match type_def.kind {
116            TypeKind::Enum => defs.push(CodecDef::Enum {
117                name: type_def.name.clone(),
118                variants: type_def.enum_values.clone(),
119            }),
120            TypeKind::InputObject | TypeKind::Object | TypeKind::Interface
121                if !root_type_names.contains(type_def.name.as_str()) =>
122            {
123                defs.push(CodecDef::Struct {
124                    name: type_def.name.clone(),
125                    kind: match type_def.kind {
126                        TypeKind::InputObject => StructKind::Input,
127                        TypeKind::Interface => StructKind::Interface,
128                        _ => StructKind::Object,
129                    },
130                    fields: type_def
131                        .fields
132                        .iter()
133                        .map(|field| FieldPlan {
134                            name: field.name.clone(),
135                            op: op_for(&field.r#type),
136                        })
137                        .collect(),
138                });
139            }
140            _ => {}
141        }
142    }
143
144    for operation in operations {
145        defs.push(CodecDef::Operation {
146            operation_type: operation.operation_type,
147            field_name: operation.field_name.clone(),
148            fields: operation
149                .arguments
150                .iter()
151                .map(|argument| FieldPlan {
152                    name: argument.name.clone(),
153                    op: op_for(&argument.r#type),
154                })
155                .collect(),
156        });
157    }
158
159    defs
160}
161
162/// Lower one [`TypeReference`] into a [`CodecOp`]: nullable wraps in an option,
163/// a list wraps its element, and a bare leaf is a scalar or a named sub-codec.
164fn op_for(ty: &TypeReference) -> CodecOp {
165    if ty.nullable {
166        let mut inner = ty.clone();
167        inner.nullable = false;
168        return CodecOp::Option(Box::new(op_for(&inner)));
169    }
170    if let Some(item) = list_item_type_reference(ty) {
171        return CodecOp::List(Box::new(op_for(&item)));
172    }
173    match ty.base.as_str() {
174        "Boolean" => CodecOp::Scalar(ScalarKind::Bool),
175        "Int" => CodecOp::Scalar(ScalarKind::Int),
176        "Float" => CodecOp::Scalar(ScalarKind::Float),
177        "String" | "ID" => CodecOp::Scalar(ScalarKind::String),
178        other => CodecOp::Named(other.to_string()),
179    }
180}
181
182/// Peel one list wrapper off `ty`, yielding the element's [`TypeReference`], or
183/// `None` if `ty` is not a list. The single source of the nested list /
184/// nullability rules both emitters previously duplicated.
185fn list_item_type_reference(ty: &TypeReference) -> Option<TypeReference> {
186    if !ty.is_list {
187        return None;
188    }
189
190    if ty.list_wrappers.is_empty() {
191        return Some(TypeReference {
192            base: ty.base.clone(),
193            nullable: ty.list_item_nullable.unwrap_or(true),
194            is_list: false,
195            list_item_nullable: None,
196            list_wrappers: Vec::new(),
197            leaf_nullable: None,
198        });
199    }
200
201    let remaining = ty.list_wrappers[1..].to_vec();
202    if remaining.is_empty() {
203        return Some(TypeReference {
204            base: ty.base.clone(),
205            nullable: ty.leaf_nullable.unwrap_or(true),
206            is_list: false,
207            list_item_nullable: None,
208            list_wrappers: Vec::new(),
209            leaf_nullable: None,
210        });
211    }
212
213    let next_nullable = remaining
214        .get(1)
215        .map(|wrapper| wrapper.nullable)
216        .unwrap_or_else(|| ty.leaf_nullable.unwrap_or(true));
217    Some(TypeReference {
218        base: ty.base.clone(),
219        nullable: remaining[0].nullable,
220        is_list: true,
221        list_item_nullable: Some(next_nullable),
222        list_wrappers: remaining,
223        leaf_nullable: ty.leaf_nullable,
224    })
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use wesley_core::{list_schema_operations_sdl, lower_schema_sdl};
231
232    fn named(defs: &[CodecDef], name: &str) -> CodecDef {
233        defs.iter()
234            .find(|def| match def {
235                CodecDef::Enum { name: n, .. } | CodecDef::Struct { name: n, .. } => n == name,
236                CodecDef::Operation { field_name, .. } => field_name == name,
237            })
238            .cloned()
239            .unwrap_or_else(|| panic!("no codec def named {name}"))
240    }
241
242    #[test]
243    fn lowers_scalars_enums_options_and_nested_lists() {
244        let sdl = include_str!("../../../test/fixtures/typescript-emitter/le-binary-codec.graphql");
245        let ir = lower_schema_sdl(sdl).expect("schema lowers");
246        let ops = list_schema_operations_sdl(sdl).expect("operations enumerable");
247        let defs = plan(&ir, &ops);
248
249        // Enum carries its variants in ordinal order.
250        assert_eq!(
251            named(&defs, "Color"),
252            CodecDef::Enum {
253                name: "Color".to_string(),
254                variants: vec!["RED".into(), "GREEN".into(), "BLUE".into()],
255            }
256        );
257
258        // The input object lowers each field's nullability/list structure.
259        let CodecDef::Struct { fields, .. } = named(&defs, "MakeWidgetInput") else {
260            panic!("MakeWidgetInput is a struct");
261        };
262        let op_of = |name: &str| fields.iter().find(|f| f.name == name).unwrap().op.clone();
263        assert_eq!(op_of("label"), CodecOp::Scalar(ScalarKind::String));
264        assert_eq!(op_of("count"), CodecOp::Scalar(ScalarKind::Int));
265        assert_eq!(
266            op_of("color"),
267            CodecOp::Option(Box::new(CodecOp::Named("Color".to_string())))
268        );
269        assert_eq!(
270            op_of("tags"),
271            CodecOp::List(Box::new(CodecOp::Scalar(ScalarKind::String)))
272        );
273        // [[Int!]!]! → list of list of int.
274        assert_eq!(
275            op_of("matrix"),
276            CodecOp::List(Box::new(CodecOp::List(Box::new(CodecOp::Scalar(
277                ScalarKind::Int
278            )))))
279        );
280
281        // Output object `type Widget` is planned; operation roots are not.
282        assert!(matches!(named(&defs, "Widget"), CodecDef::Struct { .. }));
283        assert!(!defs.iter().any(|def| matches!(
284            def,
285            CodecDef::Struct { name, .. } if name == "Mutation" || name == "Query"
286        )));
287
288        // The mutation's variables are planned as an operation.
289        assert!(matches!(
290            named(&defs, "makeWidget"),
291            CodecDef::Operation { .. }
292        ));
293    }
294}