use super::range::Range;
use super::Document;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticSeverity {
Error,
Warning,
Information,
Hint,
}
impl fmt::Display for DiagnosticSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DiagnosticSeverity::Error => write!(f, "error"),
DiagnosticSeverity::Warning => write!(f, "warning"),
DiagnosticSeverity::Information => write!(f, "info"),
DiagnosticSeverity::Hint => write!(f, "hint"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Diagnostic {
pub range: Range,
pub severity: DiagnosticSeverity,
pub message: String,
pub code: Option<String>,
pub source: String,
}
impl Diagnostic {
pub fn new(range: Range, severity: DiagnosticSeverity, message: String) -> Self {
Self {
range,
severity,
message,
code: None,
source: "lex-parser".to_string(),
}
}
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = source.into();
self
}
}
impl fmt::Display for Diagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} [{}]: {} at {}",
self.severity, self.source, self.message, self.range.start
)
}
}
impl Document {
pub fn diagnostics(&self) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
diagnostics.extend(validate_references(self));
diagnostics.extend(validate_structure(self));
diagnostics
}
}
pub fn validate_references(document: &Document) -> Vec<Diagnostic> {
use super::traits::{AstNode, Container};
use crate::lex::inlines::ReferenceType;
let mut diagnostics = Vec::new();
for reference in document.iter_all_references() {
match &reference.reference_type {
ReferenceType::FootnoteNumber { number } => {
let label = number.to_string();
if document.find_annotation_by_label(&label).is_none() {
let range = document.root.range().clone();
let diag = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
format!(
"Broken footnote reference: no annotation found with label '{label}'"
),
)
.with_code("broken-reference");
diagnostics.push(diag);
}
}
ReferenceType::AnnotationReference { label } => {
if document.find_annotation_by_label(label).is_none() {
let range = document.root.range().clone();
let diag = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
format!(
"Broken annotation reference: no annotation found with label '{label}'"
),
)
.with_code("broken-reference");
diagnostics.push(diag);
}
}
ReferenceType::Citation(citation_data) => {
for key in &citation_data.keys {
if document.find_annotation_by_label(key).is_none() {
let range = document.root.range().clone();
let diag = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
format!(
"Broken citation reference: no annotation found with label '{key}'"
),
)
.with_code("broken-citation");
diagnostics.push(diag);
}
}
}
ReferenceType::Session { target } => {
let sessions: Vec<_> = document.root.iter_sessions_recursive().collect();
let found = sessions.iter().any(|s| s.label() == target);
if !found {
let range = document.root.range().clone();
let diag = Diagnostic::new(
range,
DiagnosticSeverity::Warning,
format!("Broken session reference: no session found with title '{target}'"),
)
.with_code("broken-session-ref");
diagnostics.push(diag);
}
}
_ => {
}
}
}
diagnostics
}
pub fn validate_structure(document: &Document) -> Vec<Diagnostic> {
use super::elements::content_item::ContentItem;
use super::traits::AstNode;
let mut diagnostics = Vec::new();
for (item, _depth) in document.root.iter_all_nodes_with_depth() {
match item {
ContentItem::List(list) => {
if list.items.len() == 1 {
let diag = Diagnostic::new(
list.range().clone(),
DiagnosticSeverity::Information,
"Single-item list: consider using a paragraph instead".to_string(),
)
.with_code("single-item-list");
diagnostics.push(diag);
}
}
ContentItem::Annotation(annotation) => {
if annotation.data.label.value.is_empty() {
let diag = Diagnostic::new(
annotation.range().clone(),
DiagnosticSeverity::Error,
"Annotation has empty label".to_string(),
)
.with_code("empty-annotation-label");
diagnostics.push(diag);
}
let param_names: Vec<_> = annotation
.data
.parameters
.iter()
.map(|p| p.key.as_str())
.collect();
for (i, name) in param_names.iter().enumerate() {
if param_names[..i].contains(name) {
let diag = Diagnostic::new(
annotation.range().clone(),
DiagnosticSeverity::Warning,
format!("Duplicate parameter: '{name}'"),
)
.with_code("duplicate-parameter");
diagnostics.push(diag);
break;
}
}
}
ContentItem::VerbatimBlock(verbatim) => {
if verbatim.closing_data.label.value.is_empty() {
let diag = Diagnostic::new(
verbatim.range().clone(),
DiagnosticSeverity::Warning,
"Verbatim block has empty closing label".to_string(),
)
.with_code("empty-verbatim-label");
diagnostics.push(diag);
}
}
_ => {
}
}
}
diagnostics
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lex::parsing::parse_document;
#[test]
fn test_diagnostic_creation() {
use super::super::range::Position;
let range = Range::new(0..10, Position::new(1, 0), Position::new(1, 10));
let diag = Diagnostic::new(range, DiagnosticSeverity::Error, "Test error".to_string())
.with_code("test-001");
assert_eq!(diag.severity, DiagnosticSeverity::Error);
assert_eq!(diag.message, "Test error");
assert_eq!(diag.code, Some("test-001".to_string()));
assert_eq!(diag.source, "lex-parser");
}
#[test]
fn test_broken_footnote_reference() {
let source = "A paragraph with a footnote reference [42].\n\n";
let doc = parse_document(source).unwrap();
let diagnostics = validate_references(&doc);
assert!(!diagnostics.is_empty());
assert!(
diagnostics
.iter()
.any(|d| d.message.contains("Broken footnote reference")
&& d.message.contains("'42'"))
);
}
#[test]
fn test_valid_footnote_reference() {
let source =
"A paragraph with a footnote reference [42].\n\n:: 42 :: Footnote content.\n\n";
let doc = parse_document(source).unwrap();
let diagnostics = validate_references(&doc);
assert!(
!diagnostics
.iter()
.any(|d| d.message.contains("Broken footnote reference")),
"Expected no broken footnote reference diagnostics, got: {diagnostics:?}"
);
}
#[test]
fn test_valid_structure_no_warnings() {
let source = ":: note :: A valid annotation.\n\n";
let doc = parse_document(source).unwrap();
let diagnostics = validate_structure(&doc);
assert!(diagnostics.is_empty());
}
#[test]
fn test_document_diagnostics_api() {
let source = "A paragraph with [42].\n\n:: 42 :: Valid footnote.\n\n";
let doc = parse_document(source).unwrap();
let diagnostics = doc.diagnostics();
assert!(!diagnostics
.iter()
.any(|d| d.message.contains("Broken footnote reference")));
}
}