use std::{
io::{self, Write},
iter,
ops::Range,
};
use termcolor::{BufferWriter, ColorChoice, WriteColor};
use thiserror::Error;
mod annotation;
pub use annotation::{Annotation, AnnotationText, Severity};
mod stylesheet;
pub use stylesheet::Stylesheet;
#[derive(Debug, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
#[error("range {0} .. {1} crosses line boundary")]
MultilineRange(usize, usize),
#[error("range {0} .. {1} is invalid: {1} < {0}")]
InvalidRange(usize, usize),
#[error("range {0} .. {1} starts after last line end")]
AfterStringEnd(usize, usize),
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, PartialEq, Eq)]
#[doc(hidden)]
pub struct AnnotatedLine<'a> {
start: usize,
content: &'a str,
annotations: Vec<Annotation>,
}
impl AnnotatedLine<'_> {
pub fn start(&self) -> usize {
self.start
}
pub fn annotations(&self) -> &[Annotation] {
&self.annotations
}
pub fn content(&self) -> &str {
self.content
}
pub fn add(&mut self, annotation: Annotation) -> Result<&mut Self> {
let range = annotation.range();
if range.end - range.start > self.content.len() {
Err(Error::MultilineRange(range.start, range.end))
} else {
self.annotations.push(annotation);
Ok(self)
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct AnnotationList<'a> {
lines: Vec<AnnotatedLine<'a>>,
filename: String,
}
impl<'a> AnnotationList<'a> {
pub fn new(filename: impl AsRef<str>, string: &'a str) -> Self {
let linebreaks: Vec<_> = iter::once(0)
.chain(
string
.chars()
.enumerate()
.filter(|(_idx, c)| *c == '\n')
.map(|(idx, _c)| idx + 1),
)
.chain(iter::once(string.len()))
.collect();
let lines = linebreaks
.windows(2)
.filter(|bounds| bounds[0] != bounds[1])
.map(|bounds| AnnotatedLine {
start: bounds[0],
content: &string[bounds[0]..bounds[1]],
annotations: vec![],
})
.collect();
Self {
filename: filename.as_ref().into(),
lines,
}
}
#[doc(hidden)]
pub fn annotated_lines(&self) -> &[AnnotatedLine] {
&self.lines
}
pub fn add(&mut self, annotation: Annotation) -> Result<&mut Self> {
let range = annotation.range();
let line_idx = match self
.lines
.binary_search_by(|line| line.start.cmp(&range.start))
{
Ok(idx) => idx,
Err(idx) if idx > 0 => idx - 1,
_ => unreachable!("lines in AnnotationList not starting at 0"),
};
dbg!(&self.lines[self.lines.len() - 1]);
let line = &mut self.lines[line_idx];
dbg!(&line);
if range.start >= line.start() + line.content.len() {
Err(Error::AfterStringEnd(range.start, range.end))
} else {
self.lines[line_idx].add(annotation)?;
Ok(self)
}
}
pub fn info(
&mut self,
range: Range<usize>,
header: impl AnnotationText,
text: impl AnnotationText,
) -> Result<&mut Self> {
self.add(Annotation::info(range, header, text)?)
}
pub fn warning(
&mut self,
range: Range<usize>,
header: impl AnnotationText,
text: impl AnnotationText,
) -> Result<&mut Self> {
self.add(Annotation::warning(range, header, text)?)
}
pub fn error(
&mut self,
range: Range<usize>,
header: impl AnnotationText,
text: impl AnnotationText,
) -> Result<&mut Self> {
self.add(Annotation::error(range, header, text)?)
}
pub fn show<W: Write + WriteColor>(
&self,
mut stream: W,
stylesheet: &Stylesheet,
) -> io::Result<()> {
let mut first_output = true;
for (idx, line) in self.lines.iter().enumerate() {
for annotation in line.annotations() {
let range = annotation.range();
if first_output {
first_output = false;
} else {
stream.write(b"\n")?;
}
let severity_color = stylesheet.by_severity(&annotation.severity);
stream.set_color(severity_color)?;
write!(stream, "{}:", annotation.severity)?;
if let Some(header) = &annotation.header {
write!(stream, " {}\n", header)?;
} else {
stream.write(b"\n")?;
}
stream.set_color(&stylesheet.linenr)?;
let linenr = (idx + 1).to_string();
let nrcol_width = linenr.len() + 2;
print_n(&mut stream, b" ", linenr.len() + 1)?;
write!(stream, "--> ")?;
stream.set_color(&stylesheet.filename)?;
write!(
stream,
"{}:{}:{}\n",
self.filename,
idx + 1,
range.start - line.start() + 1
)?;
stream.set_color(&stylesheet.linenr)?;
print_n(&mut stream, b" ", nrcol_width)?;
write!(stream, "|\n {} | ", idx + 1)?;
stream.set_color(&stylesheet.content)?;
write!(stream, "{}", line.content)?;
if !line.content.ends_with('\n') {
stream.write(b"\n")?;
}
stream.set_color(&stylesheet.linenr)?;
print_n(&mut stream, b" ", nrcol_width)?;
stream.write(b"|")?;
if range.end - range.start != 0 {
stream.set_color(severity_color)?;
print_n(&mut stream, b" ", range.start - line.start + 1)?;
print_n(&mut stream, b"^", range.end - range.start)?;
if let Some(text) = &annotation.text {
write!(stream, " {}", text)?;
}
}
stream.write(b"\n")?;
stream.reset()?;
}
}
Ok(())
}
fn show_bufwriter(&self, stream: BufferWriter, stylesheet: &Stylesheet) -> io::Result<()> {
let mut buf = stream.buffer();
self.show(&mut buf, stylesheet)?;
stream.print(&buf)
}
pub fn show_stdout(&self, stylesheet: &Stylesheet) -> io::Result<()> {
let color_choice = if atty::is(atty::Stream::Stdout) {
ColorChoice::Auto
} else {
ColorChoice::Never
};
self.show_bufwriter(termcolor::BufferWriter::stdout(color_choice), stylesheet)
}
pub fn show_stderr(&self, stylesheet: &Stylesheet) -> io::Result<()> {
let color_choice = if atty::is(atty::Stream::Stderr) {
ColorChoice::Auto
} else {
ColorChoice::Never
};
self.show_bufwriter(termcolor::BufferWriter::stderr(color_choice), stylesheet)
}
pub fn to_bytes(&self) -> io::Result<Vec<u8>> {
let mut buf = termcolor::Buffer::no_color();
self.show(&mut buf, &Stylesheet::monochrome())?;
Ok(buf.into_inner())
}
pub fn to_ansi_bytes(&self, stylesheet: &Stylesheet) -> io::Result<Vec<u8>> {
let mut buf = termcolor::Buffer::ansi();
self.show(&mut buf, stylesheet)?;
Ok(buf.into_inner())
}
pub fn to_string(&self) -> io::Result<String> {
Ok(String::from_utf8(self.to_bytes()?).expect("invalid utf-8 in AnnotationList"))
}
pub fn to_ansi_string(&self, stylesheet: &Stylesheet) -> io::Result<String> {
Ok(String::from_utf8(self.to_ansi_bytes(stylesheet)?)
.expect("invalid utf-8 in AnnotationList"))
}
}
fn print_n(mut stream: impl io::Write, buf: &[u8], count: usize) -> io::Result<()> {
for _ in 0..count {
stream.write(buf)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_start_content<'a>(line: &AnnotatedLine<'a>, start: usize, content: &'a str) {
assert_eq!(line.start(), start);
assert_eq!(line.content(), content);
}
fn create_list() -> AnnotationList<'static> {
AnnotationList::new("test.txt", "\nstring\nwith\nmany\n\nnewlines\n\n")
}
#[test]
fn test_new_many_newlines() {
let annotation_list = create_list();
let mut lines = annotation_list.annotated_lines().iter();
assert_start_content(lines.next().unwrap(), 0, "\n");
assert_start_content(lines.next().unwrap(), 1, "string\n");
assert_start_content(lines.next().unwrap(), 8, "with\n");
assert_start_content(lines.next().unwrap(), 13, "many\n");
assert_start_content(lines.next().unwrap(), 18, "\n");
assert_start_content(lines.next().unwrap(), 19, "newlines\n");
assert_start_content(lines.next().unwrap(), 28, "\n");
assert!(lines.next().is_none());
}
#[test]
fn test_new_without_newlines() {
let annotation_list = AnnotationList::new("filename", "string without newlines");
let mut lines = annotation_list.annotated_lines().iter();
assert_start_content(lines.next().unwrap(), 0, "string without newlines");
assert!(lines.next().is_none());
}
#[test]
fn test_new_trailing_newline() {
let annotation_list = AnnotationList::new("filename", "string with trailing newline\n");
let mut lines = annotation_list.annotated_lines().iter();
assert_start_content(lines.next().unwrap(), 0, "string with trailing newline\n");
}
#[test]
fn test_new_leading_newline() {
let annotation_list = AnnotationList::new("filename", "\nstring with leading newline");
let mut lines = annotation_list.annotated_lines().iter();
assert_start_content(lines.next().unwrap(), 0, "\n");
assert_start_content(lines.next().unwrap(), 1, "string with leading newline");
}
#[test]
fn test_add_normal() -> Result<()> {
let ann1 = Annotation::info(1..3, "test1", "ann1")?;
let ann2 = Annotation::warning(13..17, "test2", "ann2")?;
let ann3 = Annotation::error(19..20, "test3", None)?;
let ann4 = Annotation::error(14..16, "test4", "ann4")?;
let mut list = create_list();
list.add(ann1.clone())?
.add(ann2.clone())?
.add(ann3.clone())?
.add(ann4.clone())?;
let mut other_option = create_list();
other_option
.info(1..3, "test1", "ann1")?
.warning(13..17, "test2", "ann2")?
.error(19..20, "test3", None)?
.error(14..16, "test4", "ann4")?;
assert_eq!(list, other_option);
for (idx, line) in list.annotated_lines().iter().enumerate() {
match idx {
1 => assert_eq!(line.annotations(), &[ann1.clone()]),
3 => assert_eq!(line.annotations(), &[ann2.clone(), ann4.clone()]),
5 => assert_eq!(line.annotations(), &[ann3.clone()]),
_ => assert_eq!(line.annotations(), &[]),
}
}
Ok(())
}
#[test]
fn test_add_at_the_end() -> Result<()> {
let mut list = AnnotationList::new("fname", "hello world");
list.error(10..10, None, None)?;
let mut list = AnnotationList::new("fname", "hello world\n");
list.error(11..11, None, None)?;
Ok(())
}
#[test]
fn test_invalid_adds() -> Result<()> {
let mut list = create_list();
assert_eq!(
list.add(Annotation::info(1..10, "test", "ann")?)
.unwrap_err(),
Error::MultilineRange(1, 10)
);
assert_eq!(
list.add(Annotation::info(1000..1001, "test", "ann")?)
.unwrap_err(),
Error::AfterStringEnd(1000, 1001)
);
assert_eq!(
Annotation::info(10..9, "test", "ann").unwrap_err(),
Error::InvalidRange(10, 9)
);
Ok(())
}
#[test]
fn test_to_string() -> Result<()> {
let mut list = create_list();
list.info(1..3, "test1", "ann1")?
.warning(13..17, "test2", "ann2")?
.error(19..20, "test3", None)?
.error(14..16, "test4", "ann4")?
.error(14..16, None, "ann5")?;
let result = r#"info: test1
--> test.txt:2:1
|
2 | string
| ^^ ann1
warning: test2
--> test.txt:4:1
|
4 | many
| ^^^^ ann2
error: test4
--> test.txt:4:2
|
4 | many
| ^^ ann4
error:
--> test.txt:4:2
|
4 | many
| ^^ ann5
error: test3
--> test.txt:6:1
|
6 | newlines
| ^
"#;
assert_eq!(list.to_string().unwrap(), result);
Ok(())
}
}