Skip to main content

tanzim_value/
error.rs

1use crate::Location;
2use std::fmt::{self, Display, Formatter};
3
4/// Error while deserializing configuration input.
5///
6/// [`Display`] is one line by default; use `{error:#}` for source context and caret.
7///
8/// [`Location`] is boxed so the whole [`Error`] stays small enough to return by value without
9/// tripping `clippy::result_large_err` (a [`Location`] now carries the full originating
10/// [`tanzim_source::Source`]).
11#[derive(Debug, Clone, PartialEq)]
12pub enum Error {
13    InvalidUtf8 {
14        location: Box<Location>,
15    },
16    UnsupportedType {
17        text: String,
18        location: Box<Location>,
19        found: &'static str,
20    },
21    Parse {
22        text: String,
23        location: Option<Box<Location>>,
24        message: String,
25    },
26    /// A value could not be deserialized into the requested type (`serde` Cargo feature).
27    ///
28    /// `text` holds the raw source of the offending value's origin (empty until supplied via
29    /// [`Error::with_source_text`]); when present, `{error:#}` renders a caret underline.
30    #[cfg(feature = "serde")]
31    Deserialize {
32        text: String,
33        message: String,
34        location: Option<Box<Location>>,
35    },
36}
37
38impl Error {
39    /// Stamp `location` onto a [`Error::Deserialize`] that has none yet, so errors bubbling up from
40    /// a leaf get the nearest enclosing node's position. Errors that already carry a location (or
41    /// are not deserialize errors) are returned unchanged.
42    #[cfg(feature = "serde")]
43    pub(crate) fn or_location(mut self, location: &Location) -> Self {
44        if let Self::Deserialize {
45            location: slot @ None,
46            ..
47        } = &mut self
48        {
49            *slot = Some(Box::new(location.clone()));
50        }
51        self
52    }
53
54    /// The [`Location`] of a located [`Error::Deserialize`], if any. Lets a caller (e.g. the
55    /// pipeline) look up the originating source and fill in [`Error::with_source_text`].
56    #[cfg(feature = "serde")]
57    pub fn deserialize_location(&self) -> Option<&Location> {
58        match self {
59            Self::Deserialize {
60                location: Some(location),
61                ..
62            } => Some(location),
63            _ => None,
64        }
65    }
66
67    /// Attach the raw source `text` of the offending value's origin to a [`Error::Deserialize`]
68    /// (only if it has none yet), so `{error:#}` can render a source snippet with a caret.
69    #[cfg(feature = "serde")]
70    pub fn with_source_text(mut self, text: impl Into<String>) -> Self {
71        if let Self::Deserialize { text: slot, .. } = &mut self
72            && slot.is_empty()
73        {
74            *slot = text.into();
75        }
76        self
77    }
78}
79
80fn located_message(location: &Location, message: &str) -> String {
81    format!("{message} at {location}")
82}
83
84impl Display for Error {
85    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
86        match self {
87            Self::InvalidUtf8 { location } => {
88                write!(f, "invalid utf-8 in configuration input from {location}")?;
89            }
90            Self::UnsupportedType {
91                location, found, ..
92            } => {
93                write!(
94                    f,
95                    "{}",
96                    located_message(
97                        location,
98                        &format!("unsupported configuration input type `{found}`"),
99                    )
100                )?;
101            }
102            Self::Parse {
103                location: Some(location),
104                message,
105                ..
106            } => write!(f, "{}", located_message(location, message))?,
107            Self::Parse { message, .. } => write!(f, "{message}")?,
108            #[cfg(feature = "serde")]
109            Self::Deserialize {
110                message,
111                location: Some(location),
112                ..
113            } => write!(
114                f,
115                "{}",
116                located_message(
117                    location,
118                    &format!("failed to deserialize configuration: {message}"),
119                )
120            )?,
121            #[cfg(feature = "serde")]
122            Self::Deserialize { message, .. } => {
123                write!(f, "failed to deserialize configuration: {message}")?
124            }
125        }
126
127        if !f.alternate() {
128            return Ok(());
129        }
130
131        let (text, location) = match self {
132            Self::UnsupportedType { text, location, .. } => (text.as_str(), location),
133            Self::Parse {
134                text,
135                location: Some(location),
136                ..
137            } => (text.as_str(), location),
138            #[cfg(feature = "serde")]
139            Self::Deserialize {
140                text,
141                location: Some(location),
142                ..
143            } if !text.is_empty() => (text.as_str(), location),
144            _ => return Ok(()),
145        };
146
147        let line_number = location.line.map(|line| line.get() as usize);
148        let column = location.column.map(|column| column.get() as usize);
149        let highlight = location
150            .length
151            .map_or(1, |length| length.get() as usize)
152            .max(1);
153
154        if let Some(line_number) = line_number {
155            let lines: Vec<&str> = text.split('\n').collect();
156            let start = if line_number > 1 { line_number - 2 } else { 0 };
157            let end = if line_number + 1 < lines.len() {
158                line_number + 1
159            } else {
160                lines.len()
161            };
162            let gutter_width = end.to_string().len();
163            let mut line_index = start;
164            while line_index < end {
165                let display_line = line_index + 1;
166                let line_text = display_line.to_string();
167                write!(f, "\n  ")?;
168                for _ in 0..gutter_width.saturating_sub(line_text.len()) {
169                    write!(f, " ")?;
170                }
171                write!(f, "{line_text} | ")?;
172                write!(f, "{}", lines[line_index])?;
173                if display_line == line_number {
174                    write!(f, "\n  ")?;
175                    for _ in 0..gutter_width.saturating_sub(line_text.len()) {
176                        write!(f, " ")?;
177                    }
178                    for _ in 0..line_text.len() + 1 {
179                        write!(f, " ")?;
180                    }
181                    write!(f, "| ")?;
182                    if let Some(column_number) = column {
183                        for _ in 1..column_number {
184                            write!(f, " ")?;
185                        }
186                    }
187                    for _ in 0..highlight {
188                        write!(f, "^")?;
189                    }
190                }
191                line_index += 1;
192            }
193        } else {
194            write!(f, "\n  {text}")?;
195            if let Some(column_number) = column {
196                write!(f, "\n  ")?;
197                for _ in 1..column_number {
198                    write!(f, " ")?;
199                }
200                for _ in 0..highlight {
201                    write!(f, "^")?;
202                }
203            }
204        }
205
206        Ok(())
207    }
208}
209
210impl std::error::Error for Error {}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::Location;
216
217    #[test]
218    fn default_display_is_single_line() {
219        let error = Error::UnsupportedType {
220            text: "foo: bar\nbaz: datetime\n".to_string(),
221            location: Box::new(Location::at("file", "config.toml", Some(2), Some(7), None)),
222            found: "datetime",
223        };
224        let message = error.to_string();
225        assert!(!message.contains('\n'));
226        assert!(!message.contains('^'));
227        assert!(message.contains("file:config.toml:2:7"));
228    }
229
230    #[test]
231    fn alternate_display_underlines_token() {
232        let error = Error::UnsupportedType {
233            text: "foo: bar\nbaz: datetime\n".to_string(),
234            location: Box::new(Location::at(
235                "file",
236                "config.toml",
237                Some(2),
238                Some(6),
239                Some(8),
240            )),
241            found: "datetime",
242        };
243        let message = format!("{error:#}");
244        assert!(message.contains("^^^^"));
245        assert!(message.contains("baz: datetime"));
246    }
247
248    #[test]
249    fn alternate_display_aligns_gutter_pipe() {
250        let error = Error::UnsupportedType {
251            text: "foo: bar\n\nbaz:\n\n  qux: datetime\n".to_string(),
252            location: Box::new(Location::at("file", "config.toml", Some(5), Some(8), None)),
253            found: "datetime",
254        };
255        let message = format!("{error:#}");
256        let source_line = message
257            .lines()
258            .find(|line| line.contains("qux: datetime"))
259            .expect("source line");
260        let underline_line = message
261            .lines()
262            .find(|line| line.contains('^'))
263            .expect("underline line");
264        let source_pipe = source_line.find('|').expect("source pipe");
265        let underline_pipe = underline_line.find('|').expect("underline pipe");
266        assert_eq!(source_pipe, underline_pipe);
267    }
268}