lisbeth_error/
reporter.rs

1//! Allows to report errors to the user.
2//!
3//! This module contains the [`ErrorReporter`] structure, which holds metadata
4//! about the input. This structure provides the metadata that are needed to
5//! properly report the error to the user.
6//!
7//! # Example
8//!
9//! The following code snippet shows how objects defined in the `lisbeth_error`
10//! crate interact with each other:
11//!
12//! ```rust
13//! use lisbeth_error::{
14//!     error::AnnotatedError,
15//!     reporter::ErrorReporter,
16//! };
17//!
18//!
19//! let reporter = ErrorReporter::input_file(
20//!     "docs.txt".to_string(),
21//!     "The cat are on the table.".to_string(),
22//! );
23//! let file = reporter.spanned_str();
24//!
25//! let cat = file.split_at(4).1.split_at(3).0;
26//! let are = file.split_at(8).1.split_at(3).0;
27//!
28//! let report = AnnotatedError::new(are.span(), "Conjugation error")
29//!     .with_annotation(cat.span(), "`cat` is singular,")
30//!     .with_annotation(are.span(), "but `are` is used only for plural subject");
31//!
32//! println!("{}", reporter.format_error(&report));
33//! ```
34//!
35//! This will print to STDOUT:
36//!
37//! ```none
38//! Error: Conjugation error
39//! --> docs.txt:1:9
40//!     |
41//!   1 |                                           The cat are on the table.
42//!     |                                               ^^^ ^^^
43//!     | `cat` is singular,----------------------------'   |
44//!     | but `are` is used only for plural subject---------'
45//!     |
46//! ```
47
48use std::{
49    fmt::{self, Display},
50    fs,
51    io::Error as IOError,
52};
53
54use crate::{
55    error::AnnotatedError,
56    span::{Position, Span, SpannedStr},
57};
58
59/// Holds metadata about the input, allows to report errors to the user.
60///
61/// This structure should be created before the parsing process and will provide
62/// metadata required for the [`AnnotatedError`].
63///
64/// If the reporter contains a file path, then this path must be valid UTF-8.
65/// This is needed because the file path is printed on the console when an error
66/// is reported.
67///
68/// [`AnnotatedError`]: [super::error::AnnotatedError]
69pub struct ErrorReporter {
70    path: Option<String>,
71    content: String,
72    span: Span,
73}
74
75impl ErrorReporter {
76    /// Given a file path and its content, creates a new [`ErrorReporter`].
77    ///
78    /// `path` is not checked to be a valid path.
79    pub fn input_file(path: String, content: String) -> ErrorReporter {
80        let path = Some(path);
81        let span = Span::of_file(content.as_str());
82        ErrorReporter {
83            content,
84            path,
85            span,
86        }
87    }
88
89    /// Creates an [`ErrorReporter`] with no file path, just its content.
90    ///
91    /// This can be usefull in situations in which non-file inputs such as STDIN
92    /// are processed.
93    pub fn non_file_input(content: String) -> ErrorReporter {
94        let path = None;
95        let span = Span::of_file(content.as_str());
96        ErrorReporter {
97            content,
98            path,
99            span,
100        }
101    }
102
103    /// Reads the content of `path`, and creates an [`ErrorReporter`] with it.
104    pub fn from_path(path: String) -> Result<ErrorReporter, IOError> {
105        fs::read_to_string(path.as_str())
106            .map(|content| (Span::of_file(content.as_str()), content, Some(path)))
107            .map(|(span, content, path)| ErrorReporter {
108                content,
109                path,
110                span,
111            })
112    }
113
114    /// Returns the file path, if it exists.
115    pub fn path(&self) -> Option<&str> {
116        self.path.as_deref()
117    }
118
119    /// Returns the [`SpannedStr`] associated to the whole input.
120    ///
121    /// # Example
122    ///
123    /// ```rust
124    /// use lisbeth_error::reporter::ErrorReporter;
125    ///
126    /// let file = ErrorReporter::non_file_input("Hello, world".to_string());
127    /// assert_eq!(file.spanned_str().content(), "Hello, world");
128    /// ```
129    pub fn spanned_str(&self) -> SpannedStr {
130        // self.span has been built from self.content, so this call is fine.
131        SpannedStr::assemble(self.content.as_str(), self.span)
132    }
133
134    fn code_snippet_for(&self, start_pos: Position, end_pos: Position) -> &str {
135        let (start_offset, end_offset) = (start_pos.offset() as usize, end_pos.offset() as usize);
136
137        let before_start = self.content.split_at(start_offset).0;
138        let after_end = self.content.split_at(end_offset).1;
139
140        let end_idx = end_offset as usize
141            + after_end
142                .char_indices()
143                .find(|(_, c)| *c == '\n')
144                .map(|(idx, _)| idx)
145                .unwrap_or_else(|| after_end.len());
146
147        let start_idx = before_start
148            .char_indices()
149            .rev()
150            .take_while(|(_, c)| *c != '\n')
151            .last()
152            .map(|(idx, _)| idx)
153            .unwrap_or_else(|| before_start.len());
154
155        self.content.split_at(end_idx).0.split_at(start_idx).1
156    }
157
158    /// Constructs a [`FormattedError`] from an [`AnnotatedError`].
159    ///
160    /// The returned value can finally be printed to the user.
161    pub fn format_error<'a>(&'a self, err: &'a AnnotatedError) -> FormattedError<'a> {
162        let (start_pos, end_pos) = err.bounds();
163        let stream_name = self.path();
164        let text = self.code_snippet_for(start_pos, end_pos);
165
166        let pos = err.span.start();
167        let general_msg = err.msg.as_str();
168
169        let errors = err.error_matrix();
170
171        let first_line_number = start_pos.line() as usize;
172
173        FormattedError {
174            pos,
175            first_line_number,
176            general_msg,
177            stream_name,
178            text,
179            errors,
180        }
181    }
182}
183
184/// An error object that can finally be displayed.
185///
186/// This structure is created by [`ErrorReporter::format_error`], and
187/// implements the [`Display`] trait.
188#[derive(Clone, Debug, PartialEq)]
189pub struct FormattedError<'a> {
190    pos: Position,
191    general_msg: &'a str,
192    stream_name: Option<&'a str>,
193    first_line_number: usize,
194    // Invariant: text.lines().count() == errors.len()
195    text: &'a str,
196    errors: Vec<Vec<Annotation<'a>>>,
197}
198
199impl<'a> FormattedError<'a> {
200    fn write_general_message(&self, f: &mut fmt::Formatter) -> fmt::Result {
201        writeln!(f, "Error: {}", self.general_msg)
202    }
203
204    fn write_position(&self, f: &mut fmt::Formatter) -> fmt::Result {
205        let (line, col) = (self.pos.line() + 1, self.pos.col() + 1);
206        match self.stream_name {
207            Some(name) => writeln!(f, " --> {}:{}:{}", name, line, col),
208            None => writeln!(f, " --> {}:{}", line, col),
209        }
210    }
211
212    fn write_header(&self, f: &mut fmt::Formatter) -> fmt::Result {
213        self.write_general_message(f)?;
214        self.write_position(f)
215    }
216
217    fn spacing(&self) -> usize {
218        self.errors
219            .iter()
220            .flatten()
221            .map(|ann| ann.text.len())
222            .max()
223            .unwrap_or(0)
224    }
225
226    fn write_line(
227        content: &str,
228        spacing: usize,
229        number: usize,
230        f: &mut fmt::Formatter,
231    ) -> fmt::Result {
232        writeln!(f, " {:>3} | {} {}", number, " ".repeat(spacing), content)
233    }
234
235    fn write_underlines(
236        errs: &[Annotation<'_>],
237        spacing: usize,
238        f: &mut fmt::Formatter,
239    ) -> fmt::Result {
240        write!(f, "     | {} ", " ".repeat(spacing))?;
241
242        let mut current_col_number = 0;
243        for annotation in errs {
244            let delta = annotation.col_number - current_col_number;
245            let length = usize::max(1, annotation.length);
246            let chr = if length == 1 { "|" } else { "^" };
247
248            write!(f, "{}{}", " ".repeat(delta), chr.repeat(length))?;
249
250            current_col_number += delta + length;
251        }
252
253        writeln!(f)
254    }
255
256    fn write_error_line(
257        annotation: &Annotation,
258        spacing: usize,
259        other_annotations: &[Annotation],
260        f: &mut fmt::Formatter,
261    ) -> fmt::Result {
262        let pipe_len = spacing - annotation.text.len() + annotation.col_number + 1;
263
264        write!(f, "     | {}{}'", annotation.text, "-".repeat(pipe_len))?;
265
266        let mut current_col_number = annotation.col_number;
267
268        for annotation in other_annotations {
269            let delta = annotation.col_number - current_col_number - 1;
270            write!(f, "{}|", " ".repeat(delta))?;
271
272            current_col_number = annotation.col_number;
273        }
274
275        writeln!(f)
276    }
277
278    fn write_errors(
279        annotations: &[Annotation<'_>],
280        spacing: usize,
281        f: &mut fmt::Formatter,
282    ) -> fmt::Result {
283        Self::write_underlines(annotations, spacing, f)?;
284
285        for idx in 0..annotations.len() {
286            let annotation = &annotations[idx];
287            let annotations = &annotations[idx + 1..];
288
289            Self::write_error_line(annotation, spacing, annotations, f)?;
290        }
291
292        Ok(())
293    }
294}
295
296impl<'a> Display for FormattedError<'a> {
297    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
298        self.write_header(f)?;
299
300        let spacing = self.spacing();
301
302        writeln!(f, "     |")?;
303
304        for (idx, (line, errs)) in self.text.lines().zip(self.errors.iter()).enumerate() {
305            Self::write_line(line, spacing, idx + self.first_line_number + 1, f)?;
306            Self::write_errors(errs, spacing, f)?;
307
308            writeln!(f, "     |")?;
309        }
310
311        Ok(())
312    }
313}
314
315#[derive(Clone, Debug, PartialEq)]
316pub(crate) struct Annotation<'a> {
317    pub(crate) col_number: usize,
318    pub(crate) length: usize,
319    pub(crate) text: &'a str,
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    mod reporting {
327        // In this module, a set of "correct reports" are checked.
328        use super::*;
329
330        #[test]
331        fn reporting_simple() {
332            let input_file = ErrorReporter::non_file_input("hello, world".to_string());
333
334            let hello = input_file.spanned_str().split_at(5).0;
335            let comma = input_file.spanned_str().split_at(5).1.split_at(1).0;
336            let world = input_file.spanned_str().split_at(7).1;
337
338            let report = AnnotatedError::new(comma.span(), "Don't you recognize me?")
339                .with_annotation(hello.span(), "Hi sweetie")
340                .with_annotation(world.span(), "I am not a world!")
341                .with_annotation(comma.span(), "Such cute, very comma");
342
343            let left = input_file.format_error(&report).to_string();
344
345            let right = "\
346            Error: Don't you recognize me?\n \
347             --> 1:6\n     \
348                 |\n   \
349               1 |                       hello, world\n     \
350                 |                       ^^^^^| ^^^^^\n     \
351                 | Hi sweetie------------'    | |\n     \
352                 | Such cute, very comma------' |\n     \
353                 | I am not a world!------------'\n     \
354                 |\n\
355            ";
356
357            assert_eq!(left, right);
358        }
359
360        #[test]
361        fn conjugaison_error() {
362            let reporter = ErrorReporter::input_file(
363                "docs.txt".to_string(),
364                "The cat are on the table.".to_string(),
365            );
366            let file = reporter.spanned_str();
367
368            let cat = file.split_at(4).1.split_at(3).0;
369            let are = file.split_at(8).1.split_at(3).0;
370
371            let report = AnnotatedError::new(are.span(), "Conjugation error")
372                .with_annotation(cat.span(), "`cat` is singular,")
373                .with_annotation(are.span(), "but `are` is used only for plural subject");
374
375            let left = reporter.format_error(&report).to_string();
376
377            let right = "\
378            Error: Conjugation error\n \
379             --> docs.txt:1:9\n     \
380                 |\n   \
381               1 |                                           The cat are on the table.\n     \
382                 |                                               ^^^ ^^^\n     \
383                 | `cat` is singular,----------------------------'   |\n     \
384                 | but `are` is used only for plural subject---------'\n     \
385                 |\n\
386            ";
387
388            assert_eq!(left, right);
389        }
390
391        #[test]
392        fn multiline_simple() {
393            let reporter = ErrorReporter::non_file_input("Hello\nWorld".into());
394            let content = reporter.spanned_str();
395
396            let hello = content.split_at(5).0;
397            let world = content.split_at(6).1;
398
399            let report = AnnotatedError::new(hello.span(), "Foo")
400                .with_annotation(hello.span(), "bar")
401                .with_annotation(world.span(), "baz");
402
403            let left = reporter.format_error(&report).to_string();
404
405            let right = "\
406            Error: Foo\n \
407             --> 1:1\n     \
408                 |\n   \
409               1 |     Hello\n     \
410                 |     ^^^^^\n     \
411                 | bar-'\n     \
412                 |\n   \
413               2 |     World\n     \
414                 |     ^^^^^\n     \
415                 | baz-'\n     \
416                 |\n\
417            ";
418
419            assert_eq!(left, right);
420        }
421    }
422
423    mod error_reporter {
424        use super::*;
425
426        #[test]
427        fn code_snippet_for_single_line() {
428            let foobar = "foo bar";
429            let input_file = ErrorReporter::non_file_input(foobar.to_string());
430
431            let foo = input_file.spanned_str().split_at(3).0;
432            let bar = input_file.spanned_str().split_at(4).1;
433
434            let report = AnnotatedError::new(foo.span(), "Common word found")
435                .with_annotation(foo.span(), "This happens to be a common word")
436                .with_annotation(bar.span(), "This too by the way");
437
438            let (start, end) = report.bounds();
439
440            let selected_text = input_file.code_snippet_for(start, end);
441
442            assert_eq!(selected_text, "foo bar");
443        }
444
445        #[test]
446        fn code_snippet_for_select_specific() {
447            let input_text = "foo bar\nbarbar\nbazbaz";
448            let input_file = ErrorReporter::non_file_input(input_text.to_string());
449
450            let barbar = input_file.spanned_str().split_at(8).1.split_at(6).0;
451            assert_eq!(barbar.content(), "barbar");
452            let report = AnnotatedError::new(barbar.span(), "Found a non-existant word");
453
454            let (start, end) = report.bounds();
455
456            let selected_text = input_file.code_snippet_for(start, end);
457
458            assert_eq!(selected_text, "barbar");
459        }
460    }
461}