1use std::collections::HashSet;
6use std::fmt;
7use std::sync::LazyLock;
8
9use omgbase_format::BlockKind;
10use omgbase_properties::{DocBlock, Mapping, Value};
11use regex::Regex;
12
13use crate::mask::mask_code_bytes;
14use crate::nodes::JS_WS;
15use crate::uri::normalize_uri;
16
17static MD_LINK: LazyLock<Regex> = LazyLock::new(|| {
19 Regex::new(&format!(
20 r#"(!?)\[[^\]]*\]\(([^){JS_WS}]+)(?:[{JS_WS}]+"[^"]*")?\)"#
21 ))
22 .expect("valid")
23});
24static WIKILINK: LazyLock<Regex> =
26 LazyLock::new(|| Regex::new(r"(!?)\[\[([^\]]+)\]\]").expect("valid"));
27static AUTOLINK: LazyLock<Regex> =
29 LazyLock::new(|| Regex::new(r"<(https?://[^>]+)>").expect("valid"));
30static BARE_URL: LazyLock<Regex> =
34 LazyLock::new(|| Regex::new(&format!(r"(?-u:\b)https?://[^{JS_WS})>\]]+")).expect("valid"));
35static INLINE_FIELD: LazyLock<Regex> = LazyLock::new(|| {
38 Regex::new(&format!(
39 r"(?:^|[{JS_WS}])([A-Za-z][A-Za-z0-9_]*)::[{JS_WS}]*(\[\[[^\]]+\]\]|/[^{JS_WS}]+|[Hh][Tt][Tt][Pp][Ss]?://[^{JS_WS}]+)"
40 ))
41 .expect("valid")
42});
43static WHOLE_WIKILINK: LazyLock<Regex> =
45 LazyLock::new(|| Regex::new(r"^\[\[([^\]]+)\]\]$").expect("valid"));
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
49pub enum DstKind {
50 Document,
51 Block,
52 External,
53 Collection,
54}
55
56impl DstKind {
57 #[must_use]
58 pub const fn as_str(&self) -> &'static str {
59 match self {
60 DstKind::Document => "document",
61 DstKind::Block => "block",
62 DstKind::External => "external",
63 DstKind::Collection => "collection",
64 }
65 }
66
67 #[must_use]
68 pub fn parse(s: &str) -> Option<Self> {
69 match s {
70 "document" => Some(DstKind::Document),
71 "block" => Some(DstKind::Block),
72 "external" => Some(DstKind::External),
73 "collection" => Some(DstKind::Collection),
74 _ => None,
75 }
76 }
77}
78
79impl fmt::Display for DstKind {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 f.write_str(self.as_str())
82 }
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
87pub enum Provenance {
88 Link,
89 Frontmatter,
90 InlineField,
91}
92
93impl Provenance {
94 #[must_use]
95 pub const fn as_str(&self) -> &'static str {
96 match self {
97 Provenance::Link => "link",
98 Provenance::Frontmatter => "frontmatter",
99 Provenance::InlineField => "inline_field",
100 }
101 }
102}
103
104impl fmt::Display for Provenance {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 f.write_str(self.as_str())
107 }
108}
109
110#[derive(Clone, Debug, PartialEq, Eq)]
112pub struct EdgeDescriptor {
113 pub src_block: Option<String>,
115 pub src_field: Option<String>,
117 pub predicate: String,
119 pub dst_kind: DstKind,
120 pub target: String,
123 pub anchor: Option<String>,
125 pub provenance: Provenance,
126}
127
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub enum AnchorKind {
131 Heading,
132 Ref,
133}
134
135#[must_use]
140pub fn split_fragment(dest: &str) -> (&str, Option<&str>, Option<AnchorKind>) {
141 let hash = dest.find('#');
142 let caret = dest.find('^');
143 match (caret, hash) {
144 (Some(c), None) => (&dest[..c], Some(&dest[c + 1..]), Some(AnchorKind::Ref)),
145 (Some(c), Some(h)) if c < h => (&dest[..c], Some(&dest[c + 1..]), Some(AnchorKind::Ref)),
146 (_, Some(h)) => (&dest[..h], Some(&dest[h + 1..]), Some(AnchorKind::Heading)),
147 (None, None) => (dest, None, None),
148 }
149}
150
151#[derive(Clone, Debug, PartialEq, Eq)]
153pub struct Classified {
154 pub dst_kind: DstKind,
155 pub target: String,
156 pub anchor: Option<String>,
157}
158
159#[must_use]
162pub fn classify(dest: &str) -> Classified {
163 if dest.starts_with("https://") || dest.starts_with("http://") {
164 return Classified {
165 dst_kind: DstKind::External,
166 target: normalize_uri(dest),
167 anchor: None,
168 };
169 }
170 let (path, anchor, kind) = split_fragment(dest);
171 Classified {
172 dst_kind: if kind == Some(AnchorKind::Ref) {
173 DstKind::Block
174 } else {
175 DstKind::Document
176 },
177 target: path.to_owned(),
178 anchor: anchor.map(str::to_owned),
179 }
180}
181
182fn bare_urls(scan: &str) -> Vec<(usize, usize)> {
187 let mut out = Vec::new();
188 let mut at = 0;
189 while at <= scan.len() {
190 let Some(m) = BARE_URL.find_at(scan, at) else {
191 break;
192 };
193 let preceded = scan[..m.start()]
194 .chars()
195 .next_back()
196 .is_some_and(|c| matches!(c, '(' | '"' | '['));
197 if preceded {
198 at = m.start() + 1;
199 continue;
200 }
201 out.push((m.start(), m.end()));
202 at = m.end();
203 }
204 out
205}
206
207#[must_use]
212pub fn extract_block_edges(block_id: &str, kind: BlockKind, raw: &str) -> Vec<EdgeDescriptor> {
213 let mut edges: Vec<EdgeDescriptor> = Vec::new();
214 if kind == BlockKind::CodeFence {
215 return edges;
216 }
217 let scan = mask_code_bytes(raw);
218 let mut seen: HashSet<String> = HashSet::new();
219 let mut push = |e: EdgeDescriptor| {
220 let key = format!(
221 "{}|{}|{}|{}",
222 e.predicate,
223 e.target,
224 e.anchor.as_deref().unwrap_or(""),
225 e.src_field.as_deref().unwrap_or("")
226 );
227 if seen.insert(key) {
228 edges.push(e);
229 }
230 };
231 let link = |predicate: &str, dest: &str| {
232 let c = classify(dest);
233 EdgeDescriptor {
234 src_block: Some(block_id.to_owned()),
235 src_field: None,
236 predicate: predicate.to_owned(),
237 dst_kind: c.dst_kind,
238 target: c.target,
239 anchor: c.anchor,
240 provenance: Provenance::Link,
241 }
242 };
243
244 let mut inline_targets: HashSet<String> = HashSet::new();
246 for m in INLINE_FIELD.captures_iter(&scan) {
247 let key = m[1].to_ascii_lowercase();
248 let raw_target = &m[2];
249 inline_targets.insert(raw_target.to_owned());
250 let dest = WHOLE_WIKILINK
251 .captures(raw_target)
252 .map_or(raw_target, |w| w.get(1).expect("group").as_str());
253 let c = classify(dest);
254 push(EdgeDescriptor {
255 src_block: Some(block_id.to_owned()),
256 src_field: Some(key.clone()),
257 predicate: key,
258 dst_kind: c.dst_kind,
259 target: c.target,
260 anchor: c.anchor,
261 provenance: Provenance::InlineField,
262 });
263 }
264 for m in MD_LINK.captures_iter(&scan) {
266 let dest = &m[2];
267 if inline_targets.contains(dest) {
268 continue;
269 }
270 push(link(
271 if &m[1] == "!" { "embeds" } else { "references" },
272 dest,
273 ));
274 }
275 for m in WIKILINK.captures_iter(&scan) {
277 let inner = &m[2];
278 if inline_targets.contains(&format!("[[{inner}]]")) {
279 continue;
280 }
281 push(link(
282 if &m[1] == "!" { "embeds" } else { "references" },
283 inner,
284 ));
285 }
286 let external = |uri: &str| EdgeDescriptor {
288 src_block: Some(block_id.to_owned()),
289 src_field: None,
290 predicate: "references".to_owned(),
291 dst_kind: DstKind::External,
292 target: normalize_uri(uri),
293 anchor: None,
294 provenance: Provenance::Link,
295 };
296 for m in AUTOLINK.captures_iter(&scan) {
297 push(external(&m[1]));
298 }
299 for (start, end) in bare_urls(&scan) {
300 push(external(&scan[start..end]));
301 }
302 edges
303}
304
305#[must_use]
311pub fn extract_frontmatter_edges(fm: &Mapping) -> Vec<EdgeDescriptor> {
312 let mut edges = Vec::new();
313 let mut consider = |key: &str, value: &Value| {
314 let Value::String(s) = value else {
315 return;
316 };
317 let dest = if let Some(w) = WHOLE_WIKILINK.captures(s) {
318 w.get(1).expect("group").as_str()
319 } else if s.starts_with('/') {
320 s.as_str()
321 } else {
322 return;
323 };
324 let c = classify(dest);
325 edges.push(EdgeDescriptor {
326 src_block: None,
327 src_field: Some(key.to_owned()),
328 predicate: key.to_owned(),
329 dst_kind: c.dst_kind,
330 target: c.target,
331 anchor: c.anchor,
332 provenance: Provenance::Frontmatter,
333 });
334 };
335 for (key, value) in fm.iter() {
336 match value {
337 Value::Array(items) => {
338 for v in items {
339 consider(key, v);
340 }
341 }
342 other => consider(key, other),
343 }
344 }
345 edges
346}
347
348#[must_use]
351pub fn extract_doc_edges(
352 blocks: &[DocBlock<'_>],
353 frontmatter: Option<&Mapping>,
354) -> Vec<EdgeDescriptor> {
355 fn walk(blocks: &[DocBlock<'_>], out: &mut Vec<EdgeDescriptor>) {
356 for b in blocks {
357 out.extend(extract_block_edges(b.block_id, b.kind, b.raw));
358 walk(&b.children, out);
359 }
360 }
361 let mut out = Vec::new();
362 walk(blocks, &mut out);
363 if let Some(fm) = frontmatter {
364 out.extend(extract_frontmatter_edges(fm));
365 }
366 out
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use omgbase_properties::parse_frontmatter;
373
374 fn edges(raw: &str) -> Vec<EdgeDescriptor> {
375 extract_block_edges("b_0", BlockKind::Paragraph, raw)
376 }
377
378 fn brief(e: &EdgeDescriptor) -> (&str, DstKind, &str, Option<&str>, Option<&str>, Provenance) {
379 (
380 e.predicate.as_str(),
381 e.dst_kind,
382 e.target.as_str(),
383 e.anchor.as_deref(),
384 e.src_field.as_deref(),
385 e.provenance,
386 )
387 }
388
389 #[test]
390 fn fragments_split_at_the_first_marker() {
391 assert_eq!(split_fragment("a"), ("a", None, None));
392 assert_eq!(
393 split_fragment("a#b"),
394 ("a", Some("b"), Some(AnchorKind::Heading))
395 );
396 assert_eq!(
397 split_fragment("a^b"),
398 ("a", Some("b"), Some(AnchorKind::Ref))
399 );
400 assert_eq!(
401 split_fragment("a^b#c"),
402 ("a", Some("b#c"), Some(AnchorKind::Ref))
403 );
404 assert_eq!(
405 split_fragment("a#b^c"),
406 ("a", Some("b^c"), Some(AnchorKind::Heading))
407 );
408 assert_eq!(
409 split_fragment("#H"),
410 ("", Some("H"), Some(AnchorKind::Heading))
411 );
412 assert_eq!(split_fragment("^r"), ("", Some("r"), Some(AnchorKind::Ref)));
413 assert_eq!(
414 split_fragment("a#"),
415 ("a", Some(""), Some(AnchorKind::Heading))
416 );
417 }
418
419 #[test]
420 fn classification() {
421 assert_eq!(
422 classify("https://A.com/x/#f"),
423 Classified {
424 dst_kind: DstKind::External,
425 target: "https://a.com/x".into(),
426 anchor: None
427 }
428 );
429 assert_eq!(
430 classify("note^r"),
431 Classified {
432 dst_kind: DstKind::Block,
433 target: "note".into(),
434 anchor: Some("r".into())
435 }
436 );
437 assert_eq!(
438 classify("./x.md#H"),
439 Classified {
440 dst_kind: DstKind::Document,
441 target: "./x.md".into(),
442 anchor: Some("H".into())
443 }
444 );
445 assert_eq!(
446 classify("HTTP://x"),
447 Classified {
448 dst_kind: DstKind::Document,
449 target: "HTTP://x".into(),
450 anchor: None
451 },
452 "the external test is case-sensitive"
453 );
454 assert_eq!(classify("mailto:a@b").dst_kind, DstKind::Document);
455 }
456
457 #[test]
458 fn markdown_links_and_images() {
459 let e = edges("[t](a.md)  [u](/root/x.md#H) [v](<sp ace>)");
460 assert_eq!(
461 e.iter().map(brief).collect::<Vec<_>>(),
462 [
463 (
464 "references",
465 DstKind::Document,
466 "a.md",
467 None,
468 None,
469 Provenance::Link
470 ),
471 (
472 "embeds",
473 DstKind::Document,
474 "img.png",
475 None,
476 None,
477 Provenance::Link
478 ),
479 (
480 "references",
481 DstKind::Document,
482 "/root/x.md",
483 Some("H"),
484 None,
485 Provenance::Link
486 ),
487 ]
488 );
489 assert_eq!(e[0].src_block.as_deref(), Some("b_0"));
490 let e = edges("[t](https://a.com/) [t](https://a.com)");
491 assert_eq!(e.len(), 1, "same normalized URI dedups");
492 assert_eq!(e[0].target, "https://a.com");
493 }
494
495 #[test]
496 fn wikilinks() {
497 let e = edges("[[note]] ![[img.png]] [[note|Alias]] [[a#H]] [[b^r]] [[note]]");
498 assert_eq!(
499 e.iter().map(brief).collect::<Vec<_>>(),
500 [
501 (
502 "references",
503 DstKind::Document,
504 "note",
505 None,
506 None,
507 Provenance::Link
508 ),
509 (
510 "embeds",
511 DstKind::Document,
512 "img.png",
513 None,
514 None,
515 Provenance::Link
516 ),
517 (
518 "references",
519 DstKind::Document,
520 "note|Alias",
521 None,
522 None,
523 Provenance::Link
524 ),
525 (
526 "references",
527 DstKind::Document,
528 "a",
529 Some("H"),
530 None,
531 Provenance::Link
532 ),
533 (
534 "references",
535 DstKind::Block,
536 "b",
537 Some("r"),
538 None,
539 Provenance::Link
540 ),
541 ]
542 );
543 }
544
545 #[test]
546 fn autolinks_and_bare_urls() {
547 let e = edges("<https://a.com/x> and https://b.com/y. see https://a.com/x");
548 assert_eq!(
549 e.iter().map(|e| e.target.as_str()).collect::<Vec<_>>(),
550 ["https://a.com/x", "https://b.com/y."]
551 );
552 assert!(edges("(https://a.com)").is_empty());
554 assert!(edges("\"https://a.com\"").is_empty());
555 assert!(edges("[https://a.com").is_empty());
556 assert_eq!(edges("xhttps://a.com").len(), 0, "word boundary");
557 assert_eq!(edges("=https://a.com").len(), 1);
558 let e = edges("(https://a.com/?u=https://b.com)");
560 assert_eq!(
561 e.iter().map(|e| e.target.as_str()).collect::<Vec<_>>(),
562 ["https://b.com"]
563 );
564 let e = edges("see https://a.com/x) https://b.com/y] https://c.com/z>");
566 assert_eq!(
567 e.iter().map(|e| e.target.as_str()).collect::<Vec<_>>(),
568 ["https://a.com/x", "https://b.com/y", "https://c.com/z"]
569 );
570 assert_eq!(
572 edges("https://a.com/x\u{85}y")[0].target,
573 "https://a.com/x%C2%85y"
574 );
575 assert_eq!(edges("https://a.com/x\u{A0}y")[0].target, "https://a.com/x");
576 assert_eq!(edges("[t](https://a.com)").len(), 1);
578 }
579
580 #[test]
581 fn inline_relation_fields() {
582 let e = edges(
583 "rel:: [[note]]\nOwner:: /people/a.md\nsee:: https://a.com/\nplain:: text\nx rel2:: [[n#H]]",
584 );
585 assert_eq!(
586 e.iter().map(brief).collect::<Vec<_>>(),
587 [
588 (
589 "rel",
590 DstKind::Document,
591 "note",
592 None,
593 Some("rel"),
594 Provenance::InlineField
595 ),
596 (
597 "owner",
598 DstKind::Document,
599 "/people/a.md",
600 None,
601 Some("owner"),
602 Provenance::InlineField
603 ),
604 (
605 "see",
606 DstKind::External,
607 "https://a.com",
608 None,
609 Some("see"),
610 Provenance::InlineField
611 ),
612 (
613 "rel2",
614 DstKind::Document,
615 "n",
616 Some("H"),
617 Some("rel2"),
618 Provenance::InlineField
619 ),
620 (
623 "references",
624 DstKind::External,
625 "https://a.com",
626 None,
627 None,
628 Provenance::Link
629 ),
630 ]
631 );
632 assert_eq!(edges("rel:: [[note]]").len(), 1);
634 assert_eq!(edges("rel:: [[note]] and [[note]]").len(), 1);
635 assert_eq!(edges("rel:: [[note]] and [[other]]").len(), 2);
636 assert_eq!(edges("rel:: /x.md and [t](/x.md)").len(), 1);
637 let e = edges("see:: https://a.com/x");
638 assert_eq!(e.len(), 2);
639 assert_eq!(e[1].predicate, "references");
640 let e = edges("k:: HTTPS://a.com");
642 assert_eq!(
643 (e[0].dst_kind, e[0].target.as_str()),
644 (DstKind::Document, "HTTPS://a.com")
645 );
646 assert!(
648 edges("x-rel:: [[n]]")
649 .iter()
650 .all(|e| e.provenance == Provenance::Link)
651 );
652 assert!(edges("1rel:: /x").is_empty());
653 }
654
655 #[test]
656 fn in_block_dedup_and_code() {
657 assert_eq!(edges("[a](x) [b](x) [[x]]").len(), 1);
658 assert_eq!(edges("[a](x) ").len(), 2, "different predicates");
659 assert_eq!(edges("[a](x#H) [b](x)").len(), 2, "different anchors");
660 assert!(extract_block_edges("b_0", BlockKind::CodeFence, "[t](x)").is_empty());
661 assert!(edges("`[t](x)` and ``https://a.com``").is_empty());
662 assert_eq!(edges("`x` [t](y)")[0].target, "y");
663 }
664
665 #[test]
666 fn frontmatter_relations() {
667 let fm = parse_frontmatter(
668 "Rel: \"[[note]]\"\nowner: /people/a.md\nrels: [\"[[a]]\", /b.md, plain, 3]\nnot: ./x.md\nnested:\n k: /y.md\nn: 1\nwl: [[x]]\n",
669 )
670 .unwrap();
671 let e = extract_frontmatter_edges(&fm);
672 assert_eq!(
673 e.iter().map(brief).collect::<Vec<_>>(),
674 [
675 (
676 "Rel",
677 DstKind::Document,
678 "note",
679 None,
680 Some("Rel"),
681 Provenance::Frontmatter
682 ),
683 (
684 "owner",
685 DstKind::Document,
686 "/people/a.md",
687 None,
688 Some("owner"),
689 Provenance::Frontmatter
690 ),
691 (
692 "rels",
693 DstKind::Document,
694 "a",
695 None,
696 Some("rels"),
697 Provenance::Frontmatter
698 ),
699 (
700 "rels",
701 DstKind::Document,
702 "/b.md",
703 None,
704 Some("rels"),
705 Provenance::Frontmatter
706 ),
707 ]
708 );
709 assert!(e.iter().all(|e| e.src_block.is_none()));
710 let fm = parse_frontmatter("k: \"[[a#H]]\"\nu: /x^r\n").unwrap();
711 let e = extract_frontmatter_edges(&fm);
712 assert_eq!(
713 brief(&e[0]),
714 (
715 "k",
716 DstKind::Document,
717 "a",
718 Some("H"),
719 Some("k"),
720 Provenance::Frontmatter
721 )
722 );
723 assert_eq!(
724 brief(&e[1]),
725 (
726 "u",
727 DstKind::Block,
728 "/x",
729 Some("r"),
730 Some("u"),
731 Provenance::Frontmatter
732 )
733 );
734 assert!(extract_frontmatter_edges(&Mapping::new()).is_empty());
735 }
736
737 #[test]
738 fn document_order_is_blocks_then_frontmatter() {
739 use omgbase_format::parse_markdown;
740 let tree = parse_markdown("---\nrel: /r.md\n---\n\n- [a](x)\n\n[b](y)\n");
741 let (fm, body) = (&tree.children[0], &tree.children[1..]);
742 let ids: Vec<String> = (0..DocBlock::count(body))
743 .map(|i| format!("b_{i}"))
744 .collect();
745 let blocks = DocBlock::from_blocks(body, &ids);
746 let mapping = parse_frontmatter(omgbase_properties::frontmatter_yaml(&fm.raw)).unwrap();
747 let e = extract_doc_edges(&blocks, Some(&mapping));
748 let v: Vec<(Option<&str>, &str)> = e
749 .iter()
750 .map(|e| (e.src_block.as_deref(), e.target.as_str()))
751 .collect();
752 assert_eq!(
753 v,
754 [
755 (Some("b_0"), "x"),
756 (Some("b_1"), "x"),
757 (Some("b_2"), "y"),
758 (None, "/r.md")
759 ]
760 );
761 }
762}