erebus 0.1.2

A CLI message generation library
Documentation
use unicode_segmentation::UnicodeSegmentation;
use yansi::{Color, Paint};

use crate::{render::print_gap, FileId, Files, Label, Render, Style};

impl<Id: FileId> Render<Id> for Vec<Label<Id>> {
    fn render(
        &self,
        ln_width: Option<usize>,
        files: &impl Files<FileId = Id>,
        style: &Style,
    ) -> String {
        if self.is_empty() {
            return String::new();
        }
        let Some(ln_width) = ln_width else {
            panic!("ln_width must be provided to render labels")
        };

        // Group by FileId
        let mut groups: Vec<Vec<&Label<Id>>> = Vec::new();
        for label in self {
            let group = groups
                .iter_mut()
                .find(|group| group.first().expect("must have one to exist").file == label.file);
            match group {
                Some(group) => {
                    group.push(label);
                }
                None => {
                    groups.push(vec![label]);
                }
            }
        }

        let mut result = String::new();
        for (i, group) in groups.iter().enumerate() {
            result.push_str(&render_group(group, i == 0, ln_width, files, style));
            if i != groups.len() - 1 {
                result.push(' ');
                result.push_str(&" ".repeat(ln_width));
                result.push(' ');
                result.push_str(
                    style
                        .characters
                        .vbar_gap
                        .to_string()
                        .dim()
                        .to_string()
                        .as_str(),
                );
            }
            result.push('\n');
        }
        result
    }
}

/// Render a group of labels.
///
/// # Panics
/// If all the labels do not have the same file.  
/// If a label has a negative length.  
/// If a label is multi-line (start and end are on different lines).  
pub fn render_group<Id: FileId>(
    group: &[&Label<Id>],
    first: bool,
    ln_width: usize,
    files: &impl Files<FileId = Id>,
    style: &Style,
) -> String {
    if group.is_empty() {
        return String::new();
    }
    let file = &group.first().expect("must have one to exist").file;
    assert!(
        group.iter().all(|label| label.file == *file),
        "All labels in a group must have the same file."
    );
    assert!(
        group.iter().all(|label| label.span.start < label.span.end),
        "Negative length labels are not supported."
    );

    let mut result = String::new();
    let file_name = files.name(file).expect("file must exist");

    result.push_str(
        format!(
            " {} {}{}{}{}",
            " ".repeat(ln_width),
            if first {
                style.characters.ltop.dim()
            } else {
                style.characters.lcross.dim()
            },
            style.characters.lbox.dim(),
            file_name,
            style.characters.rbox.dim()
        )
        .as_str(),
    );
    result.push('\n');
    result.push(' ');
    result.push_str(&" ".repeat(ln_width));
    result.push(' ');
    result.push_str(&style.characters.vbar.dim().to_string());

    // Group by line number
    let mut groups: Vec<Vec<(usize, &Label<Id>)>> = Vec::new();
    for label in group {
        let start = files
            .line_from_char(&label.file, label.span.start)
            .expect("start must exist");
        let end = files
            .line_from_char(&label.file, label.span.end)
            .expect("end must exist");
        assert!((start == end), "Multi-line labels are not supported.");
        let group = groups
            .iter_mut()
            .find(|group| group.first().expect("must have one to exist").0 == start);
        match group {
            Some(group) => {
                group.push((start, label));
            }
            None => {
                groups.push(vec![(start, label)]);
            }
        }
    }

    for (x, group) in groups.iter().enumerate() {
        result.push('\n');
        result.push_str(&render_line(ln_width, group, file, files, style));
        if x != groups.len() - 1 {
            print_gap(&mut result, ln_width, style);
        }
    }

    result
}

