knave-expand 0.14.22

The reading, scoping and expansion stages of knave
Documentation
use std::borrow::Cow;
use std::cell::RefCell;
use std::ops::Range;

use annotate_snippets::renderer::{DEFAULT_LINE_NUM_STYLE, Style};
use annotate_snippets::{AnnotationKind, Group, Level, Patch, Renderer, Snippet};
use rustc_hash::FxHashMap;

use super::quoted_form::QuotedForm;
use super::source_window::{LineIndex, SourceWindow};
use super::{Diagnostic, Diagnostics, Help, Label, Severity, Suggestion};
use crate::source::{SourceId, SourceMap, Span};

pub struct DiagnosticRenderer<'a> {
    sources: &'a SourceMap,
    renderer: Renderer,
    line_indexes: RefCell<FxHashMap<SourceId, LineIndex>>,
}

impl<'a> DiagnosticRenderer<'a> {
    pub const GUTTER_STYLE: Style = DEFAULT_LINE_NUM_STYLE;

    pub fn plain(sources: &'a SourceMap) -> Self {
        Self {
            sources,
            renderer: Renderer::plain(),
            line_indexes: RefCell::default(),
        }
    }

    pub fn styled(sources: &'a SourceMap) -> Self {
        Self {
            sources,
            renderer: Renderer::styled(),
            line_indexes: RefCell::default(),
        }
    }

    pub fn render_all(&self, diagnostics: &Diagnostics) -> String {
        let rendered: Vec<String> = diagnostics
            .in_source_order()
            .into_iter()
            .map(|diagnostic| self.render(diagnostic))
            .collect();
        rendered.join("\n\n")
    }

    pub fn render_note(&self, message: &str) -> String {
        self.renderer
            .render(&[Group::with_title(Level::NOTE.primary_title(message))])
    }

    pub fn render(&self, diagnostic: &Diagnostic) -> String {
        let (leading, trailing) = Self::split_helps(diagnostic.helps());
        let mut report = vec![self.main_group(diagnostic, leading)];
        report.extend(trailing.iter().map(|help| self.help_group(help)));
        self.renderer.render(&report)
    }

    fn split_helps(helps: &[Help]) -> (&[Help], &[Help]) {
        let first_suggestion = helps
            .iter()
            .position(|help| help.suggestion().is_some())
            .unwrap_or(helps.len());
        helps.split_at(first_suggestion)
    }

    fn help_group<'d>(&'d self, help: &'d Help) -> Group<'d> {
        self.suggestion_group(help).unwrap_or_else(|| {
            Group::with_title(Level::HELP.secondary_title(self.help_message(help)))
        })
    }

