Skip to main content

krabka_protocol_codegen/
fmt.rs

1//! Format generated Rust source: pretty-print the AST with `prettyplease`,
2//! then refine through `rustfmt`.
3//!
4//! The quote-based emitters build a `proc_macro2::TokenStream` and render it
5//! with `TokenStream::to_string()`, which is valid but unformatted Rust (no
6//! line breaks, ` :: ` / ` . ` spacing). rustfmt alone is not enough. When a
7//! line exceeds `max_width` and cannot be broken, for example the long
8//! fully-qualified paths in `to_owned` such as
9//! `crate::owned::common::<msg>::<struct>::TypeName`, rustfmt *gives up on
10//! that item and emits it verbatim*. That leaves the raw `quote!` ` :: `
11//! spacing.
12//!
13//! `prettyplease` pretty-prints the `syn` AST and always renders canonical,
14//! correctly-spaced source whatever the line length, so this module runs it
15//! first. The rustfmt pass then applies the project's canonical style to
16//! everything it *can* fit, and leaves the unbreakable long lines clean, as
17//! prettyplease rendered them. Both passes also work as validation. Malformed
18//! generated code fails to parse or exits non-zero here, so codegen bugs
19//! surface at regeneration time rather than three crates downstream.
20//!
21//! Between parsing and pretty-printing, [`hoist::hoist_absolute_paths`]
22//! rewrites the long crate-rooted paths the emitters write into module-scope
23//! `use` items, so the committed code passes `clippy::absolute_paths`.
24
25use std::{
26    io::{self, Write},
27    process::{Command, Stdio},
28};
29
30use crate::hoist;
31
32#[derive(Debug, thiserror::Error)]
33pub enum FmtError {
34    #[error("spawning rustfmt (is it on PATH? `rustup component add rustfmt`): {0}")]
35    Spawn(#[source] io::Error),
36    #[error("rustfmt I/O: {0}")]
37    Io(#[source] io::Error),
38    #[error("rustfmt rejected generated source (status {status}):\n{stderr}")]
39    Rejected { status: String, stderr: String },
40    #[error("rustfmt started without a stdin pipe")]
41    NoStdin,
42}
43
44/// A pass that turns generated source into the text the generator writes:
45/// [`rustfmt`] for the committed tree, or one built on [`prettyprint`] where
46/// no rustfmt binary is available.
47pub type Formatter = fn(&str) -> Result<String, FmtError>;
48
49/// Hoist long paths into imports, pretty-print `src` with prettyplease,
50/// refine it through `rustfmt` at edition 2024, and return the formatted
51/// output.
52///
53/// `src` must be a complete Rust source file, which every generated file is.
54/// This function keeps the leading `//`-style banner comment. `syn` discards
55/// line comments, so the function removes the banner before it pretty-prints
56/// and re-attaches the banner afterwards.
57/// # Errors
58/// Returns an error when the schema model is invalid or generated Rust cannot be formatted or written.
59pub fn rustfmt(src: &str) -> Result<String, FmtError> {
60    run_rustfmt(&prettyprint(src))
61}
62
63/// The first pass of [`rustfmt`]: hoist long paths into imports and
64/// pretty-print `src` with prettyplease, keeping its leading banner.
65///
66/// prettyplease normalizes spacing and breaks lines rustfmt would otherwise
67/// give up on. If the body does not parse (a real codegen bug), this function
68/// returns `src` unchanged, so the rustfmt pass surfaces the precise error.
69#[must_use]
70pub fn prettyprint(src: &str) -> String {
71    let (banner, body) = split_banner(src);
72    let body = syn::parse_file(body).map_or_else(
73        |_| body.to_owned(),
74        |mut file| {
75            hoist::hoist_absolute_paths(&mut file);
76            prettyplease::unparse(&file)
77        },
78    );
79    format!("{banner}{body}")
80}
81
82/// Split the leading banner block off from the code body. The banner is the
83/// contiguous run of blank and `//`-comment lines at the top of a generated
84/// file.
85fn split_banner(src: &str) -> (&str, &str) {
86    let mut idx = 0;
87    for line in src.split_inclusive('\n') {
88        let trimmed = line.trim_start();
89        if trimmed.is_empty() || trimmed.starts_with("//") {
90            idx += line.len();
91        } else {
92            break;
93        }
94    }
95    src.split_at(idx)
96}
97
98fn run_rustfmt(src: &str) -> Result<String, FmtError> {
99    let mut child = Command::new("rustfmt")
100        .args([
101            "--edition",
102            "2024",
103            "--emit",
104            "stdout",
105            "--quiet",
106            "--config",
107            "max_width=240",
108        ])
109        .stdin(Stdio::piped())
110        .stdout(Stdio::piped())
111        .stderr(Stdio::piped())
112        .spawn()
113        .map_err(FmtError::Spawn)?;
114    child
115        .stdin
116        .take()
117        .ok_or(FmtError::NoStdin)?
118        .write_all(src.as_bytes())
119        .map_err(FmtError::Io)?;
120    let out = child.wait_with_output().map_err(FmtError::Io)?;
121    if !out.status.success() {
122        return Err(FmtError::Rejected {
123            status: out.status.to_string(),
124            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
125        });
126    }
127    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
128}
129
130#[cfg(test)]
131mod tests {
132    use assert2::{assert, check};
133
134    use super::*;
135
136    #[test]
137    fn split_banner_separates_leading_comment_block() {
138        let src = "// AUTO-GENERATED foo\n// line two\n\npub struct X;\n";
139        let (banner, body) = split_banner(src);
140        assert!(banner == "// AUTO-GENERATED foo\n// line two\n\n");
141        assert!(body == "pub struct X;\n");
142    }
143
144    #[test]
145    fn split_banner_handles_no_banner() {
146        let src = "pub struct X;\n";
147        let (banner, body) = split_banner(src);
148        assert!(banner.is_empty());
149        assert!(body == src);
150    }
151
152    /// Regression guard for the ` :: ` / ` . ` spacing bug: a `to_owned` whose
153    /// fully-qualified return path is too long for rustfmt to fit in `max_width`.
154    /// rustfmt gives up and emits the raw `quote!` token spacing verbatim.
155    /// prettyplease must render it cleanly, and the banner must survive.
156    #[test]
157    fn formats_long_to_owned_path_without_spaced_tokens() {
158        let banner = "// AUTO-GENERATED against deadbeef. Do not edit.\n\n";
159        let body = "impl Foo { pub fn to_owned (& self) -> crate :: owned :: common :: \
160            some_very_long_owning_message_name :: some_very_long_common_struct_name :: \
161            SomeVeryLongCommonStructName { crate :: owned :: common :: \
162            some_very_long_owning_message_name :: some_very_long_common_struct_name :: \
163            SomeVeryLongCommonStructName { field_one : (self . field_one) , } } }";
164        let out = rustfmt(&format!("{banner}{body}")).expect("rustfmt+prettyplease");
165        check!(
166            out.starts_with("// AUTO-GENERATED against deadbeef."),
167            "banner must be preserved, got:\n{out}"
168        );
169        check!(!out.contains(" :: "), "spaced `::` survived:\n{out}");
170        check!(!out.contains(" . "), "spaced `.` survived:\n{out}");
171    }
172}