source_error/
lib.rs

1//! A magical type for displaying source file errors
2//!
3//! # example
4//!
5//! ```rust
6//! use source_error::{from_file, Position};
7//! use std::error::Error;
8//!
9//! fn main() -> Result<(), Box<dyn Error>> {
10//!     println!(
11//!       "{}",
12//!       from_file("whoopsie!", "tests/source.json", Position::new(3, 4))?
13//!     );
14//!     Ok(())
15//! }
16//! ```
17use colored::Colorize;
18use std::{
19    error::Error as StdError,
20    fmt, io,
21    ops::RangeInclusive,
22    path::{Path, PathBuf},
23};
24
25/// Line and column coordinates
26#[derive(Debug)]
27pub struct Position {
28    line: usize,
29    col: usize,
30}
31
32impl Position {
33    /// Return's a new `Position` given a line and column number
34    /// These should be 1-based
35    pub fn new(
36        line: usize,
37        col: usize,
38    ) -> Position {
39        Position { line, col }
40    }
41}
42
43/// An `Error` type targetting errors tied to source file contents
44///
45/// Most of the utility of this type is in its implementation of `Display` which
46/// renders the error next along with the relative line of code
47pub struct Error<S> {
48    message: String,
49    path: PathBuf,
50    lines: S,
51    position: Position,
52}
53
54impl<S> fmt::Debug for Error<S> {
55    fn fmt(
56        &self,
57        f: &mut fmt::Formatter<'_>,
58    ) -> fmt::Result {
59        f.debug_struct("Error")
60            .field("message", &self.message)
61            .field("path", &self.path.display())
62            .field("position", &self.position)
63            .finish()
64    }
65}
66
67/// Creates an `Error` with a message and path to source file a
68/// along with the relative position of the error
69///
70/// Positions should be  1-based to align with what people see in their
71/// source file editors
72pub fn from_file<M, P>(
73    message: M,
74    path: P,
75    position: Position,
76) -> io::Result<Error<String>>
77where
78    P: AsRef<Path>,
79    M: AsRef<str>,
80{
81    Ok(from_lines(
82        message,
83        path.as_ref(),
84        std::fs::read_to_string(path.as_ref())?,
85        position,
86    ))
87}
88
89/// Creates an `Error` with a message, path to source file a
90/// and source lines along with the relative position of the error
91///
92/// Positions are 1 based to align with what people see in their
93/// source file editors
94pub fn from_lines<M, P, S>(
95    message: M,
96    path: P,
97    lines: S,
98    position: Position,
99) -> Error<S>
100where
101    M: AsRef<str>,
102    P: AsRef<Path>,
103    S: AsRef<str>,
104{
105    Error {
106        message: message.as_ref().into(),
107        path: path.as_ref().into(),
108        lines,
109        position,
110    }
111}
112
113fn line_range(line: usize) -> RangeInclusive<usize> {
114    line.checked_sub(2).unwrap_or_default()..=line.checked_add(2).unwrap_or(std::usize::MAX)
115}
116
117/// Creates a colorized display of error information
118///
119/// You can disable color by exporting the `NO_COLOR` environment variable
120/// to anything but "0"
121impl<S> fmt::Display for Error<S>
122where
123    S: AsRef<str>,
124{
125    fn fmt(
126        &self,
127        f: &mut fmt::Formatter<'_>,
128    ) -> fmt::Result {
129        let Self {
130            message,
131            path,
132            lines,
133            position,
134        } = self;
135        let Position { line, col } = position;
136        let line_range = line_range(*line);
137        writeln!(f, "⚠️ {}\n", format!("error: {}", message).red())?;
138        writeln!(
139            f,
140            "   {}\n",
141            format!("at {}:{}:{}", path.display(), line, col).dimmed()
142        )?;
143        let lines = lines
144            .as_ref()
145            .lines()
146            .enumerate()
147            .filter_map(|(idx, line)| {
148                let line_idx = idx + 1;
149                if line_range.contains(&line_idx) {
150                    Some((line_idx, line))
151                } else {
152                    None
153                }
154            })
155            .collect::<Vec<_>>();
156        let max_line = lines
157            .last()
158            .map(|(idx, _)| idx.to_string().len())
159            .unwrap_or_default();
160        for (idx, matched) in lines {
161            if idx == *line {
162                write!(f, "{} ", ">".red())?;
163            } else {
164                f.write_str("  ")?;
165            }
166            writeln!(
167                f,
168                " {}{}",
169                format!("{}{} |", " ".repeat(max_line - idx.to_string().len()), idx).dimmed(),
170                matched
171            )?;
172        }
173        Ok(())
174    }
175}
176
177impl<S> StdError for Error<S> where S: AsRef<str> {}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use pretty_assertions::assert_eq;
183    use std::env;
184
185    #[test]
186    fn impl_error() {
187        fn is<E>(_: E)
188        where
189            E: StdError,
190        {
191        }
192        is(from_lines("..", "...", "", Position::new(1, 1)))
193    }
194
195    #[test]
196    fn impl_custom_debug() {
197        assert_eq!(
198            format!(
199                "{:?}",
200                from_lines(
201                    "error occurs here",
202                    "path/to/file.txt",
203                    ":)",
204                    Position::new(1, 1)
205                )
206            ),
207            "Error { message: \"error occurs here\", path: \"path/to/file.txt\", position: Position { line: 1, col: 1 } }"
208        )
209    }
210
211    #[test]
212    fn it_works() {
213        env::set_var("NO_COLOR", "");
214        let expected = include_str!("../tests/expect.txt");
215        let err = from_lines(
216            "something is definitely wrong here",
217            "../tests/source.json",
218            include_str!("../tests/source.json"),
219            Position::new(3, 4),
220        );
221        assert_eq!(format!("{}", err), expected)
222    }
223
224    #[test]
225    fn line_range_is_expected() {
226        for (given, expect) in &[
227            (1, (0, 3)),
228            (2, (0, 4)),
229            (3, (1, 5)),
230            (std::usize::MAX, (std::usize::MAX - 2, std::usize::MAX)),
231        ] {
232            let (start, end) = expect;
233            let range = line_range(*given);
234            assert_eq!(start, range.start());
235            assert_eq!(end, range.end());
236        }
237    }
238}