    fn main_group<'d>(&'d self, diagnostic: &'d Diagnostic, helps: &'d [Help]) -> Group<'d> {
        let title = Self::level(diagnostic).primary_title(diagnostic.title());
        let title = match diagnostic.code() {
            Some(code) => title.id(code.as_str()),
            None => title,
        };
        let snippets = self
            .label_sources(diagnostic)
            .into_iter()
            .map(|source| self.labeled_snippet(diagnostic, source));
        let notes = diagnostic
            .notes()
            .iter()
            .map(|note| Level::NOTE.message(note.as_str()));
        let plain_helps = helps
            .iter()
            .map(|help| Level::HELP.message(self.help_message(help)));
        title
            .elements(snippets)
            .elements(notes)
            .elements(plain_helps)
    }

    fn help_message<'d>(&self, help: &'d Help) -> Cow<'d, str> {
        match help.quoted_form() {
            Some(form) => Cow::Owned(
                help.message()
                    .replace(Help::QUOTED_FORM, &self.form_text(form)),
            ),
            None => Cow::Borrowed(help.message()),
        }
    }

    fn form_text(&self, form: Span) -> String {
        QuotedForm::of(&self.sources.file(form.source()).text()[form.range()])
    }

    fn level(diagnostic: &Diagnostic) -> Level<'static> {
        match diagnostic.severity() {
            Severity::Error => Level::ERROR,
            Severity::Note => Level::NOTE,
            Severity::Warning => Level::WARNING,
        }
    }

    fn label_sources(&self, diagnostic: &Diagnostic) -> Vec<SourceId> {
        let mut sources: Vec<SourceId> = Vec::new();
        for label in diagnostic.labels() {
            if !sources.contains(&label.span().source()) {
                sources.push(label.span().source());
            }
        }
        sources
    }

    fn labeled_snippet<'d>(
        &'d self,
        diagnostic: &'d Diagnostic,
        source: SourceId,
    ) -> Snippet<'d, annotate_snippets::Annotation<'d>> {
        let primary_span = diagnostic.primary().span();
        let labels_in_source = || {
            diagnostic
                .labels()
                .filter(move |label| label.span().source() == source)
        };
        let window = self.window(source, labels_in_source().map(|label| label.span().range()));
        let annotations = labels_in_source()
            .map(|label| Self::annotation(label, label.span() == primary_span, &window));
        self.snippet(source, &window).annotations(annotations)
    }

    fn annotation<'d>(
        label: &'d Label,
        is_primary: bool,
        window: &SourceWindow,
    ) -> annotate_snippets::Annotation<'d> {
        let kind = if is_primary {
            AnnotationKind::Primary
        } else {
            AnnotationKind::Context
        };
        kind.span(window.relative(label.span().range()))
            .label(label.message())
    }

    fn suggestion_group<'d>(&'d self, help: &'d Help) -> Option<Group<'d>> {
        let suggestion = help.suggestion()?;
        Some(
            Group::with_title(Level::HELP.secondary_title(help.message()))
                .element(self.patched_snippet(suggestion)),
        )
    }

    fn patched_snippet<'d>(&'d self, suggestion: &'d Suggestion) -> Snippet<'d, Patch<'d>> {
        let edits = suggestion.edits();
        let source = edits[0].span().source();
        let window = self.window(source, edits.iter().map(|edit| edit.span().range()));
        edits
            .iter()
            .fold(self.snippet(source, &window), |snippet, edit| {
                snippet.patch(Patch::new(
                    window.relative(edit.span().range()),
                    edit.replacement(),
                ))
            })
    }

    fn snippet<'d, T: Clone>(&'d self, source: SourceId, window: &SourceWindow) -> Snippet<'d, T> {
        let file = self.sources.file(source);
        Snippet::source(window.text(file.text()))
            .line_start(window.first_line())
            .path(file.name())
    }

    fn window(&self, source: SourceId, ranges: impl Iterator<Item = Range<usize>>) -> SourceWindow {
        let text = self.sources.file(source).text();
        self.line_indexes
            .borrow_mut()
            .entry(source)
            .or_insert_with(|| LineIndex::new(text))
            .window_covering(ranges)
    }
}

#[cfg(test)]
mod tests {
    use super::DiagnosticRenderer;
    use crate::diagnostic::{Diagnostic, DiagnosticCode, Diagnostics, Label, Suggestion};
    use crate::source::{SourceMap, Span};

    const ESCAPE: char = '\u{1b}';

    #[test]
    fn styled_rendering_colors_the_report() {
        let mut sources = SourceMap::default();
        let source = sources.add("sample.knv", "(def total)");
        let mut diagnostics = Diagnostics::default();
        diagnostics.push(Diagnostic::error(
            DiagnosticCode::MALFORMED_FORM,
            "malformed `def`",
            Label::new(Span::new(source, 0..11), "this does not fit"),
        ));
        let styled = DiagnosticRenderer::styled(&sources).render_all(&diagnostics);
        let plain = DiagnosticRenderer::plain(&sources).render_all(&diagnostics);
        assert!(styled.contains(ESCAPE));
        assert!(!plain.contains(ESCAPE));
    }

    #[test]
    fn helps_render_in_the_order_they_were_added() {
        let mut sources = SourceMap::default();
        let source = sources.add("sample.knv", "(prnt 1)");
        let span = Span::new(source, 1..5);
        let diagnostic = Diagnostic::error(
            DiagnosticCode::UNBOUND_NAME,
            "cannot find `prnt` in this scope",
            Label::new(span, "not found in this scope"),
        )
        .with_help("first help")
        .with_suggestion("a suggestion", Suggestion::new(span, "prn"))
        .with_help("last help");
        let rendered = DiagnosticRenderer::plain(&sources).render(&diagnostic);
        let first = rendered.find("first help").expect("the first help renders");
        let suggestion = rendered
            .find("a suggestion")
            .expect("the suggestion renders");
        let last = rendered.find("last help").expect("the last help renders");
        assert!(first < suggestion && suggestion < last, "{rendered}");
    }

    #[test]
    fn a_quoting_help_shows_the_form_as_written() {
        let mut sources = SourceMap::default();
        let source = sources.add("sample.knv", "(bump! (+  a\n 1))");
        let diagnostic = Diagnostic::error(
            DiagnosticCode::UNBOUND_NAME,
            "a title",
            Label::new(Span::new(source, 7..16), "a label"),
        )
        .with_quoting_help("bind it: `(local value {form})`", Span::new(source, 7..16));
        let rendered = DiagnosticRenderer::plain(&sources).render(&diagnostic);
        assert!(
            rendered.contains("bind it: `(local value (+ a 1))`"),
            "{rendered}"
        );
    }
}