Skip to main content

omgbase_graph/
edges.rs

1//! Edge extraction (§3.1, extraction `x2`): destination classification, the
2//! four block scanners in order with in-block dedup, and the frontmatter
3//! relations. Pure: descriptors out; the store resolves them (§3.2).
4
5use 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
17/// `(!?)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)`.
18static MD_LINK: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(&format!(
20        r#"(!?)\[[^\]]*\]\(([^){JS_WS}]+)(?:[{JS_WS}]+"[^"]*")?\)"#
21    ))
22    .expect("valid")
23});
24/// `(!?)\[\[([^\]]+)\]\]`.
25static WIKILINK: LazyLock<Regex> =
26    LazyLock::new(|| Regex::new(r"(!?)\[\[([^\]]+)\]\]").expect("valid"));
27/// `<(https?:\/\/[^>]+)>`.
28static AUTOLINK: LazyLock<Regex> =
29    LazyLock::new(|| Regex::new(r"<(https?://[^>]+)>").expect("valid"));
30/// `\bhttps?:\/\/[^\s)>\]]+` — the reference's `(?<![("[])` lookbehind is
31/// the preceding-character check in [`bare_urls`]. `\b` without the `u` flag
32/// is the ASCII word boundary.
33static BARE_URL: LazyLock<Regex> =
34    LazyLock::new(|| Regex::new(&format!(r"(?-u:\b)https?://[^{JS_WS})>\]]+")).expect("valid"));
35/// `(?:^|\s)([a-z][a-z0-9_]*)::\s*(\[\[[^\]]+\]\]|\/[^\s]+|https?:\/\/[^\s]+)`
36/// with `i`: ASCII letters either case for the key, and the scheme too.
37static 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});
43/// `^\[\[([^\]]+)\]\]$` (no `m`: the whole string).
44static WHOLE_WIKILINK: LazyLock<Regex> =
45    LazyLock::new(|| Regex::new(r"^\[\[([^\]]+)\]\]$").expect("valid"));
46
47/// What an edge points at (`edges.dst_kind`).
48#[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/// Where an edge was authored (`edges.provenance`).
86#[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/// An extracted edge before resolution (§3.1).
111#[derive(Clone, Debug, PartialEq, Eq)]
112pub struct EdgeDescriptor {
113    /// `None` for a frontmatter edge.
114    pub src_block: Option<String>,
115    /// The frontmatter or inline-field key; `None` for a plain link.
116    pub src_field: Option<String>,
117    /// `references`, `embeds`, or the field key.
118    pub predicate: String,
119    pub dst_kind: DstKind,
120    /// The raw target: a repo path, a wikilink name (alias included) or a
121    /// normalized URI; `""` for a pure fragment.
122    pub target: String,
123    /// The `#heading` or `^ref` fragment, without its marker.
124    pub anchor: Option<String>,
125    pub provenance: Provenance,
126}
127
128/// Which marker split the fragment off.
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub enum AnchorKind {
131    Heading,
132    Ref,
133}
134
135/// §3.1: split a destination at whichever marker comes first — a `^` before
136/// any `#` makes a ref (`a^b#c` → `("a", Some("b#c"), Ref)`), otherwise a `#`
137/// makes a heading anchor (`a#b^c` → `("a", Some("b^c"), Heading)`); no
138/// marker → `(dest, None, None)`.
139#[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/// A classified destination (§3.1).
152#[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/// §3.1: `^https?://` → external with the URI normalized; otherwise the
160/// fragment split off, a `^ref` making `dst_kind = block`, else `document`.
161#[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
182/// Bare URLs at an ASCII word boundary not preceded by `(`, `"` or `[`
183/// (the reference's lookbehind): `(start, end)` byte ranges. A rejected
184/// candidate does not hide a later one starting inside it, as with the
185/// reference's `g` scan.
186fn 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/// §3.1 per block: over `mask_code(raw)` (nothing for a `code_fence`), the
208/// inline relation fields, Markdown links and images, wikilinks, autolinks
209/// and bare URLs, in that order, deduplicated within the block on
210/// `(predicate, target, anchor, src_field)` (first wins). `raw` is the
211/// block's **own** text (`omgbase_properties::own_text`, §1) — what
212/// [`extract_doc_edges`] passes; a childless block's own text is its `raw`.
213#[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    // 1. Inline relation fields (their values are not also plain links).
247    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    // 2. Markdown links and images.
267    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    // 3. Wikilinks.
278    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    // 4. Autolinks and bare URLs → external.
289    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/// §3.1 frontmatter: for each top-level entry in parser order, over the
308/// value or each element of an array value, a string that is exactly
309/// `[[…]]` (the inner text) or starts with `/` (as is) is a relation under
310/// `predicate = src_field = key` (case preserved). Nested mappings are not
311/// walked.
312#[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/// §3.1 extraction order for a document: every block in pre-order (each
351/// block's steps 1–4 over its own text, so a link in a list item is the
352/// item's edge only — 1.1), then the frontmatter entries.
353#[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) ![i](img.png \"title\") [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        // Preceded by `(`, `"` or `[`: the link scanners own those.
556        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        // A rejected candidate does not hide a URL starting inside it.
562        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        // Trailing `)`, `>`, `]` and whitespace end a bare URL.
568        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        // JavaScript whitespace, not Unicode White_Space: U+0085 continues a URL.
574        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        // A markdown link's URL is not also a bare URL.
580        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                // The remembered raw value only guards the link and wikilink
624                // scanners (as the reference): a URL value is also a bare URL.
625                (
626                    "references",
627                    DstKind::External,
628                    "https://a.com",
629                    None,
630                    None,
631                    Provenance::Link
632                ),
633            ]
634        );
635        // The remembered value is not also a plain link.
636        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        // Scheme case-insensitive in the field, case-sensitive in classification.
644        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        // Not at a whitespace/start boundary: no field.
650        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) ![b](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        // 1.1: the list (b_0) scans its own text, so the link is the item's.
756        assert_eq!(v, [(Some("b_1"), "x"), (Some("b_2"), "y"), (None, "/r.md")]);
757    }
758}