1use std::collections::HashSet;
6use std::fmt;
7use std::sync::LazyLock;
8
9use omgbase_format::BlockKind;
10use omgbase_properties::{DocBlock, Mapping, Value, own_text};
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]
214pub fn extract_block_edges(block_id: &str, kind: BlockKind, raw: &str) -> Vec<EdgeDescriptor> {
215 let mut edges: Vec<EdgeDescriptor> = Vec::new();
216 if kind == BlockKind::CodeFence {
217 return edges;
218 }
219 let scan = mask_code_bytes(raw);
220 let mut seen: HashSet<String> = HashSet::new();
221 let mut push = |e: EdgeDescriptor| {
222 let key = format!(
223 "{}|{}|{}|{}",
224 e.predicate,
225 e.target,
226 e.anchor.as_deref().unwrap_or(""),
227 e.src_field.as_deref().unwrap_or("")
228 );
229 if seen.insert(key) {
230 edges.push(e);
231 }
232 };
233 let link = |predicate: &str, dest: &str| {
234 let c = classify(dest);
235 EdgeDescriptor {
236 src_block: Some(block_id.to_owned()),
237 src_field: None,
238 predicate: predicate.to_owned(),
239 dst_kind: c.dst_kind,
240 target: c.target,
241 anchor: c.anchor,
242 provenance: Provenance::Link,
243 }
244 };
245
246 let mut inline_targets: HashSet<String> = HashSet::new();
248 for m in INLINE_FIELD.captures_iter(&scan) {
249 let key = m[1].to_ascii_lowercase();
250 let raw_target = &m[2];
251 inline_targets.insert(raw_target.to_owned());
252 let dest = WHOLE_WIKILINK
253 .captures(raw_target)
254 .map_or(raw_target, |w| w.get(1).expect("group").as_str());
255 let c = classify(dest);
256 push(EdgeDescriptor {
257 src_block: Some(block_id.to_owned()),
258 src_field: Some(key.clone()),
259 predicate: key,
260 dst_kind: c.dst_kind,
261 target: c.target,
262 anchor: c.anchor,
263 provenance: Provenance::InlineField,
264 });
265 }
266 for m in MD_LINK.captures_iter(&scan) {
268 let dest = &m[2];
269 if inline_targets.contains(dest) {
270 continue;
271 }
272 push(link(
273 if &m[1] == "!" { "embeds" } else { "references" },
274 dest,
275 ));
276 }
277 for m in WIKILINK.captures_iter(&scan) {
279 let inner = &m[2];
280 if inline_targets.contains(&format!("[[{inner}]]")) {
281 continue;
282 }
283 push(link(
284 if &m[1] == "!" { "embeds" } else { "references" },
285 inner,
286 ));
287 }
288 let external = |uri: &str| EdgeDescriptor {
290 src_block: Some(block_id.to_owned()),
291 src_field: None,
292 predicate: "references".to_owned(),
293 dst_kind: DstKind::External,
294 target: normalize_uri(uri),
295 anchor: None,
296 provenance: Provenance::Link,
297 };
298 for m in AUTOLINK.captures_iter(&scan) {
299 push(external(&m[1]));
300 }
301 for (start, end) in bare_urls(&scan) {
302 push(external(&scan[start..end]));
303 }
304 edges
305}
306
307#[must_use]
313pub fn extract_frontmatter_edges(fm: &Mapping) -> Vec<EdgeDescriptor> {
314 let mut edges = Vec::new();
315 let mut consider = |key: &str, value: &Value| {
316 let Value::String(s) = value else {
317 return;
318 };
319 let dest = if let Some(w) = WHOLE_WIKILINK.captures(s) {
320 w.get(1).expect("group").as_str()
321 } else if s.starts_with('/') {
322 s.as_str()
323 } else {
324 return;
325 };
326 let c = classify(dest);
327 edges.push(EdgeDescriptor {
328 src_block: None,
329 src_field: Some(key.to_owned()),
330 predicate: key.to_owned(),
331 dst_kind: c.dst_kind,
332 target: c.target,
333 anchor: c.anchor,
334 provenance: Provenance::Frontmatter,
335 });
336 };
337 for (key, value) in fm.iter() {
338 match value {
339 Value::Array(items) => {
340 for v in items {
341 consider(key, v);
342 }
343 }
344 other => consider(key, other),
345 }
346 }
347 edges
348}
349
350#[must_use]
354pub fn extract_doc_edges(
355 blocks: &[DocBlock<'_>],
356 frontmatter: Option<&Mapping>,
357) -> Vec<EdgeDescriptor> {
358 fn walk(blocks: &[DocBlock<'_>], out: &mut Vec<EdgeDescriptor>) {
359 for b in blocks {
360 out.extend(extract_block_edges(b.block_id, b.kind, &own_text(b)));
361 walk(&b.children, out);
362 }
363 }
364 let mut out = Vec::new();
365 walk(blocks, &mut out);
366 if let Some(fm) = frontmatter {
367 out.extend(extract_frontmatter_edges(fm));
368 }
369 out
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use omgbase_properties::parse_frontmatter;
376
377 fn edges(raw: &str) -> Vec<EdgeDescriptor> {
378 extract_block_edges("b_0", BlockKind::Paragraph, raw)
379 }
380
381 fn brief(e: &EdgeDescriptor) -> (&str, DstKind, &str, Option<&str>, Option<&str>, Provenance) {
382 (
383 e.predicate.as_str(),
384 e.dst_kind,
385 e.target.as_str(),
386 e.anchor.as_deref(),
387 e.src_field.as_deref(),
388 e.provenance,
389 )
390 }
391
392 #[test]
393 fn fragments_split_at_the_first_marker() {
394 assert_eq!(split_fragment("a"), ("a", None, None));
395 assert_eq!(
396 split_fragment("a#b"),
397 ("a", Some("b"), Some(AnchorKind::Heading))
398 );
399 assert_eq!(
400 split_fragment("a^b"),
401 ("a", Some("b"), Some(AnchorKind::Ref))
402 );
403 assert_eq!(
404 split_fragment("a^b#c"),
405 ("a", Some("b#c"), Some(AnchorKind::Ref))
406 );
407 assert_eq!(
408 split_fragment("a#b^c"),
409 ("a", Some("b^c"), Some(AnchorKind::Heading))
410 );
411 assert_eq!(
412 split_fragment("#H"),
413 ("", Some("H"), Some(AnchorKind::Heading))
414 );
415 assert_eq!(split_fragment("^r"), ("", Some("r"), Some(AnchorKind::Ref)));
416 assert_eq!(
417 split_fragment("a#"),
418 ("a", Some(""), Some(AnchorKind::Heading))
419 );
420 }
421
422 #[test]
423 fn classification() {
424 assert_eq!(
425 classify("https://A.com/x/#f"),
426 Classified {
427 dst_kind: DstKind::External,
428 target: "https://a.com/x".into(),
429 anchor: None
430 }
431 );
432 assert_eq!(
433 classify("note^r"),
434 Classified {
435 dst_kind: DstKind::Block,
436 target: "note".into(),
437 anchor: Some("r".into())
438 }
439 );
440 assert_eq!(
441 classify("./x.md#H"),
442 Classified {
443 dst_kind: DstKind::Document,
444 target: "./x.md".into(),
445 anchor: Some("H".into())
446 }
447 );
448 assert_eq!(
449 classify("HTTP://x"),
450 Classified {
451 dst_kind: DstKind::Document,
452 target: "HTTP://x".into(),
453 anchor: None
454 },
455 "the external test is case-sensitive"
456 );
457 assert_eq!(classify("mailto:a@b").dst_kind, DstKind::Document);
458 }
459
460 #[test]
461 fn markdown_links_and_images() {
462 let e = edges("[t](a.md)  [u](/root/x.md#H) [v](<sp ace>)");
463 assert_eq!(
464 e.iter().map(brief).collect::<Vec<_>>(),
465 [
466 (
467 "references",
468 DstKind::Document,
469 "a.md",
470 None,
471 None,
472 Provenance::Link
473 ),
474 (
475 "embeds",
476 DstKind::Document,
477 "img.png",
478 None,
479 None,
480 Provenance::Link
481 ),
482 (
483 "references",
484 DstKind::Document,
485 "/root/x.md",
486 Some("H"),
487 None,
488 Provenance::Link
489 ),
490 ]
491 );
492 assert_eq!(e[0].src_block.as_deref(), Some("b_0"));
493 let e = edges("[t](https://a.com/) [t](https://a.com)");
494 assert_eq!(e.len(), 1, "same normalized URI dedups");
495 assert_eq!(e[0].target, "https://a.com");
496 }
497
498 #[test]
499 fn wikilinks() {
500 let e = edges("[[note]] ![[img.png]] [[note|Alias]] [[a#H]] [[b^r]] [[note]]");
501 assert_eq!(
502 e.iter().map(brief).collect::<Vec<_>>(),
503 [
504 (
505 "references",
506 DstKind::Document,
507 "note",
508 None,
509 None,
510 Provenance::Link
511 ),
512 (
513 "embeds",
514 DstKind::Document,
515 "img.png",
516 None,
517 None,
518 Provenance::Link
519 ),
520 (
521 "references",
522 DstKind::Document,
523 "note|Alias",
524 None,
525 None,
526 Provenance::Link
527 ),
528 (
529 "references",
530 DstKind::Document,
531 "a",
532 Some("H"),
533 None,
534 Provenance::Link
535 ),
536 (
537 "references",
538 DstKind::Block,
539 "b",
540 Some("r"),
541 None,
542 Provenance::Link
543 ),
544 ]
545 );
546 }
547
548 #[test]
549 fn autolinks_and_bare_urls() {
550 let e = edges("<https://a.com/x> and https://b.com/y. see https://a.com/x");
551 assert_eq!(
552 e.iter().map(|e| e.target.as_str()).collect::<Vec<_>>(),
553 ["https://a.com/x", "https://b.com/y."]
554 );
555 assert!(edges("(https://a.com)").is_empty());
557 assert!(edges("\"https://a.com\"").is_empty());
558 assert!(edges("[https://a.com").is_empty());
559 assert_eq!(edges("xhttps://a.com").len(), 0, "word boundary");
560 assert_eq!(edges("=https://a.com").len(), 1);
561 let e = edges("(https://a.com/?u=https://b.com)");
563 assert_eq!(
564 e.iter().map(|e| e.target.as_str()).collect::<Vec<_>>(),
565 ["https://b.com"]
566 );
567 let e = edges("see https://a.com/x) https://b.com/y] https://c.com/z>");
569 assert_eq!(
570 e.iter().map(|e| e.target.as_str()).collect::<Vec<_>>(),
571 ["https://a.com/x", "https://b.com/y", "https://c.com/z"]
572 );
573 assert_eq!(
575 edges("https://a.com/x\u{85}y")[0].target,
576 "https://a.com/x%C2%85y"
577 );
578 assert_eq!(edges("https://a.com/x\u{A0}y")[0].target, "https://a.com/x");
579 assert_eq!(edges("[t](https://a.com)").len(), 1);
581 }
582
583 #[test]
584 fn inline_relation_fields() {
585 let e = edges(
586 "rel:: [[note]]\nOwner:: /people/a.md\nsee:: https://a.com/\nplain:: text\nx rel2:: [[n#H]]",
587 );
588 assert_eq!(
589 e.iter().map(brief).collect::<Vec<_>>(),
590 [
591 (
592 "rel",
593 DstKind::Document,
594 "note",
595 None,
596 Some("rel"),
597 Provenance::InlineField
598 ),
599 (
600 "owner",
601 DstKind::Document,
602 "/people/a.md",
603 None,
604 Some("owner"),
605 Provenance::InlineField
606 ),
607 (
608 "see",
609 DstKind::External,
610 "https://a.com",
611 None,
612 Some("see"),
613 Provenance::InlineField
614 ),
615 (
616 "rel2",
617 DstKind::Document,
618 "n",
619 Some("H"),
620 Some("rel2"),
621 Provenance::InlineField
622 ),
623 (
626 "references",
627 DstKind::External,
628 "https://a.com",
629 None,
630 None,
631 Provenance::Link
632 ),
633 ]
634 );
635 assert_eq!(edges("rel:: [[note]]").len(), 1);
637 assert_eq!(edges("rel:: [[note]] and [[note]]").len(), 1);
638 assert_eq!(edges("rel:: [[note]] and [[other]]").len(), 2);
639 assert_eq!(edges("rel:: /x.md and [t](/x.md)").len(), 1);
640 let e = edges("see:: https://a.com/x");
641 assert_eq!(e.len(), 2);
642 assert_eq!(e[1].predicate, "references");
643 let e = edges("k:: HTTPS://a.com");
645 assert_eq!(
646 (e[0].dst_kind, e[0].target.as_str()),
647 (DstKind::Document, "HTTPS://a.com")
648 );
649 assert!(
651 edges("x-rel:: [[n]]")
652 .iter()
653 .all(|e| e.provenance == Provenance::Link)
654 );
655 assert!(edges("1rel:: /x").is_empty());
656 }
657
658 #[test]
659 fn in_block_dedup_and_code() {
660 assert_eq!(edges("[a](x) [b](x) [[x]]").len(), 1);
661 assert_eq!(edges("[a](x) ").len(), 2, "different predicates");
662 assert_eq!(edges("[a](x#H) [b](x)").len(), 2, "different anchors");
663 assert!(extract_block_edges("b_0", BlockKind::CodeFence, "[t](x)").is_empty());
664 assert!(edges("`[t](x)` and ``https://a.com``").is_empty());
665 assert_eq!(edges("`x` [t](y)")[0].target, "y");
666 }
667
668 #[test]
669 fn frontmatter_relations() {
670 let fm = parse_frontmatter(
671 "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",
672 )
673 .unwrap();
674 let e = extract_frontmatter_edges(&fm);
675 assert_eq!(
676 e.iter().map(brief).collect::<Vec<_>>(),
677 [
678 (
679 "Rel",
680 DstKind::Document,
681 "note",
682 None,
683 Some("Rel"),
684 Provenance::Frontmatter
685 ),
686 (
687 "owner",
688 DstKind::Document,
689 "/people/a.md",
690 None,
691 Some("owner"),
692 Provenance::Frontmatter
693 ),
694 (
695 "rels",
696 DstKind::Document,
697 "a",
698 None,
699 Some("rels"),
700 Provenance::Frontmatter
701 ),
702 (
703 "rels",
704 DstKind::Document,
705 "/b.md",
706 None,
707 Some("rels"),
708 Provenance::Frontmatter
709 ),
710 ]
711 );
712 assert!(e.iter().all(|e| e.src_block.is_none()));
713 let fm = parse_frontmatter("k: \"[[a#H]]\"\nu: /x^r\n").unwrap();
714 let e = extract_frontmatter_edges(&fm);
715 assert_eq!(
716 brief(&e[0]),
717 (
718 "k",
719 DstKind::Document,
720 "a",
721 Some("H"),
722 Some("k"),
723 Provenance::Frontmatter
724 )
725 );
726 assert_eq!(
727 brief(&e[1]),
728 (
729 "u",
730 DstKind::Block,
731 "/x",
732 Some("r"),
733 Some("u"),
734 Provenance::Frontmatter
735 )
736 );
737 assert!(extract_frontmatter_edges(&Mapping::new()).is_empty());
738 }
739
740 #[test]
741 fn document_order_is_blocks_then_frontmatter() {
742 use omgbase_format::parse_markdown;
743 let tree = parse_markdown("---\nrel: /r.md\n---\n\n- [a](x)\n\n[b](y)\n");
744 let (fm, body) = (&tree.children[0], &tree.children[1..]);
745 let ids: Vec<String> = (0..DocBlock::count(body))
746 .map(|i| format!("b_{i}"))
747 .collect();
748 let blocks = DocBlock::from_blocks(body, &ids);
749 let mapping = parse_frontmatter(omgbase_properties::frontmatter_yaml(&fm.raw)).unwrap();
750 let e = extract_doc_edges(&blocks, Some(&mapping));
751 let v: Vec<(Option<&str>, &str)> = e
752 .iter()
753 .map(|e| (e.src_block.as_deref(), e.target.as_str()))
754 .collect();
755 assert_eq!(v, [(Some("b_1"), "x"), (Some("b_2"), "y"), (None, "/r.md")]);
757 }
758}