brief-core 0.3.0

Compiler library for the Brief markup language: lexer, parser, AST, HTML/LLM emitters, formatter, and Markdown-to-Brief converter.
Documentation
use crate::span::{SourceMap, Span};
use std::fmt::Write;

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

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Code {
    InvalidUtf8 = 101,
    TabCharacter = 102,
    BomNotAtStart = 103,
    UnexpectedChar = 104,

    EmphasisSameMarker = 204,
    EmphasisCrossLine = 205,
    DoubledEmphasis = 206,
    UnterminatedEmph = 207,
    UnterminatedCode = 208,

    HeadingTooDeep = 301,
    HeadingNoSpace = 302,
    BadIndent = 303,
    BadHorizontalRule = 304,
    UnterminatedFence = 305,
    UnterminatedBlock = 306,
    InlineBlockComment = 307,
    BadListMarker = 308,
    EmptyDocument = 309,
    BadBlockquote = 310,
    StrayEnd = 311,
    StrayContent = 312,
    UnterminatedFrontmatter = 313,
    FrontmatterToml = 314,
    UnknownCodeAttribute = 315,
    ConflictingCodeAttributes = 316,
    BadHeadingAnchor = 317,

    CodeBlockLineCount = 702,
    LineCommentConverted = 703,
    RefusedLanguage = 704,

    UnknownShortcode = 401,
    ArgTypeMismatch = 402,
    MissingArg = 403,
    BadEnumValue = 404,
    FormMismatch = 405,
    BadArgSyntax = 406,
    DuplicateKwarg = 407,
    DeprecatedCalloutKind = 408,

    OrderedListSequence = 501,
    TableColumnMismatch = 502,
    HeadingMonotonic = 503,
    AlignArrayLength = 504,
    BadDefinitionList = 505,
    DuplicateHeadingAnchor = 506,

    RefMissingFile = 601,
    RefMissingAnchor = 602,
    RefBadTarget = 603,
    RefNoProject = 604,
}

impl Code {
    pub fn as_str(self) -> String {
        format!("B{:04}", self as u32)
    }

    pub fn message(self) -> &'static str {
        use Code::*;
        match self {
            InvalidUtf8 => "invalid UTF-8 in source",
            TabCharacter => "tab character is not allowed; use two spaces",
            BomNotAtStart => "byte-order mark must only appear at start of file",
            UnexpectedChar => "unexpected character",
            EmphasisSameMarker => "emphasis cannot nest with the same marker",
            EmphasisCrossLine => "emphasis must open and close on the same line",
            DoubledEmphasis => "doubled emphasis markers are not valid; use a single marker",
            UnterminatedEmph => "unterminated emphasis",
            UnterminatedCode => "unterminated inline code span",
            HeadingTooDeep => "heading level exceeds maximum of 6",
            HeadingNoSpace => "heading marker must be followed by exactly one space",
            BadIndent => "indentation must be in multiples of two spaces",
            BadHorizontalRule => "horizontal rule must be exactly three dashes",
            UnterminatedFence => "unterminated code fence",
            UnterminatedBlock => "unterminated block shortcode",
            InlineBlockComment => "block comments must start at the beginning of a line",
            BadListMarker => "invalid list marker",
            EmptyDocument => "document is empty",
            BadBlockquote => "blockquote marker must be followed by a space",
            StrayEnd => "`@end` without a matching block shortcode",
            StrayContent => "unexpected content after directive",
            UnterminatedFrontmatter => "frontmatter `+++` block is never closed",
            FrontmatterToml => "frontmatter is not valid TOML",
            UnknownCodeAttribute => "unknown code-fence attribute",
            ConflictingCodeAttributes => "conflicting code-fence attributes",
            BadHeadingAnchor => "invalid heading anchor",
            BadDefinitionList => "malformed definition list",
            CodeBlockLineCount => {
                "minified code block was originally many lines; LLM consumers cannot reference specific lines"
            }
            LineCommentConverted => "line comment converted to block-comment form for minification",
            RefusedLanguage => "language uses significant whitespace and cannot be safely minified",
            UnknownShortcode => "shortcode is not registered",
            ArgTypeMismatch => "shortcode argument has wrong type",
            MissingArg => "missing required shortcode argument",
            BadEnumValue => "argument value is not in the allowed set",
            FormMismatch => "shortcode used in the wrong form (block vs. inline)",
            BadArgSyntax => "malformed shortcode argument syntax",
            DuplicateKwarg => "keyword argument given more than once",
            DeprecatedCalloutKind => "callout kind is deprecated; use the GFM equivalent",
            OrderedListSequence => "ordered list numbering must be sequential starting from 1",
            TableColumnMismatch => "table row column count does not match header",
            HeadingMonotonic => "heading levels must increase by at most one",
            AlignArrayLength => "alignment array length must equal the column count",
            DuplicateHeadingAnchor => "heading anchor must be unique within a document",
            RefMissingFile => "cross-document reference target file does not exist in project",
            RefMissingAnchor => {
                "cross-document reference target anchor does not exist in target file"
            }
            RefBadTarget => "malformed cross-document reference target",
            RefNoProject => "`@ref` requires a `brief.toml`-rooted project; none found",
        }
    }
}

