Skip to main content

soothfast_spec/
proto.rs

1//! `.proto` message field reconciliation, parallel to [`crate::reconcile`]:
2//! matches struct fields against a proto message by tag, for consumed (not
3//! served) wire formats with no handler to hang `#[route]` off of. Both
4//! sides are read straight from source, so neither can drift.
5
6use std::collections::BTreeMap;
7
8/// One field, from either a `.proto` message or a struct's `#[prost(..)]`
9/// attributes. `ty` is the bare type token (`"string"`, `"sint64"`, ...).
10#[derive(Debug, Clone, PartialEq)]
11pub struct ProtoField {
12    pub name: String,
13    pub ty: String,
14    pub tag: u32,
15}
16
17/// Parse the scalar fields of one `message NAME { ... }` block from `.proto`
18/// text. Nested message/oneof/enum bodies are skipped by brace depth, not
19/// descended into — this is a flat wire-format check, not a full parser.
20pub fn parse_proto_message(text: &str, message: &str) -> Result<Vec<ProtoField>, String> {
21    let stripped = strip_comments(text);
22    let needle = format!("message {message}");
23    let start = stripped
24        .find(&needle)
25        .ok_or_else(|| format!("no `message {message}` in proto text"))?;
26    let brace_start = stripped[start..]
27        .find('{')
28        .map(|i| start + i)
29        .ok_or_else(|| format!("message {message} has no body"))?;
30
31    let end = matching_brace(&stripped, brace_start)
32        .ok_or_else(|| format!("message {message} body never closes"))?;
33    let body = &stripped[brace_start + 1..end];
34
35    let mut fields = Vec::new();
36    for stmt in body.split(';') {
37        let stmt = stmt.trim();
38        // Skip nested message/oneof/enum bodies rather than misparse them.
39        if stmt.is_empty() || stmt.contains('{') || stmt.contains('}') {
40            continue;
41        }
42        let Some((decl, rest)) = stmt.split_once('=') else {
43            continue; // not a `type name = tag` field (e.g. a reserved range)
44        };
45        let mut tokens: Vec<&str> = decl.split_whitespace().collect();
46        tokens.retain(|t| !matches!(*t, "repeated" | "optional" | "required"));
47        let Some(name) = tokens.pop() else { continue };
48        let ty = tokens.join(" ");
49        if ty.is_empty() {
50            continue;
51        }
52        let tag_str = rest.split('[').next().unwrap_or("").trim();
53        let Ok(tag) = tag_str.parse::<u32>() else {
54            continue;
55        };
56        fields.push(ProtoField {
57            name: name.to_string(),
58            ty,
59            tag,
60        });
61    }
62    if fields.is_empty() {
63        return Err(format!("message {message} declared no scalar fields"));
64    }
65    Ok(fields)
66}
67
68fn strip_comments(text: &str) -> String {
69    let mut out = String::with_capacity(text.len());
70    let mut chars = text.chars().peekable();
71    while let Some(c) = chars.next() {
72        if c == '/' && chars.peek() == Some(&'/') {
73            for c in chars.by_ref() {
74                if c == '\n' {
75                    out.push('\n');
76                    break;
77                }
78            }
79        } else if c == '/' && chars.peek() == Some(&'*') {
80            chars.next();
81            let mut prev = ' ';
82            for c in chars.by_ref() {
83                if prev == '*' && c == '/' {
84                    break;
85                }
86                prev = c;
87            }
88        } else {
89            out.push(c);
90        }
91    }
92    out
93}
94
95/// Extract `#[prost(..)]` field declarations from one struct in Rust source
96/// text, by scanning forward from each `#[prost(..)]` occurrence within the
97/// struct's brace-matched body to the field name it decorates. Deliberately
98/// not a full Rust parser (no `syn`) — same tradeoff as [`parse_proto_message`].
99pub fn parse_struct_prost_fields(
100    source: &str,
101    struct_name: &str,
102) -> Result<Vec<ProtoField>, String> {
103    let stripped = strip_comments(source);
104    let needle = format!("struct {struct_name}");
105    let start = stripped
106        .find(&needle)
107        .ok_or_else(|| format!("no `struct {struct_name}` found"))?;
108    let brace_start = stripped[start..]
109        .find('{')
110        .map(|i| start + i)
111        .ok_or_else(|| format!("struct {struct_name} has no body"))?;
112    let end = matching_brace(&stripped, brace_start)
113        .ok_or_else(|| format!("struct {struct_name} body never closes"))?;
114    let body = &stripped[brace_start + 1..end];
115
116    let mut fields = Vec::new();
117    let mut pos = 0;
118    while let Some(rel) = body[pos..].find("#[prost(") {
119        let args_start = pos + rel + "#[prost(".len();
120        let args_end = matching_paren(body, args_start)
121            .ok_or_else(|| format!("unterminated #[prost(..)] in struct {struct_name}"))?;
122        let after_attr = body[args_end..]
123            .find(']')
124            .map(|i| args_end + i + 1)
125            .ok_or_else(|| format!("unterminated #[prost(..)] in struct {struct_name}"))?;
126        if let (Some(ty), Some(tag)) = parse_prost_args(&body[args_start..args_end])
127            && let Some(name) = next_field_name(&body[after_attr..])
128        {
129            fields.push(ProtoField { name, ty, tag });
130        }
131        pos = after_attr;
132    }
133    Ok(fields)
134}
135
136/// Index of the `)` matching the `(` implied by `after_open` (the position
137/// right after it), or `None` if unbalanced.
138fn matching_paren(text: &str, after_open: usize) -> Option<usize> {
139    let mut depth = 1i32;
140    for (i, c) in text[after_open..].char_indices() {
141        match c {
142            '(' => depth += 1,
143            ')' => {
144                depth -= 1;
145                if depth == 0 {
146                    return Some(after_open + i);
147                }
148            }
149            _ => {}
150        }
151    }
152    None
153}
154
155/// Index of the `}` matching the `{` at `brace_start`.
156fn matching_brace(text: &str, brace_start: usize) -> Option<usize> {
157    let mut depth = 0i32;
158    for (i, c) in text[brace_start..].char_indices() {
159        match c {
160            '{' => depth += 1,
161            '}' => {
162                depth -= 1;
163                if depth == 0 {
164                    return Some(brace_start + i);
165                }
166            }
167            _ => {}
168        }
169    }
170    None
171}
172
173/// Parse `#[prost(..)]`'s parenthesized args (e.g. `sint64, tag = "20"` or
174/// `enumeration = "X", tag = "6"`) into (type, tag).
175fn parse_prost_args(args: &str) -> (Option<String>, Option<u32>) {
176    let mut ty = None;
177    let mut tag = None;
178    for token in args.split(',') {
179        let token = token.trim();
180        if token.is_empty() {
181            continue;
182        }
183        match token.split_once('=') {
184            Some((key, val)) if key.trim() == "tag" => {
185                tag = val.trim().trim_matches('"').parse::<u32>().ok();
186            }
187            Some((key, _)) => ty = Some(key.trim().to_string()),
188            None => ty = Some(token.to_string()),
189        }
190    }
191    (ty, tag)
192}
193
194/// The identifier before the next `:`, skipping visibility keywords and any
195/// further `#[..]` attributes — i.e. the field name a `#[prost(..)]`
196/// attribute decorates.
197fn next_field_name(rest: &str) -> Option<String> {
198    let mut s = rest.trim_start();
199    loop {
200        if let Some(after) = s.strip_prefix("#[") {
201            let close = after.find(']')?;
202            s = after[close + 1..].trim_start();
203        } else if let Some(after) = s.strip_prefix("pub(") {
204            let close = after.find(')')?;
205            s = after[close + 1..].trim_start();
206        } else if s.starts_with("pub") && s[3..].starts_with(char::is_whitespace) {
207            s = s[3..].trim_start();
208        } else {
209            break;
210        }
211    }
212    let name = &s[..s.find(':')?];
213    let name = name.trim();
214    (!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_'))
215        .then(|| name.to_string())
216}
217
218/// prost's `enumeration` is wire-identical to plain `int32`/`uint32`, never
219/// zigzag — anything else must match textually.
220fn types_compatible(proto_ty: &str, code_ty: &str) -> bool {
221    proto_ty == code_ty || (code_ty == "enumeration" && matches!(proto_ty, "int32" | "uint32"))
222}
223
224/// Outcome of matching struct fields to a `.proto` message by tag number.
225#[derive(Debug, Default)]
226pub struct ProtoReconciliation {
227    /// Tag declared in the `.proto` message; no struct field carries it.
228    pub missing_code: Vec<ProtoField>,
229    /// Struct field's tag; the `.proto` message doesn't declare it.
230    pub missing_spec: Vec<ProtoField>,
231    /// Same tag on both sides, but type and/or name disagree: (spec field,
232    /// code field, what).
233    pub mismatched: Vec<(ProtoField, ProtoField, String)>,
234    /// Tags present on both sides with a compatible type.
235    pub matched: usize,
236}
237
238impl ProtoReconciliation {
239    /// True when every tag matched: nothing missing on either side and no
240    /// type/name disagreements.
241    pub fn is_clean(&self) -> bool {
242        self.missing_code.is_empty() && self.missing_spec.is_empty() && self.mismatched.is_empty()
243    }
244}
245
246/// Match struct fields to declared `.proto` fields by tag number, then
247/// check that type (wire-compatibly) and name agree.
248pub fn reconcile_proto(
249    spec_fields: &[ProtoField],
250    code_fields: &[ProtoField],
251) -> ProtoReconciliation {
252    let mut rec = ProtoReconciliation::default();
253    let by_tag: BTreeMap<u32, &ProtoField> = code_fields.iter().map(|f| (f.tag, f)).collect();
254
255    for spec in spec_fields {
256        match by_tag.get(&spec.tag) {
257            None => rec.missing_code.push(spec.clone()),
258            Some(code) => {
259                let mut problems = Vec::new();
260                if !types_compatible(&spec.ty, &code.ty) {
261                    problems.push(format!("type {} != spec {}", code.ty, spec.ty));
262                }
263                if spec.name != code.name {
264                    problems.push(format!("name {} != spec {}", code.name, spec.name));
265                }
266                if problems.is_empty() {
267                    rec.matched += 1;
268                } else {
269                    rec.mismatched
270                        .push((spec.clone(), (*code).clone(), problems.join("; ")));
271                }
272            }
273        }
274    }
275
276    let declared_tags: std::collections::BTreeSet<u32> =
277        spec_fields.iter().map(|f| f.tag).collect();
278    for code in code_fields {
279        if !declared_tags.contains(&code.tag) {
280            rec.missing_spec.push(code.clone());
281        }
282    }
283    rec
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    const PROTO: &str = r#"
291        syntax = "proto3";
292        package yahoo;
293
294        message PricingData {
295            string id = 1;
296            float price = 2;
297            sint64 time = 3;
298            int32 quote_type = 6;
299            sint64 options_type = 20;
300        }
301    "#;
302
303    const SOURCE: &str = r#"
304        #[derive(Clone, PartialEq, Message)]
305        pub(crate) struct PricingData {
306            #[prost(string, tag = "1")]
307            pub id: String,
308
309            #[prost(float, tag = "2")]
310            pub price: f32,
311
312            #[prost(sint64, tag = "3")]
313            pub time: i64,
314
315            #[prost(enumeration = "QuoteTypeProto", tag = "6")]
316            pub quote_type: i32,
317
318            #[prost(enumeration = "OptionTypeProto", tag = "20")]
319            pub options_type: i32,
320        }
321    "#;
322
323    #[test]
324    fn parses_proto_message_fields_by_tag() {
325        let fields = parse_proto_message(PROTO, "PricingData").unwrap();
326        assert_eq!(fields.len(), 5);
327        assert_eq!(
328            fields[4],
329            ProtoField {
330                name: "options_type".into(),
331                ty: "sint64".into(),
332                tag: 20
333            }
334        );
335    }
336
337    #[test]
338    fn parses_struct_prost_fields() {
339        let fields = parse_struct_prost_fields(SOURCE, "PricingData").unwrap();
340        assert_eq!(fields.len(), 5);
341        assert_eq!(
342            fields[3],
343            ProtoField {
344                name: "quote_type".into(),
345                ty: "enumeration".into(),
346                tag: 6
347            }
348        );
349    }
350
351    #[test]
352    fn enumeration_is_compatible_with_plain_int32_but_not_sint64() {
353        let spec = parse_proto_message(PROTO, "PricingData").unwrap();
354        let code = parse_struct_prost_fields(SOURCE, "PricingData").unwrap();
355        let rec = reconcile_proto(&spec, &code);
356        // quote_type (int32 vs enumeration) matches; options_type (sint64
357        // vs enumeration) does not — this is the real finance-query drift.
358        assert_eq!(rec.matched, 4);
359        assert_eq!(rec.mismatched.len(), 1);
360        assert_eq!(rec.mismatched[0].0.tag, 20);
361        assert!(rec.mismatched[0].2.contains("sint64"));
362    }
363
364    #[test]
365    fn flags_missing_tags_on_either_side() {
366        let spec = vec![
367            ProtoField {
368                name: "a".into(),
369                ty: "string".into(),
370                tag: 1,
371            },
372            ProtoField {
373                name: "b".into(),
374                ty: "int32".into(),
375                tag: 2,
376            },
377        ];
378        let code = vec![
379            ProtoField {
380                name: "a".into(),
381                ty: "string".into(),
382                tag: 1,
383            },
384            ProtoField {
385                name: "c".into(),
386                ty: "bool".into(),
387                tag: 3,
388            },
389        ];
390        let rec = reconcile_proto(&spec, &code);
391        assert_eq!(rec.matched, 1);
392        assert_eq!(rec.missing_code.len(), 1); // tag 2, spec only
393        assert_eq!(rec.missing_spec.len(), 1); // tag 3, code only
394    }
395
396    #[test]
397    fn is_clean_when_everything_lines_up() {
398        let fields = vec![ProtoField {
399            name: "a".into(),
400            ty: "string".into(),
401            tag: 1,
402        }];
403        assert!(reconcile_proto(&fields, &fields).is_clean());
404    }
405}