knowledge-base-validation 0.3.0

Validation for the file-based knowledge base
Documentation
use super::Validator;
use crate::diagnostic::Diagnostics;
use crate::input::{ContextDocument, Loaded};
use knowledge_base_models::{Reference, ReferenceId};
use pulldown_cmark::{Event, Options, Parser, Tag};
use regex::Regex;
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::LazyLock;

static FOOTNOTE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[\^([^\]\r\n]+)\]").expect("valid regex"));

#[derive(Default)]
struct FootnoteDefinition {
    line: usize,
    targets: Vec<String>,
}

pub(super) fn validate(validator: &mut Validator<'_>) {
    let entities = &validator.indexes.entities;
    let references = &validator.indexes.references;
    let diagnostics = &mut validator.diagnostics;

    for context in &validator.repository.contexts {
        validate_context(context, entities.contains_key(&context.entity_id), references, diagnostics);
    }
}

fn validate_context(context: &ContextDocument, entity_exists: bool, references: &BTreeMap<ReferenceId, &Loaded<Reference>>, diagnostics: &mut Diagnostics) {
    let owner = context.entity_id.to_string();
    if !entity_exists {
        diagnostics.provenance(&context.path, None, &owner, "context document names an entity that does not exist");
    }

    let mut definitions = BTreeMap::<String, Vec<FootnoteDefinition>>::new();
    let mut references_used = BTreeMap::<String, usize>::new();
    for capture in FOOTNOTE_REFERENCE.captures_iter(&context.source) {
        let marker = capture.get(0).expect("footnote marker capture");
        if context.source.as_bytes().get(marker.end()) == Some(&b':') {
            continue;
        }
        let label = capture.get(1).expect("footnote label capture");
        references_used.entry(label.as_str().to_owned()).or_insert_with(|| line_at(&context.source, marker.start()));
    }
    let mut current_definition: Option<(String, FootnoteDefinition)> = None;
    let options = Options::ENABLE_FOOTNOTES;
    for (event, range) in Parser::new_ext(&context.source, options).into_offset_iter() {
        let line = line_at(&context.source, range.start);
        match event {
            Event::Start(Tag::FootnoteDefinition(label)) => {
                current_definition = Some((label.to_string(), FootnoteDefinition { line, targets: Vec::new() }));
            }
            Event::End(pulldown_cmark::TagEnd::FootnoteDefinition) => {
                if let Some((label, definition)) = current_definition.take() {
                    definitions.entry(label).or_default().push(definition);
                }
            }
            Event::Start(Tag::Link { dest_url, .. }) => {
                if let Some((_, definition)) = current_definition.as_mut() {
                    definition.targets.push(dest_url.to_string());
                }
            }
            Event::FootnoteReference(label) => {
                references_used.entry(label.to_string()).or_insert(line);
            }
            _ => {}
        }
    }

    for (label, line) in &references_used {
        match definitions.get(label).map(Vec::len).unwrap_or_default() {
            0 => diagnostics.provenance(&context.path, Some(*line), &owner, format!("footnote {label:?} has no definition")),
            1 => {
                parse_reference_label(label, &context.path, *line, &owner, references, diagnostics);
            }
            count => {
                parse_reference_label(label, &context.path, *line, &owner, references, diagnostics);
                diagnostics.provenance(&context.path, Some(*line), &owner, format!("footnote {label:?} has {count} definitions"));
            }
        }
    }

    for (label, entries) in definitions {
        if !references_used.contains_key(&label) {
            diagnostics.provenance(&context.path, Some(entries[0].line), &owner, format!("footnote {label:?} is unused"));
        }
        if entries.len() > 1 && !references_used.contains_key(&label) {
            diagnostics.provenance(
                &context.path,
                Some(entries[0].line),
                &owner,
                format!("footnote {label:?} has {} definitions", entries.len()),
            );
        }
        for definition in &entries {
            let reference_id = parse_reference_label(&label, &context.path, definition.line, &owner, references, diagnostics);
            if let Some(reference_id) = reference_id {
                let expected = format!("../references/{reference_id}.yaml");
                if definition.targets.as_slice() != [expected.as_str()] {
                    diagnostics.provenance(
                        &context.path,
                        Some(definition.line),
                        &owner,
                        format!("footnote {label:?} must contain exactly one link to {expected}"),
                    );
                }
            }
        }
    }
}

fn parse_reference_label(
    label: &str,
    path: &Path,
    line: usize,
    owner: &str,
    references: &BTreeMap<ReferenceId, &Loaded<Reference>>,
    diagnostics: &mut Diagnostics,
) -> Option<ReferenceId> {
    let reference_id = match serde_yaml::from_value::<ReferenceId>(label.into()) {
        Ok(reference_id) => reference_id,
        Err(_) => {
            diagnostics.provenance(path, Some(line), owner, format!("footnote label {label:?} is not a canonical reference identifier"));
            return None;
        }
    };
    if !references.contains_key(&reference_id) {
        diagnostics.provenance(path, Some(line), owner, format!("footnote {label:?} cites a reference that does not exist"));
    }
    Some(reference_id)
}

fn line_at(source: &str, offset: usize) -> usize {
    source[..offset.min(source.len())].bytes().filter(|byte| *byte == b'\n').count() + 1
}