#![doc(
html_logo_url = "https://mosaic.kjanat.dev/assets/A4.svg",
html_favicon_url = "https://mosaic.kjanat.dev/assets/A4.svg"
)]
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
pub mod codes;
mod sink;
pub use codes::{DiagnosticCategory, DiagnosticCode, DiagnosticDef};
pub use sink::{CollectingSink, DiagnosticAbort, DiagnosticResult, DiagnosticSink};
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
pub struct NodeId(pub u64);
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
pub struct ContentHash(pub u128);
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
pub struct StyleId(pub u32);
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum NodeKind {
Document,
Section,
Paragraph,
Text,
Emphasis,
Strong,
BoldItalic,
Math,
Equation,
Figure,
Image,
Table,
Citation,
Reference,
Theorem,
Footnote,
Bibliography,
Raw,
List,
ListItem,
HardBreak,
}
#[derive(Clone, Debug)]
pub struct Node {
pub id: NodeId,
pub kind: NodeKind,
pub span: SourceSpan,
pub content_hash: ContentHash,
pub style_id: StyleId,
pub children: Vec<NodeId>,
pub attributes: AttrMap,
}
pub type AttrMap = BTreeMap<String, AttrValue>;
#[derive(Clone, Debug, PartialEq)]
pub enum AttrValue {
Bool(bool),
Int(i64),
Float(f64),
Str(String),
List(Vec<Self>),
Length(f64),
Bytes(Arc<[u8]>),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourceSpan {
pub file: PathBuf,
pub start: usize,
pub end: usize,
}
impl SourceSpan {
#[must_use]
pub fn new(file: PathBuf, start: usize, end: usize) -> Self {
Self { file, start, end }
}
#[must_use]
pub fn placeholder(file: PathBuf) -> Self {
Self {
file,
start: 0,
end: 0,
}
}
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum Severity {
Error,
Warning,
Notice,
}
#[derive(Clone, Debug)]
pub enum DiagnosticAnnotation {
Related {
span: SourceSpan,
message: String,
},
Note(String),
Help(String),
Hint(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Suggestion {
pub span: SourceSpan,
pub replacement: String,
}
impl Suggestion {
#[must_use]
pub fn new(span: SourceSpan, replacement: impl Into<String>) -> Self {
Self {
span,
replacement: replacement.into(),
}
}
}
#[derive(Clone, Debug)]
pub struct Diagnostic {
def: &'static DiagnosticDef,
severity: Severity,
span: Option<SourceSpan>,
message: String,
annotations: Vec<DiagnosticAnnotation>,
suggestions: Vec<Suggestion>,
}
impl Diagnostic {
pub fn new(
def: &'static DiagnosticDef,
severity: Severity,
span: Option<SourceSpan>,
message: impl Into<String>,
) -> Self {
Self {
def,
severity,
span,
message: message.into(),
annotations: Vec::new(),
suggestions: Vec::new(),
}
}
pub fn simple(
def: &'static DiagnosticDef,
span: Option<SourceSpan>,
message: impl Into<String>,
) -> Self {
Self::new(def, def.default_severity(), span, message)
}
#[must_use]
pub fn with_annotation(mut self, annotation: DiagnosticAnnotation) -> Self {
self.annotations.push(annotation);
self
}
#[must_use]
pub fn with_suggestion(mut self, suggestion: Suggestion) -> Self {
self.suggestions.push(suggestion);
self
}
#[must_use]
pub fn with_span(mut self, span: SourceSpan) -> Self {
self.span = Some(span);
self
}
#[must_use]
pub fn def(&self) -> &'static DiagnosticDef {
self.def
}
#[must_use]
pub fn severity(&self) -> Severity {
self.severity
}
#[must_use]
pub fn span(&self) -> Option<&SourceSpan> {
self.span.as_ref()
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
pub fn annotations(&self) -> &[DiagnosticAnnotation] {
&self.annotations
}
#[must_use]
pub fn suggestions(&self) -> &[Suggestion] {
&self.suggestions
}
}
impl std::fmt::Display for Diagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.def.code(), self.message)
}
}
#[must_use]
pub fn linecol(src: &str, byte_offset: usize) -> (usize, usize) {
let mut clamped = byte_offset.min(src.len());
while clamped > 0 && !src.is_char_boundary(clamped) {
clamped -= 1;
}
let mut line = 1_usize;
let mut line_start = 0_usize;
for (i, b) in src.as_bytes().iter().enumerate().take(clamped) {
if *b == b'\n' {
line += 1;
line_start = i + 1;
}
}
let column = src[line_start..clamped].chars().count() + 1;
(line, column)
}
impl std::error::Error for Diagnostic {}
#[derive(thiserror::Error, Debug)]
pub enum CoreError {
#[error("not yet implemented: {0}")]
Unimplemented(&'static str),
#[error(transparent)]
Diagnostic(Box<Diagnostic>),
}
pub type Result<T> = std::result::Result<T, CoreError>;
#[derive(Debug)]
pub struct Document {
pub root: NodeId,
pub file: PathBuf,
nodes: BTreeMap<NodeId, Node>,
next_id: u64,
}
impl Document {
#[must_use]
pub fn new(file: PathBuf) -> Self {
let root_id = NodeId(0);
let root_node = Node {
id: root_id,
kind: NodeKind::Document,
span: SourceSpan::placeholder(file.clone()),
content_hash: ContentHash::default(),
style_id: StyleId::default(),
children: Vec::new(),
attributes: AttrMap::new(),
};
let mut nodes = BTreeMap::new();
nodes.insert(root_id, root_node);
Self {
root: root_id,
file,
nodes,
next_id: 1,
}
}
pub fn alloc(&mut self, mut node: Node) -> NodeId {
let id = NodeId(self.next_id);
self.next_id += 1;
node.id = id;
self.nodes.insert(id, node);
id
}
pub fn alloc_child(&mut self, parent: NodeId, node: Node) -> NodeId {
assert!(
self.nodes.contains_key(&parent),
"Document::alloc_child: unknown parent {parent:?}"
);
let child_id = self.alloc(node);
if let Some(parent_node) = self.nodes.get_mut(&parent) {
parent_node.children.push(child_id);
}
child_id
}
#[must_use]
pub fn get(&self, id: NodeId) -> Option<&Node> {
self.nodes.get(&id)
}
#[must_use]
pub fn get_mut(&mut self, id: NodeId) -> Option<&mut Node> {
self.nodes.get_mut(&id)
}
pub fn nodes(&self) -> impl Iterator<Item = &Node> {
self.nodes.values()
}
#[must_use]
pub fn len(&self) -> usize {
self.nodes.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() <= 1
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linecol_handles_ascii_offsets() {
let src = "ab\ncd\nef";
assert_eq!(linecol(src, 0), (1, 1));
assert_eq!(linecol(src, 1), (1, 2));
assert_eq!(linecol(src, 2), (1, 3));
assert_eq!(linecol(src, 3), (2, 1));
assert_eq!(linecol(src, 6), (3, 1));
assert_eq!(linecol(src, 7), (3, 2));
assert_eq!(linecol(src, 9999), (3, 3));
}
#[test]
fn linecol_counts_chars_not_bytes() {
let src = "µx\n字y\n";
assert_eq!(linecol(src, 0), (1, 1));
assert_eq!(linecol(src, 2), (1, 2)); assert_eq!(linecol(src, 3), (1, 3)); assert_eq!(linecol(src, 4), (2, 1)); assert_eq!(linecol(src, 7), (2, 2)); }
#[test]
fn linecol_offsets_inside_codepoints_round_down() {
let src = "µ";
assert_eq!(linecol(src, 1), (1, 1));
}
#[test]
#[should_panic(expected = "unknown parent")]
fn alloc_child_panics_on_unknown_parent() {
let mut doc = Document::new(PathBuf::from("test.mos"));
doc.alloc_child(
NodeId(9999),
Node {
id: NodeId::default(),
kind: NodeKind::Text,
span: SourceSpan::placeholder(PathBuf::from("test.mos")),
content_hash: ContentHash::default(),
style_id: StyleId::default(),
children: Vec::new(),
attributes: AttrMap::new(),
},
);
}
#[test]
fn document_alloc_and_traverse() {
let mut doc = Document::new(PathBuf::from("test.mos"));
let para = doc.alloc_child(
doc.root,
Node {
id: NodeId::default(),
kind: NodeKind::Paragraph,
span: SourceSpan::placeholder(PathBuf::from("test.mos")),
content_hash: ContentHash::default(),
style_id: StyleId::default(),
children: Vec::new(),
attributes: AttrMap::new(),
},
);
doc.alloc_child(
para,
Node {
id: NodeId::default(),
kind: NodeKind::Text,
span: SourceSpan::placeholder(PathBuf::from("test.mos")),
content_hash: ContentHash::default(),
style_id: StyleId::default(),
children: Vec::new(),
attributes: AttrMap::new(),
},
);
assert_eq!(doc.len(), 3);
assert_eq!(doc.get(doc.root).unwrap().children.len(), 1);
assert_eq!(doc.get(para).unwrap().children.len(), 1);
}
#[test]
fn suggestion_new_sets_span_and_replacement() {
let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
let suggestion = Suggestion::new(span.clone(), "@intro");
assert_eq!(suggestion.span, span);
assert_eq!(suggestion.replacement, "@intro");
}
#[test]
fn diagnostic_has_no_suggestions_by_default() {
let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label");
assert!(diagnostic.suggestions().is_empty());
}
#[test]
fn with_suggestion_accumulates_in_order() {
let first = Suggestion::new(SourceSpan::new(PathBuf::from("main.mos"), 4, 10), "@intro");
let second = Suggestion::new(
SourceSpan::new(PathBuf::from("other.mos"), 12, 15),
"@summary",
);
let diagnostic = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
.with_suggestion(first)
.with_suggestion(second);
let suggestions = diagnostic.suggestions();
assert_eq!(suggestions.len(), 2);
assert_eq!(suggestions[0].span.file, PathBuf::from("main.mos"));
assert_eq!(suggestions[0].span.start, 4);
assert_eq!(suggestions[0].span.end, 10);
assert_eq!(suggestions[0].replacement, "@intro");
assert_eq!(suggestions[1].span.file, PathBuf::from("other.mos"));
assert_eq!(suggestions[1].span.start, 12);
assert_eq!(suggestions[1].span.end, 15);
assert_eq!(suggestions[1].replacement, "@summary");
}
#[test]
fn suggestion_new_accepts_str_and_owned_string() {
let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
let from_str = Suggestion::new(span.clone(), "@intro");
let from_string = Suggestion::new(span, String::from("@intro"));
assert_eq!(from_str, from_string);
}
#[test]
fn suggestion_clone_and_equality() {
let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
let suggestion = Suggestion::new(span.clone(), "@intro");
assert_eq!(suggestion.clone(), suggestion);
assert_eq!(Suggestion::new(span.clone(), "@intro"), suggestion);
assert_ne!(Suggestion::new(span, "@outro"), suggestion);
let wider = SourceSpan::new(PathBuf::from("main.mos"), 4, 11);
assert_ne!(Suggestion::new(wider, "@intro"), suggestion);
}
#[test]
fn suggestion_empty_replacement_encodes_deletion() {
let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
let deletion = Suggestion::new(span, "");
assert!(deletion.replacement.is_empty());
assert!(deletion.span.start < deletion.span.end);
}
#[test]
fn suggestion_zero_length_span_encodes_insertion() {
let point = SourceSpan::new(PathBuf::from("main.mos"), 7, 7);
let insertion = Suggestion::new(point, "@intro");
assert_eq!(insertion.span.start, insertion.span.end);
assert_eq!(insertion.replacement, "@intro");
}
#[test]
fn suggestions_and_annotations_are_independent_channels() {
let span = SourceSpan::new(PathBuf::from("main.mos"), 4, 10);
let with_fix = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
.with_suggestion(Suggestion::new(span.clone(), "@intro"));
assert_eq!(with_fix.suggestions().len(), 1);
assert!(with_fix.annotations().is_empty());
let with_help = Diagnostic::simple(&codes::MOS0033, None, "unknown label").with_annotation(
DiagnosticAnnotation::Help("did you mean `@intro`?".to_owned()),
);
assert_eq!(with_help.annotations().len(), 1);
assert!(with_help.suggestions().is_empty());
let with_both = Diagnostic::simple(&codes::MOS0033, None, "unknown label")
.with_annotation(DiagnosticAnnotation::Help(
"did you mean `@intro`?".to_owned(),
))
.with_suggestion(Suggestion::new(span, "@intro"));
assert_eq!(with_both.suggestions().len(), 1);
assert_eq!(with_both.annotations().len(), 1);
assert_eq!(with_both.suggestions()[0].replacement, "@intro");
let help_text = match &with_both.annotations()[0] {
DiagnosticAnnotation::Help(text) => Some(text.as_str()),
_ => None,
};
assert_eq!(help_text, Some("did you mean `@intro`?"));
}
}