1use std::collections::{HashMap, HashSet};
10use std::sync::LazyLock;
11
12use regex::Regex;
13
14use crate::ast::{Block, Doc, Example};
15
16#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct Reference {
21 pub path: String,
22 pub slug: String,
23 pub text: String,
24}
25
26#[derive(Clone, Default)]
32pub struct OathWorkspace {
33 pub docs: HashMap<String, Doc>,
34 pub referenced: HashSet<String>,
37}
38
39static LINK_ONLY: LazyLock<Regex> =
44 LazyLock::new(|| Regex::new(r"^\[([^\]]*)\]\(\s*([^\s)]+)\s*\)$").unwrap());
45static PROTOCOL: LazyLock<Regex> =
46 LazyLock::new(|| Regex::new(r"(?i)^[a-z][a-z0-9+.\-]*:").unwrap());
47static NOT_SLUG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^\p{L}\p{N} _-]").unwrap());
48static CODE_SPAN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`]*)`").unwrap());
49static STRONG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*]*)\*\*").unwrap());
50static EMPH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*([^*]*)\*").unwrap());
51static UNDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"_([^_]*)_").unwrap());
52
53pub fn reference_of(text: &str, from_path: &str) -> Option<Reference> {
55 let caps = LINK_ONLY.captures(text.trim())?;
56 let link_text = caps.get(1).map_or("", |m| m.as_str()).to_string();
57 let target = caps.get(2).map_or("", |m| m.as_str());
58 if let Some(fragment) = target.strip_prefix('#') {
59 return Some(Reference {
60 path: from_path.to_string(),
61 slug: normalize_slug(fragment),
62 text: link_text,
63 });
64 }
65 let (file_part, fragment) = match target.find('#') {
66 Some(i) => (&target[..i], &target[i + 1..]),
67 None => (target, ""),
68 };
69 if !file_part.ends_with(".md") || PROTOCOL.is_match(file_part) || file_part.starts_with('/') {
73 return None;
74 }
75 Some(Reference {
76 path: join_posix(dirname_posix(from_path), file_part),
77 slug: normalize_slug(fragment),
78 text: link_text,
79 })
80}
81
82pub fn slugify(heading_text: &str) -> String {
87 let s = CODE_SPAN.replace_all(heading_text, "$1");
88 let s = STRONG.replace_all(&s, "$1");
89 let s = EMPH.replace_all(&s, "$1");
90 let s = UNDER.replace_all(&s, "$1");
91 normalize_slug(&s)
92}
93
94fn normalize_slug(s: &str) -> String {
95 NOT_SLUG
98 .replace_all(s.trim().to_lowercase().as_str(), "")
99 .replace(' ', "-")
100}
101
102fn dirname_posix(path: &str) -> &str {
103 match path.rfind('/') {
104 Some(i) => &path[..i],
105 None => "",
106 }
107}
108
109pub fn join_posix(dir: &str, rel: &str) -> String {
112 let mut segments: Vec<&str> = if dir.is_empty() {
113 Vec::new()
114 } else {
115 dir.split('/').collect()
116 };
117 for segment in rel.split('/') {
118 match segment {
119 "" | "." => continue,
120 ".." => match segments.last() {
124 Some(&"..") | None => segments.push(".."),
125 Some(_) => {
126 segments.pop();
127 }
128 },
129 other => segments.push(other),
130 }
131 }
132 segments.join("/")
133}
134
135pub fn references(doc: &Doc) -> Vec<Reference> {
138 doc.examples
139 .iter()
140 .filter_map(|ex| block_text(ex.body.first()?).and_then(|t| reference_of(t, &doc.path)))
141 .collect()
142}
143
144fn block_text(block: &Block) -> Option<&str> {
145 match block {
146 Block::Paragraph(p) => Some(&p.text),
147 Block::ListItem(l) => Some(&l.text),
148 Block::Blockquote(b) => Some(&b.text),
149 _ => None,
150 }
151}
152
153pub fn section_key(path: &str, slug: &str) -> String {
155 format!("{path}#{slug}")
156}
157
158pub fn empty_workspace() -> OathWorkspace {
161 OathWorkspace::default()
162}
163
164pub fn build_workspace(docs: &[Doc]) -> OathWorkspace {
166 let mut ws = OathWorkspace::default();
167 for doc in docs {
168 ws.docs.insert(doc.path.clone(), doc.clone());
169 }
170 for doc in docs {
171 for r in references(doc) {
172 ws.referenced.insert(section_key(&r.path, &r.slug));
173 }
174 }
175 ws
176}
177
178pub fn section_candidates<'a>(doc: &'a Doc, slug: &str) -> Vec<&'a Example> {
184 if slug.is_empty() {
185 return doc.examples.iter().collect();
186 }
187 doc.examples
188 .iter()
189 .filter(|ex| ex.scope_stack.iter().any(|h| slugify(h) == slug))
190 .collect()
191}