#[allow(clippy::too_many_lines)] // todo refactor?
fn render_line<Id: FileId>(
    width: usize,
    labels: &[(usize, &Label<Id>)],
    file: &Id,
    files: &impl Files<FileId = Id>,
    style: &Style,
) -> String {
    let line_number = labels.first().expect("must have one to exist").0;
    let mut result = String::new();
    result.push_str(
        &format!(
            " {:width$} {}",
            line_number,
            style.characters.vbar,
            width = width
        )
        .dim()
        .to_string(),
    );
    result.push(' ');

    let mut messages = Vec::new();

    let line_start = files
        .char_from_line(file, line_number)
        .expect("line must exist");
    for (_, label) in labels {
        messages.push((
            label.span.start - line_start,
            label.span.end - line_start,
            label,
        ));
    }

    // sort messages by start
    messages.sort_by(|a, b| a.0.cmp(&b.0));

    // print source
    let source = files
        .line(file, line_number)
        .expect("line must exist")
        .to_string();
    let mut source_out = String::with_capacity(source.len());
    let mut color_stack: Vec<(usize, Color)> = Vec::new();
    let mut last_end = 0;
    let mut spans = messages.clone().into_iter().peekable();
    loop {
        // get next label or end of line
        let (start, end, color) = match spans.next() {
            Some((start, end, label)) => (start, end, label.color),
            None => (source.len(), source.len(), Color::Primary),
        };
        if start != last_end {
            if color_stack.is_empty() {
                source_out.push_str(
                    &source
                        .graphemes(true)
                        .skip(last_end)
                        .take(start - last_end)
                        .collect::<String>()
                        .dim()
                        .to_string(),
                );
            } else {
                let (stack_end, stack_color) =
                    *color_stack.first().expect("must have one to exist");
                if stack_end < start {
                    source_out.push_str(
                        &Paint::new(
                            &source
                                .graphemes(true)
                                .skip(last_end)
                                .take(stack_end - last_end)
                                .collect::<String>(),
                        )
                        .fg(stack_color)
                        .to_string(),
                    );
                    color_stack.pop();
                    source_out.push_str(
                        &source
                            .graphemes(true)
                            .skip(stack_end)
                            .take(start - stack_end)
                            .collect::<String>()
                            .dim()
                            .to_string(),
                    );
                } else {
                    source_out.push_str(
                        &Paint::new(
                            &source
                                .graphemes(true)
                                .skip(last_end)
                                .take(start - last_end)
                                .collect::<String>(),
                        )
                        .fg(stack_color)
                        .to_string(),
                    );
                }
            }
        }
        let next_start = spans.peek().map_or(source.len(), |(start, _, _)| *start);
        if next_start > end {
            // paint to end
            source_out.push_str(
                &Paint::new(
                    &source
                        .graphemes(true)
                        .skip(start)
                        .take(end - start)
                        .collect::<String>(),
                )
                .fg(color)
                .to_string(),
            );
            last_end = end;
        } else {
            // paint to next label
            source_out.push_str(
                &Paint::new(
                    &source
                        .graphemes(true)
                        .skip(start)
                        .take(next_start - start)
                        .collect::<String>(),
                )
                .fg(color)
                .to_string(),
            );
            last_end = next_start;
            color_stack.push((end, color));
        }
        if last_end == source.len() {
            break;
        }
    }
    result.push_str(&source_out);
    result.push('\n');

    // print underlines
    print_gap(&mut result, width, style);
    let mut last_end = 0;
    for (start, end, label) in &messages {
        if label.content.is_none() {
            continue;
        }
        if last_end == 0 {
            result.push(' ');
        }
        let Some(gap) = start.checked_sub(last_end) else {
            continue;
        };
        result.push_str(&" ".repeat(gap));
        let len = end - start;
        result.push_str(
            &match len {
                1 => style.characters.underbar.to_string(),
                2 => format!(
                    "{}{}",
                    style.characters.underline, style.characters.underbar
                ),
                _ => format!(
                    "{}{}{}",
                    style.characters.underline,
                    style.characters.underbar,
                    style.characters.underline.to_string().repeat(len - 2)
                ),
            }
            .paint(label.color)
            .to_string(),
        );
        last_end = *end;
    }
    result.push('\n');

    // print messages
    if messages.iter().all(|(_, _, label)| label.content.is_none()) {
        return result;
    }
    let printable = messages
        .iter()
        .filter(|(_, _, label)| label.content.is_some())
        .collect::<Vec<_>>();
    let mut printed = Vec::with_capacity(printable.len());
    for i in 0..printable.len() {
        if printed.contains(&i) {
            continue;
        }
        let (start, end, label) = printable[i];
        if let Some(content) = &label.content {
            let mut line = String::new();
            print_gap(&mut line, width, style);
            line.push_str(&" ".repeat(start + (end - start).min(2)));
            line.push_str(
                &style
                    .characters
                    .lbot
                    .to_string()
                    .paint(label.color)
                    .to_string(),
            );
            line.push(' ');
            line.push_str(content);
            for (y, (next_start, _, next_label)) in printable.iter().enumerate().skip(i + 1) {
                if printed.contains(&y) {
                    continue;
                }
                if let Some(gap) = next_start
                    .checked_sub(*end)
                    .and_then(|gap| gap.checked_sub(1))
                {
                    line.push_str(&" ".repeat(gap));
                    line.push_str(
                        &style
                            .characters
                            .lbot
                            .to_string()
                            .paint(next_label.color)
                            .to_string(),
                    );
                    line.push(' ');
                    line.push_str(
                        next_label
                            .content
                            .as_ref()
                            .expect("content must exist to be printable"),
                    );
                    printed.push(y);
                }
            }
            result.push_str(&line);
            result.push('\n');
        }
        printed.push(i);
    }

    result
}