Skip to main content

mdwright_lint/stdlib/
orphan_reference_link.rs

1//! Reference link uses with no matching `[label]:` definition.
2//!
3//! Detects `[txt][label]` and `[label][]` collapsed forms where the
4//! label (case-insensitively) does not appear among the document's
5//! link reference definitions. Shortcut form `[label]` is not
6//! flagged here — too many false positives on bracketed prose.
7
8use std::collections::HashSet;
9use std::sync::OnceLock;
10
11use regex::Regex;
12
13use crate::diagnostic::Diagnostic;
14use crate::regex_util::compile_static;
15use crate::rule::LintRule;
16use mdwright_document::Document;
17
18pub struct OrphanReferenceLink;
19
20fn pattern() -> &'static Regex {
21    static RE: OnceLock<Regex> = OnceLock::new();
22    RE.get_or_init(|| compile_static(r"\[(?P<text>[^\]\n]+)\]\[(?P<label>[^\]\n]*)\]"))
23}
24
25impl LintRule for OrphanReferenceLink {
26    fn name(&self) -> &str {
27        "orphan-reference-link"
28    }
29
30    fn description(&self) -> &str {
31        "Reference-style link with no matching `[label]:` definition."
32    }
33
34    fn explain(&self) -> &str {
35        include_str!("explain/orphan_reference_link.md")
36    }
37
38    fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
39        let defs: HashSet<String> = doc.link_defs().iter().map(|d| d.label.to_ascii_lowercase()).collect();
40
41        for chunk in doc.prose_chunks() {
42            for cap in pattern().captures_iter(&chunk.text) {
43                let Some(m) = cap.get(0) else { continue };
44                let Some(text_match) = cap.name("text") else {
45                    continue;
46                };
47                let Some(label_match) = cap.name("label") else {
48                    continue;
49                };
50                let raw_label = label_match.as_str();
51                let key = if raw_label.is_empty() {
52                    text_match.as_str().to_ascii_lowercase()
53                } else {
54                    raw_label.to_ascii_lowercase()
55                };
56                if defs.contains(&key) {
57                    continue;
58                }
59                let display = if raw_label.is_empty() {
60                    text_match.as_str().to_owned()
61                } else {
62                    raw_label.to_owned()
63                };
64                let message = format!("reference link `{display}` has no `[{display}]:` definition");
65                if let Some(d) = Diagnostic::at(doc, chunk.byte_offset, m.range(), message, None) {
66                    out.push(d);
67                }
68            }
69        }
70    }
71}