Skip to main content

fleuron/content/
anchor.rs

1//! What a link in the book reaches.
2//!
3//! A link names a source and a heading in it, the way an editor that
4//! keeps one note per chapter writes it: `chapter-03.md#the-hunter`,
5//! `Chapter%203.md#The%20Hunter`, or `[[Chapter 3#The Hunter]]`. A
6//! heading anchor is not a CSS id. Two chapters can each have a
7//! heading `Notes`, and a link reaches the one in the source it names.
8
9use 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
14/// The characters a heading and a link both ignore when a link names
15/// the heading by its text. They are the ones Obsidian ignores, and
16/// `_`, which markup writes and the heading text leaves out.
17const IGNORED: &str = "!\"#$%&()*+,.:;<=>?@^`{|}~/[]\\_";
18
19/// What the links in one book can reach: each source, the headings
20/// in it, and every id a source writes.
21#[derive(Debug, Clone, Default)]
22pub struct Anchors {
23    sources: Vec<Source>,
24    /// Every written id, over the whole book. The first node to take
25    /// an id keeps it.
26    written: BTreeMap<String, NodeId>,
27    /// What a warning calls each node that a link can reach.
28    names: BTreeMap<NodeId, String>,
29}
30
31/// What one link reaches.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum LinkTarget {
34    /// A node of the book: a heading, a node with a written id, or the
35    /// first section of a source.
36    Node(NodeId),
37    /// Something outside the book, such as `https://example.com`.
38    Outside,
39    /// A source or a heading that the book does not have.
40    Missing,
41}
42
43/// The sections read from one source.
44#[derive(Debug, Clone)]
45struct Source {
46    name: Option<String>,
47    /// The name as a link matches it: lowercase, `/` between folders,
48    /// and no `.md`.
49    path: Option<String>,
50    /// The section the source opens with.
51    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    /// The text as a link written in text form matches it.
62    text: String,
63    /// The slug before a count, where the source writes no id.
64    slug: Option<String>,
65}
66
67impl Book {
68    /// What the links in this book can reach.
69    ///
70    /// Read it after [`Book::assign_node_ids`], because the nodes it
71    /// gives are the ids that numbering assigns.
72    pub fn anchors(&self) -> Anchors {
73        Anchors::of(self)
74    }
75}
76
77impl Anchors {
78    /// What the links in `book` can reach.
79    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(&section.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    /// The node a link to `url` reaches, where the link is written in
105    /// the source named `from`.
106    ///
107    /// The part of `url` before `#` names a source. It matches a
108    /// source name with no regard to case, with or without `.md`, and
109    /// with percent escapes decoded. A path that starts with `./` or
110    /// `../` is relative to the folder of `from`. Any other path
111    /// matches the end of a source name, and a source in the folder of
112    /// `from` comes first. A link with no source part names `from`.
113    ///
114    /// The part after `#` names a node in that source: an id the
115    /// source writes, the slug of a heading, or the text of a heading.
116    /// Text matches with no regard to case or punctuation, and the
117    /// first heading that matches wins. `#Part#Notes` names a heading
118    /// `Notes` under a heading `Part`. A link with no source part also
119    /// reaches an id that any source writes.
120    ///
121    /// A link with no `#` part reaches the first section of the source.
122    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    /// What a warning calls a node that a link can reach.
158    pub(crate) fn name(&self, node: NodeId) -> Option<&str> {
159        self.names.get(&node).map(String::as_str)
160    }
161
162    /// Whether a link can reach a node.
163    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    /// Gives each heading with no written id its slug. A slug that an
300    /// earlier heading or a written id in the source has takes `-2`,
301    /// then `-3`, until it is free.
302    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
348/// The slug of a heading's text: lowercase, each run of characters
349/// that are not letters or digits as one hyphen, and no hyphen at
350/// either end. Text with no letter or digit has none.
351fn 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
360/// Text as a link in text form matches it: lowercase, each ignored
361/// character as a space, and each run of spaces as one.
362fn 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
374/// A source name or a link path as the two match: lowercase, `/`
375/// between folders, `.` and `..` resolved, and no `.md`.
376fn 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
394/// Whether a url names something outside the book: it starts with a
395/// scheme such as `https:` or `mailto:`, or with `//`.
396fn 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
409/// Text with each percent escape decoded. Text whose escapes do not
410/// decode to UTF-8 stays as it is.
411fn 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    /// The node of the `nth` heading of the book, counted from 0.
490    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(&section.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    /// Slugs count per source. A slug skips the written ids of its own
521    /// source and not those of another.
522    #[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    /// Text form ignores case and punctuation. Markup is not part of
548    /// the text, and the first heading that matches wins.
549    #[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    /// `#Part#Notes` names the first `Notes` deeper than the `Part`
595    /// before it.
596    #[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                // A link with no `#` part reaches the section.
642                _ 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    /// A link with no `#` part reaches the first section of the
654    /// source, when the source is cut into several.
655    #[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    /// A link with no source part reaches an id written in another
672    /// source, after the headings of its own.
673    #[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}