Skip to main content

krabka_protocol_codegen/
driver.rs

1//! One generator run: load and validate the schemas, emit every message, and
2//! write the generated tree and its wrapper modules.
3//!
4//! The binary parses the command line and calls [`run`] with
5//! [`fmt::rustfmt`]. Tests call it with a formatter built on
6//! [`fmt::prettyprint`], which needs no rustfmt binary.
7
8use std::{
9    collections::{BTreeMap, BTreeSet},
10    fs, io,
11    path::{Path, PathBuf},
12};
13
14use proc_macro2::Ident;
15use quote::{format_ident, quote};
16
17use crate::{
18    emit::{self, wrappers::Flavor},
19    fmt::{self, Formatter},
20    ir, name_conv,
21    out::{WriteError, Written},
22    validate,
23};
24
25/// An error from one generator run.
26#[derive(Debug, thiserror::Error)]
27pub enum RunError {
28    #[error(transparent)]
29    Ir(#[from] ir::IrError),
30    #[error(transparent)]
31    Validate(#[from] validate::ValidateError),
32    #[error(transparent)]
33    Emit(#[from] emit::EmitError),
34    #[error(transparent)]
35    Io(#[from] io::Error),
36    #[error(transparent)]
37    Fmt(#[from] fmt::FmtError),
38    #[error(transparent)]
39    Write(#[from] WriteError),
40    #[error("schemas/VERSION must contain a `sha:` line")]
41    MissingSha,
42    #[error("usage: codegen [--namespace NAME] <schemas> <out>")]
43    Usage,
44    #[error("common-struct key `{0}` must be `<message_snake>/<struct_snake>`")]
45    CommonStem(String),
46    #[error("a namespaced output directory `{0}` must sit two levels below the protocol crate")]
47    NamespaceOut(PathBuf),
48}
49
50fn read_schemas_sha(schemas: &Path) -> Result<String, RunError> {
51    let version_text = fs::read_to_string(schemas.join("VERSION"))?;
52    let sha = version_text
53        .lines()
54        .find_map(|l| l.strip_prefix("sha: "))
55        .ok_or(RunError::MissingSha)?
56        .to_owned();
57    Ok(sha)
58}
59
60/// Returns true if the schema should be emitted (has at least one valid version).
61fn should_emit(spec: &ir::MessageSpec) -> bool {
62    !spec.valid_versions.is_empty()
63}
64
65/// Derive the `crates/protocol/src` directory from the generated-output dir.
66/// By convention the generated output is `crates/protocol/generated`, and src
67/// is the sibling `src`.
68fn protocol_src_from_out(out: &Path) -> PathBuf {
69    out.parent().unwrap_or(out).join("src")
70}
71
72fn write_wrapper(
73    written: &mut Written,
74    spec: &ir::MessageSpec,
75    flavor: Flavor,
76    schemas_version: &str,
77    protocol_src: &Path,
78    namespace: Option<&str>,
79) -> Result<(), RunError> {
80    let snake = name_conv::module_name(&spec.name);
81    let body = emit::wrappers::emit(spec, flavor, schemas_version, namespace)?;
82    let dir = protocol_src.join(match flavor {
83        Flavor::Owned => "owned",
84        Flavor::Borrowed => "borrowed",
85    });
86    fs::create_dir_all(&dir)?;
87    written.write(dir.join(format!("{snake}.rs")), &body)?;
88    Ok(())
89}
90
91/// Split a common-struct emitter key `<message_snake>/<struct_snake>` into its
92/// two segments.
93fn split_common_stem(stem: &str) -> Result<(&str, &str), RunError> {
94    stem.split_once('/')
95        .ok_or_else(|| RunError::CommonStem(stem.to_owned()))
96}
97
98/// Write the `include!` wrapper for one message-scoped common struct at
99/// `src/[<namespace>/]{flavor}/common/<message_snake>/<struct_snake>.rs`.
100///
101/// The wrapper pulls in the generated body from
102/// `generated/[<namespace>/]common/{flavor}/<message_snake>/<struct_snake>.<suffix>.rs`.
103/// A namespace run writes its bodies below `generated/<namespace>/`, the same
104/// way `wrappers::emit` does for ordinary message files, so the wrapper must
105/// carry the namespace segment too.
106fn write_common_wrapper(
107    written: &mut Written,
108    message_snake: &str,
109    struct_snake: &str,
110    flavor: Flavor,
111    schemas_version: &str,
112    message_src_dir: &Path,
113    namespace: Option<&str>,
114) -> io::Result<()> {
115    let suffix = match flavor {
116        Flavor::Owned => "owned",
117        Flavor::Borrowed => "borrowed",
118    };
119    let path_prefix = namespace.map_or_else(String::new, |ns| format!("{ns}/"));
120    let body = format!(
121        "{}include!(concat!(\n    env!(\"CARGO_MANIFEST_DIR\"),\n    \"/generated/{path_prefix}common/{flavor_dir}/{message_snake}/{struct_snake}.{suffix}.rs\"\n));\n",
122        emit::common::banner(schemas_version),
123        flavor_dir = flavor.dir(),
124    );
125    written.write(message_src_dir.join(format!("{struct_snake}.rs")), &body)?;
126    Ok(())
127}
128
129/// Emit the nested wrapper module tree for one flavor's message-scoped common
130/// structs:
131///
132/// - `src/{flavor}/common/mod.rs`          — `pub mod <message_snake>;` per message
133/// - `src/{flavor}/common/<msg>/mod.rs`    — `pub mod <struct_snake>;` per struct
134/// - `src/{flavor}/common/<msg>/<struct>.rs` — the `include!` wrapper
135///
136/// Returns the number of files written. It does nothing and returns 0 when
137/// `tree` is empty.
138fn write_common_wrapper_tree(
139    written: &mut Written,
140    tree: &BTreeMap<String, BTreeSet<String>>,
141    flavor: Flavor,
142    schemas_version: &str,
143    protocol_src: &Path,
144    namespace: Option<&str>,
145) -> io::Result<usize> {
146    if tree.is_empty() {
147        return Ok(0);
148    }
149    let mut count = 0;
150    let flavor_doc = match flavor {
151        Flavor::Owned => "Owned",
152        Flavor::Borrowed => "Borrowed",
153    };
154    let src_common = protocol_src.join(flavor.dir()).join("common");
155    fs::create_dir_all(&src_common)?;
156
157    let mut message_mods: Vec<Ident> = Vec::new();
158    for (message_snake, structs) in tree {
159        message_mods.push(format_ident!("{message_snake}"));
160        let message_dir = src_common.join(message_snake);
161        fs::create_dir_all(&message_dir)?;
162
163        let mut struct_mods: Vec<Ident> = Vec::new();
164        for struct_snake in structs {
165            write_common_wrapper(
166                written,
167                message_snake,
168                struct_snake,
169                flavor,
170                schemas_version,
171                &message_dir,
172                namespace,
173            )?;
174            struct_mods.push(format_ident!("{struct_snake}"));
175            count += 1;
176        }
177        let message_mod_tokens = quote!(#(pub mod #struct_mods;)*);
178        let message_mod = format!(
179            "{}{message_mod_tokens}",
180            emit::common::banner(schemas_version)
181        );
182        written.write(message_dir.join("mod.rs"), &message_mod)?;
183        count += 1;
184    }
185    let top_doc = format!(" {flavor_doc} common structs, scoped per owning message schema.");
186    let top_tokens = quote!(#![doc = #top_doc] #(pub mod #message_mods;)*);
187    let top_mod = format!("{}{top_tokens}", emit::common::banner(schemas_version));
188    written.write(src_common.join("mod.rs"), &top_mod)?;
189    count += 1;
190    Ok(count)
191}
192
193/// Generate the protocol code for the schemas in `schemas` into `out`, and the
194/// wrapper modules into the protocol crate's `src` beside it.
195///
196/// `out` is `crates/protocol/generated`, or `crates/protocol/generated/<ns>`
197/// for a `namespace` run, whose wrappers land in `src/<ns>`. Every Rust file
198/// goes through `format`. The run then deletes the generated files an earlier
199/// run wrote and this one did not. It returns the number of files written.
200///
201/// # Errors
202/// Returns an error when a schema does not load or validate, an emitter or
203/// `format` fails, or a file cannot be written or pruned.
204pub fn run(
205    schemas: &Path,
206    out: &Path,
207    namespace: Option<&str>,
208    format: Formatter,
209) -> Result<usize, RunError> {
210    let mut written = Written::default();
211    let schemas_sha = read_schemas_sha(schemas)?;
212    let specs = ir::load_dir(schemas)?;
213    validate::validate(&specs)?;
214    fs::create_dir_all(out)?;
215    // Common-struct output directory (owned flavor).
216    let common_owned_dir = out.join("common").join("owned");
217    let common_borrowed_dir = out.join("common").join("borrowed");
218
219    // Derive the protocol/src directory so we can write wrappers and mod.rs.
220    let protocol_src = match namespace {
221        None => protocol_src_from_out(out),
222        // `out` is `crates/protocol/generated/<ns>`.
223        Some(ns) => out
224            .parent()
225            .and_then(Path::parent)
226            .ok_or_else(|| RunError::NamespaceOut(out.to_path_buf()))?
227            .join("src")
228            .join(ns),
229    };
230
231    // Track the message-scoped common structs emitted, as
232    // <message_snake> -> {<struct_snake>, ...}, for the nested wrapper modules.
233    let mut all_common_owned = BTreeMap::<String, BTreeSet<String>>::new();
234    let mut all_common_borrowed = BTreeMap::<String, BTreeSet<String>>::new();
235
236    let mut count = 0;
237    for s in &specs {
238        if !should_emit(s) {
239            continue;
240        }
241        let owned_em = emit::owned_quote::emit(s, &schemas_sha)?;
242        let borrowed_em = emit::borrowed_quote::emit(s, &schemas_sha, namespace)?;
243        written.write_rs(
244            out.join(format!("{}.owned.rs", s.name)),
245            &owned_em.primary,
246            format,
247        )?;
248        written.write_rs(
249            out.join(format!("{}.borrowed.rs", s.name)),
250            &borrowed_em.primary,
251            format,
252        )?;
253        count += 2;
254        // Write common-struct generated bodies. The emitter key is the relative
255        // stem `<message_snake>/<struct_snake>`; bodies land at
256        // `generated/common/<flavor>/<message_snake>/<struct_snake>.<flavor>.rs`.
257        for (stem, body) in &owned_em.commons {
258            let (message_snake, struct_snake) = split_common_stem(stem)?;
259            let dir = common_owned_dir.join(message_snake);
260            fs::create_dir_all(&dir)?;
261            written.write_rs(dir.join(format!("{struct_snake}.owned.rs")), body, format)?;
262            all_common_owned
263                .entry(message_snake.to_owned())
264                .or_default()
265                .insert(struct_snake.to_owned());
266            count += 1;
267        }
268        for (stem, body) in &borrowed_em.commons {
269            let (message_snake, struct_snake) = split_common_stem(stem)?;
270            let dir = common_borrowed_dir.join(message_snake);
271            fs::create_dir_all(&dir)?;
272            written.write_rs(
273                dir.join(format!("{struct_snake}.borrowed.rs")),
274                body,
275                format,
276            )?;
277            all_common_borrowed
278                .entry(message_snake.to_owned())
279                .or_default()
280                .insert(struct_snake.to_owned());
281            count += 1;
282        }
283
284        // Emit wrapper files — overwrite the hand-written wrappers.
285        if emit::wrappers::should_emit_wrapper(s) {
286            write_wrapper(
287                &mut written,
288                s,
289                Flavor::Owned,
290                &schemas_sha,
291                &protocol_src,
292                namespace,
293            )?;
294            write_wrapper(
295                &mut written,
296                s,
297                Flavor::Borrowed,
298                &schemas_sha,
299                &protocol_src,
300                namespace,
301            )?;
302            count += 2;
303        }
304    }
305
306    count += write_module_files(
307        &mut written,
308        &specs,
309        &all_common_owned,
310        &all_common_borrowed,
311        &schemas_sha,
312        &protocol_src,
313        namespace,
314    )?;
315
316    // Always emit the ApiKey enum and differential dispatch table at the top level,
317    // but NOT inside a namespace dir — those files reference top-level types and
318    // belong only in the root generated/ output.
319    if namespace.is_none() {
320        let api_key_src = emit::api_key_enum_quote::emit(&specs, &schemas_sha)?;
321        written.write_rs(out.join("api_key.rs"), &api_key_src, format)?;
322        count += 1;
323
324        // Emit the differential dispatch table for the parameterised sweep test.
325        let diff_table = emit::differential_table::emit(&specs, &schemas_sha)?;
326        written.write_rs(out.join("differential_table.rs"), &diff_table, format)?;
327        count += 1;
328    }
329
330    // Drop the files an earlier schema set produced and this one does not.
331    // The sweep of `out` itself stays flat, because a namespace run owns the
332    // subdirectory below it.
333    written.prune(out, false)?;
334    written.prune(&out.join("common"), true)?;
335    written.prune(&protocol_src.join("owned"), true)?;
336    written.prune(&protocol_src.join("borrowed"), true)?;
337
338    Ok(count)
339}
340
341fn write_module_files(
342    written: &mut Written,
343    specs: &[ir::MessageSpec],
344    common_owned: &BTreeMap<String, BTreeSet<String>>,
345    common_borrowed: &BTreeMap<String, BTreeSet<String>>,
346    schemas_sha: &str,
347    protocol_src: &Path,
348    namespace: Option<&str>,
349) -> Result<usize, RunError> {
350    let mut count = write_common_wrapper_tree(
351        written,
352        common_owned,
353        Flavor::Owned,
354        schemas_sha,
355        protocol_src,
356        namespace,
357    )?;
358    count += write_common_wrapper_tree(
359        written,
360        common_borrowed,
361        Flavor::Borrowed,
362        schemas_sha,
363        protocol_src,
364        namespace,
365    )?;
366    let active: Vec<_> = specs.iter().filter(|spec| should_emit(spec)).collect();
367    let owned = emit::mod_rs::emit(
368        &active,
369        Flavor::Owned,
370        schemas_sha,
371        !common_owned.is_empty(),
372    )?;
373    let borrowed = emit::mod_rs::emit(
374        &active,
375        Flavor::Borrowed,
376        schemas_sha,
377        !common_borrowed.is_empty(),
378    )?;
379    written.write(protocol_src.join("owned/mod.rs"), &owned)?;
380    written.write(protocol_src.join("borrowed/mod.rs"), &borrowed)?;
381    count += 2;
382    if namespace.is_some() {
383        written.write(
384            protocol_src.join("mod.rs"),
385            "pub mod borrowed;\npub mod owned;\n",
386        )?;
387    }
388    Ok(count)
389}