#[derive(Clone, Debug)]
pub struct Diagnostic {
    pub code: Code,
    pub span: Span,
    pub label: Option<String>,
    pub help: Option<String>,
    pub severity: Severity,
}

impl Diagnostic {
    pub fn new(code: Code, span: Span) -> Self {
        Diagnostic {
            code,
            span,
            label: None,
            help: None,
            severity: Severity::Error,
        }
    }
    pub fn warning(code: Code, span: Span) -> Self {
        Diagnostic {
            code,
            span,
            label: None,
            help: None,
            severity: Severity::Warning,
        }
    }
    pub fn label(mut self, s: impl Into<String>) -> Self {
        self.label = Some(s.into());
        self
    }
    pub fn help(mut self, s: impl Into<String>) -> Self {
        self.help = Some(s.into());
        self
    }
}

pub fn render(diag: &Diagnostic, src: &SourceMap) -> String {
    let mut out = String::new();
    let (line, col) = src.line_col(diag.span.start);
    let prefix = match diag.severity {
        Severity::Error => "error",
        Severity::Warning => "warning",
    };
    let _ = writeln!(
        out,
        "{}[{}]: {}",
        prefix,
        diag.code.as_str(),
        diag.code.message()
    );
    let _ = writeln!(out, "  --> {}:{}:{}", src.path, line, col);
    let _ = writeln!(out, "   |");
    let line_text = src.line_text(line);
    let _ = writeln!(out, "{:>3} | {}", line, line_text);
    let pad: String = std::iter::repeat(' ').take(col.saturating_sub(1)).collect();
    let line_remaining = line_text
        .chars()
        .count()
        .saturating_sub(col.saturating_sub(1));
    let caret_len = (diag.span.len as usize).max(1).min(line_remaining.max(1));
    let carets: String = std::iter::repeat('^').take(caret_len).collect();
    let label = diag.label.as_deref().unwrap_or("");
    let _ = writeln!(out, "   | {}{} {}", pad, carets, label);
    if let Some(help) = &diag.help {
        let _ = writeln!(out, "   |");
        let _ = writeln!(out, "   = help: {}", help);
    }
    out
}

pub fn render_all(diags: &[Diagnostic], src: &SourceMap) -> String {
    diags
        .iter()
        .map(|d| render(d, src))
        .collect::<Vec<_>>()
        .join("\n")
}

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

    #[test]
    fn renders_with_caret() {
        let src = SourceMap::new("doc.brf", "abc\nhello world\n");
        let span = Span::new(4, 5);
        let d = Diagnostic::new(Code::UnexpectedChar, span).label("here");
        let out = render(&d, &src);
        assert!(out.contains("error[B0104]"));
        assert!(out.contains("doc.brf:2:1"));
        assert!(out.contains("hello world"));
        assert!(out.contains("^^^^^"));
    }

    #[test]
    fn ref_codes_render_with_correct_prefix() {
        use Code::*;
        assert_eq!(RefMissingFile.as_str(), "B0601");
        assert_eq!(RefMissingAnchor.as_str(), "B0602");
        assert_eq!(RefBadTarget.as_str(), "B0603");
        assert_eq!(RefNoProject.as_str(), "B0604");
        assert!(RefMissingFile.message().contains("file"));
        assert!(RefMissingAnchor.message().contains("anchor"));
        assert!(RefBadTarget.message().contains("target"));
        assert!(RefNoProject.message().contains("brief.toml"));
    }

    #[test]
    fn bad_definition_list_code_renders() {
        use Code::*;
        assert_eq!(BadDefinitionList.as_str(), "B0505");
        assert!(BadDefinitionList.message().contains("definition list"));
    }
}