cargo-guard 0.1.1

Cargo build script allow list
use std::ops::Range;

use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
    Frame,
    layout::{Position, Rect},
    style::{Color, Style},
    widgets::{Block, Paragraph},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

use super::selection::Selection;

#[derive(Default)]
pub(super) struct Diagnostic {
    report: String,
    rows: Vec<Range<usize>>,
    width: u16,
    top: usize,
    is_error: bool,
    pub selection: Selection,
}

impl Diagnostic {
    pub fn set_report(&mut self, report: String, is_error: bool) {
        self.is_error = is_error;
        if self.report == report {
            return;
        }
        self.report = report;
        self.rows.clear();
        self.width = 0;
        self.top = 0;
        self.selection.clear();
        self.selection.area = Rect::default();
    }

    pub fn reset_view(&mut self) {
        self.top = 0;
        self.selection.clear();
        self.selection.area = Rect::default();
    }

    pub fn selected_text(&self) -> Option<String> {
        let (start, end) = self.selection.bounds()?;
        let first = self.rows.get(start.row)?;
        let last = self.rows.get(end.row)?;
        let start = first.start + byte_column(&self.report[first.clone()], start.column);
        let end = last.start + byte_column(&self.report[last.clone()], end.end);
        Some(self.report[start..end].to_owned())
    }

    pub fn height(&mut self, width: u16, available: u16) -> u16 {
        if self.report.is_empty() {
            return 0;
        }
        self.wrap(width.saturating_sub(2));
        let height = self.rows.len().saturating_add(2).min(8) as u16;
        height.min(available.saturating_sub(4).max(3).min(available))
    }

    pub fn render(&mut self, frame: &mut Frame, area: Rect) {
        if self.report.is_empty() {
            self.selection.area = Rect::default();
            return;
        }
        let color = if self.is_error {
            Color::Red
        } else {
            Color::Yellow
        };
        let title = if self.is_error {
            " Error "
        } else {
            " Warning "
        };
        let block = Block::bordered()
            .border_style(Style::default().fg(color))
            .title(title);
        let inner = block.inner(area);
        frame.render_widget(block, area);
        self.render_body(frame, inner);
    }

    pub fn render_body(&mut self, frame: &mut Frame, inner: Rect) {
        self.wrap(inner.width);
        self.selection.area = inner;
        self.top = self.top.min(self.max_top());
        for (offset, range) in self
            .rows
            .iter()
            .skip(self.top)
            .take(usize::from(inner.height))
            .enumerate()
        {
            let text = &self.report[range.clone()];
            let row = Rect::new(inner.x, inner.y + offset as u16, inner.width, 1);
            frame.render_widget(Paragraph::new(text), row);
            if let Some(columns) = self.selection.columns(self.top + offset) {
                let end = columns
                    .end
                    .min(text.width() + 1)
                    .min(usize::from(inner.width));
                for column in columns.start.min(end)..end {
                    frame.buffer_mut()[(inner.x + column as u16, row.y)]
                        .set_style(Style::default().fg(Color::Black).bg(Color::LightBlue));
                }
            }
        }
    }

    pub fn handle_mouse(&mut self, event: MouseEvent) -> Option<bool> {
        if self.report.is_empty() || event.modifiers.contains(KeyModifiers::SHIFT) {
            return None;
        }
        let inside = self
            .selection
            .area
            .contains(Position::new(event.column, event.row));
        if !inside && !self.selection.is_dragging() {
            return None;
        }
        let scrolling = inside
            && matches!(
                event.kind,
                MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
            );
        let mut event = event;
        if scrolling {
            self.scroll(if event.kind == MouseEventKind::ScrollUp {
                -3
            } else {
                3
            });
            if self.selection.is_dragging() {
                event.kind = MouseEventKind::Drag(MouseButton::Left);
            }
        }
        let changed = self
            .selection
            .handle_mouse(event, self.top, 0, self.rows.len(), |row| {
                self.report[self.rows[row].clone()].graphemes(true)
            });
        Some(scrolling || changed)
    }

    fn max_top(&self) -> usize {
        self.rows
            .len()
            .saturating_sub(usize::from(self.selection.area.height).max(1))
    }

    pub fn scroll(&mut self, rows: isize) {
        self.top = self.top.saturating_add_signed(rows).min(self.max_top());
    }

    fn wrap(&mut self, width: u16) {
        let width = width.max(1);
        if width == self.width {
            return;
        }
        self.width = width;
        self.rows = wrap_ranges(&self.report, usize::from(width));
        self.selection.clear();
        self.top = self.top.min(self.max_top());
    }
}

fn byte_column(text: &str, column: usize) -> usize {
    let mut cells = 0;
    for (index, grapheme) in text.grapheme_indices(true) {
        if cells >= column {
            return index;
        }
        cells += grapheme.width();
    }
    text.len()
}

fn wrap_ranges(text: &str, width: usize) -> Vec<Range<usize>> {
    let mut rows = Vec::new();
    let mut base = 0;
    for line in text.split('\n') {
        let mut start = 0;
        if line.is_empty() {
            rows.push(base..base);
        }
        while start < line.len() {
            let mut cells = 0;
            let mut end = start;
            let mut word_break = None;
            for (index, grapheme) in line[start..].grapheme_indices(true) {
                let next = cells + grapheme.width();
                if next > width && end > start {
                    break;
                }
                cells = next;
                end = start + index + grapheme.len();
                if grapheme.chars().all(char::is_whitespace) {
                    word_break = Some(end);
                }
            }
            if end < line.len() {
                end = word_break.unwrap_or(end);
            }
            rows.push(base + start..base + end);
            start = end;
        }
        base += line.len() + 1;
    }
    rows
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn wrapping_preserves_whitespace_and_unicode_boundaries() {
        let text = "long/path/without/spaces\n\n  spaced words \u{754c}e\u{301}";
        for width in [1, 3, 12, 80] {
            let rows = wrap_ranges(text, width);
            let joined: String = rows.iter().map(|range| &text[range.clone()]).collect();
            assert_eq!(joined, text.replace('\n', ""));
            assert!(rows.iter().all(
                |range| text.is_char_boundary(range.start) && text.is_char_boundary(range.end)
            ));
        }
    }

    #[test]
    fn copied_selection_preserves_real_newlines_without_soft_wraps() {
        let mut diagnostic = Diagnostic::default();
        let report = "No artifacts for /a/long/path/build.rs\n\nBuild the matching target.";
        diagnostic.set_report(report.into(), true);
        diagnostic.wrap(12);
        diagnostic.selection.area = Rect::new(2, 2, 12, 30);
        let mouse = |kind, column, row| MouseEvent {
            kind,
            column,
            row,
            modifiers: KeyModifiers::NONE,
        };
        diagnostic.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 2));
        diagnostic.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 13, 31));
        diagnostic.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 13, 31));
        assert_eq!(diagnostic.selected_text().as_deref(), Some(report));
        diagnostic.set_report(report.into(), true);
        assert_eq!(diagnostic.selected_text().as_deref(), Some(report));
        diagnostic.set_report("Different error".into(), true);
        assert!(diagnostic.selected_text().is_none());
    }
}