Skip to main content

knowledge_base_validation/
lib.rs

1mod diagnostic;
2
3pub use diagnostic::{Diagnostic, ValidationLayer};
4
5use chrono::{DateTime, NaiveDate};
6use knowledge_base_models::{Cardinality, Entity, EntityId, EntityType, EntityTypeId, IdAllocation, LocalizedMap, Property, PropertyId, Reference, ReferenceId, Value, ValueType};
7use language_tags::LanguageTag;
8use pulldown_cmark::{Event, Options, Parser, Tag};
9use regex::Regex;
10use serde::de::DeserializeOwned;
11use std::collections::{BTreeMap, BTreeSet};
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::sync::LazyLock;
15use url::Url;
16
17static DECIMAL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^-?(0|[1-9][0-9]*)(\.[0-9]+)?$").expect("valid regex"));
18static DATE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$").expect("valid regex"));
19
20struct Loaded<T> {
21    path: PathBuf,
22    value: T,
23}
24
25struct ContextDocument {
26    path: PathBuf,
27    entity_id: EntityId,
28    source: String,
29}
30
31type Diagnostics = Vec<Diagnostic>;
32
33pub fn validate_repository(root: impl AsRef<Path>) -> Vec<Diagnostic> {
34    let root = root.as_ref();
35    let mut report = Vec::new();
36
37    if !root.is_dir() {
38        push(
39            &mut report,
40            ValidationLayer::Schema,
41            PathBuf::from("."),
42            None,
43            None,
44            format!("knowledge-base root is not a readable directory: {}", root.display()),
45        );
46        sort_diagnostics(&mut report);
47        return report;
48    }
49
50    let entities = load_yaml_directory::<Entity>(root, "entities", &mut report);
51    let entity_types = load_yaml_directory::<EntityType>(root, "entity_types", &mut report);
52    let properties = load_yaml_directory::<Property>(root, "properties", &mut report);
53    let references = load_yaml_directory::<Reference>(root, "references", &mut report);
54    let allocation = load_yaml_file::<IdAllocation>(root, Path::new("id_allocation.yaml"), &mut report);
55    let contexts = load_contexts(root, &mut report);
56
57    let entity_index = build_index(&entities, |item| item.id.clone(), "entity", &mut report);
58    let type_index = build_index(&entity_types, |item| item.id.clone(), "entity type", &mut report);
59    let property_index = build_index(&properties, |item| item.id.clone(), "property", &mut report);
60    let reference_index = build_index(&references, |item| item.id.clone(), "reference", &mut report);
61
62    validate_filenames(&entities, "entity", |item| item.id.as_str(), &mut report);
63    validate_filenames(&entity_types, "entity type", |item| item.id.as_str(), &mut report);
64    validate_filenames(&properties, "property", |item| item.id.as_str(), &mut report);
65    validate_filenames(&references, "reference", |item| item.id.as_str(), &mut report);
66
67    validate_entity_types(&entity_types, &reference_index, &mut report);
68    validate_properties(&properties, &type_index, &property_index, &reference_index, &mut report);
69    validate_references(&references, &mut report);
70    validate_entities(&entities, &entity_index, &type_index, &property_index, &reference_index, &mut report);
71
72    if let Some(allocation) = allocation.as_ref() {
73        validate_allocation(
74            allocation,
75            entity_index.keys().map(EntityId::number).max(),
76            property_index.keys().map(PropertyId::number).max(),
77            reference_index.keys().map(ReferenceId::number).max(),
78            type_index.keys().map(EntityTypeId::number).max(),
79            &mut report,
80        );
81    }
82
83    validate_contexts(&contexts, &entity_index, &reference_index, &mut report);
84    sort_diagnostics(&mut report);
85    report
86}
87
88fn load_yaml_directory<T: DeserializeOwned>(root: &Path, directory: &str, report: &mut Diagnostics) -> Vec<Loaded<T>> {
89    let relative = PathBuf::from(directory);
90    let path = root.join(&relative);
91    let entries = match fs::read_dir(&path) {
92        Ok(entries) => entries,
93        Err(error) => {
94            push(report, ValidationLayer::Schema, relative, None, None, format!("required directory cannot be read: {error}"));
95            return Vec::new();
96        }
97    };
98
99    let mut paths = Vec::new();
100    for entry in entries {
101        match entry {
102            Ok(entry) => paths.push(entry.path()),
103            Err(error) => push(
104                report,
105                ValidationLayer::Schema,
106                PathBuf::from(directory),
107                None,
108                None,
109                format!("directory entry cannot be read: {error}"),
110            ),
111        }
112    }
113    paths.sort();
114
115    let mut loaded = Vec::new();
116    for path in paths {
117        let relative_path = relative_path(root, &path);
118        let is_yaml_file = path.is_file() && path.extension().and_then(|extension| extension.to_str()) == Some("yaml");
119        if !is_yaml_file {
120            push(
121                report,
122                ValidationLayer::Schema,
123                relative_path,
124                None,
125                None,
126                "unexpected entry; managed directories may contain only .yaml files",
127            );
128            continue;
129        }
130        if let Some(value) = load_yaml_at::<T>(&path, relative_path.clone(), report) {
131            loaded.push(Loaded { path: relative_path, value });
132        }
133    }
134    loaded
135}
136
137fn load_yaml_file<T: DeserializeOwned>(root: &Path, relative: &Path, report: &mut Diagnostics) -> Option<Loaded<T>> {
138    let value = load_yaml_at::<T>(&root.join(relative), relative.to_path_buf(), report)?;
139    Some(Loaded {
140        path: relative.to_path_buf(),
141        value,
142    })
143}
144
145fn load_yaml_at<T: DeserializeOwned>(path: &Path, relative: PathBuf, report: &mut Diagnostics) -> Option<T> {
146    let source = match fs::read_to_string(path) {
147        Ok(source) => source,
148        Err(error) => {
149            push(report, ValidationLayer::Schema, relative, None, None, format!("file cannot be read: {error}"));
150            return None;
151        }
152    };
153
154    let value = match serde_yaml::from_str::<serde_yaml::Value>(&source) {
155        Ok(value) => value,
156        Err(error) => {
157            push(
158                report,
159                ValidationLayer::Schema,
160                relative,
161                error.location().map(|location| location.line()),
162                None,
163                format!("invalid YAML: {error}"),
164            );
165            return None;
166        }
167    };
168    match serde_yaml::from_value(value) {
169        Ok(value) => Some(value),
170        Err(error) => {
171            push(
172                report,
173                ValidationLayer::Schema,
174                relative,
175                error.location().map(|location| location.line()),
176                None,
177                format!("invalid file shape: {error}"),
178            );
179            None
180        }
181    }
182}
183
184fn load_contexts(root: &Path, report: &mut Diagnostics) -> Vec<ContextDocument> {
185    let directory = root.join("entity_context");
186    if !directory.exists() {
187        return Vec::new();
188    }
189    let entries = match fs::read_dir(&directory) {
190        Ok(entries) => entries,
191        Err(error) => {
192            push(
193                report,
194                ValidationLayer::Schema,
195                PathBuf::from("entity_context"),
196                None,
197                None,
198                format!("optional context directory cannot be read: {error}"),
199            );
200            return Vec::new();
201        }
202    };
203
204    let mut paths = entries.filter_map(Result::ok).map(|entry| entry.path()).collect::<Vec<_>>();
205    paths.sort();
206    let mut contexts = Vec::new();
207    for path in paths {
208        let relative = relative_path(root, &path);
209        if !path.is_file() || path.extension().and_then(|value| value.to_str()) != Some("md") {
210            push(
211                report,
212                ValidationLayer::Schema,
213                relative,
214                None,
215                None,
216                "unexpected entry; entity_context may contain only .md files",
217            );
218            continue;
219        }
220        let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else {
221            push(report, ValidationLayer::Schema, relative, None, None, "context filename is not valid UTF-8");
222            continue;
223        };
224        let entity_id = match serde_yaml::from_value::<EntityId>(stem.into()) {
225            Ok(identifier) => identifier,
226            Err(_) => {
227                push(
228                    report,
229                    ValidationLayer::Schema,
230                    relative,
231                    None,
232                    Some(stem.to_owned()),
233                    "context filename must be a canonical entity identifier",
234                );
235                continue;
236            }
237        };
238        match fs::read_to_string(&path) {
239            Ok(source) => contexts.push(ContextDocument {
240                path: relative,
241                entity_id,
242                source,
243            }),
244            Err(error) => push(
245                report,
246                ValidationLayer::Schema,
247                relative,
248                None,
249                Some(entity_id.to_string()),
250                format!("context document cannot be read: {error}"),
251            ),
252        }
253    }
254    contexts
255}
256
257fn build_index<'a, T, I, F>(items: &'a [Loaded<T>], identifier: F, kind: &str, report: &mut Diagnostics) -> BTreeMap<I, &'a Loaded<T>>
258where
259    I: Clone + Ord + ToString,
260    F: Fn(&T) -> I,
261{
262    let mut index = BTreeMap::new();
263    for item in items {
264        let id = identifier(&item.value);
265        if let Some(previous) = index.insert(id.clone(), item) {
266            push(
267                report,
268                ValidationLayer::Schema,
269                item.path.clone(),
270                None,
271                Some(id.to_string()),
272                format!("duplicate {kind} identifier; also declared in {}", previous.path.display()),
273            );
274        }
275    }
276    index
277}
278
279fn validate_filenames<T, F>(items: &[Loaded<T>], kind: &str, identifier: F, report: &mut Diagnostics)
280where
281    F: Fn(&T) -> &str,
282{
283    for item in items {
284        let stem = item.path.file_stem().and_then(|value| value.to_str());
285        let id = identifier(&item.value);
286        if stem != Some(id) {
287            push(
288                report,
289                ValidationLayer::Schema,
290                item.path.clone(),
291                None,
292                Some(id.to_owned()),
293                format!("{kind} filename must be exactly {id}.yaml"),
294            );
295        }
296    }
297}
298
299fn validate_entity_types(items: &[Loaded<EntityType>], references: &BTreeMap<ReferenceId, &Loaded<Reference>>, report: &mut Diagnostics) {
300    for item in items {
301        let id = item.value.id.to_string();
302        validate_localized_map(&item.path, &id, "labels", &item.value.labels, true, references, report);
303        validate_localized_map(&item.path, &id, "descriptions", &item.value.descriptions, false, references, report);
304    }
305}
306
307fn validate_properties(
308    items: &[Loaded<Property>],
309    types: &BTreeMap<EntityTypeId, &Loaded<EntityType>>,
310    properties: &BTreeMap<PropertyId, &Loaded<Property>>,
311    references: &BTreeMap<ReferenceId, &Loaded<Reference>>,
312    report: &mut Diagnostics,
313) {
314    for item in items {
315        let property = &item.value;
316        let id = property.id.to_string();
317        validate_localized_map(&item.path, &id, "labels", &property.labels, true, references, report);
318        validate_localized_map(&item.path, &id, "descriptions", &property.descriptions, false, references, report);
319
320        if property.subject_types.is_empty() {
321            schema(report, item, &id, "subject_types must not be empty");
322        }
323        for type_id in &property.subject_types {
324            if !types.contains_key(type_id) {
325                ontology(report, item, &id, format!("subject type {type_id} does not exist"));
326            }
327        }
328
329        match (&property.value_type, &property.target_types) {
330            (ValueType::Entity, Some(targets)) if targets.is_empty() => {
331                schema(report, item, &id, "target_types must not be empty");
332            }
333            (ValueType::Entity, None) => {
334                schema(report, item, &id, "entity-valued property requires target_types");
335            }
336            (ValueType::Entity, Some(_)) | (_, None) => {}
337            (_, Some(_)) => schema(report, item, &id, "target_types is allowed only for entity-valued properties"),
338        }
339        if let Some(targets) = &property.target_types {
340            for type_id in targets {
341                if !types.contains_key(type_id) {
342                    ontology(report, item, &id, format!("target type {type_id} does not exist"));
343                }
344            }
345        }
346        for qualifier in &property.allowed_qualifiers {
347            if !properties.contains_key(qualifier) {
348                ontology(report, item, &id, format!("allowed qualifier property {qualifier} does not exist"));
349            }
350        }
351    }
352}
353
354fn validate_references(items: &[Loaded<Reference>], report: &mut Diagnostics) {
355    for item in items {
356        let reference = &item.value;
357        let id = reference.id.to_string();
358        validate_url(&item.path, &id, "url", &reference.url, report);
359        if let Some(url) = &reference.archive_url {
360            validate_url(&item.path, &id, "archive_url", url, report);
361        }
362        if DateTime::parse_from_rfc3339(&reference.retrieved_at).is_err() {
363            schema(report, item, &id, "retrieved_at must be an RFC 3339 timestamp");
364        }
365    }
366}
367
368fn validate_entities(
369    items: &[Loaded<Entity>],
370    entities: &BTreeMap<EntityId, &Loaded<Entity>>,
371    types: &BTreeMap<EntityTypeId, &Loaded<EntityType>>,
372    properties: &BTreeMap<PropertyId, &Loaded<Property>>,
373    references: &BTreeMap<ReferenceId, &Loaded<Reference>>,
374    report: &mut Diagnostics,
375) {
376    for item in items {
377        let entity = &item.value;
378        let id = entity.id.to_string();
379        validate_localized_map(&item.path, &id, "labels", &entity.labels, true, references, report);
380        validate_localized_map(&item.path, &id, "descriptions", &entity.descriptions, false, references, report);
381
382        if entity.entity_types.is_empty() {
383            schema(report, item, &id, "entity_types must not be empty");
384        }
385        for classification in &entity.entity_types {
386            if !types.contains_key(&classification.value) {
387                ontology(report, item, &id, format!("classified entity type {} does not exist", classification.value));
388            }
389            validate_provenance(&item.path, &id, "classification", &classification.references, references, report);
390        }
391        for image in &entity.images {
392            validate_url(&item.path, &id, "image url", &image.url, report);
393            if let Some(url) = &image.attribution_url {
394                validate_url(&item.path, &id, "image attribution_url", url, report);
395            }
396            if image.attribution.trim().is_empty() {
397                schema(report, item, &id, "image attribution must not be empty");
398            }
399            validate_provenance(&item.path, &id, "image", &image.references, references, report);
400        }
401
402        let mut statement_ids = BTreeSet::new();
403        let mut property_counts = BTreeMap::<PropertyId, usize>::new();
404        for statement in &entity.statements {
405            if !statement_ids.insert(statement.id.clone()) {
406                ontology(report, item, &id, format!("statement identifier {} is duplicated", statement.id));
407            }
408            *property_counts.entry(statement.property.clone()).or_default() += 1;
409            validate_provenance(&item.path, &format!("{id}/{}", statement.id), "statement", &statement.references, references, report);
410            validate_value(&item.path, &format!("{id}/{}", statement.id), &statement.value, report);
411
412            let main_property = properties.get(&statement.property).map(|item| &item.value);
413            if let Some(main_property) = main_property {
414                validate_property_use(item, entity, &format!("{id}/{}", statement.id), main_property, &statement.value, entities, report);
415            } else {
416                ontology(report, item, &id, format!("statement property {} does not exist", statement.property));
417            }
418
419            for qualifier in &statement.qualifiers {
420                validate_value(&item.path, &format!("{id}/{}/{}", statement.id, qualifier.property), &qualifier.value, report);
421                if main_property.is_some_and(|property| !property.allowed_qualifiers.contains(&qualifier.property)) {
422                    ontology(
423                        report,
424                        item,
425                        &id,
426                        format!(
427                            "qualifier {} is not allowed by property {}",
428                            qualifier.property,
429                            main_property.expect("checked as present").id
430                        ),
431                    );
432                }
433                let Some(qualifier_property) = properties.get(&qualifier.property).map(|item| &item.value) else {
434                    ontology(report, item, &id, format!("qualifier property {} does not exist", qualifier.property));
435                    continue;
436                };
437                validate_property_use(
438                    item,
439                    entity,
440                    &format!("{id}/{}/{}", statement.id, qualifier.property),
441                    qualifier_property,
442                    &qualifier.value,
443                    entities,
444                    report,
445                );
446            }
447        }
448        for (property_id, count) in property_counts {
449            if count > 1 && properties.get(&property_id).is_some_and(|property| property.value.cardinality == Cardinality::One) {
450                ontology(report, item, &id, format!("property {property_id} has cardinality one but occurs {count} times"));
451            }
452        }
453    }
454}
455
456fn validate_property_use(
457    item: &Loaded<Entity>,
458    entity: &Entity,
459    owner: &str,
460    property: &Property,
461    value: &Value,
462    entities: &BTreeMap<EntityId, &Loaded<Entity>>,
463    report: &mut Diagnostics,
464) {
465    let entity_types = entity.entity_types.iter().map(|classification| &classification.value).collect::<BTreeSet<_>>();
466    if !property.subject_types.iter().any(|subject_type| entity_types.contains(subject_type)) {
467        ontology(report, item, owner, format!("property {} is not applicable to this entity", property.id));
468    }
469    if value.value_type() != property.value_type {
470        ontology(
471            report,
472            item,
473            owner,
474            format!(
475                "property {} requires {} values but statement uses {}",
476                property.id,
477                value_type_name(property.value_type),
478                value_type_name(value.value_type())
479            ),
480        );
481        return;
482    }
483    if let Value::Entity { value: target_id } = value {
484        let Some(target) = entities.get(target_id) else {
485            ontology(report, item, owner, format!("target entity {target_id} does not exist"));
486            return;
487        };
488        let target_types = target.value.entity_types.iter().map(|classification| &classification.value).collect::<BTreeSet<_>>();
489        if !property
490            .target_types
491            .as_ref()
492            .is_some_and(|allowed| allowed.iter().any(|type_id| target_types.contains(type_id)))
493        {
494            ontology(
495                report,
496                item,
497                owner,
498                format!("target entity {target_id} has none of property {}'s permitted target types", property.id),
499            );
500        }
501    }
502}
503
504fn validate_value(path: &Path, owner: &str, value: &Value, report: &mut Diagnostics) {
505    let message = match value {
506        Value::Decimal { value } if !DECIMAL.is_match(value) => Some("decimal value must use canonical quoted base-10 syntax"),
507        Value::Date { value } if !DATE.is_match(value) || NaiveDate::parse_from_str(value, "%Y-%m-%d").is_err() => Some("date value must be a real ISO 8601 calendar date"),
508        Value::Datetime { value } if DateTime::parse_from_rfc3339(value).is_err() => Some("datetime value must be an RFC 3339 timestamp"),
509        Value::Url { value } if Url::parse(value).is_err() => Some("url value must be an absolute URL"),
510        Value::Coordinate { latitude, longitude } => {
511            if !DECIMAL.is_match(latitude) || !within_absolute_bound(latitude, 90) {
512                Some("coordinate latitude must be canonical decimal text between -90 and 90")
513            } else if !DECIMAL.is_match(longitude) || !within_absolute_bound(longitude, 180) {
514                Some("coordinate longitude must be canonical decimal text between -180 and 180")
515            } else {
516                None
517            }
518        }
519        _ => None,
520    };
521    if let Some(message) = message {
522        push(report, ValidationLayer::Schema, path.to_path_buf(), None, Some(owner.to_owned()), message);
523    }
524}
525
526fn within_absolute_bound(value: &str, bound: u64) -> bool {
527    let unsigned = value.strip_prefix('-').unwrap_or(value);
528    let (integer, fraction) = unsigned.split_once('.').unwrap_or((unsigned, ""));
529    match integer.len().cmp(&bound.to_string().len()) {
530        std::cmp::Ordering::Less => true,
531        std::cmp::Ordering::Greater => false,
532        std::cmp::Ordering::Equal => match integer.parse::<u64>() {
533            Ok(integer) if integer < bound => true,
534            Ok(integer) if integer == bound => fraction.bytes().all(|byte| byte == b'0'),
535            _ => false,
536        },
537    }
538}
539
540fn value_type_name(value_type: ValueType) -> &'static str {
541    match value_type {
542        ValueType::Entity => "entity",
543        ValueType::String => "string",
544        ValueType::Integer => "integer",
545        ValueType::Decimal => "decimal",
546        ValueType::Boolean => "boolean",
547        ValueType::Date => "date",
548        ValueType::Datetime => "datetime",
549        ValueType::Url => "url",
550        ValueType::Coordinate => "coordinate",
551    }
552}
553
554fn validate_localized_map(
555    path: &Path,
556    owner: &str,
557    field: &str,
558    values: &LocalizedMap,
559    required: bool,
560    references: &BTreeMap<ReferenceId, &Loaded<Reference>>,
561    report: &mut Diagnostics,
562) {
563    if required && values.is_empty() {
564        push(
565            report,
566            ValidationLayer::Schema,
567            path.to_path_buf(),
568            None,
569            Some(owner.to_owned()),
570            format!("{field} must not be empty"),
571        );
572    }
573    let mut normalized = BTreeSet::new();
574    for (locale, value) in values {
575        if locale.parse::<LanguageTag>().is_err() {
576            push(
577                report,
578                ValidationLayer::Schema,
579                path.to_path_buf(),
580                None,
581                Some(owner.to_owned()),
582                format!("{field} locale {locale:?} is not a well-formed BCP 47 tag"),
583            );
584        }
585        if !normalized.insert(locale.to_ascii_lowercase()) {
586            push(
587                report,
588                ValidationLayer::Schema,
589                path.to_path_buf(),
590                None,
591                Some(owner.to_owned()),
592                format!("{field} contains locale {locale:?} more than once ignoring case"),
593            );
594        }
595        validate_provenance(path, owner, &format!("{field}.{locale}"), &value.references, references, report);
596    }
597}
598
599fn validate_provenance(path: &Path, owner: &str, field: &str, reference_ids: &[ReferenceId], references: &BTreeMap<ReferenceId, &Loaded<Reference>>, report: &mut Diagnostics) {
600    if reference_ids.is_empty() {
601        push(
602            report,
603            ValidationLayer::Schema,
604            path.to_path_buf(),
605            None,
606            Some(owner.to_owned()),
607            format!("{field} references must not be empty"),
608        );
609    }
610    for reference_id in reference_ids {
611        if !references.contains_key(reference_id) {
612            push(
613                report,
614                ValidationLayer::Provenance,
615                path.to_path_buf(),
616                None,
617                Some(owner.to_owned()),
618                format!("{field} cites missing reference {reference_id}"),
619            );
620        }
621    }
622}
623
624fn validate_url(path: &Path, owner: &str, field: &str, value: &str, report: &mut Diagnostics) {
625    if Url::parse(value).is_err() {
626        push(
627            report,
628            ValidationLayer::Schema,
629            path.to_path_buf(),
630            None,
631            Some(owner.to_owned()),
632            format!("{field} must be an absolute URL"),
633        );
634    }
635}
636
637fn validate_allocation(
638    allocation: &Loaded<IdAllocation>,
639    max_entity: Option<u64>,
640    max_property: Option<u64>,
641    max_reference: Option<u64>,
642    max_type: Option<u64>,
643    report: &mut Diagnostics,
644) {
645    if allocation.value.version != 1 {
646        schema(report, allocation, "id_allocation", "version must be 1");
647    }
648    for (field, next, maximum) in [
649        ("entity", allocation.value.next.entity, max_entity),
650        ("property", allocation.value.next.property, max_property),
651        ("reference", allocation.value.next.reference, max_reference),
652        ("entity_type", allocation.value.next.entity_type, max_type),
653    ] {
654        if next == 0 {
655            schema(report, allocation, "id_allocation", format!("next.{field} must be positive"));
656        } else if maximum.is_some_and(|maximum| next <= maximum) {
657            schema(
658                report,
659                allocation,
660                "id_allocation",
661                format!("next.{field} must be greater than the greatest used identifier number ({})", maximum.unwrap_or_default()),
662            );
663        }
664    }
665}
666
667#[derive(Default)]
668struct FootnoteDefinition {
669    line: usize,
670    targets: Vec<String>,
671}
672
673fn validate_contexts(
674    contexts: &[ContextDocument],
675    entities: &BTreeMap<EntityId, &Loaded<Entity>>,
676    references: &BTreeMap<ReferenceId, &Loaded<Reference>>,
677    report: &mut Diagnostics,
678) {
679    for context in contexts {
680        let owner = context.entity_id.to_string();
681        if !entities.contains_key(&context.entity_id) {
682            push(
683                report,
684                ValidationLayer::Provenance,
685                context.path.clone(),
686                None,
687                Some(owner.clone()),
688                "context document names an entity that does not exist",
689            );
690        }
691
692        let mut definitions = BTreeMap::<String, Vec<FootnoteDefinition>>::new();
693        let mut references_used = BTreeMap::<String, usize>::new();
694        let mut current_definition: Option<(String, FootnoteDefinition)> = None;
695        let options = Options::ENABLE_FOOTNOTES;
696        for (event, range) in Parser::new_ext(&context.source, options).into_offset_iter() {
697            let line = line_at(&context.source, range.start);
698            match event {
699                Event::Start(Tag::FootnoteDefinition(label)) => {
700                    current_definition = Some((label.to_string(), FootnoteDefinition { line, targets: Vec::new() }));
701                }
702                Event::End(pulldown_cmark::TagEnd::FootnoteDefinition) => {
703                    if let Some((label, definition)) = current_definition.take() {
704                        definitions.entry(label).or_default().push(definition);
705                    }
706                }
707                Event::Start(Tag::Link { dest_url, .. }) => {
708                    if let Some((_, definition)) = current_definition.as_mut() {
709                        definition.targets.push(dest_url.to_string());
710                    }
711                }
712                Event::FootnoteReference(label) => {
713                    references_used.entry(label.to_string()).or_insert(line);
714                }
715                _ => {}
716            }
717        }
718
719        for (label, line) in &references_used {
720            parse_reference_label(label, &context.path, *line, &owner, references, report);
721            match definitions.get(label).map(Vec::len).unwrap_or_default() {
722                0 => push(
723                    report,
724                    ValidationLayer::Provenance,
725                    context.path.clone(),
726                    Some(*line),
727                    Some(owner.clone()),
728                    format!("footnote {label:?} has no definition"),
729                ),
730                1 => {}
731                count => push(
732                    report,
733                    ValidationLayer::Provenance,
734                    context.path.clone(),
735                    Some(*line),
736                    Some(owner.clone()),
737                    format!("footnote {label:?} has {count} definitions"),
738                ),
739            }
740        }
741
742        for (label, entries) in definitions {
743            if entries.len() > 1 && !references_used.contains_key(&label) {
744                push(
745                    report,
746                    ValidationLayer::Provenance,
747                    context.path.clone(),
748                    Some(entries[0].line),
749                    Some(owner.clone()),
750                    format!("footnote {label:?} has {} definitions", entries.len()),
751                );
752            }
753            for definition in &entries {
754                let reference_id = parse_reference_label(&label, &context.path, definition.line, &owner, references, report);
755                if let Some(reference_id) = reference_id {
756                    let expected = format!("../references/{reference_id}.yaml");
757                    if definition.targets.as_slice() != [expected.as_str()] {
758                        push(
759                            report,
760                            ValidationLayer::Provenance,
761                            context.path.clone(),
762                            Some(definition.line),
763                            Some(owner.clone()),
764                            format!("footnote {label:?} must contain exactly one link to {expected}"),
765                        );
766                    }
767                }
768            }
769        }
770    }
771}
772
773fn parse_reference_label(
774    label: &str,
775    path: &Path,
776    line: usize,
777    owner: &str,
778    references: &BTreeMap<ReferenceId, &Loaded<Reference>>,
779    report: &mut Diagnostics,
780) -> Option<ReferenceId> {
781    let reference_id = match serde_yaml::from_value::<ReferenceId>(label.into()) {
782        Ok(reference_id) => reference_id,
783        Err(_) => {
784            push(
785                report,
786                ValidationLayer::Provenance,
787                path.to_path_buf(),
788                Some(line),
789                Some(owner.to_owned()),
790                format!("footnote label {label:?} is not a canonical reference identifier"),
791            );
792            return None;
793        }
794    };
795    if !references.contains_key(&reference_id) {
796        push(
797            report,
798            ValidationLayer::Provenance,
799            path.to_path_buf(),
800            Some(line),
801            Some(owner.to_owned()),
802            format!("footnote {label:?} cites a reference that does not exist"),
803        );
804    }
805    Some(reference_id)
806}
807
808fn schema<T>(report: &mut Diagnostics, item: &Loaded<T>, identifier: &str, message: impl Into<String>) {
809    push(report, ValidationLayer::Schema, item.path.clone(), None, Some(identifier.to_owned()), message);
810}
811
812fn ontology<T>(report: &mut Diagnostics, item: &Loaded<T>, identifier: &str, message: impl Into<String>) {
813    push(report, ValidationLayer::Ontology, item.path.clone(), None, Some(identifier.to_owned()), message);
814}
815
816fn push(report: &mut Diagnostics, layer: ValidationLayer, path: PathBuf, line: Option<usize>, identifier: Option<String>, message: impl Into<String>) {
817    report.push(Diagnostic {
818        layer,
819        path,
820        line,
821        identifier,
822        message: message.into(),
823    });
824}
825
826fn sort_diagnostics(diagnostics: &mut [Diagnostic]) {
827    diagnostics.sort_by(|left, right| {
828        (&left.path, left.line.unwrap_or(usize::MAX), &left.identifier, &left.message, left.layer).cmp(&(
829            &right.path,
830            right.line.unwrap_or(usize::MAX),
831            &right.identifier,
832            &right.message,
833            right.layer,
834        ))
835    });
836}
837
838fn relative_path(root: &Path, path: &Path) -> PathBuf {
839    path.strip_prefix(root).map(Path::to_path_buf).unwrap_or_else(|_| path.to_path_buf())
840}
841
842fn line_at(source: &str, offset: usize) -> usize {
843    source[..offset.min(source.len())].bytes().filter(|byte| *byte == b'\n').count() + 1
844}