use super::*;
use crate::okf::model::Dialect;
fn extract_links(body: &str, source_dir: &str, dialect: Dialect) -> Vec<Link> {
extract(body, source_dir, &Profile::for_dialect(dialect)).links
}
fn vault() -> Profile {
Profile::for_dialect(Dialect::Obsidian)
}
fn props_of(link: &Link) -> Vec<(&str, &str)> {
link.props
.iter()
.map(|(k, v)| {
(
k.as_str(),
match v {
Value::String(s) => s.as_str(),
_ => panic!("edge props are strings"),
},
)
})
.collect()
}
#[test]
fn titled_link_yields_typed_edge() {
let body = "Joined with [customers](/tables/customers.md \"JOINS_WITH\") here.";
let links = extract_links(body, "tables", Dialect::Okf);
assert_eq!(links.len(), 1);
assert_eq!(links[0].target, "tables/customers");
assert_eq!(links[0].conn_type, "JOINS_WITH");
}
#[test]
fn section_header_inference() {
let body = "# Citations\n[1] [src](/references/x.md)\n# Joins\nsee [y](/tables/y.md)";
let links = extract_links(body, "tables", Dialect::Okf);
let by_target: std::collections::HashMap<_, _> = links
.iter()
.map(|l| (l.target.as_str(), l.conn_type.as_str()))
.collect();
assert_eq!(by_target.get("references/x"), Some(&"CITES"));
assert_eq!(by_target.get("tables/y"), Some(&"JOINS_WITH"));
}
#[test]
fn untyped_link_defaults_to_links_to() {
let body = "See [other](./other.md) for details.";
let links = extract_links(body, "tables", Dialect::Okf);
assert_eq!(links[0].target, "tables/other");
assert_eq!(links[0].conn_type, "LINKS_TO");
}
#[test]
fn relative_parent_paths_resolve() {
let body = "Part of the [sales dataset](../datasets/sales.md).";
let links = extract_links(body, "tables", Dialect::Okf);
assert_eq!(links[0].target, "datasets/sales");
}
#[test]
fn tooltip_title_is_not_a_type() {
let body = "See [customers](/tables/customers.md \"the customers table\").";
let links = extract_links(body, "tables", Dialect::Okf);
assert_eq!(links[0].conn_type, "LINKS_TO");
}
#[test]
fn external_captured_non_md_skipped() {
let body = "# Citations\n[site](https://example.com) and [dir](subdir/) and [doc](./pic.png)";
let links = extract_links(body, "", Dialect::Okf);
assert_eq!(links.len(), 1);
assert!(links[0].is_external);
assert_eq!(links[0].target, "https://example.com");
assert_eq!(links[0].conn_type, "CITES");
}
#[test]
fn images_and_fenced_code_skipped() {
let body = "\n```sql\nSELECT [a](/tables/y.md)\n```\n[real](/tables/z.md)";
let links = extract_links(body, "tables", Dialect::Okf);
assert_eq!(links.len(), 1);
assert_eq!(links[0].target, "tables/z");
}
#[test]
fn wikilinks_only_in_loose_dialect() {
let body = "See [[other-note]] and [[sub/thing|alias]].";
assert!(extract_links(body, "", Dialect::Okf).is_empty());
let links = extract_links(body, "", Dialect::Loose);
assert_eq!(links.len(), 2);
assert_eq!(links[0].target, "other-note");
assert_eq!(links[1].target, "sub/thing");
}
#[test]
fn embedded_wikilink_is_not_a_link() {
let links = extract_links(
"![[diagram.png]] and ![[note|alias]] but [[real-note]]",
"",
Dialect::Loose,
);
let targets: Vec<&str> = links.iter().map(|l| l.target.as_str()).collect();
assert_eq!(targets, vec!["real-note"]);
}
#[test]
fn tag_line_is_not_a_heading_and_keeps_its_links() {
let links = extract_links("#project see [[Alice]]", "", Dialect::Loose);
assert_eq!(links.len(), 1, "a `#tag` line is prose, not a heading");
assert_eq!(links[0].target, "Alice");
assert_eq!(links[0].conn_type, "LINKS_TO");
}
#[test]
fn heading_rule_decides_the_section_a_link_carries() {
let section_under = |heading: &str| {
let body = format!("{heading}\nsee [[Alice]]");
let links = extract(&body, "", &vault()).links;
assert_eq!(links.len(), 1, "{heading}");
props_of(&links[0])
.into_iter()
.find(|(k, _)| *k == "section")
.map(|(_, v)| v.to_string())
};
assert_eq!(section_under("# Joins").as_deref(), Some("Joins"));
assert_eq!(section_under("###\tDeps").as_deref(), Some("Deps"));
assert_eq!(
section_under("#"),
None,
"an empty heading names no section"
);
assert_eq!(section_under("#related"), None, "a `#tag` line is prose");
assert_eq!(section_under("####### Deep"), None, "seven hashes is prose");
assert_eq!(section_under("plain"), None);
}
#[test]
fn real_heading_still_types_the_links_below_it() {
let links = extract_links(
"# Related work\n#seealso\nsee [[Alice]]",
"",
Dialect::Loose,
);
assert_eq!(links.len(), 1);
assert_eq!(
links[0].conn_type, "RELATED",
"heading ladder still applies"
);
}
#[test]
fn loose_dialect_link_set_is_unchanged() {
let body = "see [[other-note]], ![[img.png]], [x](/tables/y.md) and #tag [[Alice]]";
let got = extract(body, "tables", &Profile::for_dialect(Dialect::Loose));
let links: Vec<(&str, &str, bool)> = got
.links
.iter()
.map(|l| (l.target.as_str(), l.conn_type.as_str(), l.props.is_empty()))
.collect();
assert_eq!(
links,
vec![
("other-note", "LINKS_TO", true),
("tables/y", "LINKS_TO", true),
("Alice", "LINKS_TO", true),
]
);
assert!(got.tags.is_empty(), "inline tags are a vault rule only");
}
#[test]
fn wikilink_anchor_is_stripped() {
let links = extract_links(
"see [[Design Notes#Goals]] and [[api#parse]]",
"",
Dialect::Loose,
);
let targets: Vec<&str> = links.iter().map(|l| l.target.as_str()).collect();
assert_eq!(targets, vec!["Design Notes", "api"]);
}
#[test]
fn vault_body_link_carries_its_section() {
let got = extract(
"Above every heading: [[atlas]].\n\n## Deep dive\n\nBelow one: [[bob]].",
"",
&vault(),
);
assert_eq!(
props_of(&got.links[0]),
Vec::new(),
"above the first heading"
);
assert_eq!(props_of(&got.links[1]), vec![("section", "Deep dive")]);
}
#[test]
fn vault_fragment_link_carries_its_anchor() {
let got = extract(
"## Notes\n[[atlas#Overview]] and [x](sub/b.md#usage) and [[bob#^b-12]]",
"",
&vault(),
);
let by_target: std::collections::HashMap<&str, Vec<(&str, &str)>> = got
.links
.iter()
.map(|l| (l.target.as_str(), props_of(l)))
.collect();
assert_eq!(
by_target["atlas"],
vec![("section", "Notes"), ("anchor", "Overview")]
);
assert_eq!(
by_target["sub/b"],
vec![("section", "Notes"), ("anchor", "usage")],
"a path link's fragment is an anchor too, and never part of the target"
);
assert_eq!(
by_target["bob"],
vec![("section", "Notes"), ("anchor", "^b-12")],
"a block reference keeps its caret"
);
}
#[test]
fn a_heading_line_states_its_own_links_and_pictures() {
let got = extract(
concat!(
"## Gallery  beside [[atlas]]\n",
"Below it: [[bob]].\n",
),
"",
&vault(),
);
assert_eq!(
attach(&got),
vec beside [[atlas]]")
)],
"the picture is referenced, and its section is the heading it sits in"
);
let links: Vec<(&str, Vec<(&str, &str)>)> = got
.links
.iter()
.map(|l| (l.target.as_str(), props_of(l)))
.collect();
assert_eq!(
links,
vec beside [[atlas]]"
)]
),
(
"bob",
vec beside [[atlas]]"
)]
),
],
"one section string for the heading's own link and for the line \
below it — two values would split one section into two edge groups"
);
}
#[test]
fn a_heading_lines_markdown_link_takes_the_headings_own_edge_type() {
let got = extract(
"## Related work, see [Alice](people/alice.md)\n",
"",
&vault(),
);
assert_eq!(got.links.len(), 1);
assert_eq!(got.links[0].target, "people/alice");
assert_eq!(
got.links[0].conn_type, "RELATED",
"the heading types the link written on it, as it types the ones below"
);
let escaping = extract("## See \n", "", &vault());
assert_eq!(
escaping.path_errors,
vec!["`../../etc/passwd.png` escapes the vault root".to_string()],
"a reference on a heading meets §9 like any other"
);
}
#[test]
fn a_heading_line_is_scanned_in_every_dialect() {
for dialect in [Dialect::Okf, Dialect::Loose] {
let got = extract(
"## See [Alice](people/alice.md) #tag \n",
"",
&Profile::for_dialect(dialect),
);
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["people/alice"],
"{dialect:?} reads the link and drops the image, as it does in prose"
);
assert!(got.attachments.is_empty(), "{dialect:?}");
assert!(got.tags.is_empty(), "{dialect:?}");
assert!(got.links[0].props.is_empty(), "{dialect:?}");
}
}
#[test]
fn vault_embed_of_a_note_is_an_edge_of_a_file_is_not() {
let got = extract("![[old]] ![[notes/deep.md]] ![[diagram.png]]", "", &vault());
let got: Vec<(&str, &str)> = got
.links
.iter()
.map(|l| (l.target.as_str(), l.conn_type.as_str()))
.collect();
assert_eq!(
got,
vec![("old", "EMBEDS"), ("notes/deep", "EMBEDS")],
"`.png` is an attachment, not a link"
);
}
#[test]
fn loose_still_drops_every_embed() {
let links = extract_links("![[old]] and ![[diagram.png]]", "", Dialect::Loose);
assert!(links.is_empty(), "EMBEDS is a vault rule");
}
#[test]
fn vault_inline_tags_honour_the_four_exclusions() {
let body = concat!(
"# Heading #inhead\n",
"A #plain tag and a #kebab-case/nested one.\n",
"Not in a `span with #incode in it`, not in https://ex.com/p#frag,\n",
"not in [[Note#Section]], and #2026 is not a tag.\n",
"```\n#infence\n```\n",
"#plain again is not a second tag.\n",
);
let got = extract(body, "", &vault());
assert_eq!(
got.tags,
vec!["inhead", "plain", "kebab-case/nested"],
"a heading's own `#` is not a tag, but a tag written in one is"
);
}
#[test]
fn loose_extracts_no_inline_tags() {
assert!(
extract("A #plain tag.", "", &Profile::for_dialect(Dialect::Loose))
.tags
.is_empty()
);
}
fn attach(got: &Extraction) -> Vec<(&str, Option<&str>, Option<&str>)> {
got.attachments
.iter()
.map(|a| (a.target.as_str(), a.alt.as_deref(), a.section.as_deref()))
.collect()
}
#[test]
fn vault_captures_all_three_attachment_spellings() {
let got = extract(
concat!(
" and ![[plain.png]]\n",
"## Figures\n",
" then ![[diagram.png|A diagram]]\n",
"and  has no alt, ![[notes/deep.md]] is a note,\n",
"![[nameless]] is a note too, and \n",
"and  drops its fragment.\n",
" and  are neither.\n",
),
"notes",
&vault(),
);
assert_eq!(
attach(&got),
vec![
("img/bare.png", None, None),
("plain.png", None, None),
("../img/faults.png", Some("Fault map"), Some("Figures")),
("diagram.png", Some("A diagram"), Some("Figures")),
("img/blank.png", None, Some("Figures")),
("img/frag.png", Some("frag"), Some("Figures")),
],
"an external URL, a `.md` target and an extension-less one are not \
attachments — in either spelling"
);
assert!(
!got.links.iter().any(|l| l.target.contains("other")),
"and `` is not a link either: the `!` still \
disqualifies it"
);
let embeds: Vec<&str> = got
.links
.iter()
.filter(|l| l.conn_type == EMBEDS_CONN_TYPE)
.map(|l| l.target.as_str())
.collect();
assert_eq!(
embeds,
vec!["notes/deep", "nameless"],
"the note embeds on those lines are still links"
);
}
#[test]
fn vault_plain_link_to_a_file_is_an_attachment_reference() {
let got = extract(
concat!(
"## Downloads\n",
"[The handbook](../img/handbook.pdf) and [a map](../img/faults.png),\n",
"[back to top](#downloads) and [the note](other.md) are not,\n",
"and neither are [mail](mailto:a@b.com), [dir](sub/) or\n",
"[site](https://example.com/x.zip).\n",
),
"notes",
&vault(),
);
assert_eq!(
attach(&got),
vec![
(
"../img/handbook.pdf",
Some("The handbook"),
Some("Downloads")
),
("../img/faults.png", Some("a map"), Some("Downloads")),
],
"the link text is the reference's alt, exactly as ``'s is"
);
let links: Vec<&str> = got.links.iter().map(|l| l.target.as_str()).collect();
assert_eq!(
links,
vec!["notes/other", "https://example.com/x.zip"],
"a `.md` target is still a note link and an http one still a Source"
);
assert!(
got.path_errors.is_empty(),
"an in-page anchor, a mailto and a directory link are silent no-ops"
);
}
#[test]
fn okf_and_loose_drop_a_plain_link_to_a_file() {
for dialect in [Dialect::Okf, Dialect::Loose] {
let got = extract("[handbook](img/h.pdf)", "", &Profile::for_dialect(dialect));
assert!(
got.attachments.is_empty() && got.links.is_empty(),
"{dialect:?} reads no attachments, so the link stays dropped"
);
}
}
#[test]
fn an_escaped_wikilink_is_literal_text() {
let got = extract(r"Write \[\[atlas]] to mean the literal text.", "", &vault());
assert!(
got.links.is_empty() && got.attachments.is_empty(),
"an escaped wikilink names nothing"
);
}
#[test]
fn an_indented_code_block_is_scanned_like_prose() {
let got = extract(
"## Example\n\n [[atlas]] and \n",
"",
&vault(),
);
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["atlas"],
"four-space indentation exempts nothing; fence it instead"
);
assert_eq!(
attach(&got),
vec![("img/x.png", Some("m"), Some("Example"))]
);
}
#[test]
fn markdown_syntax_inside_an_html_block_is_scanned() {
let got = extract(
concat!(
"## Gallery\n",
"<div><a href=\"bob.md\">bob</a> <img src=\"img/y.png\"></div>\n",
"<div>[[atlas]] and </div>\n",
),
"",
&vault(),
);
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["atlas"],
"`<a href>` is not a link; the wikilink beside it is"
);
assert_eq!(
attach(&got),
vec![("img/x.png", Some("m"), Some("Gallery"))],
"`<img src>` is not a reference; the markdown image beside it is"
);
}
#[test]
fn okf_and_loose_capture_no_attachments() {
for dialect in [Dialect::Okf, Dialect::Loose] {
let got = extract(
" and ![[y.png]]",
"",
&Profile::for_dialect(dialect),
);
assert!(
got.attachments.is_empty(),
"{dialect:?} still drops every image reference"
);
assert!(got.links.is_empty(), "{dialect:?} mints no link either");
}
}
#[test]
fn frontmatter_wikilink_values_name_their_targets() {
let one = Value::String("[[Seismic interpretation]]".to_string());
assert_eq!(
wikilink_targets(&one),
Some(vec!["Seismic interpretation".to_string()])
);
let list = Value::List(vec![
Value::String("[[A]]".to_string()),
Value::String(" [[sub/B.md#frag|shown]] ".to_string()),
]);
assert_eq!(
wikilink_targets(&list),
Some(vec!["A".to_string(), "sub/B".to_string()])
);
let mixed = Value::List(vec![
Value::String("[[A]]".to_string()),
Value::String("plain".to_string()),
]);
assert_eq!(wikilink_targets(&mixed), None);
assert_eq!(
wikilink_targets(&Value::String("see [[A]] there".to_string())),
None,
"a wikilink inside prose is not a typed-edge value"
);
assert_eq!(wikilink_targets(&Value::List(Vec::new())), None);
assert_eq!(wikilink_targets(&Value::Int64(3)), None);
}
#[test]
fn upper_snake_spells_the_edge_type() {
assert_eq!(upper_snake("depends_on"), "DEPENDS_ON");
assert_eq!(upper_snake("see also"), "SEE_ALSO");
assert_eq!(upper_snake("metadata.source"), "METADATA_SOURCE");
assert_eq!(upper_snake("--x--"), "X");
assert_eq!(upper_snake("---"), "");
}
#[test]
fn percent_decode_leaves_a_literal_percent_alone() {
assert_eq!(percent_decode("img/a%20b.png"), "img/a b.png");
assert_eq!(percent_decode("img/50%25.png"), "img/50%.png");
assert_eq!(percent_decode("notes/r%C3%A5data.md"), "notes/rådata.md");
assert_eq!(percent_decode("img/100%.png"), "img/100%.png");
assert_eq!(percent_decode("img/%zz.png"), "img/%zz.png");
assert_eq!(percent_decode("img/a%2.png"), "img/a%2.png");
assert_eq!(percent_decode("img/%FF.png"), "img/%FF.png");
assert!(matches!(percent_decode("img/plain.png"), Cow::Borrowed(_)));
}
#[test]
fn a_rooted_target_is_vault_relative_not_absolute() {
assert_eq!(path_error("/img/x.png", "notes"), None);
assert!(path_error("/../img/x.png", "notes").is_some());
assert_eq!(path_error("../img/x.png", "notes"), None);
assert_eq!(path_error("../../img/x.png", "notes/deep"), None);
assert!(path_error("../../../img/x.png", "notes/deep").is_some());
assert!(path_error("\\\\server\\share\\x.png", "").is_some());
assert!(path_error("D:\\vault\\x.md", "").is_some());
assert_eq!(
path_error("C:notes/x.md", ""),
None,
"no separator, no drive"
);
}
#[test]
fn a_tilde_line_inside_a_backtick_fence_does_not_resume_scanning() {
let got = extract(
concat!(
"```text\n",
"~~~\n",
"[[atlas]] and  and #buried\n",
"```\n",
"[real](/notes/z.md)\n",
),
"",
&vault(),
);
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["notes/z"],
"everything between the ``` delimiters is code"
);
assert!(attach(&got).is_empty());
assert!(got.tags.is_empty());
}
#[test]
fn linked_image_yields_the_thumbnail_and_the_outer_link() {
let got = extract(
"## Figures\n\n[](img/faults.png)\n",
"",
&vault(),
);
assert_eq!(
attach(&got),
vec![
("img/thumb.png", Some("Fault map"), Some("Figures")),
("img/faults.png", Some("Fault map"), Some("Figures")),
],
"both halves are references; the outer one wears the inner alt"
);
assert!(got.links.is_empty(), "neither half is a note");
}
#[test]
fn a_linked_image_pointing_at_a_note_is_still_a_link() {
let got = extract("[](/notes/atlas.md)\n", "", &vault());
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["notes/atlas"]
);
assert_eq!(attach(&got), vec![("img/x.png", Some("map"), None)]);
}
#[test]
fn link_text_may_wrap_a_line_but_never_a_paragraph() {
let got = extract(
"see the [Binary\nExtensions](/notes/atlas.md) page\n",
"",
&vault(),
);
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["notes/atlas"]
);
let across = extract("a [open\n\nclose](/notes/atlas.md) b\n", "", &vault());
assert!(
across.links.is_empty(),
"a blank line ends the paragraph, and the bracket with it"
);
}
#[test]
fn a_comment_hides_links_tags_and_attachments() {
let inline = extract(
"## Notes\n\nkeep [[atlas]] %%drop [[ghost]]  #hidden%% here\n",
"",
&vault(),
);
assert_eq!(
inline
.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["atlas"]
);
assert!(attach(&inline).is_empty());
assert!(inline.tags.is_empty());
let block = extract(
"%%\n[[ghost]]  #hidden\n%%\n\n[[atlas]] #kept\n",
"",
&vault(),
);
assert_eq!(
block
.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["atlas"]
);
assert!(attach(&block).is_empty());
assert_eq!(block.tags, vec!["kept"]);
let heading = extract("%%\n# Hidden\n%%\n\n[[atlas]]\n", "", &vault());
assert_eq!(props_of(&heading.links[0]), Vec::new());
assert_eq!(crate::okf::first_heading("%%\n# Hidden\n%%\n"), None);
}
#[test]
fn a_target_whose_fifth_byte_is_inside_a_character_is_read_not_sliced() {
let got = extract(
"see [[RMS_—_RMS_API_1.13_documentation]] and [x](a_—_b.md)\n",
"",
&vault(),
);
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["RMS_—_RMS_API_1.13_documentation", "a_—_b"]
);
assert!(got.path_errors.is_empty());
let refused = extract("see [x](FILE:///etc/passwd)\n", "", &vault());
assert_eq!(refused.path_errors.len(), 1);
}
#[test]
fn a_code_span_hides_links_tags_and_attachments() {
let got = extract(
"keep [[atlas]] `[[ghost]]  [t](x.md) #hidden` here #kept\n",
"",
&vault(),
);
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["atlas"]
);
assert!(attach(&got).is_empty());
assert_eq!(got.tags, vec!["kept"]);
let wrapped = extract(
"`[[ghost]]\n#hidden still code` then [[atlas]] #kept\n",
"",
&vault(),
);
assert_eq!(
wrapped
.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["atlas"]
);
assert_eq!(wrapped.tags, vec!["kept"]);
let okf = extract_links(
"see `[t](/tables/x.md)` and [u](/tables/y.md)",
"",
Dialect::Okf,
);
assert_eq!(
okf.iter().map(|l| l.target.as_str()).collect::<Vec<_>>(),
vec!["tables/y"]
);
}
#[test]
fn a_code_span_can_still_be_a_links_display_text() {
let got = extract(
"see [`atlas.md`](atlas.md) and [[beta|`inline`]]\n",
"",
&vault(),
);
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["atlas", "beta"]
);
let subscript = extract("see [the `rows[0]` case](atlas.md)\n", "", &vault());
assert_eq!(subscript.links.len(), 1);
assert_eq!(subscript.links[0].target, "atlas");
let picture = extract("![a `b[0]` c](img/x.png)\n", "", &vault());
assert_eq!(
picture
.attachments
.iter()
.map(|a| (a.target.as_str(), a.alt.as_deref().unwrap_or("")))
.collect::<Vec<_>>(),
vec![("img/x.png", "a `b[0]` c")]
);
}
#[test]
fn a_hash_glued_to_a_code_span_is_still_not_a_tag() {
let got = extract("`code`#glued and #free\n", "", &vault());
assert_eq!(got.tags, vec!["free"]);
}
#[test]
fn a_setext_heading_names_the_section_below_it() {
let got = extract(
"Related work\n============\n\nsee [[atlas]] and \n",
"",
&vault(),
);
assert_eq!(props_of(&got.links[0]), vec![("section", "Related work")]);
assert_eq!(
attach(&got),
vec![("img/x.png", Some("m"), Some("Related work"))]
);
assert_eq!(
got.links[0].conn_type, "RELATED",
"the heading ladder reads a setext heading too"
);
}
#[test]
fn every_sections_value_names_a_heading_of_the_same_tree() {
let bodies = [
"# Joins\n[x](/tables/y.md)\n## Deep\nsee [[Alice]]\n",
"Setext\n------\n\n\n\n### #\n[[atlas]]\n",
"no heading at all: [[atlas]]\n",
"## Figures\n\n| a | b |\n|---|---|\n| [[atlas]] |  |\n",
"## Steps\n\n- one [[atlas]]\n - two \n",
"## Quote\n\n> [!note] Title\n> see [[atlas]]\n",
];
for body in bodies {
let tree = crate::okf::structure::parse_blocks(body);
let got = extract(body, "", &vault());
let headings: Vec<&str> = tree.headings.iter().map(|h| h.text.as_str()).collect();
let sections = got
.links
.iter()
.flat_map(|l| l.props.iter())
.filter(|(k, _)| k == "section")
.map(|(_, v)| match v {
Value::String(s) => s.clone(),
other => panic!("section is a string, got {other:?}"),
})
.chain(got.attachments.iter().filter_map(|a| a.section.clone()));
for section in sections {
assert!(
headings.contains(§ion.as_str()),
"{body:?}: section {section:?} is not a heading of the tree {headings:?}"
);
}
}
}
fn structured_vault() -> Profile {
let mut profile = vault();
profile.structure = Some(crate::okf::structure::StructureProfile::default());
profile
}
#[test]
fn display_text_is_the_edge_label_only_where_structure_is_declared() {
let body = "# Notes\n\nSee [[atlas|the atlas]] and [[atlas#Maps|its maps]].\n";
let plain = extract(body, "", &vault()).links;
assert_eq!(
plain.iter().map(props_of).collect::<Vec<_>>(),
vec![
vec![("section", "Notes")],
vec![("section", "Notes"), ("anchor", "Maps")],
],
"without `structure:` the display text is dropped, as it always was"
);
let structured = extract(body, "", &structured_vault()).links;
assert_eq!(
structured.iter().map(props_of).collect::<Vec<_>>(),
vec![
vec![("section", "Notes"), ("label", "the atlas")],
vec![
("section", "Notes"),
("anchor", "Maps"),
("label", "its maps")
],
]
);
let markdown = extract("[the atlas](atlas.md)\n", "", &structured_vault()).links;
assert_eq!(
markdown.iter().map(props_of).collect::<Vec<_>>(),
vec![vec![]]
);
}
#[test]
fn an_escaped_pipe_in_a_table_cell_names_the_note_and_keeps_its_text() {
let body = "| topic | guide |\n|---|---|\n| chunking | [[Usage\\|the guide]] |\n";
let got = extract(body, "", &structured_vault()).links;
assert_eq!(
got.iter()
.map(|l| (l.target.as_str(), props_of(l)))
.collect::<Vec<_>>(),
vec![("Usage", vec![("label", "the guide")])]
);
let anchored = extract(
"| a | [[Usage#Sub\\|text]] |\n|---|---|\n| b | c |\n",
"",
&vault(),
)
.links;
assert_eq!(
anchored
.iter()
.map(|l| (l.target.as_str(), props_of(l)))
.collect::<Vec<_>>(),
vec![("Usage", vec![("anchor", "Sub")])]
);
assert_eq!(
wikilink_targets(&Value::String("[[Usage\\|the guide]]".to_string())),
Some(vec!["Usage".to_string()])
);
}
fn typed(body: &str) -> Vec<(String, String)> {
extract(body, "", &vault())
.links
.into_iter()
.map(|l| (l.target, l.conn_type))
.collect()
}
#[test]
fn a_brace_after_a_wikilink_names_the_edge_type() {
assert_eq!(
typed("see [[Atlas]]{see-also} and [[Api]]{api}\n"),
vec![
("Atlas".to_string(), "SEE_ALSO".to_string()),
("Api".to_string(), "API".to_string())
]
);
assert_eq!(
typed("[[Atlas]]{SEE_ALSO}\n"),
typed("[[Atlas]]{see-also}\n"),
"the suffix normalises, so the two spellings are one type"
);
}
#[test]
fn a_typed_link_outranks_the_heading_it_sits_under() {
assert_eq!(
typed("## Related work\n\n[[Atlas]]{see-also} and [[Api]]\n"),
vec![
("Atlas".to_string(), "SEE_ALSO".to_string()),
("Api".to_string(), "RELATED".to_string())
],
"rung 0 beats the built-in ladder, and only for the link that wrote it"
);
let mut profile = vault();
profile
.heading_edges
.insert("Related work".to_string(), "RELATED_TO".to_string());
let got = extract("## Related work\n\n[[Atlas]]{see-also}\n", "", &profile);
assert_eq!(
got.links[0].conn_type, "SEE_ALSO",
"and it beats `heading_edges:` too"
);
}
#[test]
fn a_typed_link_keeps_its_display_text_and_its_anchor() {
let got = extract(
"## Notes\n\n[[Atlas#Overview|the atlas]]{see-also}\n",
"",
&structured_vault(),
);
assert_eq!(got.links[0].target, "Atlas");
assert_eq!(got.links[0].conn_type, "SEE_ALSO");
assert_eq!(
props_of(&got.links[0]),
vec![
("section", "Notes"),
("anchor", "Overview"),
("label", "the atlas")
]
);
}
#[test]
fn a_typed_link_is_read_inside_a_table_cell() {
let got = extract(
"| topic | link |\n|---|---|\n| a | [[Atlas\\|the atlas]]{see-also} |\n",
"",
&structured_vault(),
);
assert_eq!(
got.links
.iter()
.map(|l| (l.target.as_str(), l.conn_type.as_str(), props_of(l)))
.collect::<Vec<_>>(),
vec![("Atlas", "SEE_ALSO", vec![("label", "the atlas")])],
"the `\\|` escape belongs to the cell, and the brace still follows the `]]`"
);
}
#[test]
fn a_brace_that_names_no_type_stays_prose_and_warns() {
for suffix in ["{}", "{see also}", "{3d}", "{-}"] {
let body = format!("[[Atlas]]{suffix}\n");
let got = extract(&body, "", &vault());
assert_eq!(
got.links[0].conn_type, "LINKS_TO",
"`{suffix}` names no type, so the link keeps its default"
);
assert_eq!(got.warnings.len(), 1, "`{suffix}`: {:?}", got.warnings);
assert!(
got.warnings[0].contains(suffix) && got.warnings[0].contains("[[Atlas]]"),
"the warning names what was written: {}",
got.warnings[0]
);
}
}
#[test]
fn a_brace_the_link_does_not_touch_is_ordinary_prose() {
for body in [
"[[Atlas]] {see-also}\n",
"[[Atlas]]{see-also\n",
"[[Atlas]]{see-\nalso}\n",
] {
let got = extract(body, "", &vault());
assert_eq!(got.links[0].conn_type, "LINKS_TO", "{body:?}");
assert!(
got.warnings.is_empty(),
"a brace that is not attached to the link says nothing about it: {:?}",
got.warnings
);
}
}
#[test]
fn one_mistake_written_twice_is_one_warning() {
let got = extract(
"[[Atlas]]{see also} again [[Atlas]]{see also}\n",
"",
&vault(),
);
assert_eq!(got.warnings.len(), 1, "{:?}", got.warnings);
}
#[test]
fn a_tag_after_a_link_is_a_tag_and_not_a_link_type() {
let got = extract("[[Atlas]] #see-also\n", "", &vault());
assert_eq!(got.links[0].conn_type, "LINKS_TO");
assert_eq!(got.tags, vec!["see-also"], "it is still a tag");
assert!(got.warnings.is_empty());
}
#[test]
fn an_embed_keeps_embeds_and_the_brace_after_it_is_prose() {
let got = extract("![[Atlas]]{see-also}\n", "", &vault());
assert_eq!(
got.links
.iter()
.map(|l| (l.target.as_str(), l.conn_type.as_str()))
.collect::<Vec<_>>(),
vec![("Atlas", "EMBEDS")],
"a transclusion's type is what it is"
);
assert!(got.warnings.is_empty());
}
#[test]
fn only_the_vault_dialect_reads_a_typed_link() {
let got = extract(
"[[Atlas]]{see-also}\n",
"",
&Profile::for_dialect(Dialect::Loose),
);
assert_eq!(got.links[0].conn_type, "LINKS_TO");
assert!(got.warnings.is_empty());
}
fn tag_spans<'b>(body: &'b str, got: &'b Extraction) -> Vec<(&'b str, &'b str)> {
got.tag_spans
.iter()
.map(|t| (t.name.as_str(), &body[t.range.clone()]))
.collect()
}
#[test]
fn every_tag_occurrence_carries_the_range_of_its_own_token() {
let body = concat!(
"# Heading #inhead\n",
"A #plain tag and a #kebab-case/nested one.\n",
"#plain again is the same tag written twice.\n",
);
let got = extract(body, "", &vault());
assert_eq!(
tag_spans(body, &got),
vec![
("inhead", "#inhead"),
("plain", "#plain"),
("kebab-case/nested", "#kebab-case/nested"),
("plain", "#plain"),
],
"every occurrence, in body order"
);
assert_eq!(
got.tags,
vec!["inhead", "plain", "kebab-case/nested"],
"the deduplicated names are unchanged"
);
}
#[test]
fn tag_ranges_survive_a_table_cell_and_a_callout() {
let body = concat!(
"> [!warning] Careful\n",
"> Tagged #incallout here.\n",
"\n",
"| a | b |\n",
"|---|---|\n",
"| #incell | plain |\n",
);
let got = extract(body, "", &vault());
assert_eq!(
tag_spans(body, &got),
vec![("incallout", "#incallout"), ("incell", "#incell")]
);
}
#[test]
fn a_multibyte_code_span_does_not_shift_the_tags_after_it() {
let body = "Prose `café #nope` then #after it.\n";
let got = extract(body, "", &vault());
assert_eq!(tag_spans(body, &got), vec![("after", "#after")]);
}