Skip to main content

hdl_cat_error/
lib.rs

1//! Shared error type for the `hdl-cat` workspace.
2//!
3//! This crate defines a single [`Error`] enum used throughout every other
4//! `hdl-cat-*` crate.  Each variant wraps an underlying concrete error
5//! from `std`, from `comp-cat-rs`, or from a domain context in hdl-cat.
6//!
7//! # Design
8//!
9//! Error handling is explicit and hand-rolled — no `thiserror`, no
10//! `anyhow`, no silent panics.  Every fallible operation in the
11//! workspace returns `Result<T, Error>`.
12//!
13//! `From` impls are provided for every underlying error type so that
14//! `?` propagates cleanly at every call site.
15
16use comp_cat_rs::collapse::free_category::FreeCategoryError;
17
18/// A bit width, measured in bits.
19///
20/// Used in [`Error::WidthMismatch`] to communicate expected/actual
21/// operand widths when a hardware operation's width constraint is
22/// violated.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct Width(u32);
25
26impl Width {
27    /// Construct a `Width` from a raw bit count.
28    #[must_use]
29    pub fn new(bits: u32) -> Self {
30        Self(bits)
31    }
32
33    /// The underlying bit count.
34    #[must_use]
35    pub fn bits(self) -> u32 {
36        self.0
37    }
38}
39
40impl core::fmt::Display for Width {
41    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
42        write!(f, "{} bit(s)", self.0)
43    }
44}
45
46/// A cycle index in a simulation.
47///
48/// Used by [`Error::ImmatureSim`] and by `hdl-cat-sim`'s
49/// `TimedSample` type.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
51pub struct Cycle(u64);
52
53impl Cycle {
54    /// Construct a `Cycle` from a raw index.
55    #[must_use]
56    pub fn new(index: u64) -> Self {
57        Self(index)
58    }
59
60    /// The underlying cycle index.
61    #[must_use]
62    pub fn index(self) -> u64 {
63        self.0
64    }
65}
66
67impl core::fmt::Display for Cycle {
68    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
69        write!(f, "cycle {}", self.0)
70    }
71}
72
73/// A human-readable type name used in mismatch diagnostics.
74///
75/// Opaque newtype: construct via [`TypeName::new`], display via
76/// [`core::fmt::Display`].  Exists so that `Error::TypeMismatch`
77/// cannot be confused with raw `String` domain primitives.
78#[derive(Debug, Clone, PartialEq, Eq, Hash)]
79pub struct TypeName(String);
80
81impl TypeName {
82    /// Construct a `TypeName` from any displayable value.
83    pub fn new(name: impl Into<String>) -> Self {
84        Self(name.into())
85    }
86
87    /// The underlying string.
88    #[must_use]
89    pub fn as_str(&self) -> &str {
90        &self.0
91    }
92}
93
94impl core::fmt::Display for TypeName {
95    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
96        f.write_str(&self.0)
97    }
98}
99
100/// A signal name used in trace / codegen diagnostics.
101#[derive(Debug, Clone, PartialEq, Eq, Hash)]
102pub struct SignalName(String);
103
104impl SignalName {
105    /// Construct a `SignalName` from any displayable value.
106    pub fn new(name: impl Into<String>) -> Self {
107        Self(name.into())
108    }
109
110    /// The underlying string.
111    #[must_use]
112    pub fn as_str(&self) -> &str {
113        &self.0
114    }
115}
116
117impl core::fmt::Display for SignalName {
118    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
119        f.write_str(&self.0)
120    }
121}
122
123/// The workspace-wide error enum.
124///
125/// Every fallible operation in `hdl-cat-*` returns `Result<T, Error>`.
126/// Variants either wrap an underlying error type or encode a
127/// domain-specific failure.
128///
129/// # Examples
130///
131/// ```
132/// use hdl_cat_error::{Error, Width};
133///
134/// let e = Error::WidthMismatch {
135///     expected: Width::new(8),
136///     actual: Width::new(4),
137/// };
138/// assert_eq!(
139///     e.to_string(),
140///     "width mismatch: expected 8 bit(s), got 4 bit(s)",
141/// );
142/// ```
143#[derive(Debug)]
144pub enum Error {
145    /// Underlying I/O failure.
146    Io(std::io::Error),
147
148    /// Underlying `core::fmt` failure (e.g. from a writer).
149    Fmt(core::fmt::Error),
150
151    /// Underlying integer parse failure.
152    ParseInt(core::num::ParseIntError),
153
154    /// A free-category IR construction error from `comp-cat-rs`.
155    FreeCategory(FreeCategoryError),
156
157    /// A value did not fit its declared hardware width.
158    WidthMismatch {
159        /// The width declared by the target type.
160        expected: Width,
161        /// The width actually supplied.
162        actual: Width,
163    },
164
165    /// A hardware value's runtime type does not match the expected type.
166    TypeMismatch {
167        /// The expected type's display name.
168        expected: TypeName,
169        /// The actual type's display name.
170        actual: TypeName,
171    },
172
173    /// Two signals from different clock domains were composed.
174    ClockDomainMismatch,
175
176    /// A signal referenced in an IR or codegen pass was never defined.
177    UndefinedSignal {
178        /// The name that could not be resolved.
179        name: SignalName,
180    },
181
182    /// A simulation attempted to read a register before the first
183    /// clock edge had advanced state beyond its initial value.
184    ImmatureSim {
185        /// The cycle at which the read was attempted.
186        cycle: Cycle,
187    },
188
189    /// A value overflowed its declared range during arithmetic.
190    Overflow {
191        /// The width of the operation that overflowed.
192        width: Width,
193    },
194
195    /// An IR op has no Circom lowering in the current backend version.
196    ///
197    /// The payload is a short static identifier (e.g. `"add"`,
198    /// `"reg"`) naming the op and the reason the Circom emitter
199    /// refused it.  Combinational bitwise ops, `Const`, `Slice`, and
200    /// `Concat` are supported in v1; arithmetic and stateful ops are
201    /// scheduled for a follow-up.
202    UnsupportedInCircom(&'static str),
203
204    /// A Circom signal's declared bit width exceeds the target field's
205    /// maximum safe width.
206    ///
207    /// Circom's scalar fields are prime of ~254 or ~64 bits depending
208    /// on the target curve.  An intermediate whose worst-case witness
209    /// would exceed that width cannot be emitted without lowering
210    /// through a `Num2Bits` gadget.
211    CircomWidthOverflow {
212        /// The width of the signal the emitter was asked to produce.
213        width: Width,
214        /// The target field's maximum safe bit width.
215        field_bits: Width,
216    },
217}
218
219impl core::fmt::Display for Error {
220    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
221        match self {
222            Self::Io(e) => write!(f, "I/O error: {e}"),
223            Self::Fmt(e) => write!(f, "formatter error: {e}"),
224            Self::ParseInt(e) => write!(f, "parse-int error: {e}"),
225            Self::FreeCategory(e) => write!(f, "free-category error: {e}"),
226            Self::WidthMismatch { expected, actual } => {
227                write!(f, "width mismatch: expected {expected}, got {actual}")
228            }
229            Self::TypeMismatch { expected, actual } => {
230                write!(f, "type mismatch: expected {expected}, got {actual}")
231            }
232            Self::ClockDomainMismatch => f.write_str("clock domain mismatch"),
233            Self::UndefinedSignal { name } => write!(f, "undefined signal: {name}"),
234            Self::ImmatureSim { cycle } => {
235                write!(f, "simulation read before first clock edge at {cycle}")
236            }
237            Self::Overflow { width } => write!(f, "arithmetic overflow at {width}"),
238            Self::UnsupportedInCircom(what) => {
239                write!(f, "op not supported by the Circom backend: {what}")
240            }
241            Self::CircomWidthOverflow { width, field_bits } => write!(
242                f,
243                "Circom signal width {width} exceeds field capacity {field_bits}",
244            ),
245        }
246    }
247}
248
249impl std::error::Error for Error {
250    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
251        match self {
252            Self::Io(e) => Some(e),
253            Self::Fmt(e) => Some(e),
254            Self::ParseInt(e) => Some(e),
255            Self::FreeCategory(e) => Some(e),
256            Self::WidthMismatch { .. }
257            | Self::TypeMismatch { .. }
258            | Self::ClockDomainMismatch
259            | Self::UndefinedSignal { .. }
260            | Self::ImmatureSim { .. }
261            | Self::Overflow { .. }
262            | Self::UnsupportedInCircom(_)
263            | Self::CircomWidthOverflow { .. } => None,
264        }
265    }
266}
267
268impl From<std::io::Error> for Error {
269    fn from(e: std::io::Error) -> Self {
270        Self::Io(e)
271    }
272}
273
274impl From<core::fmt::Error> for Error {
275    fn from(e: core::fmt::Error) -> Self {
276        Self::Fmt(e)
277    }
278}
279
280impl From<core::num::ParseIntError> for Error {
281    fn from(e: core::num::ParseIntError) -> Self {
282        Self::ParseInt(e)
283    }
284}
285
286impl From<FreeCategoryError> for Error {
287    fn from(e: FreeCategoryError) -> Self {
288        Self::FreeCategory(e)
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::{Cycle, Error, SignalName, TypeName, Width};
295
296    #[test]
297    fn width_mismatch_displays_both_widths() {
298        let e = Error::WidthMismatch {
299            expected: Width::new(8),
300            actual: Width::new(4),
301        };
302        assert_eq!(
303            e.to_string(),
304            "width mismatch: expected 8 bit(s), got 4 bit(s)",
305        );
306    }
307
308    #[test]
309    fn type_mismatch_displays_both_names() {
310        let e = Error::TypeMismatch {
311            expected: TypeName::new("Bits<8>"),
312            actual: TypeName::new("Bits<4>"),
313        };
314        assert_eq!(e.to_string(), "type mismatch: expected Bits<8>, got Bits<4>");
315    }
316
317    #[test]
318    fn clock_domain_mismatch_displays_message() {
319        assert_eq!(Error::ClockDomainMismatch.to_string(), "clock domain mismatch");
320    }
321
322    #[test]
323    fn undefined_signal_displays_name() {
324        let e = Error::UndefinedSignal {
325            name: SignalName::new("clk"),
326        };
327        assert_eq!(e.to_string(), "undefined signal: clk");
328    }
329
330    #[test]
331    fn immature_sim_displays_cycle() {
332        let e = Error::ImmatureSim { cycle: Cycle::new(0) };
333        assert_eq!(
334            e.to_string(),
335            "simulation read before first clock edge at cycle 0",
336        );
337    }
338
339    #[test]
340    fn overflow_displays_width() {
341        let e = Error::Overflow { width: Width::new(16) };
342        assert_eq!(e.to_string(), "arithmetic overflow at 16 bit(s)");
343    }
344
345    #[test]
346    fn from_io_error_wraps_without_loss() {
347        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "nope");
348        let e: Error = io.into();
349        let msg = e.to_string();
350        assert!(msg.starts_with("I/O error: "));
351        assert!(msg.contains("nope"));
352    }
353
354    #[test]
355    fn question_mark_propagates_parse_int_into_error() -> Result<(), Error> {
356        let n: i32 = "42".parse()?;
357        assert_eq!(n, 42);
358        Ok(())
359    }
360
361    #[test]
362    fn width_round_trips_through_accessor() {
363        assert_eq!(Width::new(12).bits(), 12);
364    }
365
366    #[test]
367    fn cycle_round_trips_through_accessor() {
368        assert_eq!(Cycle::new(7).index(), 7);
369    }
370
371    #[test]
372    fn type_name_round_trips_through_accessor() {
373        assert_eq!(TypeName::new("Bool").as_str(), "Bool");
374    }
375
376    #[test]
377    fn signal_name_round_trips_through_accessor() {
378        assert_eq!(SignalName::new("rst").as_str(), "rst");
379    }
380
381    #[test]
382    fn unsupported_in_circom_displays_reason() {
383        let e = Error::UnsupportedInCircom("add");
384        assert_eq!(
385            e.to_string(),
386            "op not supported by the Circom backend: add",
387        );
388    }
389
390    #[test]
391    fn circom_width_overflow_displays_both_widths() {
392        let e = Error::CircomWidthOverflow {
393            width: Width::new(300),
394            field_bits: Width::new(252),
395        };
396        assert_eq!(
397            e.to_string(),
398            "Circom signal width 300 bit(s) exceeds field capacity 252 bit(s)",
399        );
400    }
401}