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}
27
28fn located_message(location: &Location, message: &str) -> String {
29    format!("{message} at {location}")
30}
31
32impl Display for Error {
33    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::InvalidUtf8 { location } => {
36                write!(f, "invalid utf-8 in configuration input from {location}")?;
37            }
38            Self::UnsupportedType {
39                location, found, ..
40            } => {
41                write!(
42                    f,
43                    "{}",
44                    located_message(
45                        location,
46                        &format!("unsupported configuration input type `{found}`"),
47                    )
48                )?;
49            }
50            Self::Parse {
51                location: Some(location),
52                message,
53                ..
54            } => write!(f, "{}", located_message(location, message))?,
55            Self::Parse { message, .. } => write!(f, "{message}")?,
56        }
57
58        if !f.alternate() {
59            return Ok(());
60        }
61
62        let (text, location) = match self {
63            Self::UnsupportedType { text, location, .. } => (text.as_str(), location),
64            Self::Parse {
65                text,
66                location: Some(location),
67                ..
68            } => (text.as_str(), location),
69            _ => return Ok(()),
70        };
71
72        let line_number = location.line.map(|line| line.get() as usize);
73        let column = location.column.map(|column| column.get() as usize);
74        let highlight = location
75            .length
76            .map_or(1, |length| length.get() as usize)
77            .max(1);
78
79        if let Some(line_number) = line_number {
80            let lines: Vec<&str> = text.split('\n').collect();
81            let start = if line_number > 1 { line_number - 2 } else { 0 };
82            let end = if line_number + 1 < lines.len() {
83                line_number + 1
84            } else {
85                lines.len()
86            };
87            let gutter_width = end.to_string().len();
88            let mut line_index = start;
89            while line_index < end {
90                let display_line = line_index + 1;
91                let line_text = display_line.to_string();
92                write!(f, "\n  ")?;
93                for _ in 0..gutter_width.saturating_sub(line_text.len()) {
94                    write!(f, " ")?;
95                }
96                write!(f, "{line_text} | ")?;
97                write!(f, "{}", lines[line_index])?;
98                if display_line == line_number {
99                    write!(f, "\n  ")?;
100                    for _ in 0..gutter_width.saturating_sub(line_text.len()) {
101                        write!(f, " ")?;
102                    }
103                    for _ in 0..line_text.len() + 1 {
104                        write!(f, " ")?;
105                    }
106                    write!(f, "| ")?;
107                    if let Some(column_number) = column {
108                        for _ in 1..column_number {
109                            write!(f, " ")?;
110                        }
111                    }
112                    for _ in 0..highlight {
113                        write!(f, "^")?;
114                    }
115                }
116                line_index += 1;
117            }
118        } else {
119            write!(f, "\n  {text}")?;
120            if let Some(column_number) = column {
121                write!(f, "\n  ")?;
122                for _ in 1..column_number {
123                    write!(f, " ")?;
124                }
125                for _ in 0..highlight {
126                    write!(f, "^")?;
127                }
128            }
129        }
130
131        Ok(())
132    }
133}
134
135impl std::error::Error for Error {}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::Location;
141
142    #[test]
143    fn default_display_is_single_line() {
144        let error = Error::UnsupportedType {
145            text: "foo: bar\nbaz: datetime\n".to_string(),
146            location: Box::new(Location::at("file", "config.toml", Some(2), Some(7), None)),
147            found: "datetime",
148        };
149        let message = error.to_string();
150        assert!(!message.contains('\n'));
151        assert!(!message.contains('^'));
152        assert!(message.contains("file:config.toml:2:7"));
153    }
154
155    #[test]
156    fn alternate_display_underlines_token() {
157        let error = Error::UnsupportedType {
158            text: "foo: bar\nbaz: datetime\n".to_string(),
159            location: Box::new(Location::at(
160                "file",
161                "config.toml",
162                Some(2),
163                Some(6),
164                Some(8),
165            )),
166            found: "datetime",
167        };
168        let message = format!("{error:#}");
169        assert!(message.contains("^^^^"));
170        assert!(message.contains("baz: datetime"));
171    }
172
173    #[test]
174    fn alternate_display_aligns_gutter_pipe() {
175        let error = Error::UnsupportedType {
176            text: "foo: bar\n\nbaz:\n\n  qux: datetime\n".to_string(),
177            location: Box::new(Location::at("file", "config.toml", Some(5), Some(8), None)),
178            found: "datetime",
179        };
180        let message = format!("{error:#}");
181        let source_line = message
182            .lines()
183            .find(|line| line.contains("qux: datetime"))
184            .expect("source line");
185        let underline_line = message
186            .lines()
187            .find(|line| line.contains('^'))
188            .expect("underline line");
189        let source_pipe = source_line.find('|').expect("source pipe");
190        let underline_pipe = underline_line.find('|').expect("underline pipe");
191        assert_eq!(source_pipe, underline_pipe);
192    }
193}