1use crate::concept_id::ConceptId;
19use crate::markdown::{clean_destination, code_free_lines, is_escaped, parse_inline_link};
20use std::fmt;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
24pub enum LinkKind {
25 Absolute,
27 Relative,
29 External,
31 Anchor,
33 Other,
35}
36
37impl LinkKind {
38 #[must_use]
40 pub const fn as_str(&self) -> &'static str {
41 match self {
42 Self::Absolute => "absolute",
43 Self::Relative => "relative",
44 Self::External => "external",
45 Self::Anchor => "anchor",
46 Self::Other => "other",
47 }
48 }
49}
50
51impl fmt::Display for LinkKind {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 f.write_str(self.as_str())
54 }
55}
56
57impl AsRef<str> for LinkKind {
58 fn as_ref(&self) -> &str {
59 self.as_str()
60 }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct ParseLinkKindError(pub String);
66
67impl fmt::Display for ParseLinkKindError {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 write!(f, "unknown link kind: {:?}", self.0)
70 }
71}
72
73impl std::error::Error for ParseLinkKindError {}
74
75impl std::str::FromStr for LinkKind {
76 type Err = ParseLinkKindError;
77 fn from_str(s: &str) -> Result<Self, Self::Err> {
78 match s.trim().to_ascii_lowercase().as_str() {
79 "absolute" => Ok(Self::Absolute),
80 "relative" => Ok(Self::Relative),
81 "external" => Ok(Self::External),
82 "anchor" => Ok(Self::Anchor),
83 "other" => Ok(Self::Other),
84 other => Err(ParseLinkKindError(other.to_string())),
85 }
86 }
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct Link {
92 pub text: String,
94 pub target: String,
96 pub kind: LinkKind,
98}
99
100impl Link {
101 #[must_use]
103 pub fn classify(target: &str) -> LinkKind {
104 let t = target.trim();
105 if t.is_empty() {
106 LinkKind::Other
107 } else if t.starts_with('#') {
108 LinkKind::Anchor
109 } else if is_external(t) {
110 LinkKind::External
111 } else if t.starts_with('/') {
112 LinkKind::Absolute
113 } else {
114 LinkKind::Relative
115 }
116 }
117
118 #[must_use]
129 pub fn resolve(&self, source: &ConceptId) -> Option<ConceptId> {
130 self.resolve_all(source).into_iter().next()
131 }
132
133 #[must_use]
141 pub fn resolve_all(&self, source: &ConceptId) -> Vec<ConceptId> {
142 let mut out = Vec::new();
143 let mut push = |target: &str| {
144 let id = match self.kind {
145 LinkKind::Absolute => resolve_absolute_path(target),
146 LinkKind::Relative => resolve_relative_path(target, source),
147 _ => None,
148 };
149 if let Some(id) = id
150 && !out.contains(&id)
151 {
152 out.push(id);
153 }
154 };
155 let target = strip_anchor(&self.target);
159 push(target);
160 if let Some(decoded) = percent_decode(target) {
161 push(&decoded);
162 }
163 out
164 }
165
166 #[must_use]
168 pub fn target_without_anchor(&self) -> &str {
169 strip_anchor(&self.target)
170 }
171
172 #[must_use]
174 pub fn anchor(&self) -> Option<&str> {
175 self.target.find('#').map(|i| &self.target[i + 1..])
176 }
177}
178
179fn percent_decode(s: &str) -> Option<String> {
182 if !s.contains('%') {
183 return None;
184 }
185 let bytes = s.as_bytes();
186 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
187 let mut decoded_any = false;
188 let mut i = 0;
189 while i < bytes.len() {
190 let escape = (bytes[i] == b'%' && i + 3 <= bytes.len())
191 .then(|| &bytes[i + 1..i + 3])
192 .filter(|hex| hex.iter().all(u8::is_ascii_hexdigit));
193 if let Some(hex) = escape {
194 let hex = std::str::from_utf8(hex).ok()?;
195 out.push(u8::from_str_radix(hex, 16).ok()?);
196 decoded_any = true;
197 i += 3;
198 } else {
199 out.push(bytes[i]);
200 i += 1;
201 }
202 }
203 if !decoded_any {
204 return None;
205 }
206 String::from_utf8(out).ok()
207}
208
209#[derive(Clone, Debug, PartialEq, Eq)]
215pub struct Citation {
216 pub number: u32,
218 pub text: Option<String>,
220 pub target: Option<String>,
222 pub raw: String,
224}
225
226fn is_external(t: &str) -> bool {
234 t.starts_with("//") || has_uri_scheme(t)
235}
236
237fn has_uri_scheme(t: &str) -> bool {
239 let Some((scheme, _)) = t.split_once(':') else {
240 return false;
241 };
242 let mut chars = scheme.chars();
243 chars.next().is_some_and(|c| c.is_ascii_alphabetic())
244 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
245}
246
247fn strip_anchor(target: &str) -> &str {
248 target.find('#').map_or(target, |i| &target[..i])
249}
250
251fn resolve_absolute_path(t: &str) -> Option<ConceptId> {
252 if t.ends_with('/') {
253 return None; }
255 normalize_segments(t, &[])
258 .and_then(strip_md)
259 .and_then(|segs| ConceptId::new(segs).ok())
260}
261
262fn resolve_relative_path(t: &str, source: &ConceptId) -> Option<ConceptId> {
263 if t.is_empty() || t.ends_with('/') {
264 return None;
265 }
266 let base = source
268 .parent()
269 .map(|p| p.segments().to_vec())
270 .unwrap_or_default();
271 normalize_segments(t, &base)
272 .and_then(strip_md)
273 .and_then(|segs| ConceptId::new(segs).ok())
274}
275
276fn normalize_segments(path: &str, base: &[String]) -> Option<Vec<String>> {
282 let mut segs = base.to_vec();
283 for comp in path.split('/') {
284 match comp {
285 "" | "." => {}
286 ".." => {
287 segs.pop()?;
288 }
289 other => segs.push(other.to_string()),
290 }
291 }
292 Some(segs)
293}
294
295fn strip_md(mut segs: Vec<String>) -> Option<Vec<String>> {
297 let last = segs.last_mut()?;
298 if let Some(s) = last.strip_suffix(".md") {
299 *last = s.to_string();
300 }
301 Some(segs)
302}
303
304#[must_use]
322pub fn field_path_candidates(raw: &str, from: &ConceptId) -> Vec<String> {
323 let target = raw.trim();
324 match Link::classify(target) {
325 LinkKind::Absolute => normalize_segments(strip_anchor(target), &[])
326 .map(|segments| segments.join("/"))
327 .into_iter()
328 .collect(),
329 LinkKind::Relative => {
330 let base = from
331 .parent()
332 .map(|p| p.segments().to_vec())
333 .unwrap_or_default();
334 let stripped = strip_anchor(target);
335 let mut out = Vec::new();
336 if let Some(path) = normalize_segments(stripped, &base) {
337 out.push(path.join("/"));
338 }
339 if let Some(path) = normalize_segments(stripped, &[]) {
340 let from_root = path.join("/");
341 if !out.contains(&from_root) {
342 out.push(from_root);
343 }
344 }
345 out.retain(|p| !p.is_empty());
346 out
347 }
348 _ => Vec::new(),
349 }
350}
351
352#[must_use]
355pub fn concept_id_for_path(path: &str) -> Option<ConceptId> {
356 let stem = path.strip_suffix(".md")?;
357 ConceptId::parse(stem).ok()
358}
359
360#[must_use]
363pub fn extract_links(body: &str) -> Vec<Link> {
364 let mut links = Vec::new();
365 for (_, line) in code_free_lines(body) {
366 scan_line_links(&line, &mut links);
367 }
368 links
369}
370
371fn scan_line_links(line: &str, out: &mut Vec<Link>) {
373 let chars: Vec<char> = line.chars().collect();
374 let mut i = 0;
375 while i < chars.len() {
376 if chars[i] == '['
377 && !is_escaped(&chars, i)
378 && let Some((text, dest, next)) = parse_inline_link(&chars, i)
379 {
380 let target = clean_destination(&dest);
381 out.push(Link {
382 text,
383 kind: Link::classify(&target),
384 target,
385 });
386 i = next;
387 continue;
388 }
389 i += 1;
390 }
391}
392
393#[must_use]
395pub fn extract_citations(body: &str) -> Vec<Citation> {
396 let mut out = Vec::new();
397 let mut in_section = false;
398 for line in body.lines() {
399 let trimmed = line.trim();
400 if let Some(heading) = trimmed.strip_prefix('#') {
401 let title = heading.trim_start_matches('#').trim();
402 if in_section {
403 break;
405 }
406 in_section = title.eq_ignore_ascii_case("citations");
407 continue;
408 }
409 if !in_section || trimmed.is_empty() {
410 continue;
411 }
412 if let Some(cit) = parse_citation_line(trimmed) {
413 out.push(cit);
414 }
415 }
416 out
417}
418
419fn parse_citation_line(line: &str) -> Option<Citation> {
421 let rest = line.strip_prefix('[')?;
422 let close = rest.find(']')?;
423 let number: u32 = rest[..close].trim().parse().ok()?;
424 let after = rest[close + 1..].trim().to_string();
425
426 let mut text = None;
428 let mut target = None;
429 let chars: Vec<char> = after.chars().collect();
430 if let Some(open) = chars.iter().position(|&c| c == '[')
431 && let Some((t, dest, _)) = parse_inline_link(&chars, open)
432 {
433 text = Some(t);
434 target = Some(clean_destination(&dest));
435 }
436 Some(Citation {
437 number,
438 text,
439 target,
440 raw: after,
441 })
442}