noxid-source 0.2.0

Source text, spans, and diagnostics primitives for the Noxid compiler
Documentation
use std::fmt;
use std::path::{Path, PathBuf};

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SourceId(pub u32);

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Span {
    pub start: u32,
    pub end: u32,
}

impl Span {
    pub const fn new(start: usize, end: usize) -> Self {
        Self {
            start: start as u32,
            end: end as u32,
        }
    }

    pub const fn empty(at: usize) -> Self {
        Self::new(at, at)
    }

    pub fn join(self, other: Self) -> Self {
        Self {
            start: self.start.min(other.start),
            end: self.end.max(other.end),
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Position {
    pub line: u32,
    pub column: u32,
    pub offset: u32,
}

#[derive(Clone, Debug)]
pub struct SourceFile {
    pub id: SourceId,
    pub path: PathBuf,
    text: String,
    line_starts: Vec<u32>,
}

impl SourceFile {
    pub fn new(id: SourceId, path: impl Into<PathBuf>, text: impl Into<String>) -> Self {
        let text = text.into();
        let mut line_starts = vec![0];
        for (index, byte) in text.bytes().enumerate() {
            if byte == b'\n' {
                line_starts.push((index + 1) as u32);
            }
        }
        Self {
            id,
            path: path.into(),
            text,
            line_starts,
        }
    }

    pub fn text(&self) -> &str {
        &self.text
    }

    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn slice(&self, span: Span) -> &str {
        self.text
            .get(span.start as usize..span.end as usize)
            .unwrap_or("")
    }

    pub fn position(&self, offset: u32) -> Position {
        let line = self
            .line_starts
            .partition_point(|start| *start <= offset)
            .saturating_sub(1);
        Position {
            line: line as u32 + 1,
            column: offset - self.line_starts[line] + 1,
            offset,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Severity {
    Error,
    Warning,
    Info,
}

impl Severity {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Error => "error",
            Self::Warning => "warning",
            Self::Info => "info",
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Label {
    pub span: Span,
    pub message: String,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiagnosticField {
    pub key: String,
    pub value: String,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Diagnostic {
    pub code: &'static str,
    pub severity: Severity,
    pub message: String,
    pub span: Span,
    // The file this diagnostic belongs to, for multi-file compilations.
    pub path: Option<String>,
    pub symbol: Option<String>,
    pub labels: Vec<Label>,
    pub notes: Vec<String>,
    pub fields: Vec<DiagnosticField>,
}

impl Diagnostic {
    pub fn error(code: &'static str, message: impl Into<String>, span: Span) -> Self {
        Self {
            code,
            severity: Severity::Error,
            message: message.into(),
            span,
            path: None,
            symbol: None,
            labels: vec![],
            notes: vec![],
            fields: vec![],
        }
    }

    pub fn warning(code: &'static str, message: impl Into<String>, span: Span) -> Self {
        Self {
            code,
            severity: Severity::Warning,
            message: message.into(),
            span,
            path: None,
            symbol: None,
            labels: vec![],
            notes: vec![],
            fields: vec![],
        }
    }

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

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

    pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.fields.push(DiagnosticField {
            key: key.into(),
            value: value.into(),
        });
        self
    }

    pub fn render(&self, source: &SourceFile) -> String {
        let pos = source.position(self.span.start);
        format!(
            "{}:{}:{}: {}[{}]: {}",
            source.path.display(),
            pos.line,
            pos.column,
            self.severity.as_str(),
            self.code,
            self.message
        )
    }

    pub fn to_json(&self) -> String {
        let symbol = self
            .symbol
            .as_ref()
            .map(|s| format!("\"{}\"", json_escape(s)))
            .unwrap_or_else(|| "null".into());
        let fields = self
            .fields
            .iter()
            .map(|f| format!("\"{}\":\"{}\"", json_escape(&f.key), json_escape(&f.value)))
            .collect::<Vec<_>>()
            .join(",");
        let fixes = self.fixes_json();
        let path = self
            .path
            .as_ref()
            .map(|path| format!("\"{}\"", json_escape(path)))
            .unwrap_or_else(|| "null".into());
        format!(
            "{{\"code\":\"{}\",\"severity\":\"{}\",\"message\":\"{}\",\"span\":{{\"start\":{},\"end\":{}}},\"path\":{},\"symbol\":{},\"fields\":{{{}}},\"fixes\":{fixes}}}",
            self.code,
            self.severity.as_str(),
            json_escape(&self.message),
            self.span.start,
            self.span.end,
            path,
            symbol,
            fields
        )
    }

    fn fixes_json(&self) -> String {
        let operation = if self.code.contains("TRANSITION") {
            "inspect_machine"
        } else if self.code.contains("EXHAUSTIVE") || self.code.contains("MISSING_CASE") {
            "add_missing_cases"
        } else if self.code.starts_with("UNKNOWN_") || self.code.contains("UNRESOLVED") {
            "find_symbol"
        } else if self.code.contains("TYPE") {
            "inspect_expected_type"
        } else if self.code.starts_with("A11Y_") {
            "apply_accessibility_semantics"
        } else {
            return "[]".into();
        };
        let symbol = self
            .symbol
            .as_ref()
            .map(|value| format!(",\"symbol\":\"{}\"", json_escape(value)))
            .unwrap_or_default();
        let arguments = self
            .fields
            .iter()
            .map(|field| {
                format!(
                    "\"{}\":\"{}\"",
                    json_escape(&field.key),
                    json_escape(&field.value)
                )
            })
            .collect::<Vec<_>>()
            .join(",");
        format!(
            "[{{\"kind\":\"semantic-operation\",\"operation\":\"{operation}\",\"applicability\":\"compiler-validated\",\"targetSpan\":{{\"start\":{},\"end\":{}}}{symbol},\"arguments\":{{{arguments}}}}}]",
            self.span.start, self.span.end
        )
    }
}

pub fn json_escape(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for ch in value.chars() {
        match ch {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out
}

/// Escape a string for a JavaScript string literal in generated code.
///
/// The union of what the client, SSR, and server emitters each used to escape
/// on their own: `json_escape`'s quotes, backslash, and control characters,
/// plus `<`, `>`, and `&` so user data containing `</script>` can never close
/// the surrounding tag or open an HTML entity when a module is inlined into a
/// document, plus U+2028 and U+2029, which JSON permits raw but JavaScript
/// treats as line terminators. One escape, one threat model, three emitters.
pub fn js_escape(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for ch in json_escape(value).chars() {
        match ch {
            '<' => out.push_str("\\u003c"),
            '>' => out.push_str("\\u003e"),
            '&' => out.push_str("\\u0026"),
            '\u{2028}' => out.push_str("\\u2028"),
            '\u{2029}' => out.push_str("\\u2029"),
            other => out.push(other),
        }
    }
    out
}

/// Escape a string for HTML text or a double-quoted attribute value. `&` is
/// handled first by construction, so an escaped `&` is never re-escaped.
pub fn html_escape(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for ch in value.chars() {
        match ch {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            other => out.push(other),
        }
    }
    out
}

impl fmt::Display for Span {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}..{}", self.start, self.end)
    }
}

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

    /// One escape, one threat model: the union of what the client, SSR, and
    /// server emitters used to cover separately. Dropping any arm here lets a
    /// generated module either break out of an inline `<script>` or acquire a
    /// line terminator JavaScript honours and JSON does not.
    #[test]
    fn js_escape_covers_the_union_of_every_emitter_threat_model() {
        assert_eq!(js_escape("say \"hi\""), "say \\\"hi\\\"");
        assert_eq!(js_escape("back\\slash"), "back\\\\slash");
        assert_eq!(js_escape("line\nfeed\ttab\r"), "line\\nfeed\\ttab\\r");
        assert_eq!(js_escape("bell\u{7}"), "bell\\u0007");
        assert_eq!(js_escape("</script>"), "\\u003c/script\\u003e");
        assert_eq!(js_escape("a & b"), "a \\u0026 b");
        assert_eq!(
            js_escape("split\u{2028}here\u{2029}too"),
            "split\\u2028here\\u2029too"
        );
        assert_eq!(js_escape("plain"), "plain");
    }

    #[test]
    fn html_escape_covers_text_and_double_quoted_attribute_positions() {
        assert_eq!(
            html_escape("<img src=\"x\" onerror=y & z>"),
            "&lt;img src=&quot;x&quot; onerror=y &amp; z&gt;"
        );
        // `&` is escaped first by construction, so an escape is never doubled.
        assert_eq!(html_escape("&lt;"), "&amp;lt;");
        assert_eq!(html_escape("plain"), "plain");
    }

    #[test]
    fn maps_utf8_byte_offsets_to_lines_and_columns() {
        let source = SourceFile::new(SourceId(0), "test.nox", "one\nthree");
        assert_eq!(
            source.position(4),
            Position {
                line: 2,
                column: 1,
                offset: 4
            }
        );
    }

    #[test]
    fn structured_diagnostics_include_machine_actionable_fixes() {
        let diagnostic = Diagnostic::error("TYPE_MISMATCH", "expected Int", Span::new(2, 7))
            .with_symbol("state:Counter.count")
            .with_field("expected", "Int")
            .with_field("found", "String");
        let json = diagnostic.to_json();
        assert!(json.contains("\"operation\":\"inspect_expected_type\""));
        assert!(json.contains("\"applicability\":\"compiler-validated\""));
        assert!(json.contains("\"expected\":\"Int\""));
    }

    #[test]
    fn warning_diagnostics_are_structured_without_becoming_errors() {
        let diagnostic = Diagnostic::warning(
            "SCENARIO_PROSE_UNEXECUTED",
            "prose scenario steps are documentation, not executable proof",
            Span::new(4, 12),
        )
        .with_symbol("scenario:Counter.Legacy");

        assert_eq!(diagnostic.severity, Severity::Warning);
        assert!(diagnostic.to_json().contains("\"severity\":\"warning\""));
    }
}