show_my_errors/
lib.rs

1//! This is a library to display simple errors in colorful, rustc-like way.
2//! It can't show multi-line errors or draw arrows between parts of code, but its interface
3//! is simple and easy to use. If you want something more complex, you probably should use
4//! [annotate-snippets](https://docs.rs/annotate-snippets), which is used by rustc itself.
5//!
6//! ## Basic usage
7//! Entry point of this library is [`AnnotationList`]. You should create it, add some errors
8//! and then use [`.show_stderr()`](AnnotationList::show_stderr) or
9//! [`.show_stdout()`](AnnotationList::show_stdout)
10//! with some [`Stylesheet`] to display the message.
11//! ```rust
12//! # use std::error::Error;
13//! # use show_my_errors::AnnotationList;
14//! # fn main() -> Result<(), Box<dyn Error>> {
15//! let mut list = AnnotationList::new("hello.txt", "Hello world!");
16//! list
17//!     .warning(4..7, "punctuation problem", "you probably forgot a comma")?
18//!     .info(0..0, "consider adding some translations", None)?;
19//! assert_eq!(list.to_string()?, r#"warning: punctuation problem
20//!   --> hello.txt:1:5
21//!    |
22//!  1 | Hello world!
23//!    |     ^^^ you probably forgot a comma
24//!
25//! info: consider adding some translations
26//!   --> hello.txt:1:1
27//!    |
28//!  1 | Hello world!
29//!    |
30//! "#);
31//! # Ok(())
32//! # }
33//! ```
34
35use std::{
36    io::{self, Write},
37    iter,
38    ops::Range,
39};
40use termcolor::{BufferWriter, ColorChoice, WriteColor};
41use thiserror::Error;
42
43mod annotation;
44pub use annotation::{Annotation, AnnotationText, Severity};
45
46mod stylesheet;
47pub use stylesheet::Stylesheet;
48
49#[derive(Debug, Error, PartialEq, Eq)]
50#[non_exhaustive]
51/// Errors that can occure while constructing [`AnnotationList`]. Fields of each variant are the
52/// start and the end of range, respectively.
53pub enum Error {
54    /// Provided annotation range crosses line boundary
55    #[error("range {0} .. {1} crosses line boundary")]
56    MultilineRange(usize, usize),
57    /// Range `end` is greater than its `start`
58    #[error("range {0} .. {1} is invalid: {1} < {0}")]
59    InvalidRange(usize, usize),
60    /// Range starts after last line end
61    #[error("range {0} .. {1} starts after last line end")]
62    AfterStringEnd(usize, usize),
63}
64
65pub type Result<T, E = Error> = std::result::Result<T, E>;
66
67#[derive(Debug, PartialEq, Eq)]
68#[doc(hidden)]
69pub struct AnnotatedLine<'a> {
70    start: usize,
71    content: &'a str,
72    annotations: Vec<Annotation>,
73}
74
75impl AnnotatedLine<'_> {
76    pub fn start(&self) -> usize {
77        self.start
78    }
79
80    pub fn annotations(&self) -> &[Annotation] {
81        &self.annotations
82    }
83
84    pub fn content(&self) -> &str {
85        self.content
86    }
87
88    pub fn add(&mut self, annotation: Annotation) -> Result<&mut Self> {
89        let range = annotation.range();
90        if range.end - range.start > self.content.len() {
91            Err(Error::MultilineRange(range.start, range.end))
92        } else {
93            self.annotations.push(annotation);
94            Ok(self)
95        }
96    }
97}
98
99/// List of annotations applied to some input string.
100/// Doesn't owns string, so has a limited lifetime.
101#[derive(Debug, PartialEq, Eq)]
102pub struct AnnotationList<'a> {
103    lines: Vec<AnnotatedLine<'a>>,
104    filename: String,
105}
106
107impl<'a> AnnotationList<'a> {
108    /// Create an annotation list from string. `filename` is used only to format messages, so
109    /// corresponding file doesn't need to exist.
110    pub fn new(filename: impl AsRef<str>, string: &'a str) -> Self {
111        let linebreaks: Vec<_> = iter::once(0)
112            .chain(
113                string
114                    .chars()
115                    .enumerate()
116                    .filter(|(_idx, c)| *c == '\n')
117                    .map(|(idx, _c)| idx + 1),
118            )
119            .chain(iter::once(string.len()))
120            .collect();
121        let lines = linebreaks
122            .windows(2)
123            // Last line when there is a newline at the and of string
124            .filter(|bounds| bounds[0] != bounds[1])
125            .map(|bounds| AnnotatedLine {
126                start: bounds[0],
127                content: &string[bounds[0]..bounds[1]],
128                annotations: vec![],
129            })
130            .collect();
131        Self {
132            filename: filename.as_ref().into(),
133            lines,
134        }
135    }
136
137    #[doc(hidden)]
138    pub fn annotated_lines(&self) -> &[AnnotatedLine] {
139        &self.lines
140    }
141
142    /// Add an [`Annotation`] to list. You may also use [`.info()`](AnnotationList::info),
143    /// [`.warning()`](AnnotationList::warning) and [`.error()`](AnnotationList::error) methods.
144    pub fn add(&mut self, annotation: Annotation) -> Result<&mut Self> {
145        let range = annotation.range();
146        let line_idx = match self
147            .lines
148            .binary_search_by(|line| line.start.cmp(&range.start))
149        {
150            Ok(idx) => idx,
151            Err(idx) if idx > 0 => idx - 1,
152            _ => unreachable!("lines in AnnotationList not starting at 0"),
153        };
154        let line = &mut self.lines[line_idx];
155        if range.start >= line.start() + line.content.len() {
156            Err(Error::AfterStringEnd(range.start, range.end))
157        } else {
158            self.lines[line_idx].add(annotation)?;
159            Ok(self)
160        }
161    }
162
163    /// Add an [`Severity::Info`] annotation to list. See [`Annotation::new`] docs for details
164    pub fn info(
165        &mut self,
166        range: Range<usize>,
167        header: impl AnnotationText,
168        text: impl AnnotationText,
169    ) -> Result<&mut Self> {
170        self.add(Annotation::info(range, header, text)?)
171    }
172
173    /// Add an [`Severity::Warning`] annotation to list. See [`Annotation::new`] docs for details
174    pub fn warning(
175        &mut self,
176        range: Range<usize>,
177        header: impl AnnotationText,
178        text: impl AnnotationText,
179    ) -> Result<&mut Self> {
180        self.add(Annotation::warning(range, header, text)?)
181    }
182
183    /// Add an [`Severity::Error`] annotation to list. See [`Annotation::new`] docs for details
184    pub fn error(
185        &mut self,
186        range: Range<usize>,
187        header: impl AnnotationText,
188        text: impl AnnotationText,
189    ) -> Result<&mut Self> {
190        self.add(Annotation::error(range, header, text)?)
191    }
192
193    /// Print an error message to stream using given stylesheet. If your stream implements
194    /// [`Write`](std::io::Write), but not [`WriteColor`](termcolor::WriteColor), consider wrapping
195    /// it into [`termcolor::Ansi`] or [`termcolor::NoColor`].
196    ///
197    /// This method uses no buffering, so you probably want to pass [`termcolor::Buffer`] to it
198    /// rather than raw stream.
199    ///
200    /// If you want to just print message to stdout/stderr, consider using
201    /// [`.print_stdout()`](AnnotationList::show_stdout) or
202    /// [`.print_stderr()`](AnnotationList::show_stderr) instead.
203    pub fn show<W: Write + WriteColor>(
204        &self,
205        mut stream: W,
206        stylesheet: &Stylesheet,
207    ) -> io::Result<()> {
208        let mut first_output = true;
209        for (idx, line) in self.lines.iter().enumerate() {
210            for annotation in line.annotations() {
211                let range = annotation.range();
212
213                // Padding
214                if first_output {
215                    first_output = false;
216                } else {
217                    stream.write(b"\n")?;
218                }
219
220                // Severity and header
221                let severity_color = stylesheet.by_severity(&annotation.severity);
222                stream.set_color(severity_color)?;
223                write!(stream, "{}:", annotation.severity)?;
224                if let Some(header) = &annotation.header {
225                    write!(stream, " {}\n", header)?;
226                } else {
227                    stream.write(b"\n")?;
228                }
229
230                // Line numbers column & filename
231                stream.set_color(&stylesheet.linenr)?;
232                let linenr = (idx + 1).to_string();
233                let nrcol_width = linenr.len() + 2;
234                print_n(&mut stream, b" ", linenr.len() + 1)?;
235                write!(stream, "--> ")?;
236                stream.set_color(&stylesheet.filename)?;
237                write!(
238                    stream,
239                    "{}:{}:{}\n",
240                    self.filename,
241                    idx + 1,
242                    range.start - line.start() + 1
243                )?;
244                stream.set_color(&stylesheet.linenr)?;
245                print_n(&mut stream, b" ", nrcol_width)?;
246                write!(stream, "|\n {} | ", idx + 1)?;
247
248                // Line content
249                stream.set_color(&stylesheet.content)?;
250                write!(stream, "{}", line.content)?;
251                if !line.content.ends_with('\n') {
252                    stream.write(b"\n")?;
253                }
254
255                // Line numbers column
256                stream.set_color(&stylesheet.linenr)?;
257                print_n(&mut stream, b" ", nrcol_width)?;
258                stream.write(b"|")?;
259
260                // Annotation
261                if range.end - range.start != 0 {
262                    stream.set_color(severity_color)?;
263                    print_n(&mut stream, b" ", range.start - line.start + 1)?;
264                    print_n(&mut stream, b"^", range.end - range.start)?;
265                    if let Some(text) = &annotation.text {
266                        write!(stream, " {}", text)?;
267                    }
268                }
269                stream.write(b"\n")?;
270                stream.reset()?;
271            }
272        }
273        Ok(())
274    }
275
276    fn show_bufwriter(&self, stream: BufferWriter, stylesheet: &Stylesheet) -> io::Result<()> {
277        let mut buf = stream.buffer();
278        self.show(&mut buf, stylesheet)?;
279        stream.print(&buf)
280    }
281
282    /// Print error message to stdout. Output will be colorized if stdout is a TTY
283    pub fn show_stdout(&self, stylesheet: &Stylesheet) -> io::Result<()> {
284        let color_choice = if atty::is(atty::Stream::Stdout) {
285            ColorChoice::Auto
286        } else {
287            ColorChoice::Never
288        };
289        self.show_bufwriter(termcolor::BufferWriter::stdout(color_choice), stylesheet)
290    }
291
292    /// Print error message to stderr. Output will be colorized if stderr is a TTY
293    pub fn show_stderr(&self, stylesheet: &Stylesheet) -> io::Result<()> {
294        let color_choice = if atty::is(atty::Stream::Stderr) {
295            ColorChoice::Auto
296        } else {
297            ColorChoice::Never
298        };
299        self.show_bufwriter(termcolor::BufferWriter::stderr(color_choice), stylesheet)
300    }
301
302    /// "Print" monochrome message to `Vec<u8>`
303    pub fn to_bytes(&self) -> io::Result<Vec<u8>> {
304        let mut buf = termcolor::Buffer::no_color();
305        self.show(&mut buf, &Stylesheet::monochrome())?;
306        Ok(buf.into_inner())
307    }
308
309    /// "Print" message to `Vec<u8>`, colorizing it using ANSI escape codes
310    pub fn to_ansi_bytes(&self, stylesheet: &Stylesheet) -> io::Result<Vec<u8>> {
311        let mut buf = termcolor::Buffer::ansi();
312        self.show(&mut buf, stylesheet)?;
313        Ok(buf.into_inner())
314    }
315
316    /// "Print" monochrome message to [`String`]
317    /// # Panics
318    /// Panics if message cannot be converted to UTF-8
319    pub fn to_string(&self) -> io::Result<String> {
320        Ok(String::from_utf8(self.to_bytes()?).expect("invalid utf-8 in AnnotationList"))
321    }
322
323    /// "Print" message to [`String`], colorizing it using ANSI escape codes
324    /// # Panics
325    /// Panics if message cannot be converted to UTF-8
326    pub fn to_ansi_string(&self, stylesheet: &Stylesheet) -> io::Result<String> {
327        Ok(String::from_utf8(self.to_ansi_bytes(stylesheet)?)
328            .expect("invalid utf-8 in AnnotationList"))
329    }
330}
331
332fn print_n(mut stream: impl io::Write, buf: &[u8], count: usize) -> io::Result<()> {
333    for _ in 0..count {
334        stream.write(buf)?;
335    }
336    Ok(())
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    fn assert_start_content<'a>(line: &AnnotatedLine<'a>, start: usize, content: &'a str) {
344        assert_eq!(line.start(), start);
345        assert_eq!(line.content(), content);
346    }
347
348    fn create_list() -> AnnotationList<'static> {
349        AnnotationList::new("test.txt", "\nstring\nwith\nmany\n\nnewlines\n\n")
350    }
351
352    #[test]
353    fn test_new_many_newlines() {
354        let annotation_list = create_list();
355        let mut lines = annotation_list.annotated_lines().iter();
356        assert_start_content(lines.next().unwrap(), 0, "\n");
357        assert_start_content(lines.next().unwrap(), 1, "string\n");
358        assert_start_content(lines.next().unwrap(), 8, "with\n");
359        assert_start_content(lines.next().unwrap(), 13, "many\n");
360        assert_start_content(lines.next().unwrap(), 18, "\n");
361        assert_start_content(lines.next().unwrap(), 19, "newlines\n");
362        assert_start_content(lines.next().unwrap(), 28, "\n");
363        assert!(lines.next().is_none());
364    }
365
366    #[test]
367    fn test_new_without_newlines() {
368        let annotation_list = AnnotationList::new("filename", "string without newlines");
369        let mut lines = annotation_list.annotated_lines().iter();
370        assert_start_content(lines.next().unwrap(), 0, "string without newlines");
371        assert!(lines.next().is_none());
372    }
373
374    #[test]
375    fn test_new_trailing_newline() {
376        let annotation_list = AnnotationList::new("filename", "string with trailing newline\n");
377        let mut lines = annotation_list.annotated_lines().iter();
378        assert_start_content(lines.next().unwrap(), 0, "string with trailing newline\n");
379    }
380
381    #[test]
382    fn test_new_leading_newline() {
383        let annotation_list = AnnotationList::new("filename", "\nstring with leading newline");
384        let mut lines = annotation_list.annotated_lines().iter();
385        assert_start_content(lines.next().unwrap(), 0, "\n");
386        assert_start_content(lines.next().unwrap(), 1, "string with leading newline");
387    }
388
389    #[test]
390    fn test_add_normal() -> Result<()> {
391        let ann1 = Annotation::info(1..3, "test1", "ann1")?;
392        let ann2 = Annotation::warning(13..17, "test2", "ann2")?;
393        let ann3 = Annotation::error(19..20, "test3", None)?;
394        let ann4 = Annotation::error(14..16, "test4", "ann4")?;
395
396        let mut list = create_list();
397        list.add(ann1.clone())?
398            .add(ann2.clone())?
399            .add(ann3.clone())?
400            .add(ann4.clone())?;
401
402        let mut other_option = create_list();
403        other_option
404            .info(1..3, "test1", "ann1")?
405            .warning(13..17, "test2", "ann2")?
406            .error(19..20, "test3", None)?
407            .error(14..16, "test4", "ann4")?;
408        assert_eq!(list, other_option);
409
410        for (idx, line) in list.annotated_lines().iter().enumerate() {
411            match idx {
412                1 => assert_eq!(line.annotations(), &[ann1.clone()]),
413                3 => assert_eq!(line.annotations(), &[ann2.clone(), ann4.clone()]),
414                5 => assert_eq!(line.annotations(), &[ann3.clone()]),
415                _ => assert_eq!(line.annotations(), &[]),
416            }
417        }
418        Ok(())
419    }
420
421    #[test]
422    fn test_add_at_the_end() -> Result<()> {
423        let mut list = AnnotationList::new("fname", "hello world");
424        list.error(10..10, None, None)?;
425        let mut list = AnnotationList::new("fname", "hello world\n");
426        list.error(11..11, None, None)?;
427        Ok(())
428    }
429
430    #[test]
431    fn test_invalid_adds() -> Result<()> {
432        let mut list = create_list();
433        assert_eq!(
434            list.add(Annotation::info(1..10, "test", "ann")?)
435                .unwrap_err(),
436            Error::MultilineRange(1, 10)
437        );
438        assert_eq!(
439            list.add(Annotation::info(1000..1001, "test", "ann")?)
440                .unwrap_err(),
441            Error::AfterStringEnd(1000, 1001)
442        );
443        assert_eq!(
444            Annotation::info(10..9, "test", "ann").unwrap_err(),
445            Error::InvalidRange(10, 9)
446        );
447        Ok(())
448    }
449
450    #[test]
451    fn test_to_string() -> Result<()> {
452        let mut list = create_list();
453        list.info(1..3, "test1", "ann1")?
454            .warning(13..17, "test2", "ann2")?
455            .error(19..20, "test3", None)?
456            .error(14..16, "test4", "ann4")?
457            .error(14..16, None, "ann5")?;
458        let result = r#"info: test1
459  --> test.txt:2:1
460   |
461 2 | string
462   | ^^ ann1
463
464warning: test2
465  --> test.txt:4:1
466   |
467 4 | many
468   | ^^^^ ann2
469
470error: test4
471  --> test.txt:4:2
472   |
473 4 | many
474   |  ^^ ann4
475
476error:
477  --> test.txt:4:2
478   |
479 4 | many
480   |  ^^ ann5
481
482error: test3
483  --> test.txt:6:1
484   |
485 6 | newlines
486   | ^
487"#;
488        assert_eq!(list.to_string().unwrap(), result);
489        Ok(())
490    }
491}