use crate::inline::extract_references;
use lex_config::{DiagnosticsRulesConfig, RuleConfig, Severity};
use lex_core::lex::ast::{
Annotation, ContentItem, Document, Range, Session, Table, TableRow, TextContent,
};
use lex_core::lex::inlines::ReferenceType;
use lex_extension_host::Registry;
use std::borrow::Cow;
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiagnosticKind {
MissingFootnoteDefinition,
UnusedFootnoteDefinition,
TableInconsistentColumns,
SchemaValidation(SchemaValidationKind),
Handler {
namespace: String,
code: Option<String>,
},
ForbiddenLabelPrefix,
UnknownLexCanonical,
UnclosedAnnotation,
MissingSessionTarget,
MissingDefinitionTarget,
MissingAnnotationTarget,
MissingCitationTarget,
MalformedUrl,
MissingFileTarget,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticSeverity {
Error,
Warning,
Info,
Hint,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaValidationKind {
UnknownLabel,
MissingParam,
ParamTypeMismatch,
BadAttachment,
BodyShapeMismatch,
}
impl SchemaValidationKind {
pub fn code(&self) -> &'static str {
match self {
SchemaValidationKind::UnknownLabel => "schema.unknown-label",
SchemaValidationKind::MissingParam => "schema.missing-param",
SchemaValidationKind::ParamTypeMismatch => "schema.param-type-mismatch",
SchemaValidationKind::BadAttachment => "schema.bad-attachment",
SchemaValidationKind::BodyShapeMismatch => "schema.body-shape-mismatch",
}
}
}
impl DiagnosticKind {
pub fn code(&self) -> Cow<'static, str> {
match self {
DiagnosticKind::MissingFootnoteDefinition => "missing-footnote".into(),
DiagnosticKind::UnusedFootnoteDefinition => "unused-footnote".into(),
DiagnosticKind::TableInconsistentColumns => "table-inconsistent-columns".into(),
DiagnosticKind::SchemaValidation(kind) => kind.code().into(),
DiagnosticKind::Handler { namespace, code } => match code {
Some(c) => format!("{namespace}.{c}").into(),
None => format!("{namespace}.diagnostic").into(),
},
DiagnosticKind::ForbiddenLabelPrefix => "forbidden-label-prefix".into(),
DiagnosticKind::UnknownLexCanonical => "unknown-lex-canonical".into(),
DiagnosticKind::UnclosedAnnotation => "unclosed-annotation".into(),
DiagnosticKind::MissingSessionTarget => "missing-session-target".into(),
DiagnosticKind::MissingDefinitionTarget => "missing-definition-target".into(),
DiagnosticKind::MissingAnnotationTarget => "missing-annotation-target".into(),
DiagnosticKind::MissingCitationTarget => "missing-citation-target".into(),
DiagnosticKind::MalformedUrl => "malformed-url".into(),
DiagnosticKind::MissingFileTarget => "missing-file-target".into(),
}
}
}
pub fn apply_rules<F>(diagnostics: &mut Vec<AnalysisDiagnostic>, lookup_rule: F)
where
F: Fn(&str) -> Option<RuleConfig>,
{
diagnostics.retain_mut(|diag| {
let code = diag.kind.code();
let Some(rule) = lookup_rule(&code) else {
return true;
};
match rule.severity() {
Severity::Allow => false,
Severity::Warn => true,
Severity::Deny => {
diag.severity = DiagnosticSeverity::Error;
true
}
}
});
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnalysisDiagnostic {
pub range: Range,
pub severity: DiagnosticSeverity,
pub kind: DiagnosticKind,
pub message: String,
}
pub fn analyze(document: &Document) -> Vec<AnalysisDiagnostic> {
let registry = Registry::new();
analyze_with_registry(document, ®istry)
}
pub fn analyze_with_registry(document: &Document, registry: &Registry) -> Vec<AnalysisDiagnostic> {
let mut diagnostics = Vec::new();
check_footnotes(document, &mut diagnostics);
check_tables(document, &mut diagnostics);
check_labels(document, &mut diagnostics);
check_unclosed_annotations(document, &mut diagnostics);
crate::label_dispatch::dispatch_labels(document, registry, &mut diagnostics);
diagnostics
}
fn check_unclosed_annotations(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
fn emit(
tl: &lex_core::lex::ast::elements::paragraph::TextLine,
out: &mut Vec<AnalysisDiagnostic>,
) {
if looks_like_unclosed_annotation(tl.text()) {
out.push(AnalysisDiagnostic {
range: tl.location.clone(),
severity: DiagnosticSeverity::Warning,
kind: DiagnosticKind::UnclosedAnnotation,
message: "this line looks like an annotation but has no closing `::`, \
so it is treated as text. Close the marker to make it an \
annotation, e.g. `:: label ::`."
.to_string(),
});
}
}
fn walk(item: &ContentItem, out: &mut Vec<AnalysisDiagnostic>) {
if let ContentItem::Paragraph(p) = item {
for line in &p.lines {
if let ContentItem::TextLine(tl) = line {
emit(tl, out);
}
}
}
if let Some(children) = item.children() {
for child in children {
walk(child, out);
}
}
}
for child in &document.root.children {
walk(child, diagnostics);
}
}
fn looks_like_unclosed_annotation(text: &str) -> bool {
let Some(rest) = text.trim().strip_prefix("::") else {
return false;
};
let mut in_quotes = false;
let mut chars = rest.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' => in_quotes = !in_quotes,
':' if !in_quotes && chars.peek() == Some(&':') => return false,
_ => {}
}
}
let label = rest.trim_start();
rest.len() != label.len() && label.chars().next().is_some_and(|c| c.is_alphabetic())
}
pub fn analyze_with_rules(
document: &Document,
registry: &Registry,
rules: &DiagnosticsRulesConfig,
) -> Vec<AnalysisDiagnostic> {
let mut diagnostics = analyze_with_registry(document, registry);
apply_rules(&mut diagnostics, |code| rules.lookup_by_code(code).cloned());
diagnostics
}
pub fn analyze_references(document: &Document) -> Vec<AnalysisDiagnostic> {
use crate::reference_targets::{targets_from_reference_type, ReferenceTarget};
use crate::references::target_resolves;
let mut diagnostics = Vec::new();
crate::utils::for_each_text_content(document, &mut |text| {
for reference in extract_references(text) {
let (kind, render): (DiagnosticKind, String) = match &reference.reference_type {
ReferenceType::Session { target } if !target.trim().is_empty() => (
DiagnosticKind::MissingSessionTarget,
format!(
"Session reference [#{}] has no matching session",
target.trim()
),
),
ReferenceType::General { target } if !target.trim().is_empty() => (
DiagnosticKind::MissingDefinitionTarget,
format!("Reference [{}] has no matching definition", target.trim()),
),
ReferenceType::AnnotationReference { label } if !label.trim().is_empty() => (
DiagnosticKind::MissingAnnotationTarget,
format!(
"Annotation reference [::{}] has no matching annotation",
label.trim()
),
),
ReferenceType::Url { target } if !target.trim().is_empty() => {
let target = target.trim();
if url_is_malformed(target) {
diagnostics.push(AnalysisDiagnostic {
range: reference.range.clone(),
severity: DiagnosticSeverity::Warning,
kind: DiagnosticKind::MalformedUrl,
message: format!("URL [{target}] is malformed"),
});
}
continue;
}
ReferenceType::Citation(data) => {
for key in &data.keys {
if key.trim().is_empty() {
continue;
}
let target = ReferenceTarget::CitationKey(key.trim().to_string());
if !target_resolves(document, &target) {
diagnostics.push(AnalysisDiagnostic {
range: reference.range.clone(),
severity: DiagnosticSeverity::Warning,
kind: DiagnosticKind::MissingCitationTarget,
message: format!(
"Citation [@{}] has no matching annotation or definition",
key.trim()
),
});
}
}
continue;
}
_ => continue,
};
let resolves = targets_from_reference_type(&reference.reference_type)
.iter()
.any(|t| target_resolves(document, t));
if !resolves {
diagnostics.push(AnalysisDiagnostic {
range: reference.range.clone(),
severity: DiagnosticSeverity::Warning,
kind,
message: render,
});
}
}
});
diagnostics
}
fn url_is_malformed(target: &str) -> bool {
url::Url::parse(target).is_err()
}
#[derive(Debug, Clone)]
pub struct FileReference {
pub target: String,
pub range: Range,
}
pub fn collect_file_references(document: &Document) -> Vec<FileReference> {
use lex_core::lex::ast::traits::AstNode;
let mut refs = Vec::new();
crate::utils::for_each_text_content(document, &mut |text| {
for reference in extract_references(text) {
if let ReferenceType::File { target } = &reference.reference_type {
if !target.trim().is_empty() {
refs.push(FileReference {
target: target.clone(),
range: reference.range.clone(),
});
}
}
}
});
for item in document.root.iter_all_nodes() {
if let ContentItem::VerbatimBlock(verbatim) = item {
if let Some(param) = verbatim
.closing_data
.parameters
.iter()
.find(|p| p.key == "src")
{
let target = param.unquoted_value();
let trimmed = target.trim();
if !trimmed.is_empty() && !is_url_like(trimmed) {
refs.push(FileReference {
target: target.clone(),
range: verbatim.range().clone(),
});
}
}
}
}
refs
}
fn is_url_like(src: &str) -> bool {
src.starts_with("http://")
|| src.starts_with("https://")
|| src.starts_with("mailto:")
|| has_url_scheme(src)
}
fn has_url_scheme(src: &str) -> bool {
let Some((scheme, _)) = src.split_once("://") else {
return false;
};
scheme.len() >= 2
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
fn check_labels(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
use lex_core::lex::assembling::stages::normalize_labels::{
classify_label, RejectReason, Resolution,
};
use lex_core::lex::ast::Label;
fn emit(label: &Label, diagnostics: &mut Vec<AnalysisDiagnostic>) {
if let Resolution::Rejected(reason) = classify_label(&label.value) {
let message = reason.message();
let kind = match reason {
RejectReason::Forbidden { .. } => DiagnosticKind::ForbiddenLabelPrefix,
RejectReason::UnknownCanonical { .. } => DiagnosticKind::UnknownLexCanonical,
};
diagnostics.push(AnalysisDiagnostic {
range: label.location.clone(),
severity: DiagnosticSeverity::Error,
kind,
message,
});
}
}
fn walk_item(item: &ContentItem, diagnostics: &mut Vec<AnalysisDiagnostic>) {
match item {
ContentItem::Annotation(a) => emit(&a.data.label, diagnostics),
ContentItem::VerbatimBlock(v) => emit(&v.closing_data.label, diagnostics),
ContentItem::Table(t) => {
for row in t.header_rows.iter().chain(t.body_rows.iter()) {
for cell in &row.cells {
for child in cell.children.iter() {
walk_item(child, diagnostics);
}
}
}
if let Some(footnotes) = t.footnotes.as_ref() {
for ann in footnotes.annotations() {
walk_annotation(ann, diagnostics);
}
for fn_item in footnotes.items.iter() {
walk_item(fn_item, diagnostics);
}
}
}
_ => {}
}
if let Some(attached) = attached_annotations(item) {
for annotation in attached {
walk_annotation(annotation, diagnostics);
}
}
if let Some(children) = item.children() {
for child in children {
walk_item(child, diagnostics);
}
}
}
fn walk_annotation(annotation: &Annotation, diagnostics: &mut Vec<AnalysisDiagnostic>) {
emit(&annotation.data.label, diagnostics);
for child in annotation.children.iter() {
walk_item(child, diagnostics);
}
}
fn walk_session(session: &Session, diagnostics: &mut Vec<AnalysisDiagnostic>) {
for annotation in session.annotations() {
walk_annotation(annotation, diagnostics);
}
for child in &session.children {
walk_item(child, diagnostics);
}
}
fn attached_annotations(item: &ContentItem) -> Option<&[Annotation]> {
match item {
ContentItem::Session(s) => Some(s.annotations()),
ContentItem::Paragraph(p) => Some(p.annotations()),
ContentItem::Definition(d) => Some(d.annotations()),
ContentItem::List(l) => Some(l.annotations()),
ContentItem::ListItem(li) => Some(li.annotations()),
ContentItem::VerbatimBlock(v) => Some(v.annotations()),
ContentItem::Table(t) => Some(t.annotations()),
_ => None,
}
}
for annotation in document.annotations() {
walk_annotation(annotation, diagnostics);
}
walk_session(&document.root, diagnostics);
}
fn check_footnotes(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
let outer_defs: HashSet<u32> = crate::utils::collect_footnote_definitions(document)
.into_iter()
.filter_map(|(label, _)| label.parse::<u32>().ok())
.collect();
if let Some(title) = &document.title {
check_text(&title.content, &outer_defs, diagnostics);
}
for annotation in document.annotations() {
check_annotation(annotation, &outer_defs, diagnostics);
}
check_session(&document.root, &outer_defs, diagnostics);
}
fn check_session(
session: &Session,
defs: &HashSet<u32>,
diagnostics: &mut Vec<AnalysisDiagnostic>,
) {
check_text(&session.title, defs, diagnostics);
for annotation in session.annotations() {
check_annotation(annotation, defs, diagnostics);
}
for child in session.children.iter() {
check_content(child, defs, diagnostics);
}
}
fn check_content(
item: &ContentItem,
defs: &HashSet<u32>,
diagnostics: &mut Vec<AnalysisDiagnostic>,
) {
match item {
ContentItem::Paragraph(p) => {
for line in &p.lines {
if let ContentItem::TextLine(tl) = line {
check_text(&tl.content, defs, diagnostics);
}
}
for annotation in p.annotations() {
check_annotation(annotation, defs, diagnostics);
}
}
ContentItem::Session(s) => check_session(s, defs, diagnostics),
ContentItem::List(list) => {
for annotation in list.annotations() {
check_annotation(annotation, defs, diagnostics);
}
for entry in &list.items {
if let ContentItem::ListItem(li) = entry {
for text in &li.text {
check_text(text, defs, diagnostics);
}
for annotation in li.annotations() {
check_annotation(annotation, defs, diagnostics);
}
for child in li.children.iter() {
check_content(child, defs, diagnostics);
}
}
}
}
ContentItem::Definition(def) => {
check_text(&def.subject, defs, diagnostics);
for annotation in def.annotations() {
check_annotation(annotation, defs, diagnostics);
}
for child in def.children.iter() {
check_content(child, defs, diagnostics);
}
}
ContentItem::Annotation(a) => check_annotation(a, defs, diagnostics),
ContentItem::VerbatimBlock(v) => {
check_text(&v.subject, defs, diagnostics);
for annotation in v.annotations() {
check_annotation(annotation, defs, diagnostics);
}
}
ContentItem::Table(table) => check_table(table, defs, diagnostics),
_ => {}
}
}
fn check_annotation(
annotation: &Annotation,
defs: &HashSet<u32>,
diagnostics: &mut Vec<AnalysisDiagnostic>,
) {
for child in annotation.children.iter() {
check_content(child, defs, diagnostics);
}
}
fn check_table(
table: &Table,
outer_defs: &HashSet<u32>,
diagnostics: &mut Vec<AnalysisDiagnostic>,
) {
let table_defs = table_footnote_numbers(table);
if table_defs.is_empty() {
check_table_text(table, outer_defs, diagnostics);
return;
}
let mut scope = outer_defs.clone();
scope.extend(table_defs);
check_table_text(table, &scope, diagnostics);
}
fn check_table_text(table: &Table, defs: &HashSet<u32>, diagnostics: &mut Vec<AnalysisDiagnostic>) {
check_text(&table.subject, defs, diagnostics);
for row in table.all_rows() {
for cell in &row.cells {
check_text(&cell.content, defs, diagnostics);
}
}
for annotation in table.annotations() {
check_annotation(annotation, defs, diagnostics);
}
}
fn table_footnote_numbers(table: &Table) -> HashSet<u32> {
let Some(list) = &table.footnotes else {
return HashSet::new();
};
let mut numbers = HashSet::new();
for entry in &list.items {
if let ContentItem::ListItem(li) = entry {
let label = li
.marker()
.trim()
.trim_end_matches(['.', ')', ':'].as_ref())
.trim();
if let Ok(n) = label.parse::<u32>() {
numbers.insert(n);
}
}
}
numbers
}
fn check_text(text: &TextContent, defs: &HashSet<u32>, diagnostics: &mut Vec<AnalysisDiagnostic>) {
for reference in extract_references(text) {
if let ReferenceType::FootnoteNumber { number } = reference.reference_type {
if !defs.contains(&number) {
diagnostics.push(AnalysisDiagnostic {
range: reference.range,
severity: DiagnosticSeverity::Error,
kind: DiagnosticKind::MissingFootnoteDefinition,
message: format!(
"Footnote [{number}] has no matching footnote definition in scope"
),
});
}
}
}
}
fn check_tables(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
visit_tables_in_session(&document.root, diagnostics);
}
fn visit_tables_in_session(session: &Session, diagnostics: &mut Vec<AnalysisDiagnostic>) {
for child in session.children.iter() {
visit_tables_in_content(child, diagnostics);
}
}
fn visit_tables_in_content(item: &ContentItem, diagnostics: &mut Vec<AnalysisDiagnostic>) {
match item {
ContentItem::Table(table) => check_table_columns(table, diagnostics),
ContentItem::Session(session) => visit_tables_in_session(session, diagnostics),
ContentItem::Definition(def) => {
for child in def.children.iter() {
visit_tables_in_content(child, diagnostics);
}
}
ContentItem::List(list) => {
for entry in &list.items {
if let ContentItem::ListItem(li) = entry {
for child in li.children.iter() {
visit_tables_in_content(child, diagnostics);
}
}
}
}
ContentItem::Annotation(ann) => {
for child in ann.children.iter() {
visit_tables_in_content(child, diagnostics);
}
}
_ => {}
}
}
fn check_table_columns(table: &Table, diagnostics: &mut Vec<AnalysisDiagnostic>) {
let rows: Vec<_> = table.all_rows().collect();
if rows.len() < 2 {
return;
}
let widths = compute_row_widths(&rows);
let expected = widths[0];
for (i, &width) in widths.iter().enumerate().skip(1) {
if width != expected {
diagnostics.push(AnalysisDiagnostic {
range: rows[i].location.clone(),
severity: DiagnosticSeverity::Warning,
kind: DiagnosticKind::TableInconsistentColumns,
message: format!(
"Row has {width} columns, expected {expected} (matching first row)"
),
});
}
}
}
fn compute_row_widths(rows: &[&TableRow]) -> Vec<usize> {
let mut carry: Vec<usize> = Vec::new();
let mut widths = Vec::with_capacity(rows.len());
for row in rows {
let mut col = 0;
for cell in &row.cells {
while col < carry.len() && carry[col] > 0 {
col += 1;
}
let end = col + cell.colspan;
if end > carry.len() {
carry.resize(end, 0);
}
for slot in carry.iter_mut().take(end).skip(col) {
*slot = cell.rowspan;
}
col = end;
}
let width = carry
.iter()
.rposition(|&r| r > 0)
.map(|i| i + 1)
.unwrap_or(0);
widths.push(width);
for c in carry.iter_mut().take(width) {
if *c > 0 {
*c -= 1;
}
}
carry.truncate(width);
}
widths
}
#[cfg(test)]
mod tests {
use super::*;
use lex_core::lex::parsing::parse_document_permissive;
use lex_core::lex::testing::lexplore::Lexplore;
fn unclosed_annotation_diags(source: &str) -> Vec<AnalysisDiagnostic> {
let doc = parse_document_permissive(source).expect("permissive parse");
analyze(&doc)
.into_iter()
.filter(|d| d.kind == DiagnosticKind::UnclosedAnnotation)
.collect()
}
#[test]
fn unclosed_annotation_warns_on_open_form() {
let diags = unclosed_annotation_diags("Open form:\n\t:: note severity=high\n");
assert_eq!(diags.len(), 1, "expected one unclosed-annotation warning");
assert_eq!(diags[0].severity, DiagnosticSeverity::Warning);
assert_eq!(diags[0].kind.code(), "unclosed-annotation");
}
#[test]
fn unclosed_annotation_silent_on_closed_form_and_prose() {
assert!(unclosed_annotation_diags(":: note severity=high ::\n\nBody.\n").is_empty());
assert!(unclosed_annotation_diags("Use :: to start a marker.\n").is_empty());
}
#[test]
fn looks_like_unclosed_annotation_heuristic() {
assert!(looks_like_unclosed_annotation(":: note"));
assert!(looks_like_unclosed_annotation(" :: note severity=high"));
assert!(looks_like_unclosed_annotation(":: note foo=\":: value\""));
assert!(!looks_like_unclosed_annotation(":: note ::"));
assert!(!looks_like_unclosed_annotation(
":: note foo=\":: value\" ::"
)); assert!(!looks_like_unclosed_annotation("::note")); assert!(!looks_like_unclosed_annotation("::")); assert!(!looks_like_unclosed_annotation("just prose"));
}
fn footnote_diags(doc: &Document) -> Vec<AnalysisDiagnostic> {
analyze(doc)
.into_iter()
.filter(|d| d.kind == DiagnosticKind::MissingFootnoteDefinition)
.collect()
}
fn label_diags(source: &str) -> Vec<AnalysisDiagnostic> {
let doc = parse_document_permissive(source).expect("permissive parse");
analyze(&doc)
.into_iter()
.filter(|d| {
matches!(
d.kind,
DiagnosticKind::ForbiddenLabelPrefix | DiagnosticKind::UnknownLexCanonical
)
})
.collect()
}
#[test]
fn check_labels_emits_for_doc_prefix() {
let diags = label_diags(":: doc.table :: x\n\nBody.\n");
assert_eq!(diags.len(), 1, "expected 1 forbidden-prefix diagnostic");
assert_eq!(diags[0].kind, DiagnosticKind::ForbiddenLabelPrefix);
assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
assert!(
diags[0].message.contains("doc.table") && diags[0].message.contains("reserved"),
"message names the offending prefix; got: {}",
diags[0].message
);
}
#[test]
fn check_labels_emits_for_unknown_lex_canonical() {
let diags = label_diags(":: lex.foobar :: x\n\nBody.\n");
assert_eq!(diags.len(), 1, "expected 1 unknown-canonical diagnostic");
assert_eq!(diags[0].kind, DiagnosticKind::UnknownLexCanonical);
assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
assert!(
diags[0].message.contains("lex.foobar"),
"message names the offending label; got: {}",
diags[0].message
);
}
#[test]
fn check_labels_silent_on_accepted_forms() {
let sources = [
":: author :: Alice\n\nBody.\n",
":: metadata.author :: Alice\n\nBody.\n",
":: lex.metadata.author :: Alice\n\nBody.\n",
":: acme.task :: x\n\nBody.\n",
];
for src in sources {
let diags = label_diags(src);
assert!(
diags.is_empty(),
"no label diagnostics expected for {src:?}; got {diags:?}"
);
}
}
#[test]
fn check_labels_finds_verbatim_closer_violations() {
let diags =
label_diags("Table:\n | a | b |\n |---|---|\n | 1 | 2 |\n:: doc.table ::\n");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].kind, DiagnosticKind::ForbiddenLabelPrefix);
}
#[test]
fn check_labels_emits_each_offending_site_exactly_once() {
let src = ":: doc.outer ::\n :: doc.inner :: nested body\n\n:: doc.sibling :: x\n";
let diags = label_diags(src);
assert_eq!(
diags.len(),
3,
"exactly one diagnostic per offending site: {diags:?}"
);
for d in &diags {
assert_eq!(d.kind, DiagnosticKind::ForbiddenLabelPrefix);
}
}
#[test]
fn detects_missing_footnote_definition() {
let doc = Lexplore::footnotes(1).parse().unwrap();
let diags = analyze(&doc);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].kind, DiagnosticKind::MissingFootnoteDefinition);
}
#[test]
fn ignores_valid_footnote_with_notes_annotation() {
let doc = Lexplore::footnotes(2).parse().unwrap();
assert!(footnote_diags(&doc).is_empty());
}
#[test]
fn ignores_valid_list_footnote_in_session() {
let doc = Lexplore::footnotes(3).parse().unwrap();
assert!(footnote_diags(&doc).is_empty());
}
#[test]
fn list_without_notes_annotation_is_not_footnotes() {
let doc = Lexplore::footnotes(4).parse().unwrap();
assert_eq!(footnote_diags(&doc).len(), 1);
}
fn table_diags(doc: &Document) -> Vec<AnalysisDiagnostic> {
analyze(doc)
.into_iter()
.filter(|d| d.kind == DiagnosticKind::TableInconsistentColumns)
.collect()
}
#[test]
fn detects_inconsistent_table_columns() {
let doc = Lexplore::table(13).parse().unwrap();
let diags = table_diags(&doc);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("2 columns"));
assert!(diags[0].message.contains("expected 3"));
}
#[test]
fn consistent_table_no_diagnostic() {
let doc = Lexplore::table(1).parse().unwrap();
assert!(table_diags(&doc).is_empty());
}
#[test]
fn table_with_rowspan_counts_carry_over() {
let doc = Lexplore::table(17).parse().unwrap();
let diags = table_diags(&doc);
assert!(
diags.is_empty(),
"rowspan carry-over should not trigger inconsistent-columns, got: {diags:?}"
);
}
#[test]
fn table_with_colspan_and_rowspan_mixed() {
let doc = Lexplore::table(18).parse().unwrap();
let diags = table_diags(&doc);
assert!(
diags.is_empty(),
"mixed colspan/rowspan should not trigger inconsistent-columns, got: {diags:?}"
);
}
#[test]
fn table_with_colspan_counts_effective_width() {
let doc = Lexplore::table(4).parse().unwrap();
assert!(table_diags(&doc).is_empty());
}
#[test]
fn footnote_ref_in_table_cell_is_checked() {
let doc = Lexplore::footnotes(9).parse().unwrap();
let diags = footnote_diags(&doc);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("[1]"));
}
#[test]
fn table_scoped_footnotes_resolve_cell_refs() {
let doc = Lexplore::footnotes(11).parse().unwrap();
let diags = footnote_diags(&doc);
assert!(
diags.is_empty(),
"table-scoped cell refs should resolve to table.footnotes, got: {diags:?}"
);
}
#[test]
fn table_scoped_footnotes_do_not_leak_out() {
let doc = Lexplore::footnotes(12).parse().unwrap();
let diags = footnote_diags(&doc);
assert_eq!(
diags.len(),
1,
"only the paragraph ref [1] should be unresolved, got: {diags:?}"
);
assert!(diags[0].message.contains("[1]"));
}
fn dummy_diag(kind: DiagnosticKind, severity: DiagnosticSeverity) -> AnalysisDiagnostic {
AnalysisDiagnostic {
range: Range::default(),
severity,
kind,
message: "test".into(),
}
}
#[test]
fn diagnostic_kind_code_matches_lookup_for_every_builtin() {
let rules = DiagnosticsRulesConfig::default();
for kind in [
DiagnosticKind::MissingFootnoteDefinition,
DiagnosticKind::UnusedFootnoteDefinition,
DiagnosticKind::TableInconsistentColumns,
DiagnosticKind::ForbiddenLabelPrefix,
DiagnosticKind::UnknownLexCanonical,
DiagnosticKind::SchemaValidation(SchemaValidationKind::UnknownLabel),
DiagnosticKind::SchemaValidation(SchemaValidationKind::MissingParam),
DiagnosticKind::SchemaValidation(SchemaValidationKind::ParamTypeMismatch),
DiagnosticKind::SchemaValidation(SchemaValidationKind::BadAttachment),
DiagnosticKind::SchemaValidation(SchemaValidationKind::BodyShapeMismatch),
] {
let code = kind.code();
assert!(
rules.lookup_by_code(&code).is_some(),
"DiagnosticsRulesConfig is missing a field for built-in code {code:?} \
— add it to lookup_by_code (and likely as a struct field too)"
);
}
}
#[test]
fn handler_code_carries_namespace_prefix() {
let with_code = DiagnosticKind::Handler {
namespace: "acme".into(),
code: Some("task-stuck".into()),
};
assert_eq!(with_code.code(), "acme.task-stuck");
let without_code = DiagnosticKind::Handler {
namespace: "acme".into(),
code: None,
};
assert_eq!(without_code.code(), "acme.diagnostic");
}
#[test]
fn apply_rules_matches_extension_code_via_side_channel() {
use std::collections::BTreeMap;
let lookup = |code: &str, side: &BTreeMap<String, lex_config::RuleConfig>| {
DiagnosticsRulesConfig::default()
.lookup_by_code(code)
.cloned()
.or_else(|| side.get(code).cloned())
};
let side: BTreeMap<String, lex_config::RuleConfig> = [(
"acme.foo".to_string(),
lex_config::RuleConfig::Bare(Severity::Allow),
)]
.into_iter()
.collect();
let mut diags = vec![dummy_diag(
DiagnosticKind::Handler {
namespace: "acme".into(),
code: Some("foo".into()),
},
DiagnosticSeverity::Error,
)];
apply_rules(&mut diags, |code| lookup(code, &side));
assert!(diags.is_empty(), "allow drops the extension diagnostic");
let side: BTreeMap<String, lex_config::RuleConfig> = [(
"acme.foo".to_string(),
lex_config::RuleConfig::Bare(Severity::Warn),
)]
.into_iter()
.collect();
let mut diags = vec![dummy_diag(
DiagnosticKind::Handler {
namespace: "acme".into(),
code: Some("foo".into()),
},
DiagnosticSeverity::Error,
)];
apply_rules(&mut diags, |code| lookup(code, &side));
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].severity,
DiagnosticSeverity::Error,
"warn preserves the handler's intrinsic severity"
);
let side: BTreeMap<String, lex_config::RuleConfig> = [(
"acme.foo".to_string(),
lex_config::RuleConfig::Bare(Severity::Deny),
)]
.into_iter()
.collect();
let mut diags = vec![dummy_diag(
DiagnosticKind::Handler {
namespace: "acme".into(),
code: Some("foo".into()),
},
DiagnosticSeverity::Error,
)];
apply_rules(&mut diags, |code| lookup(code, &side));
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
let side: BTreeMap<String, lex_config::RuleConfig> = [(
"acme.other".to_string(),
lex_config::RuleConfig::Bare(Severity::Allow),
)]
.into_iter()
.collect();
let mut diags = vec![dummy_diag(
DiagnosticKind::Handler {
namespace: "acme".into(),
code: Some("foo".into()),
},
DiagnosticSeverity::Warning,
)];
apply_rules(&mut diags, |code| lookup(code, &side));
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, DiagnosticSeverity::Warning);
}
#[test]
fn apply_rules_allow_drops_diagnostic() {
let mut diags = vec![dummy_diag(
DiagnosticKind::MissingFootnoteDefinition,
DiagnosticSeverity::Error,
)];
let rules = DiagnosticsRulesConfig {
missing_footnote: lex_config::RuleConfig::Bare(Severity::Allow),
..Default::default()
};
apply_rules(&mut diags, |code| rules.lookup_by_code(code).cloned());
assert!(diags.is_empty(), "allow should drop the diagnostic");
}
#[test]
fn apply_rules_deny_upgrades_to_error() {
let mut diags = vec![dummy_diag(
DiagnosticKind::TableInconsistentColumns,
DiagnosticSeverity::Warning,
)];
let rules = DiagnosticsRulesConfig {
table_inconsistent_columns: lex_config::RuleConfig::Bare(Severity::Deny),
..Default::default()
};
apply_rules(&mut diags, |code| rules.lookup_by_code(code).cloned());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
}
#[test]
fn apply_rules_warn_keeps_intrinsic_severity() {
let mut diags = vec![dummy_diag(
DiagnosticKind::TableInconsistentColumns,
DiagnosticSeverity::Warning,
)];
let rules = DiagnosticsRulesConfig {
table_inconsistent_columns: lex_config::RuleConfig::Bare(Severity::Warn),
..Default::default()
};
apply_rules(&mut diags, |code| rules.lookup_by_code(code).cloned());
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].severity,
DiagnosticSeverity::Warning,
"warn should not change the intrinsic severity"
);
}
#[test]
fn apply_rules_unknown_code_is_passthrough() {
let mut diags = vec![dummy_diag(
DiagnosticKind::Handler {
namespace: "acme".into(),
code: Some("unknown".into()),
},
DiagnosticSeverity::Warning,
)];
let rules = DiagnosticsRulesConfig::default();
apply_rules(&mut diags, |code| rules.lookup_by_code(code).cloned());
assert_eq!(diags.len(), 1, "unknown codes should pass through");
assert_eq!(diags[0].severity, DiagnosticSeverity::Warning);
}
#[test]
fn apply_rules_preserves_order_of_kept_diagnostics() {
let mut diags = vec![
dummy_diag(
DiagnosticKind::MissingFootnoteDefinition,
DiagnosticSeverity::Error,
),
dummy_diag(
DiagnosticKind::UnusedFootnoteDefinition,
DiagnosticSeverity::Warning,
),
dummy_diag(
DiagnosticKind::TableInconsistentColumns,
DiagnosticSeverity::Warning,
),
];
let rules = DiagnosticsRulesConfig {
missing_footnote: lex_config::RuleConfig::Bare(Severity::Allow),
table_inconsistent_columns: lex_config::RuleConfig::Bare(Severity::Deny),
..Default::default()
};
apply_rules(&mut diags, |code| rules.lookup_by_code(code).cloned());
assert_eq!(diags.len(), 2);
assert_eq!(diags[0].kind, DiagnosticKind::UnusedFootnoteDefinition);
assert_eq!(diags[0].severity, DiagnosticSeverity::Warning);
assert_eq!(diags[1].kind, DiagnosticKind::TableInconsistentColumns);
assert_eq!(diags[1].severity, DiagnosticSeverity::Error);
}
fn reference_diags(source: &str) -> Vec<AnalysisDiagnostic> {
let doc = parse_document_permissive(source).expect("permissive parse");
analyze_references(&doc)
}
fn ref_codes(source: &str) -> Vec<String> {
let mut codes: Vec<String> = reference_diags(source)
.into_iter()
.map(|d| d.kind.code().into_owned())
.collect();
codes.sort();
codes
}
#[test]
fn references_pass_is_not_run_by_the_always_on_analyser() {
let doc = parse_document_permissive("Body with a [Dangling] reference.\n")
.expect("permissive parse");
let always_on = analyze(&doc);
assert!(
always_on
.iter()
.all(|d| !d.kind.code().starts_with("missing-")
|| d.kind == DiagnosticKind::MissingFootnoteDefinition),
"always-on analyser must not emit reference-target diagnostics"
);
}
#[test]
fn dangling_definition_reference_flagged() {
let codes = ref_codes("1. Intro\n\n See [Nope].\n");
assert_eq!(codes, vec!["missing-definition-target"]);
}
#[test]
fn dangling_session_reference_flagged() {
let codes = ref_codes("1. Intro\n\n See [#9.9].\n");
assert_eq!(codes, vec!["missing-session-target"]);
}
#[test]
fn dangling_annotation_reference_flagged() {
let codes = ref_codes("1. Intro\n\n See [::ghost].\n");
assert_eq!(codes, vec!["missing-annotation-target"]);
}
#[test]
fn dangling_citation_flagged() {
let codes = ref_codes("1. Intro\n\n See [@missing2024].\n");
assert_eq!(codes, vec!["missing-citation-target"]);
}
#[test]
fn resolved_references_are_clean() {
let source = ":: mynote ::\n\
\x20 Note body.\n\
\n\
Cache:\n\
\x20 Definition body.\n\
\n\
2. Topic\n\
\n\
\x20 See [Cache] and [::mynote] and [#2].\n";
assert!(
reference_diags(source).is_empty(),
"resolved references must be clean: {:?}",
reference_diags(source)
);
}
#[test]
fn citation_resolves_via_annotation_label() {
let source = ":: spec ::\n Body.\n\n1. Intro\n\n See [@spec].\n";
assert!(reference_diags(source).is_empty());
}
#[test]
fn annotation_matching_is_case_insensitive() {
let source = ":: mynote ::\n Body.\n\n1. Intro\n\n See [::MyNote].\n";
assert!(reference_diags(source).is_empty());
}
#[test]
fn placeholders_never_flagged() {
assert!(reference_diags("1. Intro\n\n A [TK] and [TK-later].\n").is_empty());
}
#[test]
fn each_unresolved_citation_key_is_flagged() {
let diags = reference_diags("1. Intro\n\n See [@a; @b].\n");
let citation = diags
.iter()
.filter(|d| d.kind == DiagnosticKind::MissingCitationTarget)
.count();
assert_eq!(
citation, 2,
"both unresolved keys must be flagged: {diags:?}"
);
}
#[test]
fn reference_findings_default_to_warning() {
let diags = reference_diags("1. Intro\n\n See [Nope].\n");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, DiagnosticSeverity::Warning);
}
#[test]
fn malformed_url_embedded_space_flagged() {
let codes = ref_codes("1. Intro\n\n See [https://exa mple.com].\n");
assert_eq!(codes, vec!["malformed-url"]);
}
#[test]
fn malformed_url_empty_host_flagged() {
let codes = ref_codes("1. Intro\n\n See [https:// ].\n");
assert_eq!(codes, vec!["malformed-url"]);
}
#[test]
fn well_formed_https_url_not_flagged() {
assert!(
reference_diags("1. Intro\n\n See [https://example.com/path?q=1].\n").is_empty(),
"a well-formed https URL must not be flagged"
);
}
#[test]
fn well_formed_http_url_not_flagged() {
assert!(reference_diags("1. Intro\n\n See [http://example.com].\n").is_empty());
}
#[test]
fn well_formed_mailto_not_flagged() {
assert!(
reference_diags("1. Intro\n\n Write [mailto:hi@example.com].\n").is_empty(),
"a well-formed mailto must not be flagged"
);
}
#[test]
fn malformed_url_defaults_to_warning() {
let diags = reference_diags("1. Intro\n\n See [https://exa mple.com].\n");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].kind, DiagnosticKind::MalformedUrl);
assert_eq!(diags[0].severity, DiagnosticSeverity::Warning);
}
#[test]
fn url_check_makes_no_network_calls_by_construction() {
assert!(!url_is_malformed("https://example.com"));
assert!(url_is_malformed("https://exa mple.com"));
assert!(!url_is_malformed("mailto:a@b.com"));
}
fn file_ref_targets(source: &str) -> Vec<String> {
let doc = parse_document_permissive(source).expect("permissive parse");
let mut targets: Vec<String> = collect_file_references(&doc)
.into_iter()
.map(|r| r.target)
.collect();
targets.sort();
targets
}
#[test]
fn collects_inline_file_references() {
let source = "1. Intro\n\n See [./a.txt] and [../b] and [/c] but not [Nope].\n";
assert_eq!(
file_ref_targets(source),
vec!["../b".to_string(), "./a.txt".to_string(), "/c".to_string()]
);
}
#[test]
fn collects_verbatim_src_but_not_lex_include() {
let source = "Photo:\n Caption.\n:: image src=./diagram.png ::\n\n";
assert_eq!(file_ref_targets(source), vec!["./diagram.png".to_string()]);
}
#[test]
fn verbatim_src_is_unquoted() {
let source = "Photo:\n Caption.\n:: image src=\"./diagram.png\" ::\n\n";
assert_eq!(file_ref_targets(source), vec!["./diagram.png".to_string()]);
}
#[test]
fn ignores_url_references() {
assert!(file_ref_targets("1. Intro\n\n See [https://example.com].\n").is_empty());
assert!(file_ref_targets(
"Photo:\n Caption.\n:: image src=https://example.com/diagram.png ::\n\n"
)
.is_empty());
assert!(file_ref_targets(
"Photo:\n Caption.\n:: image src=\"https://example.com/diagram.png\" ::\n\n"
)
.is_empty());
}
#[test]
fn is_url_like_matches_real_schemes_not_windows_drives() {
assert!(is_url_like("https://example.com"));
assert!(is_url_like("http://example.com"));
assert!(is_url_like("mailto:user@example.com"));
assert!(is_url_like("ftp://host/path"));
assert!(!is_url_like("C://path"));
assert!(!is_url_like("C:\\path"));
assert!(!is_url_like("./rel/path"));
}
}