Skip to main content

krabka_protocol_codegen/
resolve.rs

1//! Classify `PascalCase` type references in a `MessageSpec` as nested,
2//! common, or unknown.
3//!
4//! The emitter uses this classification to compute the Rust type path.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::{
9    ir::{FieldSpec, MessageSpec},
10    name_conv,
11};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum StructKind {
15    /// Inline-defined under a field in the same message. The emitter writes
16    /// it as a sibling type in the same file.
17    Nested,
18    /// Top-level `commonStructs` entry on the parent spec. The emitter writes
19    /// it into the shared `common/` module.
20    Common,
21}
22
23#[derive(Debug, Clone)]
24pub struct Resolution {
25    pub kind: StructKind,
26    /// The path to use in generated code (owned flavor without `<'a>`).
27    pub rust_path: String,
28    /// Whether the borrowed-flavor type for this struct carries a `'a` lifetime
29    /// parameter (true for common structs with string/bytes/records fields, and
30    /// for nested structs whose fields recursively need a lifetime).
31    pub needs_lifetime: bool,
32}
33
34#[derive(Debug, thiserror::Error)]
35pub enum ResolveError {
36    #[error("unresolved type reference `{type_name}` in message `{message}`")]
37    Unknown { message: String, type_name: String },
38}
39
40fn base_type(t: &str) -> &str {
41    t.strip_prefix("[]").unwrap_or(t)
42}
43
44fn is_struct_type(t: &str) -> bool {
45    t.chars().next().is_some_and(char::is_uppercase)
46}
47
48/// Returns true if any field, at any depth, carries a borrowed lifetime in the
49/// generated Rust type. Those fields are `string`, `bytes`, `records`, or a
50/// nested struct whose own fields need a lifetime at any depth.
51///
52/// For a common-struct reference, which is a `PascalCase` type where
53/// `f.fields.is_empty()`, the caller must consult the resolution map under
54/// construction. This function handles those separately.
55fn fields_need_lifetime(fields: &[FieldSpec]) -> bool {
56    fields.iter().any(|f| {
57        let base = base_type(&f.field_type);
58        matches!(base, "string" | "bytes" | "records")
59            || (!f.fields.is_empty() && fields_need_lifetime(&f.fields))
60    })
61}
62
63/// Walk fields to find inline-defined nested structs (those with `fields:`).
64fn walk(fields: &[FieldSpec], map: &mut HashMap<String, Resolution>) {
65    for f in fields {
66        if !f.fields.is_empty() {
67            let type_name = base_type(&f.field_type);
68            let nl = fields_need_lifetime(&f.fields);
69            map.insert(
70                type_name.to_owned(),
71                Resolution {
72                    kind: StructKind::Nested,
73                    rust_path: type_name.to_owned(),
74                    needs_lifetime: nl,
75                },
76            );
77            walk(&f.fields, map);
78        }
79    }
80}
81
82/// Walk fields again to verify every struct-typed reference resolves.
83fn check(
84    fields: &[FieldSpec],
85    map: &HashMap<String, Resolution>,
86    message: &str,
87) -> Result<(), ResolveError> {
88    for f in fields {
89        let base = base_type(&f.field_type);
90        if is_struct_type(base) && !map.contains_key(base) {
91            return Err(ResolveError::Unknown {
92                message: message.to_owned(),
93                type_name: base.to_owned(),
94            });
95        }
96        check(&f.fields, map, message)?;
97    }
98    Ok(())
99}
100
101/// Compute whether a common struct needs a `<'a>` lifetime. Its fields may
102/// themselves reference other common structs by name, with empty `f.fields`.
103/// This function runs a simple fixpoint over the set of common struct names.
104fn common_struct_needs_lifetime(
105    cs_fields: &[FieldSpec],
106    common_names_needing_lifetime: &HashSet<String>,
107) -> bool {
108    cs_fields.iter().any(|f| {
109        let base = base_type(&f.field_type);
110        matches!(base, "string" | "bytes" | "records")
111            || (!f.fields.is_empty()
112                && common_struct_needs_lifetime(&f.fields, common_names_needing_lifetime))
113            || (is_struct_type(base)
114                && f.fields.is_empty()
115                && common_names_needing_lifetime.contains(base))
116    })
117}
118
119/// Build a resolution map for one message. The map takes each `PascalCase`
120/// type name referenced anywhere in the field tree to its kind and Rust path.
121/// # Errors
122/// Returns an error when the schema model is invalid or generated Rust cannot be formatted or written.
123pub fn resolve_message(spec: &MessageSpec) -> Result<HashMap<String, Resolution>, ResolveError> {
124    let mut map = HashMap::new();
125
126    // ── Pass 1: compute which common structs need '<'a>' via fixpoint ─────────
127    // Start with those that directly have string/bytes/records fields, then
128    // expand transitively (a common struct that references a lifetime-bearing
129    // common struct also needs '<'a>').
130    let mut cs_needing_lt = HashSet::new();
131    let mut changed = true;
132    while changed {
133        changed = false;
134        for cs in &spec.common_structs {
135            if cs_needing_lt.contains(&cs.name) {
136                continue; // already known
137            }
138            if common_struct_needs_lifetime(&cs.fields, &cs_needing_lt) {
139                cs_needing_lt.insert(cs.name.clone());
140                changed = true;
141            }
142        }
143    }
144
145    // ── Pass 2: insert common struct resolutions with correct needs_lifetime ──
146    // Common structs first — they win if there's a name collision with a nested
147    // (in practice this doesn't happen but we don't need to enforce it).
148    //
149    // `commonStructs` are MESSAGE-LOCAL in Kafka schemas: two different messages
150    // may each declare a struct of the same name with different fields. We scope
151    // every common struct under its owning message so identically-named structs
152    // never collide. The wrapper module nests as
153    // `src/{flavor}/common/<message_snake>/<struct_snake>.rs`, so the path from a
154    // message file is `super::common::<message_snake>::<struct_snake>::TypeName`.
155    let message_snake = name_conv::module_name(&spec.name);
156    for cs in &spec.common_structs {
157        let snake = name_conv::module_name(&cs.name);
158        map.insert(
159            cs.name.clone(),
160            Resolution {
161                kind: StructKind::Common,
162                rust_path: format!("super::common::{message_snake}::{snake}::{}", cs.name),
163                needs_lifetime: cs_needing_lt.contains(&cs.name),
164            },
165        );
166    }
167
168    walk(&spec.fields, &mut map);
169    check(&spec.fields, &map, &spec.name)?;
170
171    Ok(map)
172}
173
174#[cfg(test)]
175mod tests {
176    use std::path::PathBuf;
177
178    use assert2::assert;
179
180    use super::*;
181    use crate::ir;
182
183    fn load(name: &str) -> MessageSpec {
184        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
185            .parent()
186            .unwrap()
187            .join("protocol")
188            .join("schemas");
189        let specs = ir::load_dir(&dir).unwrap();
190        specs.into_iter().find(|s| s.name == name).unwrap()
191    }
192
193    #[test]
194    fn api_versions_request_has_no_nested_structs() {
195        let spec = load("ApiVersionsRequest");
196        let map = resolve_message(&spec).unwrap();
197        assert!(map.is_empty(), "found unexpected struct refs: {map:?}");
198    }
199
200    #[test]
201    fn metadata_request_resolves_topics() {
202        let spec = load("MetadataRequest");
203        let map = resolve_message(&spec).unwrap();
204        // MetadataRequest declares a nested MetadataRequestTopic struct.
205        assert!(
206            map.contains_key("MetadataRequestTopic"),
207            "did not resolve MetadataRequestTopic: {map:?}"
208        );
209    }
210}