crol-cli 0.1.0

The crol command-line tool: check, evaluate, format and explain CROL data
//! Printing diagnostics: rendered with source snippets on standard error,
//! or as JSON lines on standard output (`--message-format json`).

use std::io::{IsTerminal, Write};

use codespan_reporting::diagnostic::{Diagnostic, Label, Severity};
use codespan_reporting::files::SimpleFiles;
use codespan_reporting::term::termcolor::{ColorChoice, StandardStream};
use codespan_reporting::term::{self, Config};
use crol_eval::Sources;
use crol_syntax::SyntaxError;

use crate::{ColorWhen, MessageFormat};

/// Writes diagnostics.
pub(crate) struct Reporter {
    stderr: StandardStream,
    config: Config,
    format: MessageFormat,
}

impl Reporter {
    pub(crate) fn new(color: ColorWhen, format: MessageFormat) -> Self {
        let choice = match color {
            ColorWhen::Always => ColorChoice::Always,
            ColorWhen::Never => ColorChoice::Never,
            ColorWhen::Auto if std::io::stderr().is_terminal() => ColorChoice::Auto,
            ColorWhen::Auto => ColorChoice::Never,
        };
        Self {
            stderr: StandardStream::stderr(choice),
            config: Config::default(),
            format,
        }
    }

    /// Reports the syntax errors of the file `name`, whose text is `text`.
    pub(crate) fn syntax_errors(&mut self, name: &str, text: &str, errors: &[SyntaxError]) {
        let mut sources = Sources::new();
        let file = sources.add(name, text);
        let diagnostics: Vec<_> = errors
            .iter()
            .map(|e| {
                crol_eval::Diagnostic::error(crol_eval::Span::new(file, e.range()), e.message())
            })
            .collect();
        self.diagnostics(&sources, &diagnostics);
    }

    /// Reports evaluation diagnostics.
    pub(crate) fn diagnostics(&mut self, sources: &Sources, diagnostics: &[crol_eval::Diagnostic]) {
        if self.format == MessageFormat::Json {
            let mut stdout = std::io::stdout().lock();
            for diagnostic in diagnostics {
                let _ = writeln!(stdout, "{}", json(sources, diagnostic));
            }
            return;
        }
        let mut files = SimpleFiles::new();
        for (_, file) in sources.files() {
            files.add(file.name().to_owned(), file.text().to_owned());
        }
        for diagnostic in diagnostics {
            let range = |span: crol_eval::Span| {
                usize::from(span.range.start())..usize::from(span.range.end())
            };
            // A detached span is in no file, so it gets no snippet.
            let in_a_file = |span: crol_eval::Span| sources.get(span.file).is_some();
            let mut labels = Vec::new();
            if in_a_file(diagnostic.span) {
                labels.push(Label::primary(
                    diagnostic.span.file.index(),
                    range(diagnostic.span),
                ));
            }
            for related in diagnostic.related.iter().filter(|r| in_a_file(r.span)) {
                labels.push(
                    Label::secondary(related.span.file.index(), range(related.span))
                        .with_message(&related.message),
                );
            }
            let severity = match diagnostic.severity {
                crol_eval::Severity::Error => Severity::Error,
                crol_eval::Severity::Warning => Severity::Warning,
            };
            let mut rendered = Diagnostic::new(severity)
                .with_message(&diagnostic.message)
                .with_labels(labels);
            if let Some(context) = &diagnostic.context {
                rendered = rendered.with_notes(vec![format!("while evaluating {context}")]);
            }
            let emitted =
                term::emit_to_write_style(&mut self.stderr.lock(), &self.config, &files, &rendered);
            // No diagnostic is lost because its snippet cannot be rendered.
            // If standard error itself fails, there is nowhere to report it.
            if emitted.is_err() {
                self.note(&diagnostic.display(sources).to_string());
            }
        }
    }

    /// Reports an error that is not about a place in a file.
    pub(crate) fn error(&mut self, message: &str) {
        if self.format == MessageFormat::Json {
            let diagnostic = crol_eval::Diagnostic::error(crol_eval::Span::detached(), message);
            let line = json(&Sources::new(), &diagnostic);
            let _ = writeln!(std::io::stdout().lock(), "{line}");
            return;
        }
        let diagnostic = Diagnostic::<()>::error().with_message(message);
        let files = codespan_reporting::files::SimpleFile::new("", "");
        let _ =
            term::emit_to_write_style(&mut self.stderr.lock(), &self.config, &files, &diagnostic);
    }

    /// Writes a line of plain text to standard error.
    pub(crate) fn note(&mut self, message: &str) {
        let _ = writeln!(self.stderr.lock(), "{message}");
    }
}

/// A diagnostic as one line of JSON.
fn json(sources: &Sources, diagnostic: &crol_eval::Diagnostic) -> String {
    let place = |span: crol_eval::Span| {
        // A detached span is in no file.
        let Some(file) = sources.get(span.file) else {
            let none = "null";
            return format!(
                "\"file\":{none},\"line\":{none},\"column\":{none},\"end_line\":{none},\"end_column\":{none}"
            );
        };
        let start = file.line_col(span.range.start());
        let end = file.line_col(span.range.end());
        format!(
            "\"file\":{},\"line\":{},\"column\":{},\"end_line\":{},\"end_column\":{}",
            json_string(file.name()),
            start.line,
            start.col,
            end.line,
            end.col
        )
    };
    let severity = match diagnostic.severity {
        crol_eval::Severity::Error => "error",
        crol_eval::Severity::Warning => "warning",
    };
    let related: Vec<String> = diagnostic
        .related
        .iter()
        .filter(|r| sources.get(r.span.file).is_some())
        .map(|r| {
            format!(
                "{{{},\"message\":{}}}",
                place(r.span),
                json_string(&r.message)
            )
        })
        .collect();
    let context = diagnostic
        .context
        .as_deref()
        .map_or_else(|| "null".to_owned(), json_string);
    format!(
        "{{\"severity\":\"{severity}\",\"message\":{},{},\"context\":{context},\"related\":[{}]}}",
        json_string(&diagnostic.message),
        place(diagnostic.span),
        related.join(",")
    )
}

fn json_string(text: &str) -> String {
    let mut out = String::with_capacity(text.len() + 2);
    out.push('"');
    for c in text.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}