Skip to main content

backhopper_core/snapshot/
parser.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Canonical reader for the snapshot file format.
6//!
7//! Produces `Snapshot<Canonical>` only when the input parses *and* is in
8//! canonical order. Non-canonical input is a hard error so we never
9//! silently accept a hand-edited snapshot that has drifted out of order.
10
11use std::fmt::Debug;
12use std::num::ParseIntError;
13use std::str::FromStr;
14
15use time::OffsetDateTime;
16use time::format_description::well_known::Rfc3339;
17
18use crate::errors::{NameError, SnapshotError};
19use crate::model::names::{
20    ApplicationName, Arity, CommitSha, DependencyName, DependencyVersion, FieldName, FunctionName,
21    MacroName, ModuleName, ProjectName, RecordName, TagName, TypeName,
22};
23use crate::model::snapshot::{
24    ArityMatch, CallbackSig, Deprecation, DeprecationReplacement, FORMAT_VERSION, FunArity,
25    HrlFile, IfdefGuardKind, IfdefMacro, Module, Provenance, RecordDecl, RecordField,
26    SUPPORTED_FORMAT_VERSIONS, Snapshot, SnapshotHeader, SpecSig, TestExportVariant,
27    TestOnlyExport, TypeArity, TypeDecl, VariantCBlock, VendoredDep, VendoredDepSource,
28    VersionedMachineVersion, Visibility, WireConstantBinding, WireValue, state,
29};
30use crate::snapshot::SNAPSHOT_SIZE_LIMIT;
31
32pub fn parse(input: &str) -> Result<Snapshot<state::Canonical>, SnapshotError> {
33    if input.len() > SNAPSHOT_SIZE_LIMIT {
34        return Err(SnapshotError::SizeLimit {
35            size: input.len(),
36            limit: SNAPSHOT_SIZE_LIMIT,
37        });
38    }
39    let mut parser = Parser::new(input);
40    parser.parse()
41}
42
43struct Parser<'a> {
44    lines: Vec<(usize, &'a str)>,
45    cursor: usize,
46}
47
48impl<'a> Parser<'a> {
49    fn new(input: &'a str) -> Self {
50        let lines = input.lines().enumerate().map(|(i, l)| (i + 1, l)).collect();
51        Self { lines, cursor: 0 }
52    }
53
54    fn parse(&mut self) -> Result<Snapshot<state::Canonical>, SnapshotError> {
55        let header = self.parse_header()?;
56        let mut modules: Vec<Module> = Vec::new();
57        let mut headers: Vec<HrlFile> = Vec::new();
58        let mut headers_started = false;
59        loop {
60            self.skip_blank();
61            let Some((lineno, line)) = self.peek() else {
62                break;
63            };
64            if let Some(rest) = line.strip_prefix("module ") {
65                if headers_started {
66                    return Err(SnapshotError::NotCanonical {
67                        line: lineno,
68                        detail: "module after header block".into(),
69                    });
70                }
71                self.advance();
72                let name = ModuleName::from_str(rest.trim()).map_err(SnapshotError::Name)?;
73                if let Some(prev) = modules.last()
74                    && name <= prev.name
75                {
76                    return Err(SnapshotError::NotCanonical {
77                        line: lineno,
78                        detail: format!("modules out of order at {name}"),
79                    });
80                }
81                let module = self.parse_module_body(name)?;
82                modules.push(module);
83            } else if let Some(rest) = line.strip_prefix("header ") {
84                headers_started = true;
85                self.advance();
86                let path = rest.trim().to_owned();
87                if let Some(prev) = headers.last()
88                    && path.as_str() <= prev.path.as_str()
89                {
90                    return Err(SnapshotError::NotCanonical {
91                        line: lineno,
92                        detail: format!("headers out of order at {path}"),
93                    });
94                }
95                let hrl = self.parse_header_body(path)?;
96                headers.push(hrl);
97            } else {
98                return Err(SnapshotError::UnexpectedToken {
99                    line: lineno,
100                    detail: format!("expected 'module' or 'header', got {line:?}"),
101                });
102            }
103        }
104        Ok(Snapshot::from_canonical_parts(header, modules, headers))
105    }
106
107    fn parse_header(&mut self) -> Result<SnapshotHeader, SnapshotError> {
108        let mut project: Option<ProjectName> = None;
109        let mut tag: Option<TagName> = None;
110        let mut branch: Option<String> = None;
111        let mut commit: Option<CommitSha> = None;
112        let mut scanned_paths: Vec<String> = Vec::new();
113        let mut apps_scanned: Vec<ApplicationName> = Vec::new();
114        let mut generated_by: Option<String> = None;
115        let mut generated_at: Option<OffsetDateTime> = None;
116        let mut extractor_version: String = String::new();
117        let mut dep_pins: Vec<VendoredDep> = Vec::new();
118        let mut format_version_seen = false;
119        loop {
120            let Some((lineno, line)) = self.peek() else {
121                break;
122            };
123            let Some(content) = line.strip_prefix("# ") else {
124                break;
125            };
126            self.advance();
127            if content == "backhopper snapshot" {
128                continue;
129            }
130            let (key, value) =
131                content
132                    .split_once(": ")
133                    .ok_or_else(|| SnapshotError::MalformedHeader {
134                        line: lineno,
135                        detail: format!("missing ': ' in header line {line:?}"),
136                    })?;
137            match key {
138                "format-version" => {
139                    let parsed: u32 =
140                        value.parse().map_err(|_| SnapshotError::MalformedHeader {
141                            line: lineno,
142                            detail: format!("non-integer format-version {value:?}"),
143                        })?;
144                    if !SUPPORTED_FORMAT_VERSIONS.contains(&parsed) {
145                        return Err(SnapshotError::UnknownFormatVersion {
146                            found: value.to_owned(),
147                            expected: FORMAT_VERSION,
148                        });
149                    }
150                    format_version_seen = true;
151                }
152                "project" => {
153                    project = Some(ProjectName::from_str(value).map_err(SnapshotError::Name)?);
154                }
155                "tag" => {
156                    tag = Some(TagName::from_str(value).map_err(SnapshotError::Name)?);
157                }
158                "branch" => {
159                    branch = Some(value.to_owned());
160                }
161                "commit" => {
162                    commit = Some(CommitSha::from_str(value).map_err(SnapshotError::Name)?);
163                }
164                "scanned-paths" => {
165                    scanned_paths = value
166                        .split(',')
167                        .map(|p| p.trim().to_owned())
168                        .filter(|p| !p.is_empty())
169                        .collect();
170                }
171                "apps-scanned" => {
172                    apps_scanned = value
173                        .split(',')
174                        .map(str::trim)
175                        .filter(|s| !s.is_empty())
176                        .map(ApplicationName::from_str)
177                        .collect::<Result<_, _>>()
178                        .map_err(SnapshotError::Name)?;
179                }
180                "generated-by" => generated_by = Some(value.to_owned()),
181                "extractor-version" => value.clone_into(&mut extractor_version),
182                "dep-pin" => {
183                    dep_pins.push(parse_dep_pin_line(lineno, value)?);
184                }
185                "generated-at" => {
186                    generated_at = Some(OffsetDateTime::parse(value, &Rfc3339).map_err(|_| {
187                        SnapshotError::MalformedHeader {
188                            line: lineno,
189                            detail: format!("invalid timestamp {value:?}"),
190                        }
191                    })?);
192                }
193                other => {
194                    return Err(SnapshotError::UnknownHeaderKey {
195                        line: lineno,
196                        key: other.to_owned(),
197                    });
198                }
199            }
200        }
201        if !format_version_seen {
202            return Err(SnapshotError::MissingHeaderKey {
203                key: "format-version",
204            });
205        }
206        Ok(SnapshotHeader {
207            project: project.ok_or(SnapshotError::MissingHeaderKey { key: "project" })?,
208            tag: tag.ok_or(SnapshotError::MissingHeaderKey { key: "tag" })?,
209            branch,
210            commit: commit.ok_or(SnapshotError::MissingHeaderKey { key: "commit" })?,
211            scanned_paths,
212            apps_scanned,
213            generated_by: generated_by.ok_or(SnapshotError::MissingHeaderKey {
214                key: "generated-by",
215            })?,
216            generated_at: generated_at.ok_or(SnapshotError::MissingHeaderKey {
217                key: "generated-at",
218            })?,
219            extractor_version,
220            dep_pins,
221        })
222    }
223
224    fn parse_module_body(&mut self, name: ModuleName) -> Result<Module, SnapshotError> {
225        let mut module = Module::new(name);
226        let mut state = ClassOrder::new("entry-class order");
227        loop {
228            let Some((lineno, line)) = self.peek() else {
229                break;
230            };
231            if !line.starts_with("  ") {
232                break;
233            }
234            let trimmed = &line[2..];
235            self.advance();
236            if let Some(rest) = trimmed.strip_prefix("visibility ") {
237                state.advance(EntryClass::Visibility, lineno)?;
238                let kw = rest.trim();
239                module.visibility =
240                    Visibility::from_keyword(kw).ok_or_else(|| SnapshotError::UnexpectedToken {
241                        line: lineno,
242                        detail: format!("unknown visibility {kw:?}"),
243                    })?;
244            } else if let Some(rest) = trimmed.strip_prefix("path ") {
245                state.advance(EntryClass::Path, lineno)?;
246                module.path = Some(rest.trim().to_owned());
247            } else if let Some(rest) = trimmed.strip_prefix("app ") {
248                state.advance(EntryClass::App, lineno)?;
249                let app = ApplicationName::from_str(rest.trim()).map_err(SnapshotError::Name)?;
250                module.app = Some(app);
251            } else if let Some(rest) = trimmed.strip_prefix("behaviour ") {
252                state.advance(EntryClass::Behaviour, lineno)?;
253                let m = ModuleName::from_str(rest.trim()).map_err(SnapshotError::Name)?;
254                if let Some(prev) = module.behaviours.last()
255                    && &m <= prev
256                {
257                    return Err(SnapshotError::NotCanonical {
258                        line: lineno,
259                        detail: format!("behaviour {m} out of order"),
260                    });
261                }
262                module.behaviours.push(m);
263            } else if let Some(rest) = trimmed.strip_prefix("export ") {
264                state.advance(EntryClass::Export, lineno)?;
265                let fa = parse_fun_arity(rest.trim()).map_err(SnapshotError::Name)?;
266                check_order(&module.exports, &fa, lineno, "export")?;
267                module.exports.push(fa);
268            } else if let Some(rest) = trimmed.strip_prefix("export_type ") {
269                state.advance(EntryClass::ExportType, lineno)?;
270                let ta = parse_type_arity(rest.trim()).map_err(SnapshotError::Name)?;
271                check_order(&module.export_types, &ta, lineno, "export_type")?;
272                module.export_types.push(ta);
273            } else if let Some(rest) = trimmed.strip_prefix("callback ") {
274                state.advance(EntryClass::Callback, lineno)?;
275                let (fa, sig) = parse_fun_arity_and_signature(rest).map_err(|e| {
276                    SnapshotError::UnexpectedToken {
277                        line: lineno,
278                        detail: e,
279                    }
280                })?;
281                let signature = self.collect_continuation(sig);
282                module.callbacks.push(CallbackSig {
283                    name: fa.name,
284                    arity: fa.arity,
285                    signature,
286                });
287            } else if let Some(rest) = trimmed.strip_prefix("optional_callback ") {
288                state.advance(EntryClass::OptionalCallback, lineno)?;
289                let fa = parse_fun_arity(rest.trim()).map_err(SnapshotError::Name)?;
290                check_order(&module.optional_callbacks, &fa, lineno, "optional_callback")?;
291                module.optional_callbacks.push(fa);
292            } else if let Some(rest) = trimmed.strip_prefix("spec ") {
293                state.advance(EntryClass::Spec, lineno)?;
294                let (fa, sig) = parse_fun_arity_and_signature(rest).map_err(|e| {
295                    SnapshotError::UnexpectedToken {
296                        line: lineno,
297                        detail: e,
298                    }
299                })?;
300                let signature = self.collect_continuation(sig);
301                module.specs.push(SpecSig {
302                    name: fa.name,
303                    arity: fa.arity,
304                    signature,
305                });
306            } else if let Some(rest) = trimmed.strip_prefix("type ") {
307                state.advance(EntryClass::Type, lineno)?;
308                let (ta, rhs) =
309                    parse_type_decl_line(rest).map_err(|e| SnapshotError::UnexpectedToken {
310                        line: lineno,
311                        detail: e,
312                    })?;
313                let rhs_full = self.collect_continuation(rhs);
314                module.types.push(TypeDecl {
315                    name: ta.name,
316                    arity: ta.arity,
317                    rhs: rhs_full,
318                });
319            } else if let Some(rest) = trimmed.strip_prefix("opaque ") {
320                state.advance(EntryClass::Opaque, lineno)?;
321                let ta = parse_type_arity(rest.trim()).map_err(SnapshotError::Name)?;
322                check_order(&module.opaques, &ta, lineno, "opaque")?;
323                module.opaques.push(ta);
324            } else if let Some(rest) = trimmed.strip_prefix("deprecated ") {
325                state.advance(EntryClass::Deprecated, lineno)?;
326                let dep = parse_deprecation(rest).map_err(|e| SnapshotError::UnexpectedToken {
327                    line: lineno,
328                    detail: e,
329                })?;
330                module.deprecations.push(dep);
331            } else if let Some(rest) = trimmed.strip_prefix("record ") {
332                state.advance(EntryClass::Record, lineno)?;
333                let name = RecordName::from_str(rest.trim()).map_err(SnapshotError::Name)?;
334                let fields = self.parse_record_fields()?;
335                module.records.push(RecordDecl { name, fields });
336            } else if let Some(rest) = trimmed.strip_prefix("test_only_export ") {
337                state.advance(EntryClass::TestOnlyExport, lineno)?;
338                let entry = parse_test_only_export(rest).map_err(|detail| {
339                    SnapshotError::UnexpectedToken {
340                        line: lineno,
341                        detail,
342                    }
343                })?;
344                module.test_only_exports.push(entry);
345            } else if let Some(rest) = trimmed.strip_prefix("ifdef_macro ") {
346                state.advance(EntryClass::IfdefMacro, lineno)?;
347                let entry =
348                    parse_ifdef_macro(rest).map_err(|detail| SnapshotError::UnexpectedToken {
349                        line: lineno,
350                        detail,
351                    })?;
352                module.ifdef_macros.push(entry);
353            } else if let Some(rest) = trimmed.strip_prefix("variant_c_block ") {
354                state.advance(EntryClass::VariantCBlock, lineno)?;
355                let entry = parse_variant_c_block(rest).map_err(|detail| {
356                    SnapshotError::UnexpectedToken {
357                        line: lineno,
358                        detail,
359                    }
360                })?;
361                module.variant_c_blocks.push(entry);
362            } else if let Some(rest) = trimmed.strip_prefix("versioned_machine ") {
363                state.advance(EntryClass::VersionedMachine, lineno)?;
364                if module.versioned_machine_version.is_some() {
365                    return Err(SnapshotError::UnexpectedToken {
366                        line: lineno,
367                        detail: "duplicate versioned_machine entry".to_owned(),
368                    });
369                }
370                let entry = parse_versioned_machine(rest).map_err(|detail| {
371                    SnapshotError::UnexpectedToken {
372                        line: lineno,
373                        detail,
374                    }
375                })?;
376                module.versioned_machine_version = Some(entry);
377            } else if let Some(rest) = trimmed.strip_prefix("wire_constant ") {
378                state.advance(EntryClass::WireConstant, lineno)?;
379                let entry =
380                    parse_wire_constant(rest).map_err(|detail| SnapshotError::UnexpectedToken {
381                        line: lineno,
382                        detail,
383                    })?;
384                if let Some(prev) = module.wire_constants.last()
385                    && entry.macro_name <= prev.macro_name
386                {
387                    return Err(SnapshotError::NotCanonical {
388                        line: lineno,
389                        detail: format!("wire_constant {} out of order", entry.macro_name),
390                    });
391                }
392                module.wire_constants.push(entry);
393            } else {
394                return Err(SnapshotError::UnexpectedToken {
395                    line: lineno,
396                    detail: format!("unknown module entry {trimmed:?}"),
397                });
398            }
399        }
400        Ok(module)
401    }
402
403    fn parse_header_body(&mut self, path: String) -> Result<HrlFile, SnapshotError> {
404        let mut hrl = HrlFile::new(path);
405        let mut state = ClassOrder::new("hrl-entry-class order");
406        loop {
407            let Some((lineno, line)) = self.peek() else {
408                break;
409            };
410            if !line.starts_with("  ") {
411                break;
412            }
413            let trimmed = &line[2..];
414            self.advance();
415            if let Some(rest) = trimmed.strip_prefix("type ") {
416                state.advance(HrlEntryClass::Type, lineno)?;
417                let (ta, rhs) =
418                    parse_type_decl_line(rest).map_err(|e| SnapshotError::UnexpectedToken {
419                        line: lineno,
420                        detail: e,
421                    })?;
422                let rhs_full = self.collect_continuation(rhs);
423                hrl.types.push(TypeDecl {
424                    name: ta.name,
425                    arity: ta.arity,
426                    rhs: rhs_full,
427                });
428            } else if let Some(rest) = trimmed.strip_prefix("opaque ") {
429                state.advance(HrlEntryClass::Opaque, lineno)?;
430                let ta = parse_type_arity(rest.trim()).map_err(SnapshotError::Name)?;
431                hrl.opaques.push(ta);
432            } else if let Some(rest) = trimmed.strip_prefix("record ") {
433                state.advance(HrlEntryClass::Record, lineno)?;
434                let name = RecordName::from_str(rest.trim()).map_err(SnapshotError::Name)?;
435                let fields = self.parse_record_fields()?;
436                hrl.records.push(RecordDecl { name, fields });
437            } else {
438                return Err(SnapshotError::UnexpectedToken {
439                    line: lineno,
440                    detail: format!("unknown header entry {trimmed:?}"),
441                });
442            }
443        }
444        Ok(hrl)
445    }
446
447    fn parse_record_fields(&mut self) -> Result<Vec<RecordField>, SnapshotError> {
448        let mut fields: Vec<RecordField> = Vec::new();
449        while let Some((flineno, fline)) = self.peek() {
450            if !fline.starts_with("    ") {
451                break;
452            }
453            let ftrimmed = &fline[4..];
454            if let Some(fbody) = ftrimmed.strip_prefix("field ") {
455                self.advance();
456                let (fname_str, ftype_repr) = match fbody.split_once(" :: ") {
457                    Some((n, t)) => (n.trim(), Some(t.trim().to_owned())),
458                    None => (fbody.trim(), None),
459                };
460                fields.push(RecordField {
461                    name: FieldName::from_str(fname_str).map_err(SnapshotError::Name)?,
462                    type_repr: ftype_repr,
463                });
464            } else if fline.starts_with("     ") {
465                let Some(last) = fields.last_mut() else {
466                    return Err(SnapshotError::UnexpectedToken {
467                        line: flineno,
468                        detail: format!("expected 'field', got {ftrimmed:?}"),
469                    });
470                };
471                let Some(repr) = last.type_repr.as_mut() else {
472                    return Err(SnapshotError::UnexpectedToken {
473                        line: flineno,
474                        detail: format!(
475                            "type continuation but the previous field has no '::' type: {fline:?}",
476                        ),
477                    });
478                };
479                self.advance();
480                repr.push('\n');
481                repr.push_str(fline);
482            } else {
483                return Err(SnapshotError::UnexpectedToken {
484                    line: flineno,
485                    detail: format!("expected 'field', got {ftrimmed:?}"),
486                });
487            }
488        }
489        Ok(fields)
490    }
491
492    fn collect_continuation(&mut self, first: String) -> String {
493        let mut s = first;
494        while let Some((_, line)) = self.peek()
495            && line.starts_with("       ")
496        {
497            self.advance();
498            s.push('\n');
499            s.push_str(line);
500        }
501        s
502    }
503
504    fn peek(&self) -> Option<(usize, &'a str)> {
505        self.lines.get(self.cursor).copied()
506    }
507
508    fn advance(&mut self) {
509        self.cursor += 1;
510    }
511
512    fn skip_blank(&mut self) {
513        while let Some((_, line)) = self.peek() {
514            if line.is_empty() {
515                self.advance();
516            } else {
517                break;
518            }
519        }
520    }
521}
522
523fn parse_fun_arity(s: &str) -> Result<FunArity, NameError> {
524    let (n, a) = s
525        .split_once('/')
526        .ok_or_else(|| NameError::PatternMismatch {
527            kind: "function/arity",
528            value: s.to_owned(),
529            pattern: "name/arity",
530        })?;
531    Ok(FunArity {
532        name: FunctionName::from_str(n)?,
533        arity: Arity::from_str(a)?,
534    })
535}
536
537fn parse_type_arity(s: &str) -> Result<TypeArity, NameError> {
538    let (n, a) = s
539        .split_once('/')
540        .ok_or_else(|| NameError::PatternMismatch {
541            kind: "type/arity",
542            value: s.to_owned(),
543            pattern: "name/arity",
544        })?;
545    Ok(TypeArity {
546        name: TypeName::from_str(n)?,
547        arity: Arity::from_str(a)?,
548    })
549}
550
551fn parse_fun_arity_and_signature(s: &str) -> Result<(FunArity, String), String> {
552    let (head, sig) = s
553        .split_once(' ')
554        .ok_or_else(|| format!("expected 'name/arity body' in {s:?}"))?;
555    let fa = parse_fun_arity(head).map_err(|e| e.to_string())?;
556    Ok((fa, sig.to_owned()))
557}
558
559fn parse_test_only_export(s: &str) -> Result<TestOnlyExport, String> {
560    let mut parts = s.split_whitespace();
561    let variant_token = parts
562        .next()
563        .ok_or_else(|| format!("missing variant in test_only_export {s:?}"))?;
564    let variant = TestExportVariant::from_label(variant_token)
565        .ok_or_else(|| format!("unknown test_only_export variant {variant_token:?}"))?;
566    let fa_token = parts
567        .next()
568        .ok_or_else(|| format!("missing function/arity in test_only_export {s:?}"))?;
569    let fa = parse_fun_arity(fa_token).map_err(|e| e.to_string())?;
570    let mut export_line: Option<usize> = None;
571    let mut body_line: Option<usize> = None;
572    for token in parts {
573        let (key, value) = token
574            .split_once('=')
575            .ok_or_else(|| format!("expected key=value in test_only_export, got {token:?}"))?;
576        match key {
577            "export_line" => {
578                export_line = Some(
579                    value
580                        .parse()
581                        .map_err(|e: ParseIntError| format!("invalid export_line: {e}"))?,
582                );
583            }
584            "body_line" => {
585                body_line = Some(
586                    value
587                        .parse()
588                        .map_err(|e: ParseIntError| format!("invalid body_line: {e}"))?,
589                );
590            }
591            other => return Err(format!("unknown test_only_export key {other:?}")),
592        }
593    }
594    let export_line = export_line.ok_or_else(|| "missing export_line".to_owned())?;
595    if matches!(variant, TestExportVariant::B) && body_line.is_none() {
596        return Err("variant=b requires body_line".into());
597    }
598    Ok(TestOnlyExport {
599        function: fa.name,
600        arity: fa.arity,
601        export_line,
602        body_line,
603        variant,
604    })
605}
606
607fn parse_ifdef_macro(s: &str) -> Result<IfdefMacro, String> {
608    let mut parts = s.split_whitespace();
609    let name_token = parts
610        .next()
611        .ok_or_else(|| format!("missing macro name in ifdef_macro {s:?}"))?;
612    let name = name_token.to_owned();
613    let mut guard_kind: Option<IfdefGuardKind> = None;
614    let mut line: Option<usize> = None;
615    for token in parts {
616        let (key, value) = token
617            .split_once('=')
618            .ok_or_else(|| format!("expected key=value in ifdef_macro, got {token:?}"))?;
619        match key {
620            "guard" => {
621                guard_kind = Some(
622                    IfdefGuardKind::from_label(value)
623                        .ok_or_else(|| format!("unknown ifdef_macro guard {value:?}"))?,
624                );
625            }
626            "line" => {
627                line = Some(
628                    value
629                        .parse()
630                        .map_err(|e: ParseIntError| format!("invalid line: {e}"))?,
631                );
632            }
633            other => return Err(format!("unknown ifdef_macro key {other:?}")),
634        }
635    }
636    let guard_kind = guard_kind.ok_or_else(|| "missing guard".to_owned())?;
637    let line = line.ok_or_else(|| "missing line".to_owned())?;
638    Ok(IfdefMacro {
639        name,
640        line,
641        guard_kind,
642    })
643}
644
645fn parse_variant_c_block(s: &str) -> Result<VariantCBlock, String> {
646    let mut guard: Option<String> = None;
647    let mut start_line: Option<usize> = None;
648    let mut else_line: Option<usize> = None;
649    let mut end_line: Option<usize> = None;
650    for token in s.split_whitespace() {
651        let (key, value) = token
652            .split_once('=')
653            .ok_or_else(|| format!("expected key=value in variant_c_block, got {token:?}"))?;
654        match key {
655            "guard" => guard = Some(value.to_owned()),
656            "start_line" => {
657                start_line = Some(
658                    value
659                        .parse()
660                        .map_err(|e: ParseIntError| format!("invalid start_line: {e}"))?,
661                );
662            }
663            "else_line" => {
664                else_line = Some(
665                    value
666                        .parse()
667                        .map_err(|e: ParseIntError| format!("invalid else_line: {e}"))?,
668                );
669            }
670            "end_line" => {
671                end_line = Some(
672                    value
673                        .parse()
674                        .map_err(|e: ParseIntError| format!("invalid end_line: {e}"))?,
675                );
676            }
677            other => return Err(format!("unknown variant_c_block key {other:?}")),
678        }
679    }
680    let guard = guard.ok_or_else(|| "missing guard".to_owned())?;
681    let start_line = start_line.ok_or_else(|| "missing start_line".to_owned())?;
682    let end_line = end_line.ok_or_else(|| "missing end_line".to_owned())?;
683    Ok(VariantCBlock {
684        guard,
685        start_line,
686        end_line,
687        else_line,
688    })
689}
690
691fn parse_type_decl_line(s: &str) -> Result<(TypeArity, String), String> {
692    let (head, rhs) = s
693        .split_once(" :: ")
694        .ok_or_else(|| format!("expected 'name/arity :: rhs' in {s:?}"))?;
695    let ta = parse_type_arity(head).map_err(|e| e.to_string())?;
696    Ok((ta, rhs.to_owned()))
697}
698
699fn parse_deprecation(rest: &str) -> Result<Deprecation, String> {
700    if rest.trim_start().starts_with("module") {
701        return Ok(Deprecation {
702            function: None,
703            arity_match: ArityMatch::Any,
704            since: None,
705            replacement: None,
706            reason: None,
707            module_wide: true,
708        });
709    }
710    let mut tokens = rest.split_whitespace().peekable();
711    let head = tokens.next().ok_or("empty deprecation")?;
712    let (function, arity_match) = if head == "*" {
713        (None, ArityMatch::Any)
714    } else {
715        let (n, a) = head
716            .split_once('/')
717            .ok_or_else(|| format!("expected name/arity got {head:?}"))?;
718        let arity_match = if a == "*" {
719            ArityMatch::Any
720        } else {
721            ArityMatch::Exact {
722                arity: Arity::from_str(a).map_err(|e| e.to_string())?,
723            }
724        };
725        let function = FunctionName::from_str(n).map_err(|e| e.to_string())?;
726        (Some(function), arity_match)
727    };
728    let mut since: Option<TagName> = None;
729    let mut replacement: Option<DeprecationReplacement> = None;
730    let mut reason: Option<String> = None;
731    while let Some(tok) = tokens.next() {
732        match tok {
733            "since" => {
734                let value = tokens.next().ok_or("missing 'since' value")?;
735                since = Some(TagName::from_str(value).map_err(|e| e.to_string())?);
736            }
737            "use" => {
738                let target = tokens.next().ok_or("missing 'use' target")?;
739                let (n, a) = target
740                    .split_once('/')
741                    .ok_or_else(|| format!("expected use name/arity got {target:?}"))?;
742                replacement = Some(DeprecationReplacement {
743                    function: FunctionName::from_str(n).map_err(|e| e.to_string())?,
744                    arity: Arity::from_str(a).map_err(|e| e.to_string())?,
745                });
746            }
747            "reason" => {
748                // decode the reason from its first to last quote, preserving internal spacing and escapes
749                let open = rest.find('"').ok_or("reason must be a quoted string")?;
750                let close = rest.rfind('"').ok_or("reason must be a quoted string")?;
751                if close <= open {
752                    return Err("reason must be a quoted string".into());
753                }
754                reason = Some(unescape_reason(&rest[open + 1..close]));
755                break;
756            }
757            other => return Err(format!("unexpected deprecation token {other:?}")),
758        }
759    }
760    Ok(Deprecation {
761        function,
762        arity_match,
763        since,
764        replacement,
765        reason,
766        module_wide: false,
767    })
768}
769
770// reverse the writer's Rust-debug quoting; unknown escapes pass through so no bytes are lost
771fn unescape_reason(s: &str) -> String {
772    let mut out = String::with_capacity(s.len());
773    let mut chars = s.chars();
774    while let Some(c) = chars.next() {
775        if c != '\\' {
776            out.push(c);
777            continue;
778        }
779        match chars.next() {
780            Some('n') => out.push('\n'),
781            Some('t') => out.push('\t'),
782            Some('r') => out.push('\r'),
783            Some('"') => out.push('"'),
784            Some('\\') => out.push('\\'),
785            Some(other) => {
786                out.push('\\');
787                out.push(other);
788            }
789            None => out.push('\\'),
790        }
791    }
792    out
793}
794
795/// A `name/arity` entry with a canonical order, so one `check_order`
796/// gates both the function-arity and type-arity streams.
797trait ArityOrdered {
798    type Key: Ord;
799    fn order_key(&self) -> Self::Key;
800    fn describe(&self) -> String;
801}
802
803impl ArityOrdered for FunArity {
804    type Key = (FunctionName, Arity);
805    fn order_key(&self) -> Self::Key {
806        (self.name.clone(), self.arity)
807    }
808    fn describe(&self) -> String {
809        format!("{}/{}", self.name, self.arity)
810    }
811}
812
813impl ArityOrdered for TypeArity {
814    type Key = (TypeName, Arity);
815    fn order_key(&self) -> Self::Key {
816        (self.name.clone(), self.arity)
817    }
818    fn describe(&self) -> String {
819        format!("{}/{}", self.name, self.arity)
820    }
821}
822
823fn check_order<T: ArityOrdered>(
824    existing: &[T],
825    item: &T,
826    lineno: usize,
827    label: &str,
828) -> Result<(), SnapshotError> {
829    if let Some(prev) = existing.last() {
830        if item.order_key() <= prev.order_key() {
831            return Err(SnapshotError::NotCanonical {
832                line: lineno,
833                detail: format!("{label} {} out of order", item.describe()),
834            });
835        }
836    }
837    Ok(())
838}
839
840fn parse_dep_pin_line(line: usize, value: &str) -> Result<VendoredDep, SnapshotError> {
841    let (name_part, after_eq) =
842        value
843            .split_once('=')
844            .ok_or_else(|| SnapshotError::MalformedHeader {
845                line,
846                detail: format!("dep-pin missing '=': {value:?}"),
847            })?;
848    let name_str = name_part.trim();
849    let source_and_version = after_eq.trim();
850    let (source_label, version_str) =
851        source_and_version
852            .split_once(' ')
853            .ok_or_else(|| SnapshotError::MalformedHeader {
854                line,
855                detail: format!("dep-pin missing version after source: {value:?}"),
856            })?;
857    let source_label = source_label.trim();
858    let source = VendoredDepSource::from_label(source_label).ok_or_else(|| {
859        SnapshotError::MalformedHeader {
860            line,
861            detail: format!("unknown dep-pin source {source_label:?}"),
862        }
863    })?;
864    let name = DependencyName::from_str(name_str).map_err(SnapshotError::Name)?;
865    let version = DependencyVersion::from_str(version_str.trim()).map_err(SnapshotError::Name)?;
866    Ok(VendoredDep {
867        name,
868        version,
869        source,
870    })
871}
872
873fn parse_versioned_machine(rest: &str) -> Result<VersionedMachineVersion, String> {
874    let (fa_part, after_fa) = rest
875        .split_once('=')
876        .ok_or_else(|| format!("versioned_machine missing '=': {rest:?}"))?;
877    let fa = parse_fun_arity(fa_part.trim()).map_err(|e| e.to_string())?;
878    let after_fa = after_fa.trim();
879    let (value_part, provenance_part) = match after_fa.split_once(' ') {
880        Some((v, p)) => (v.trim(), p.trim()),
881        None => (after_fa, ""),
882    };
883    let value = match value_part {
884        "-" => None,
885        s => Some(
886            s.parse::<u64>()
887                .map_err(|e| format!("versioned_machine value: {e}"))?,
888        ),
889    };
890    let provenance = parse_provenance(provenance_part)?;
891    Ok(VersionedMachineVersion {
892        function: fa.name,
893        arity: fa.arity,
894        value,
895        provenance,
896    })
897}
898
899fn parse_provenance(rest: &str) -> Result<Provenance, String> {
900    if rest.is_empty() || rest == "literal" {
901        return Ok(Provenance::Literal);
902    }
903    if let Some(after) = rest.strip_prefix("macro_body ") {
904        let (name_str, at_tail) = match after.split_once(" at ") {
905            Some((n, p)) => (n.trim(), Some(p.trim().to_owned())),
906            None => (after.trim(), None),
907        };
908        let macro_name = MacroName::from_str(name_str).map_err(|e| e.to_string())?;
909        return Ok(Provenance::MacroBody {
910            macro_name,
911            defined_in: at_tail,
912        });
913    }
914    Err(format!("unknown provenance {rest:?}"))
915}
916
917fn parse_wire_constant(rest: &str) -> Result<WireConstantBinding, String> {
918    let (name_part, after_eq) = rest
919        .split_once('=')
920        .ok_or_else(|| format!("wire_constant missing '=': {rest:?}"))?;
921    let macro_name = MacroName::from_str(name_part.trim()).map_err(|e| e.to_string())?;
922    let body = after_eq.trim();
923    let (body_no_at, defined_in) = match body.rsplit_once(" at ") {
924        Some((b, p)) => (b.trim(), Some(p.trim().to_owned())),
925        None => (body, None),
926    };
927    let (kind, value_str) = body_no_at
928        .split_once(' ')
929        .ok_or_else(|| format!("wire_constant missing type/value: {rest:?}"))?;
930    let value = match kind.trim() {
931        "u64" => WireValue::U64(
932            value_str
933                .trim()
934                .parse::<u64>()
935                .map_err(|e| format!("wire_constant u64 value: {e}"))?,
936        ),
937        "bytes" => WireValue::Bytes(value_str.trim().as_bytes().to_vec()),
938        "opaque" => WireValue::Opaque(value_str.trim().to_owned()),
939        other => return Err(format!("unknown wire_constant kind {other:?}")),
940    };
941    Ok(WireConstantBinding {
942        macro_name,
943        value,
944        defined_in,
945    })
946}
947
948#[derive(Debug)]
949struct ClassOrder<T> {
950    label: &'static str,
951    last: Option<T>,
952}
953
954impl<T: Copy + Ord + Debug> ClassOrder<T> {
955    fn new(label: &'static str) -> Self {
956        Self { label, last: None }
957    }
958
959    fn advance(&mut self, c: T, line: usize) -> Result<(), SnapshotError> {
960        if let Some(prev) = self.last
961            && c < prev
962        {
963            return Err(SnapshotError::NotCanonical {
964                line,
965                detail: format!("{}: {c:?} after {prev:?}", self.label),
966            });
967        }
968        self.last = Some(c);
969        Ok(())
970    }
971}
972
973#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
974enum EntryClass {
975    Visibility,
976    Path,
977    App,
978    Behaviour,
979    Export,
980    ExportType,
981    Callback,
982    OptionalCallback,
983    Spec,
984    Type,
985    Opaque,
986    Deprecated,
987    Record,
988    TestOnlyExport,
989    IfdefMacro,
990    VariantCBlock,
991    VersionedMachine,
992    WireConstant,
993}
994
995#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
996enum HrlEntryClass {
997    Type,
998    Opaque,
999    Record,
1000}