mdwright_lint/stdlib/
orphan_reference_link.rs1use 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}