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};
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).
211#[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    // 1. Inline relation fields (their values are not also plain links).
245    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    // 2. Markdown links and images.
265    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    // 3. Wikilinks.
276    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    // 4. Autolinks and bare URLs → external.
287    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/// §3.1 frontmatter: for each top-level entry in parser order, over the
306/// value or each element of an array value, a string that is exactly
307/// `[[…]]` (the inner text) or starts with `/` (as is) is a relation under
308/// `predicate = src_field = key` (case preserved). Nested mappings are not
309/// walked.
310#[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/// §3.1 extraction order for a document: every block in pre-order (each
349/// block's steps 1–4), then the frontmatter entries.
350#[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) ![i](img.png \"title\") [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        // Preceded by `(`, `"` or `[`: the link scanners own those.
553        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        // A rejected candidate does not hide a URL starting inside it.
559        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        // Trailing `)`, `>`, `]` and whitespace end a bare URL.
565        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        // JavaScript whitespace, not Unicode White_Space: U+0085 continues a URL.
571        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        // A markdown link's URL is not also a bare URL.
577        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                // The remembered raw value only guards the link and wikilink
621                // scanners (as the reference): a URL value is also a bare URL.
622                (
623                    "references",
624                    DstKind::External,
625                    "https://a.com",
626                    None,
627                    None,
628                    Provenance::Link
629                ),
630            ]
631        );
632        // The remembered value is not also a plain link.
633        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        // Scheme case-insensitive in the field, case-sensitive in classification.
641        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        // Not at a whitespace/start boundary: no field.
647        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) ![b](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}