1use std::collections::{BTreeMap, BTreeSet};
10
11use super::{Block, Book, Inline, NodeId, Section, block_attributes, block_id, inline_attributes};
12use super::{inline_id, rows, text};
13
14const IGNORED: &str = "!\"#$%&()*+,.:;<=>?@^`{|}~/[]\\_";
18
19#[derive(Debug, Clone, Default)]
22pub struct Anchors {
23 sources: Vec<Source>,
24 written: BTreeMap<String, NodeId>,
27 names: BTreeMap<NodeId, String>,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum LinkTarget {
34 Node(NodeId),
37 Outside,
39 Missing,
41}
42
43#[derive(Debug, Clone)]
45struct Source {
46 name: Option<String>,
47 path: Option<String>,
50 first: NodeId,
52 written: BTreeMap<String, NodeId>,
53 slugs: BTreeMap<String, NodeId>,
54 headings: Vec<Heading>,
55}
56
57#[derive(Debug, Clone)]
58struct Heading {
59 node: NodeId,
60 level: u8,
61 text: String,
63 slug: Option<String>,
65}
66
67impl Book {
68 pub fn anchors(&self) -> Anchors {
73 Anchors::of(self)
74 }
75}
76
77impl Anchors {
78 pub fn of(book: &Book) -> Anchors {
80 let mut anchors = Anchors::default();
81 let mut index: BTreeMap<Option<&str>, usize> = BTreeMap::new();
82 for section in &book.sections {
83 let at = *index.entry(section.source.as_deref()).or_insert_with(|| {
84 anchors.sources.push(Source::new(section));
85 anchors.sources.len() - 1
86 });
87 let source = &mut anchors.sources[at];
88 source.collect(§ion.blocks);
89 }
90 for source in &mut anchors.sources {
91 source.count_slugs();
92 for (id, node) in &source.written {
93 anchors.written.entry(id.clone()).or_insert(*node);
94 }
95 }
96 let sources = std::mem::take(&mut anchors.sources);
97 for source in &sources {
98 anchors.name_source(source);
99 }
100 anchors.sources = sources;
101 anchors
102 }
103
104 pub fn resolve(&self, url: &str, from: Option<&str>) -> LinkTarget {
123 if url.is_empty() {
124 return LinkTarget::Missing;
125 }
126 if outside(url) {
127 return LinkTarget::Outside;
128 }
129 let (path, fragment) = match url.split_once('#') {
130 Some((path, fragment)) => (path, Some(fragment)),
131 None => (url, None),
132 };
133 let from = self.sources.iter().position(|s| s.name.as_deref() == from);
134 let source = if path.is_empty() {
135 from
136 } else {
137 match self.find(&decode(path), from) {
138 Some(source) => Some(source),
139 None => return LinkTarget::Missing,
140 }
141 };
142 let fragment = fragment.map(decode).filter(|fragment| !fragment.is_empty());
143 let found = match &fragment {
144 None if path.is_empty() => None,
145 None => source.map(|source| self.sources[source].first),
146 Some(fragment) => source
147 .and_then(|source| self.sources[source].find(fragment))
148 .or_else(|| {
149 path.is_empty()
150 .then(|| self.written.get(fragment).copied())
151 .flatten()
152 }),
153 };
154 found.map_or(LinkTarget::Missing, LinkTarget::Node)
155 }
156
157 pub(crate) fn name(&self, node: NodeId) -> Option<&str> {
159 self.names.get(&node).map(String::as_str)
160 }
161
162 pub(crate) fn reaches(&self, node: NodeId) -> bool {
164 self.names.contains_key(&node)
165 }
166
167 fn find(&self, path: &str, from: Option<usize>) -> Option<usize> {
168 let folder = from
169 .and_then(|from| self.sources[from].path.as_deref())
170 .and_then(|path| path.rsplit_once('/'))
171 .map_or("", |(folder, _)| folder);
172 let relative = path.starts_with("./") || path.starts_with("../");
173 let rooted = path.starts_with('/');
174 let wanted = match (relative, folder.is_empty()) {
175 (true, false) => path_key(&format!("{folder}/{path}")),
176 (true, true) => path_key(path),
177 (false, _) => path_key(path.trim_start_matches('/')),
178 };
179 let named = || {
180 self.sources
181 .iter()
182 .enumerate()
183 .filter_map(|(at, source)| Some((at, source.path.as_deref()?)))
184 };
185 if let Some((at, _)) = named().find(|(_, path)| *path == wanted) {
186 return Some(at);
187 }
188 if relative || rooted || wanted.is_empty() {
189 return None;
190 }
191 let ending = format!("/{wanted}");
192 let within = format!("{folder}/");
193 let ends = || named().filter(|(_, path)| path.ends_with(&ending));
194 ends()
195 .find(|(_, path)| !folder.is_empty() && path.starts_with(&within))
196 .or_else(|| ends().next())
197 .map(|(at, _)| at)
198 }
199
200 fn name_source(&mut self, source: &Source) {
201 let prefix = source.name.as_deref().unwrap_or_default();
202 if let Some(name) = &source.name {
203 self.names.insert(source.first, name.clone());
204 }
205 for (id, node) in &source.written {
206 self.names.entry(*node).or_insert_with(|| id.clone());
207 }
208 for (slug, node) in &source.slugs {
209 self.names
210 .entry(*node)
211 .or_insert_with(|| format!("{prefix}#{slug}"));
212 }
213 for heading in source.headings.iter().filter(|h| !h.text.is_empty()) {
214 self.names
215 .entry(heading.node)
216 .or_insert_with(|| format!("{prefix}#{}", heading.text));
217 }
218 }
219}
220
221impl Source {
222 fn new(section: &Section) -> Source {
223 Source {
224 name: section.source.clone(),
225 path: section.source.as_deref().map(path_key),
226 first: section.id,
227 written: BTreeMap::new(),
228 slugs: BTreeMap::new(),
229 headings: Vec::new(),
230 }
231 }
232
233 fn collect(&mut self, blocks: &[Block]) {
234 for block in blocks {
235 self.write(block_id(block), &block_attributes(block).id);
236 match block {
237 Block::Heading {
238 id,
239 level,
240 inlines,
241 attributes,
242 ..
243 } => {
244 let words = text(inlines);
245 self.headings.push(Heading {
246 node: *id,
247 level: u8::from(*level),
248 text: matched(&words),
249 slug: attributes.id.is_none().then(|| slug(&words)).flatten(),
250 });
251 self.collect_inlines(inlines);
252 }
253 Block::Paragraph { inlines, .. } => self.collect_inlines(inlines),
254 Block::Blockquote { blocks, .. } => self.collect(blocks),
255 Block::List { items, .. } => {
256 for item in items {
257 self.write(item.id, &item.attributes.id);
258 self.collect(&item.blocks);
259 }
260 }
261 Block::Table { head, body, .. } => {
262 for row in rows(head, body) {
263 self.write(row.id, &row.attributes.id);
264 for cell in &row.cells {
265 self.write(cell.id, &cell.attributes.id);
266 self.collect(&cell.blocks);
267 }
268 }
269 }
270 Block::CodeBlock { .. }
271 | Block::ThematicBreak { .. }
272 | Block::PageBreak { .. }
273 | Block::ColumnBreak { .. }
274 | Block::Image { .. } => {}
275 }
276 }
277 }
278
279 fn collect_inlines(&mut self, inlines: &[Inline]) {
280 for inline in inlines {
281 self.write(inline_id(inline), &inline_attributes(inline).id);
282 if let Inline::Emphasis { children, .. }
283 | Inline::Strong { children, .. }
284 | Inline::Link { children, .. }
285 | Inline::Strikethrough { children, .. }
286 | Inline::Span { children, .. } = inline
287 {
288 self.collect_inlines(children);
289 }
290 }
291 }
292
293 fn write(&mut self, node: NodeId, id: &Option<String>) {
294 if let Some(id) = id {
295 self.written.entry(id.clone()).or_insert(node);
296 }
297 }
298
299 fn count_slugs(&mut self) {
303 let mut taken: BTreeSet<String> = self.written.keys().cloned().collect();
304 for heading in &self.headings {
305 let Some(base) = &heading.slug else {
306 continue;
307 };
308 let mut slug = base.clone();
309 let mut count = 2;
310 while taken.contains(&slug) {
311 slug = format!("{base}-{count}");
312 count += 1;
313 }
314 taken.insert(slug.clone());
315 self.slugs.insert(slug, heading.node);
316 }
317 }
318
319 fn find(&self, fragment: &str) -> Option<NodeId> {
320 if let Some(node) = self
321 .written
322 .get(fragment)
323 .or_else(|| self.slugs.get(fragment))
324 {
325 return Some(*node);
326 }
327 let wanted: Vec<String> = fragment
328 .split('#')
329 .map(matched)
330 .filter(|text| !text.is_empty())
331 .collect();
332 let mut wanted = wanted.iter();
333 let mut next = wanted.next()?;
334 let mut level = 0;
335 for heading in &self.headings {
336 if heading.level > level && heading.text == *next {
337 level = heading.level;
338 match wanted.next() {
339 Some(text) => next = text,
340 None => return Some(heading.node),
341 }
342 }
343 }
344 None
345 }
346}
347
348fn slug(text: &str) -> Option<String> {
352 let lower = text.to_lowercase();
353 let words: Vec<&str> = lower
354 .split(|c: char| !c.is_alphanumeric())
355 .filter(|word| !word.is_empty())
356 .collect();
357 (!words.is_empty()).then(|| words.join("-"))
358}
359
360fn matched(text: &str) -> String {
363 let spaced: String = text
364 .chars()
365 .map(|c| if IGNORED.contains(c) { ' ' } else { c })
366 .collect();
367 spaced
368 .split_whitespace()
369 .collect::<Vec<_>>()
370 .join(" ")
371 .to_lowercase()
372}
373
374fn path_key(path: &str) -> String {
377 let lower = path.replace('\\', "/").to_lowercase();
378 let lower = lower.strip_suffix(".md").unwrap_or(&lower);
379 let rooted = lower.starts_with('/');
380 let mut parts: Vec<&str> = Vec::new();
381 for part in lower.split('/') {
382 match part {
383 "" | "." => {}
384 ".." if parts.last().is_some_and(|last| *last != "..") => {
385 parts.pop();
386 }
387 part => parts.push(part),
388 }
389 }
390 let joined = parts.join("/");
391 if rooted { format!("/{joined}") } else { joined }
392}
393
394fn outside(url: &str) -> bool {
397 if url.starts_with("//") {
398 return true;
399 }
400 let Some((scheme, _)) = url.split_once(':') else {
401 return false;
402 };
403 scheme.starts_with(|c: char| c.is_ascii_alphabetic())
404 && scheme
405 .chars()
406 .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
407}
408
409fn decode(text: &str) -> String {
412 let bytes = text.as_bytes();
413 let mut out = Vec::with_capacity(bytes.len());
414 let mut at = 0;
415 while at < bytes.len() {
416 let escape = bytes
417 .get(at + 1..at + 3)
418 .filter(|hex| bytes[at] == b'%' && hex.iter().all(u8::is_ascii_hexdigit));
419 match escape {
420 Some(hex) => {
421 let hex = std::str::from_utf8(hex).expect("hex digits are ASCII");
422 out.push(u8::from_str_radix(hex, 16).expect("two hex digits are a byte"));
423 at += 3;
424 }
425 None => {
426 out.push(bytes[at]);
427 at += 1;
428 }
429 }
430 }
431 String::from_utf8(out).unwrap_or_else(|_| text.to_string())
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::content::{Attributes, HeadingLevel};
438
439 fn words(value: &str) -> Inline {
440 Inline::Text {
441 id: NodeId::UNASSIGNED,
442 value: value.into(),
443 attributes: Attributes::default(),
444 position: None,
445 span: None,
446 }
447 }
448
449 fn heading_at(level: HeadingLevel, inlines: Vec<Inline>, written: Option<&str>) -> Block {
450 Block::Heading {
451 id: NodeId::UNASSIGNED,
452 level,
453 inlines,
454 attributes: Attributes {
455 id: written.map(Into::into),
456 classes: Vec::new(),
457 },
458 position: None,
459 span: None,
460 }
461 }
462
463 fn h1(title: &str) -> Block {
464 heading_at(HeadingLevel::H1, vec![words(title)], None)
465 }
466
467 fn h2(title: &str) -> Block {
468 heading_at(HeadingLevel::H2, vec![words(title)], None)
469 }
470
471 fn source(name: Option<&str>, blocks: Vec<Block>) -> Section {
472 Section {
473 attributes: Default::default(),
474 source: name.map(Into::into),
475 blocks,
476 ..Section::default()
477 }
478 }
479
480 fn book(sections: Vec<Section>) -> Book {
481 let mut book = Book {
482 sections,
483 ..Book::default()
484 };
485 book.assign_node_ids();
486 book
487 }
488
489 fn heading(book: &Book, nth: usize) -> LinkTarget {
491 let mut headings = Vec::new();
492 fn each(blocks: &[Block], out: &mut Vec<NodeId>) {
493 for block in blocks {
494 match block {
495 Block::Heading { id, .. } => out.push(*id),
496 Block::Blockquote { blocks, .. } => each(blocks, out),
497 _ => {}
498 }
499 }
500 }
501 for section in &book.sections {
502 each(§ion.blocks, &mut headings);
503 }
504 LinkTarget::Node(headings[nth])
505 }
506
507 #[test]
508 fn a_slug_is_the_text_lowercased_with_hyphens() {
509 for (title, want) in [
510 ("The Hunter", Some("the-hunter")),
511 (" Chapter 1: “Arrival”! ", Some("chapter-1-arrival")),
512 ("Café Noir", Some("café-noir")),
513 ("— ? —", None),
514 ("", None),
515 ] {
516 assert_eq!(slug(title).as_deref(), want, "{title:?}");
517 }
518 }
519
520 #[test]
523 fn slugs_count_per_source() {
524 let book = book(vec![
525 source(Some("one.md"), vec![h1("Notes"), h1("Notes")]),
526 source(
527 Some("two.md"),
528 vec![
529 h1("Notes"),
530 heading_at(HeadingLevel::H1, vec![words("Hunt")], Some("hunt")),
531 h1("Hunt"),
532 ],
533 ),
534 source(Some("three.md"), vec![h1("Hunt")]),
535 ]);
536 let anchors = book.anchors();
537 let at = |url, from| anchors.resolve(url, Some(from));
538 assert_eq!(at("#notes", "one.md"), heading(&book, 0));
539 assert_eq!(at("#notes-2", "one.md"), heading(&book, 1));
540 assert_eq!(at("#notes", "two.md"), heading(&book, 2));
541 assert_eq!(at("#notes-2", "two.md"), LinkTarget::Missing);
542 assert_eq!(at("#hunt", "two.md"), heading(&book, 3));
543 assert_eq!(at("#hunt-2", "two.md"), heading(&book, 4));
544 assert_eq!(at("#hunt", "three.md"), heading(&book, 5));
545 }
546
547 #[test]
550 fn text_form_matches_as_obsidian_does() {
551 let book = book(vec![source(
552 Some("note.md"),
553 vec![
554 heading_at(
555 HeadingLevel::H2,
556 vec![
557 words("What's "),
558 Inline::Emphasis {
559 id: NodeId::UNASSIGNED,
560 children: vec![words("new")],
561 attributes: Attributes::default(),
562 position: None,
563 span: None,
564 },
565 words("?"),
566 ],
567 None,
568 ),
569 h2("A: B / C"),
570 h2("A B C"),
571 ],
572 )]);
573 let anchors = book.anchors();
574 for (url, nth) in [
575 ("#What's *new*?", 0),
576 ("#what's new", 0),
577 ("#What's%20_new_", 0),
578 ("#A B / C", 1),
579 ("#a b c", 1),
580 ("#a-b-c-2", 2),
581 ] {
582 assert_eq!(
583 anchors.resolve(url, Some("note.md")),
584 heading(&book, nth),
585 "{url}"
586 );
587 }
588 assert_eq!(
589 anchors.resolve("#whats new", Some("note.md")),
590 LinkTarget::Missing
591 );
592 }
593
594 #[test]
597 fn a_nested_heading_follows_its_parents() {
598 let book = book(vec![source(
599 Some("note.md"),
600 vec![
601 h2("Notes"),
602 h1("Part"),
603 h2("Notes"),
604 h1("Other"),
605 h2("Notes"),
606 ],
607 )]);
608 let anchors = book.anchors();
609 let at = |url| anchors.resolve(url, Some("note.md"));
610 assert_eq!(at("#Notes"), heading(&book, 0));
611 assert_eq!(at("#Part#Notes"), heading(&book, 2));
612 assert_eq!(at("#Other#Notes"), heading(&book, 4));
613 assert_eq!(at("#Notes#Part"), LinkTarget::Missing);
614 }
615
616 #[test]
617 fn a_file_matches_as_obsidian_finds_it() {
618 let book = book(vec![
619 source(Some("vault/part-1/Chapter 3.md"), vec![h1("One")]),
620 source(Some("vault/part-2/Chapter 3.md"), vec![h1("Two")]),
621 source(Some("vault/part-2/notes.md"), vec![h1("Notes")]),
622 source(Some("vault/index.md"), vec![h1("Index")]),
623 ]);
624 let anchors = book.anchors();
625 let at = |url, from| anchors.resolve(url, Some(from));
626 let notes = "vault/part-2/notes.md";
627 let index = "vault/index.md";
628 for (url, from, nth) in [
629 ("Chapter 3#One", index, 0),
630 ("chapter 3.MD#one", index, 0),
631 ("Chapter%203.md#One", index, 0),
632 ("Chapter 3#Two", notes, 1),
633 ("./Chapter 3.md#Two", notes, 1),
634 ("../part-1/Chapter 3#One", notes, 0),
635 ("part-2/chapter 3#two", index, 1),
636 ("vault/part-2/Chapter 3#Two", index, 1),
637 ("notes", index, 2),
638 ] {
639 let found = anchors.resolve(url, Some(from));
640 match heading(&book, nth) {
641 _ if !url.contains('#') => {
643 assert_eq!(found, LinkTarget::Node(book.sections[nth].id), "{url}")
644 }
645 want => assert_eq!(found, want, "{url} from {from}"),
646 }
647 }
648 assert_eq!(at("./Chapter 3#One", notes), LinkTarget::Missing);
649 assert_eq!(at("/part-2/notes", index), LinkTarget::Missing);
650 assert_eq!(at("elsewhere.md#One", index), LinkTarget::Missing);
651 }
652
653 #[test]
656 fn a_file_alone_reaches_its_first_section() {
657 let book = book(vec![
658 source(Some("one.md"), vec![h1("A")]),
659 source(Some("two.md"), vec![h1("B")]),
660 source(Some("two.md"), vec![h1("C")]),
661 ]);
662 let anchors = book.anchors();
663 assert_eq!(
664 anchors.resolve("two", Some("one.md")),
665 LinkTarget::Node(book.sections[1].id)
666 );
667 assert_eq!(anchors.name(book.sections[1].id), Some("two.md"));
668 assert!(!anchors.reaches(book.sections[2].id));
669 }
670
671 #[test]
674 fn a_written_id_reaches_across_sources() {
675 let chase = heading_at(HeadingLevel::H1, vec![words("Chase")], Some("hunt"));
676 let book = book(vec![
677 source(Some("one.md"), vec![chase]),
678 source(Some("two.md"), vec![h1("Other")]),
679 source(Some("three.md"), vec![h1("Hunt")]),
680 ]);
681 let anchors = book.anchors();
682 assert_eq!(anchors.resolve("#hunt", Some("two.md")), heading(&book, 0));
683 assert_eq!(
684 anchors.resolve("#hunt", Some("three.md")),
685 heading(&book, 2)
686 );
687 assert_eq!(
688 anchors.resolve("two.md#hunt", Some("one.md")),
689 LinkTarget::Missing
690 );
691 assert_eq!(
692 anchors.name(block_id(&book.sections[0].blocks[0])),
693 Some("hunt")
694 );
695 assert_eq!(
696 anchors.name(block_id(&book.sections[2].blocks[0])),
697 Some("three.md#hunt")
698 );
699 }
700
701 #[test]
702 fn a_url_with_a_scheme_is_outside() {
703 let book = book(vec![source(None, vec![h1("A")])]);
704 let anchors = book.anchors();
705 for url in ["https://example.com", "mailto:a@b.c", "//example.com/x"] {
706 assert_eq!(anchors.resolve(url, None), LinkTarget::Outside, "{url}");
707 }
708 for url in ["", "#", "#b", "a.md", "A B#c"] {
709 assert_eq!(anchors.resolve(url, None), LinkTarget::Missing, "{url}");
710 }
711 assert_eq!(anchors.resolve("#a", None), heading(&book, 0));
712 }
713
714 #[test]
715 fn a_percent_escape_decodes() {
716 assert_eq!(decode("Chapter%203"), "Chapter 3");
717 assert_eq!(decode("caf%C3%A9"), "café");
718 assert_eq!(decode("100% sure%2"), "100% sure%2");
719 assert_eq!(decode("%FF"), "%FF");
720 }
721}