drawlang-syntax 0.1.0

Lexer, parser, lossless syntax tree, and formatter for the drawlang DSL
Documentation
//! Diagnostics: structured errors/warnings with spans, rendered Rust-style
//! (colored, with source excerpts and carets) or as JSON for agents.

use crate::span::{LineCol, SourceFile, Span};
use serde::Serialize;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    Error,
    Warning,
}

/// A labeled span inside a diagnostic. The first label is primary (rendered
/// with `^^^`), the rest are secondary (rendered with `---`).
#[derive(Debug, Clone, Serialize)]
pub struct Label {
    pub span: Span,
    pub message: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
    pub severity: Severity,
    /// Stable code like `E0214` or `W0301`. See `drawlang explain <code>`.
    pub code: &'static str,
    pub message: String,
    pub labels: Vec<Label>,
    pub help: Option<String>,
    pub notes: Vec<String>,
}

impl Diagnostic {
    pub fn error(code: &'static str, message: impl Into<String>) -> Self {
        Diagnostic {
            severity: Severity::Error,
            code,
            message: message.into(),
            labels: Vec::new(),
            help: None,
            notes: Vec::new(),
        }
    }

    pub fn warning(code: &'static str, message: impl Into<String>) -> Self {
        Diagnostic {
            severity: Severity::Warning,
            ..Self::error(code, message)
        }
    }

    pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Self {
        self.labels.push(Label {
            span,
            message: message.into(),
        });
        self
    }

    pub fn with_help(mut self, help: impl Into<String>) -> Self {
        self.help = Some(help.into());
        self
    }

    pub fn with_note(mut self, note: impl Into<String>) -> Self {
        self.notes.push(note.into());
        self
    }

    pub fn primary_span(&self) -> Option<Span> {
        self.labels.first().map(|l| l.span)
    }
}

/// JSON-facing shape: spans resolved to line/col so agents need no math.
#[derive(Debug, Serialize)]
pub struct ResolvedDiagnostic<'a> {
    pub severity: Severity,
    pub code: &'static str,
    pub message: &'a str,
    pub file: &'a str,
    pub labels: Vec<ResolvedLabel<'a>>,
    pub help: Option<&'a str>,
    pub notes: &'a [String],
}

#[derive(Debug, Serialize)]
pub struct ResolvedLabel<'a> {
    pub message: &'a str,
    pub span: Span,
    pub start: LineCol,
    pub end: LineCol,
    /// The exact source text the label points at.
    pub text: &'a str,
}

pub fn resolve<'a>(d: &'a Diagnostic, src: &'a SourceFile) -> ResolvedDiagnostic<'a> {
    ResolvedDiagnostic {
        severity: d.severity,
        code: d.code,
        message: &d.message,
        file: &src.name,
        labels: d
            .labels
            .iter()
            .map(|l| ResolvedLabel {
                message: &l.message,
                span: l.span,
                start: src.line_col(l.span.start),
                end: src.line_col(l.span.end),
                text: src.snippet(l.span),
            })
            .collect(),
        help: d.help.as_deref(),
        notes: &d.notes,
    }
}

// ---------------------------------------------------------------- rendering

pub struct Styles {
    bold: &'static str,
    red: &'static str,
    yellow: &'static str,
    blue: &'static str,
    green: &'static str,
    reset: &'static str,
}

impl Styles {
    pub fn colored() -> Self {
        Styles {
            bold: "\x1b[1m",
            red: "\x1b[1;31m",
            yellow: "\x1b[1;33m",
            blue: "\x1b[1;34m",
            green: "\x1b[1;32m",
            reset: "\x1b[0m",
        }
    }

    pub fn plain() -> Self {
        Styles {
            bold: "",
            red: "",
            yellow: "",
            blue: "",
            green: "",
            reset: "",
        }
    }
}

