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