Skip to main content

ferroui_hot_patch/
admission.rs

1use quote::ToTokens;
2use std::collections::BTreeSet;
3use syn::visit::Visit;
4#[derive(Debug, PartialEq, Eq)]
5pub enum Admission {
6    Unchanged,
7    Patch { functions: Vec<String> },
8    RestartRequired(String),
9}
10/// Only top-level, registered, synchronous free-function bodies may change. Signatures,
11/// attributes and every other token must remain identical. Unknown syntax fails closed.
12pub fn admit(before: &str, after: &str, registered: &BTreeSet<String>) -> Admission {
13    let (Ok(mut old), Ok(mut new)) = (syn::parse_file(before), syn::parse_file(after)) else {
14        return Admission::RestartRequired("Rust source cannot be parsed".into());
15    };
16    if old.to_token_stream().to_string() == new.to_token_stream().to_string() {
17        return Admission::Unchanged;
18    }
19    let mut changed = Vec::new();
20    for item in &mut old.items {
21        let syn::Item::Fn(previous) = item else {
22            continue;
23        };
24        let name = previous.sig.ident.to_string();
25        if !registered.contains(&name) {
26            continue;
27        }
28        let Some(syn::Item::Fn(next)) = new.items.iter_mut().find(|item| matches!(item, syn::Item::Fn(function) if function.sig.ident == previous.sig.ident)) else {
29            return Admission::RestartRequired(format!("Registered function removed: {name}"));
30        };
31        if previous.block.to_token_stream().to_string() == next.block.to_token_stream().to_string()
32        {
33            continue;
34        }
35        if unsupported_signature(&previous.sig) {
36            return Admission::RestartRequired(format!("Unsupported patch boundary: {name}"));
37        }
38        for body in [&previous.block, &next.block] {
39            if unsupported_body(body) {
40                return Admission::RestartRequired(format!(
41                    "Closure, macro, async block, unsafe block or declaration in {name}"
42                ));
43            }
44        }
45        previous.block = Box::new(syn::parse_quote!({}));
46        next.block = Box::new(syn::parse_quote!({}));
47        changed.push(name);
48    }
49    if old.to_token_stream().to_string() != new.to_token_stream().to_string() || changed.is_empty()
50    {
51        Admission::RestartRequired("Change outside registered function bodies".into())
52    } else {
53        Admission::Patch { functions: changed }
54    }
55}
56fn unsupported_signature(signature: &syn::Signature) -> bool {
57    signature.asyncness.is_some()
58        || signature.unsafety.is_some()
59        || signature.constness.is_some()
60        || !signature.generics.params.is_empty()
61}
62fn unsupported_body(body: &syn::Block) -> bool {
63    let mut unsupported = Unsupported(false);
64    unsupported.visit_block(body);
65    unsupported.0
66}
67pub(crate) fn validate_registration(source: &str, name: &str) -> Result<(), String> {
68    let file = syn::parse_file(source).map_err(|error| error.to_string())?;
69    let function = file
70        .items
71        .iter()
72        .find_map(|item| match item {
73            syn::Item::Fn(function) if function.sig.ident == name => Some(function),
74            _ => None,
75        })
76        .ok_or_else(|| format!("Registered top-level function not found: {name}"))?;
77    if unsupported_signature(&function.sig) || unsupported_body(&function.block) {
78        return Err(format!("Unsupported patch boundary: {name}"));
79    }
80    Ok(())
81}
82struct Unsupported(bool);
83impl<'ast> Visit<'ast> for Unsupported {
84    fn visit_expr_closure(&mut self, _: &'ast syn::ExprClosure) {
85        self.0 = true;
86    }
87    fn visit_expr_async(&mut self, _: &'ast syn::ExprAsync) {
88        self.0 = true;
89    }
90    fn visit_expr_unsafe(&mut self, _: &'ast syn::ExprUnsafe) {
91        self.0 = true;
92    }
93    fn visit_item(&mut self, _: &'ast syn::Item) {
94        self.0 = true;
95    }
96    fn visit_macro(&mut self, _: &'ast syn::Macro) {
97        self.0 = true;
98    }
99}
100#[cfg(test)]
101mod tests {
102    use super::*;
103    fn check(old: &str, new: &str) -> Admission {
104        admit(old, new, &BTreeSet::from(["click".into()]))
105    }
106    #[test]
107    fn admits_body_change() {
108        assert!(matches!(
109            check(
110                "fn click(x: i32) -> i32 { x+1 }",
111                "fn click(x: i32) -> i32 { x+2 }"
112            ),
113            Admission::Patch { .. }
114        ));
115    }
116    #[test]
117    fn rejects_signature() {
118        assert!(matches!(
119            check("fn click(x: i32) { }", "fn click(x: i64) { }"),
120            Admission::RestartRequired(_)
121        ));
122    }
123    #[test]
124    fn rejects_layout_and_static() {
125        for addition in [
126            "struct State { x: i64 }",
127            "static VALUE: i32 = 1;",
128            "fn other() {}",
129        ] {
130            assert!(matches!(
131                check("fn click() {}", &format!("fn click() {{}} {addition}")),
132                Admission::RestartRequired(_)
133            ));
134        }
135    }
136    #[test]
137    fn rejects_closure_and_macro() {
138        for body in [
139            "let f = || 1;",
140            "println!(\"changed\");",
141            "struct New;",
142            "let f = async { 1 };",
143            "unsafe {}",
144        ] {
145            assert!(matches!(
146                check("fn click() {}", &format!("fn click() {{ {body} }}")),
147                Admission::RestartRequired(_)
148            ));
149        }
150    }
151    #[test]
152    fn ignores_whitespace() {
153        assert_eq!(
154            check("fn click() {}", "fn click( ) { }"),
155            Admission::Unchanged
156        );
157    }
158}