/// Render one diagnostic in rustc style:
///
/// ```text
/// error[E0214]: unknown port `pciex` on `gpus[0]`
///   --> arch.drawl:42:18
///    |
/// 42 |   host.cpu -> gpus[0].pciex : "PCIe 5.0 x16"
///    |               ^^^^^^^^^^^^^ component `gpu` has no port `pciex`
///    |
/// help: `gpu` defines ports `nvlink`, `pcie` — did you mean `pcie`?
/// ```
pub fn render(d: &Diagnostic, src: &SourceFile, st: &Styles) -> String {
    let mut out = String::new();
    let (sev_color, sev_name) = match d.severity {
        Severity::Error => (st.red, "error"),
        Severity::Warning => (st.yellow, "warning"),
    };
    out.push_str(&format!(
        "{sev_color}{sev_name}[{code}]{reset}{bold}: {msg}{reset}\n",
        code = d.code,
        msg = d.message,
        sev_color = sev_color,
        reset = st.reset,
        bold = st.bold,
    ));

    if let Some(primary) = d.primary_span() {
        let lc = src.line_col(primary.start);
        // Gutter width fits the largest referenced line number.
        let max_line = d
            .labels
            .iter()
            .map(|l| src.line_col(l.span.start).line)
            .max()
            .unwrap_or(lc.line);
        let gw = max_line.to_string().len();
        out.push_str(&format!(
            "{:gw$}{blue}-->{reset} {}:{}:{}\n",
            "",
            src.name,
            lc.line,
            lc.col,
            gw = gw + 1,
            blue = st.blue,
            reset = st.reset,
        ));
        out.push_str(&format!(
            "{:gw$} {blue}|{reset}\n",
            "",
            gw = gw,
            blue = st.blue,
            reset = st.reset
        ));

        for (i, label) in d.labels.iter().enumerate() {
            let is_primary = i == 0;
            let l_start = src.line_col(label.span.start);
            let l_end = src.line_col(label.span.end);
            let line_text = src.line_text(l_start.line);
            out.push_str(&format!(
                "{blue}{:>gw$} |{reset} {}\n",
                l_start.line,
                line_text,
                gw = gw,
                blue = st.blue,
                reset = st.reset,
            ));
            // Caret column accounting: columns are char-based already.
            let pad = l_start.col - 1;
            let width = if l_end.line == l_start.line {
                (l_end.col - l_start.col).max(1)
            } else {
                line_text.chars().count().saturating_sub(pad).max(1)
            };
            let (mark, color) = if is_primary {
                ("^", sev_color)
            } else {
                ("-", st.blue)
            };
            out.push_str(&format!(
                "{blue}{:gw$} |{reset} {:pad$}{color}{marks} {msg}{reset}\n",
                "",
                "",
                gw = gw,
                pad = pad,
                color = color,
                marks = mark.repeat(width),
                msg = label.message,
                blue = st.blue,
                reset = st.reset,
            ));
        }
        out.push_str(&format!(
            "{:gw$} {blue}|{reset}\n",
            "",
            gw = gw,
            blue = st.blue,
            reset = st.reset
        ));
    }

    if let Some(help) = &d.help {
        out.push_str(&format!(
            "{green}help{reset}{bold}:{reset} {help}\n",
            green = st.green,
            reset = st.reset,
            bold = st.bold,
            help = help
        ));
    }
    for note in &d.notes {
        out.push_str(&format!(
            "{blue}note{reset}{bold}:{reset} {note}\n",
            blue = st.blue,
            reset = st.reset,
            bold = st.bold,
            note = note
        ));
    }
    out
}

/// Render a batch plus the closing summary line, e.g.
/// `error: could not check `arch.drawl` (3 errors, 1 warning)`.
pub fn render_all(diags: &[Diagnostic], src: &SourceFile, st: &Styles) -> String {
    let mut out = String::new();
    for d in diags {
        out.push_str(&render(d, src, st));
        out.push('\n');
    }
    let errors = diags
        .iter()
        .filter(|d| d.severity == Severity::Error)
        .count();
    let warnings = diags
        .iter()
        .filter(|d| d.severity == Severity::Warning)
        .count();
    if errors > 0 || warnings > 0 {
        let mut parts = Vec::new();
        if errors > 0 {
            parts.push(format!(
                "{} error{}",
                errors,
                if errors == 1 { "" } else { "s" }
            ));
        }
        if warnings > 0 {
            parts.push(format!(
                "{} warning{}",
                warnings,
                if warnings == 1 { "" } else { "s" }
            ));
        }
        let (color, word) = if errors > 0 {
            (st.red, "error")
        } else {
            (st.yellow, "warning")
        };
        out.push_str(&format!(
            "{color}{word}{reset}{bold}: `{file}` emitted {summary}{reset}\n",
            color = color,
            word = word,
            file = src.name,
            summary = parts.join(", "),
            reset = st.reset,
            bold = st.bold,
        ));
    }
    out
}

/// Levenshtein distance, used for did-you-mean suggestions.
pub fn edit_distance(a: &str, b: &str) -> usize {
    let a: Vec<char> = a.chars().collect();
    let b: Vec<char> = b.chars().collect();
    let mut prev: Vec<usize> = (0..=b.len()).collect();
    let mut cur = vec![0; b.len() + 1];
    for i in 1..=a.len() {
        cur[0] = i;
        for j in 1..=b.len() {
            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
            cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
        }
        std::mem::swap(&mut prev, &mut cur);
    }
    prev[b.len()]
}

/// Pick the closest candidate within a sane distance for "did you mean".
pub fn suggest<'a>(input: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
    let max = (input.chars().count() / 3).max(1) + 1;
    candidates
        .into_iter()
        .map(|c| (edit_distance(input, c), c))
        .filter(|(d, _)| *d <= max)
        .min_by_key(|(d, _)| *d)
        .map(|(_, c)| c)
}