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,
}
#[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(),
}
}
}
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
}
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::process_full_permissive;
use lex_core::lex::testing::lexplore::Lexplore;
fn unclosed_annotation_diags(source: &str) -> Vec<AnalysisDiagnostic> {
let doc = process_full_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 = process_full_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);
}
}