mdbook/preprocess/
links.rs

1use crate::errors::*;
2use crate::utils::{
3    take_anchored_lines, take_lines, take_rustdoc_include_anchored_lines,
4    take_rustdoc_include_lines,
5};
6use regex::{CaptureMatches, Captures, Regex};
7use std::fs;
8use std::ops::{Bound, Range, RangeBounds, RangeFrom, RangeFull, RangeTo};
9use std::path::{Path, PathBuf};
10
11use super::{Preprocessor, PreprocessorContext};
12use crate::book::{Book, BookItem};
13use log::{error, warn};
14use once_cell::sync::Lazy;
15
16const ESCAPE_CHAR: char = '\\';
17const MAX_LINK_NESTED_DEPTH: usize = 10;
18
19/// A preprocessor for expanding helpers in a chapter. Supported helpers are:
20///
21/// - `{{# include}}` - Insert an external file of any type. Include the whole file, only particular
22///.  lines, or only between the specified anchors.
23/// - `{{# rustdoc_include}}` - Insert an external Rust file, showing the particular lines
24///.  specified or the lines between specified anchors, and include the rest of the file behind `#`.
25///   This hides the lines from initial display but shows them when the reader expands the code
26///   block and provides them to Rustdoc for testing.
27/// - `{{# playground}}` - Insert runnable Rust files
28/// - `{{# title}}` - Override \<title\> of a webpage.
29#[derive(Default)]
30pub struct LinkPreprocessor;
31
32impl LinkPreprocessor {
33    pub(crate) const NAME: &'static str = "links";
34
35    /// Create a new `LinkPreprocessor`.
36    pub fn new() -> Self {
37        LinkPreprocessor
38    }
39}
40
41impl Preprocessor for LinkPreprocessor {
42    fn name(&self) -> &str {
43        Self::NAME
44    }
45
46    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
47        let src_dir = ctx.root.join(&ctx.config.book.src);
48
49        book.for_each_mut(|section: &mut BookItem| {
50            if let BookItem::Chapter(ref mut ch) = *section {
51                if let Some(ref chapter_path) = ch.path {
52                    let base = chapter_path
53                        .parent()
54                        .map(|dir| src_dir.join(dir))
55                        .expect("All book items have a parent");
56
57                    let mut chapter_title = ch.name.clone();
58                    let content =
59                        replace_all(&ch.content, base, chapter_path, 0, &mut chapter_title);
60                    ch.content = content;
61                    if chapter_title != ch.name {
62                        ctx.chapter_titles
63                            .borrow_mut()
64                            .insert(chapter_path.clone(), chapter_title);
65                    }
66                }
67            }
68        });
69
70        Ok(book)
71    }
72}
73
74fn replace_all<P1, P2>(
75    s: &str,
76    path: P1,
77    source: P2,
78    depth: usize,
79    chapter_title: &mut String,
80) -> String
81where
82    P1: AsRef<Path>,
83    P2: AsRef<Path>,
84{
85    // When replacing one thing in a string by something with a different length,
86    // the indices after that will not correspond,
87    // we therefore have to store the difference to correct this
88    let path = path.as_ref();
89    let source = source.as_ref();
90    let mut previous_end_index = 0;
91    let mut replaced = String::new();
92
93    for link in find_links(s) {
94        replaced.push_str(&s[previous_end_index..link.start_index]);
95
96        match link.render_with_path(&path, chapter_title) {
97            Ok(new_content) => {
98                if depth < MAX_LINK_NESTED_DEPTH {
99                    if let Some(rel_path) = link.link_type.relative_path(path) {
100                        replaced.push_str(&replace_all(
101                            &new_content,
102                            rel_path,
103                            source,
104                            depth + 1,
105                            chapter_title,
106                        ));
107                    } else {
108                        replaced.push_str(&new_content);
109                    }
110                } else {
111                    error!(
112                        "Stack depth exceeded in {}. Check for cyclic includes",
113                        source.display()
114                    );
115                }
116                previous_end_index = link.end_index;
117            }
118            Err(e) => {
119                error!("Error updating \"{}\", {}", link.link_text, e);
120                for cause in e.chain().skip(1) {
121                    warn!("Caused By: {}", cause);
122                }
123
124                // This should make sure we include the raw `{{# ... }}` snippet
125                // in the page content if there are any errors.
126                previous_end_index = link.start_index;
127            }
128        }
129    }
130
131    replaced.push_str(&s[previous_end_index..]);
132    replaced
133}
134
135#[derive(PartialEq, Debug, Clone)]
136enum LinkType<'a> {
137    Escaped,
138    Include(PathBuf, RangeOrAnchor),
139    Playground(PathBuf, Vec<&'a str>),
140    RustdocInclude(PathBuf, RangeOrAnchor),
141    Title(&'a str),
142}
143
144#[derive(PartialEq, Debug, Clone)]
145enum RangeOrAnchor {
146    Range(LineRange),
147    Anchor(String),
148}
149
150// A range of lines specified with some include directive.
151#[allow(clippy::enum_variant_names)] // The prefix can't be removed, and is meant to mirror the contained type
152#[derive(PartialEq, Debug, Clone)]
153enum LineRange {
154    Range(Range<usize>),
155    RangeFrom(RangeFrom<usize>),
156    RangeTo(RangeTo<usize>),
157    RangeFull(RangeFull),
158}
159
160impl RangeBounds<usize> for LineRange {
161    fn start_bound(&self) -> Bound<&usize> {
162        match self {
163            LineRange::Range(r) => r.start_bound(),
164            LineRange::RangeFrom(r) => r.start_bound(),
165            LineRange::RangeTo(r) => r.start_bound(),
166            LineRange::RangeFull(r) => r.start_bound(),
167        }
168    }
169
170    fn end_bound(&self) -> Bound<&usize> {
171        match self {
172            LineRange::Range(r) => r.end_bound(),
173            LineRange::RangeFrom(r) => r.end_bound(),
174            LineRange::RangeTo(r) => r.end_bound(),
175            LineRange::RangeFull(r) => r.end_bound(),
176        }
177    }
178}
179
180impl From<Range<usize>> for LineRange {
181    fn from(r: Range<usize>) -> LineRange {
182        LineRange::Range(r)
183    }
184}
185
186impl From<RangeFrom<usize>> for LineRange {
187    fn from(r: RangeFrom<usize>) -> LineRange {
188        LineRange::RangeFrom(r)
189    }
190}
191
192impl From<RangeTo<usize>> for LineRange {
193    fn from(r: RangeTo<usize>) -> LineRange {
194        LineRange::RangeTo(r)
195    }
196}
197
198impl From<RangeFull> for LineRange {
199    fn from(r: RangeFull) -> LineRange {
200        LineRange::RangeFull(r)
201    }
202}
203
204impl<'a> LinkType<'a> {
205    fn relative_path<P: AsRef<Path>>(self, base: P) -> Option<PathBuf> {
206        let base = base.as_ref();
207        match self {
208            LinkType::Escaped => None,
209            LinkType::Include(p, _) => Some(return_relative_path(base, &p)),
210            LinkType::Playground(p, _) => Some(return_relative_path(base, &p)),
211            LinkType::RustdocInclude(p, _) => Some(return_relative_path(base, &p)),
212            LinkType::Title(_) => None,
213        }
214    }
215}
216fn return_relative_path<P: AsRef<Path>>(base: P, relative: P) -> PathBuf {
217    base.as_ref()
218        .join(relative)
219        .parent()
220        .expect("Included file should not be /")
221        .to_path_buf()
222}
223
224fn parse_range_or_anchor(parts: Option<&str>) -> RangeOrAnchor {
225    let mut parts = parts.unwrap_or("").splitn(3, ':').fuse();
226
227    let next_element = parts.next();
228    let start = if let Some(value) = next_element.and_then(|s| s.parse::<usize>().ok()) {
229        // subtract 1 since line numbers usually begin with 1
230        Some(value.saturating_sub(1))
231    } else if let Some("") = next_element {
232        None
233    } else if let Some(anchor) = next_element {
234        return RangeOrAnchor::Anchor(String::from(anchor));
235    } else {
236        None
237    };
238
239    let end = parts.next();
240    // If `end` is empty string or any other value that can't be parsed as a usize, treat this
241    // include as a range with only a start bound. However, if end isn't specified, include only
242    // the single line specified by `start`.
243    let end = end.map(|s| s.parse::<usize>());
244
245    match (start, end) {
246        (Some(start), Some(Ok(end))) => RangeOrAnchor::Range(LineRange::from(start..end)),
247        (Some(start), Some(Err(_))) => RangeOrAnchor::Range(LineRange::from(start..)),
248        (Some(start), None) => RangeOrAnchor::Range(LineRange::from(start..start + 1)),
249        (None, Some(Ok(end))) => RangeOrAnchor::Range(LineRange::from(..end)),
250        (None, None) | (None, Some(Err(_))) => RangeOrAnchor::Range(LineRange::from(RangeFull)),
251    }
252}
253
254fn parse_include_path(path: &str) -> LinkType<'static> {
255    let mut parts = path.splitn(2, ':');
256
257    let path = parts.next().unwrap().into();
258    let range_or_anchor = parse_range_or_anchor(parts.next());
259
260    LinkType::Include(path, range_or_anchor)
261}
262
263fn parse_rustdoc_include_path(path: &str) -> LinkType<'static> {
264    let mut parts = path.splitn(2, ':');
265
266    let path = parts.next().unwrap().into();
267    let range_or_anchor = parse_range_or_anchor(parts.next());
268
269    LinkType::RustdocInclude(path, range_or_anchor)
270}
271
272#[derive(PartialEq, Debug, Clone)]
273struct Link<'a> {
274    start_index: usize,
275    end_index: usize,
276    link_type: LinkType<'a>,
277    link_text: &'a str,
278}
279
280impl<'a> Link<'a> {
281    fn from_capture(cap: Captures<'a>) -> Option<Link<'a>> {
282        let link_type = match (cap.get(0), cap.get(1), cap.get(2)) {
283            (_, Some(typ), Some(title)) if typ.as_str() == "title" => {
284                Some(LinkType::Title(title.as_str()))
285            }
286            (_, Some(typ), Some(rest)) => {
287                let mut path_props = rest.as_str().split_whitespace();
288                let file_arg = path_props.next();
289                let props: Vec<&str> = path_props.collect();
290
291                match (typ.as_str(), file_arg) {
292                    ("include", Some(pth)) => Some(parse_include_path(pth)),
293                    ("playground", Some(pth)) => Some(LinkType::Playground(pth.into(), props)),
294                    ("playpen", Some(pth)) => {
295                        warn!(
296                            "the {{{{#playpen}}}} expression has been \
297                            renamed to {{{{#playground}}}}, \
298                            please update your book to use the new name"
299                        );
300                        Some(LinkType::Playground(pth.into(), props))
301                    }
302                    ("rustdoc_include", Some(pth)) => Some(parse_rustdoc_include_path(pth)),
303                    _ => None,
304                }
305            }
306            (Some(mat), None, None) if mat.as_str().starts_with(ESCAPE_CHAR) => {
307                Some(LinkType::Escaped)
308            }
309            _ => None,
310        };
311
312        link_type.and_then(|lnk_type| {
313            cap.get(0).map(|mat| Link {
314                start_index: mat.start(),
315                end_index: mat.end(),
316                link_type: lnk_type,
317                link_text: mat.as_str(),
318            })
319        })
320    }
321
322    fn render_with_path<P: AsRef<Path>>(
323        &self,
324        base: P,
325        chapter_title: &mut String,
326    ) -> Result<String> {
327        let base = base.as_ref();
328        match self.link_type {
329            // omit the escape char
330            LinkType::Escaped => Ok((&self.link_text[1..]).to_owned()),
331            LinkType::Include(ref pat, ref range_or_anchor) => {
332                let target = base.join(pat);
333
334                fs::read_to_string(&target)
335                    .map(|s| match range_or_anchor {
336                        RangeOrAnchor::Range(range) => take_lines(&s, range.clone()),
337                        RangeOrAnchor::Anchor(anchor) => take_anchored_lines(&s, anchor),
338                    })
339                    .with_context(|| {
340                        format!(
341                            "Could not read file for link {} ({})",
342                            self.link_text,
343                            target.display(),
344                        )
345                    })
346            }
347            LinkType::RustdocInclude(ref pat, ref range_or_anchor) => {
348                let target = base.join(pat);
349
350                fs::read_to_string(&target)
351                    .map(|s| match range_or_anchor {
352                        RangeOrAnchor::Range(range) => {
353                            take_rustdoc_include_lines(&s, range.clone())
354                        }
355                        RangeOrAnchor::Anchor(anchor) => {
356                            take_rustdoc_include_anchored_lines(&s, anchor)
357                        }
358                    })
359                    .with_context(|| {
360                        format!(
361                            "Could not read file for link {} ({})",
362                            self.link_text,
363                            target.display(),
364                        )
365                    })
366            }
367            LinkType::Playground(ref pat, ref attrs) => {
368                let target = base.join(pat);
369
370                let mut contents = fs::read_to_string(&target).with_context(|| {
371                    format!(
372                        "Could not read file for link {} ({})",
373                        self.link_text,
374                        target.display()
375                    )
376                })?;
377                let ftype = if !attrs.is_empty() { "rust," } else { "rust" };
378                if !contents.ends_with('\n') {
379                    contents.push('\n');
380                }
381                Ok(format!(
382                    "```{}{}\n{}```\n",
383                    ftype,
384                    attrs.join(","),
385                    contents
386                ))
387            }
388            LinkType::Title(title) => {
389                *chapter_title = title.to_owned();
390                Ok(String::new())
391            }
392        }
393    }
394}
395
396struct LinkIter<'a>(CaptureMatches<'a, 'a>);
397
398impl<'a> Iterator for LinkIter<'a> {
399    type Item = Link<'a>;
400    fn next(&mut self) -> Option<Link<'a>> {
401        for cap in &mut self.0 {
402            if let Some(inc) = Link::from_capture(cap) {
403                return Some(inc);
404            }
405        }
406        None
407    }
408}
409
410fn find_links(contents: &str) -> LinkIter<'_> {
411    // lazily compute following regex
412    // r"\\\{\{#.*\}\}|\{\{#([a-zA-Z0-9]+)\s*([^}]+)\}\}")?;
413    static RE: Lazy<Regex> = Lazy::new(|| {
414        Regex::new(
415            r"(?x)              # insignificant whitespace mode
416        \\\{\{\#.*\}\}      # match escaped link
417        |                   # or
418        \{\{\s*             # link opening parens and whitespace
419        \#([a-zA-Z0-9_]+)   # link type
420        \s+                 # separating whitespace
421        ([^}]+)             # link target path and space separated properties
422        \}\}                # link closing parens",
423        )
424        .unwrap()
425    });
426
427    LinkIter(RE.captures_iter(contents))
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_replace_all_escaped() {
436        let start = r"
437        Some text over here.
438        ```hbs
439        \{{#include file.rs}} << an escaped link!
440        ```";
441        let end = r"
442        Some text over here.
443        ```hbs
444        {{#include file.rs}} << an escaped link!
445        ```";
446        let mut chapter_title = "test_replace_all_escaped".to_owned();
447        assert_eq!(replace_all(start, "", "", 0, &mut chapter_title), end);
448    }
449
450    #[test]
451    fn test_set_chapter_title() {
452        let start = r"{{#title My Title}}
453        # My Chapter
454        ";
455        let end = r"
456        # My Chapter
457        ";
458        let mut chapter_title = "test_set_chapter_title".to_owned();
459        assert_eq!(replace_all(start, "", "", 0, &mut chapter_title), end);
460        assert_eq!(chapter_title, "My Title");
461    }
462
463    #[test]
464    fn test_find_links_no_link() {
465        let s = "Some random text without link...";
466        assert!(find_links(s).collect::<Vec<_>>() == vec![]);
467    }
468
469    #[test]
470    fn test_find_links_partial_link() {
471        let s = "Some random text with {{#playground...";
472        assert!(find_links(s).collect::<Vec<_>>() == vec![]);
473        let s = "Some random text with {{#include...";
474        assert!(find_links(s).collect::<Vec<_>>() == vec![]);
475        let s = "Some random text with \\{{#include...";
476        assert!(find_links(s).collect::<Vec<_>>() == vec![]);
477    }
478
479    #[test]
480    fn test_find_links_empty_link() {
481        let s = "Some random text with {{#playground}} and {{#playground   }} {{}} {{#}}...";
482        assert!(find_links(s).collect::<Vec<_>>() == vec![]);
483    }
484
485    #[test]
486    fn test_find_links_unknown_link_type() {
487        let s = "Some random text with {{#playgroundz ar.rs}} and {{#incn}} {{baz}} {{#bar}}...";
488        assert!(find_links(s).collect::<Vec<_>>() == vec![]);
489    }
490
491    #[test]
492    fn test_find_links_simple_link() {
493        let s = "Some random text with {{#playground file.rs}} and {{#playground test.rs }}...";
494
495        let res = find_links(s).collect::<Vec<_>>();
496        println!("\nOUTPUT: {:?}\n", res);
497
498        assert_eq!(
499            res,
500            vec![
501                Link {
502                    start_index: 22,
503                    end_index: 45,
504                    link_type: LinkType::Playground(PathBuf::from("file.rs"), vec![]),
505                    link_text: "{{#playground file.rs}}",
506                },
507                Link {
508                    start_index: 50,
509                    end_index: 74,
510                    link_type: LinkType::Playground(PathBuf::from("test.rs"), vec![]),
511                    link_text: "{{#playground test.rs }}",
512                },
513            ]
514        );
515    }
516
517    #[test]
518    fn test_find_links_with_special_characters() {
519        let s = "Some random text with {{#playground foo-bar\\baz/_c++.rs}}...";
520
521        let res = find_links(s).collect::<Vec<_>>();
522        println!("\nOUTPUT: {:?}\n", res);
523
524        assert_eq!(
525            res,
526            vec![Link {
527                start_index: 22,
528                end_index: 57,
529                link_type: LinkType::Playground(PathBuf::from("foo-bar\\baz/_c++.rs"), vec![]),
530                link_text: "{{#playground foo-bar\\baz/_c++.rs}}",
531            },]
532        );
533    }
534
535    #[test]
536    fn test_find_links_with_range() {
537        let s = "Some random text with {{#include file.rs:10:20}}...";
538        let res = find_links(s).collect::<Vec<_>>();
539        println!("\nOUTPUT: {:?}\n", res);
540        assert_eq!(
541            res,
542            vec![Link {
543                start_index: 22,
544                end_index: 48,
545                link_type: LinkType::Include(
546                    PathBuf::from("file.rs"),
547                    RangeOrAnchor::Range(LineRange::from(9..20))
548                ),
549                link_text: "{{#include file.rs:10:20}}",
550            }]
551        );
552    }
553
554    #[test]
555    fn test_find_links_with_line_number() {
556        let s = "Some random text with {{#include file.rs:10}}...";
557        let res = find_links(s).collect::<Vec<_>>();
558        println!("\nOUTPUT: {:?}\n", res);
559        assert_eq!(
560            res,
561            vec![Link {
562                start_index: 22,
563                end_index: 45,
564                link_type: LinkType::Include(
565                    PathBuf::from("file.rs"),
566                    RangeOrAnchor::Range(LineRange::from(9..10))
567                ),
568                link_text: "{{#include file.rs:10}}",
569            }]
570        );
571    }
572
573    #[test]
574    fn test_find_links_with_from_range() {
575        let s = "Some random text with {{#include file.rs:10:}}...";
576        let res = find_links(s).collect::<Vec<_>>();
577        println!("\nOUTPUT: {:?}\n", res);
578        assert_eq!(
579            res,
580            vec![Link {
581                start_index: 22,
582                end_index: 46,
583                link_type: LinkType::Include(
584                    PathBuf::from("file.rs"),
585                    RangeOrAnchor::Range(LineRange::from(9..))
586                ),
587                link_text: "{{#include file.rs:10:}}",
588            }]
589        );
590    }
591
592    #[test]
593    fn test_find_links_with_to_range() {
594        let s = "Some random text with {{#include file.rs::20}}...";
595        let res = find_links(s).collect::<Vec<_>>();
596        println!("\nOUTPUT: {:?}\n", res);
597        assert_eq!(
598            res,
599            vec![Link {
600                start_index: 22,
601                end_index: 46,
602                link_type: LinkType::Include(
603                    PathBuf::from("file.rs"),
604                    RangeOrAnchor::Range(LineRange::from(..20))
605                ),
606                link_text: "{{#include file.rs::20}}",
607            }]
608        );
609    }
610
611    #[test]
612    fn test_find_links_with_full_range() {
613        let s = "Some random text with {{#include file.rs::}}...";
614        let res = find_links(s).collect::<Vec<_>>();
615        println!("\nOUTPUT: {:?}\n", res);
616        assert_eq!(
617            res,
618            vec![Link {
619                start_index: 22,
620                end_index: 44,
621                link_type: LinkType::Include(
622                    PathBuf::from("file.rs"),
623                    RangeOrAnchor::Range(LineRange::from(..))
624                ),
625                link_text: "{{#include file.rs::}}",
626            }]
627        );
628    }
629
630    #[test]
631    fn test_find_links_with_no_range_specified() {
632        let s = "Some random text with {{#include file.rs}}...";
633        let res = find_links(s).collect::<Vec<_>>();
634        println!("\nOUTPUT: {:?}\n", res);
635        assert_eq!(
636            res,
637            vec![Link {
638                start_index: 22,
639                end_index: 42,
640                link_type: LinkType::Include(
641                    PathBuf::from("file.rs"),
642                    RangeOrAnchor::Range(LineRange::from(..))
643                ),
644                link_text: "{{#include file.rs}}",
645            }]
646        );
647    }
648
649    #[test]
650    fn test_find_links_with_anchor() {
651        let s = "Some random text with {{#include file.rs:anchor}}...";
652        let res = find_links(s).collect::<Vec<_>>();
653        println!("\nOUTPUT: {:?}\n", res);
654        assert_eq!(
655            res,
656            vec![Link {
657                start_index: 22,
658                end_index: 49,
659                link_type: LinkType::Include(
660                    PathBuf::from("file.rs"),
661                    RangeOrAnchor::Anchor(String::from("anchor"))
662                ),
663                link_text: "{{#include file.rs:anchor}}",
664            }]
665        );
666    }
667
668    #[test]
669    fn test_find_links_escaped_link() {
670        let s = "Some random text with escaped playground \\{{#playground file.rs editable}} ...";
671
672        let res = find_links(s).collect::<Vec<_>>();
673        println!("\nOUTPUT: {:?}\n", res);
674
675        assert_eq!(
676            res,
677            vec![Link {
678                start_index: 41,
679                end_index: 74,
680                link_type: LinkType::Escaped,
681                link_text: "\\{{#playground file.rs editable}}",
682            }]
683        );
684    }
685
686    #[test]
687    fn test_find_playgrounds_with_properties() {
688        let s =
689            "Some random text with escaped playground {{#playground file.rs editable }} and some \
690                 more\n text {{#playground my.rs editable no_run should_panic}} ...";
691
692        let res = find_links(s).collect::<Vec<_>>();
693        println!("\nOUTPUT: {:?}\n", res);
694        assert_eq!(
695            res,
696            vec![
697                Link {
698                    start_index: 41,
699                    end_index: 74,
700                    link_type: LinkType::Playground(PathBuf::from("file.rs"), vec!["editable"]),
701                    link_text: "{{#playground file.rs editable }}",
702                },
703                Link {
704                    start_index: 95,
705                    end_index: 145,
706                    link_type: LinkType::Playground(
707                        PathBuf::from("my.rs"),
708                        vec!["editable", "no_run", "should_panic"],
709                    ),
710                    link_text: "{{#playground my.rs editable no_run should_panic}}",
711                },
712            ]
713        );
714    }
715
716    #[test]
717    fn test_find_all_link_types() {
718        let s =
719            "Some random text with escaped playground {{#include file.rs}} and \\{{#contents are \
720                 insignifficant in escaped link}} some more\n text  {{#playground my.rs editable \
721                 no_run should_panic}} ...";
722
723        let res = find_links(s).collect::<Vec<_>>();
724        println!("\nOUTPUT: {:?}\n", res);
725        assert_eq!(res.len(), 3);
726        assert_eq!(
727            res[0],
728            Link {
729                start_index: 41,
730                end_index: 61,
731                link_type: LinkType::Include(
732                    PathBuf::from("file.rs"),
733                    RangeOrAnchor::Range(LineRange::from(..))
734                ),
735                link_text: "{{#include file.rs}}",
736            }
737        );
738        assert_eq!(
739            res[1],
740            Link {
741                start_index: 66,
742                end_index: 115,
743                link_type: LinkType::Escaped,
744                link_text: "\\{{#contents are insignifficant in escaped link}}",
745            }
746        );
747        assert_eq!(
748            res[2],
749            Link {
750                start_index: 133,
751                end_index: 183,
752                link_type: LinkType::Playground(
753                    PathBuf::from("my.rs"),
754                    vec!["editable", "no_run", "should_panic"]
755                ),
756                link_text: "{{#playground my.rs editable no_run should_panic}}",
757            }
758        );
759    }
760
761    #[test]
762    fn parse_without_colon_includes_all() {
763        let link_type = parse_include_path("arbitrary");
764        assert_eq!(
765            link_type,
766            LinkType::Include(
767                PathBuf::from("arbitrary"),
768                RangeOrAnchor::Range(LineRange::from(RangeFull))
769            )
770        );
771    }
772
773    #[test]
774    fn parse_with_nothing_after_colon_includes_all() {
775        let link_type = parse_include_path("arbitrary:");
776        assert_eq!(
777            link_type,
778            LinkType::Include(
779                PathBuf::from("arbitrary"),
780                RangeOrAnchor::Range(LineRange::from(RangeFull))
781            )
782        );
783    }
784
785    #[test]
786    fn parse_with_two_colons_includes_all() {
787        let link_type = parse_include_path("arbitrary::");
788        assert_eq!(
789            link_type,
790            LinkType::Include(
791                PathBuf::from("arbitrary"),
792                RangeOrAnchor::Range(LineRange::from(RangeFull))
793            )
794        );
795    }
796
797    #[test]
798    fn parse_with_garbage_after_two_colons_includes_all() {
799        let link_type = parse_include_path("arbitrary::NaN");
800        assert_eq!(
801            link_type,
802            LinkType::Include(
803                PathBuf::from("arbitrary"),
804                RangeOrAnchor::Range(LineRange::from(RangeFull))
805            )
806        );
807    }
808
809    #[test]
810    fn parse_with_one_number_after_colon_only_that_line() {
811        let link_type = parse_include_path("arbitrary:5");
812        assert_eq!(
813            link_type,
814            LinkType::Include(
815                PathBuf::from("arbitrary"),
816                RangeOrAnchor::Range(LineRange::from(4..5))
817            )
818        );
819    }
820
821    #[test]
822    fn parse_with_one_based_start_becomes_zero_based() {
823        let link_type = parse_include_path("arbitrary:1");
824        assert_eq!(
825            link_type,
826            LinkType::Include(
827                PathBuf::from("arbitrary"),
828                RangeOrAnchor::Range(LineRange::from(0..1))
829            )
830        );
831    }
832
833    #[test]
834    fn parse_with_zero_based_start_stays_zero_based_but_is_probably_an_error() {
835        let link_type = parse_include_path("arbitrary:0");
836        assert_eq!(
837            link_type,
838            LinkType::Include(
839                PathBuf::from("arbitrary"),
840                RangeOrAnchor::Range(LineRange::from(0..1))
841            )
842        );
843    }
844
845    #[test]
846    fn parse_start_only_range() {
847        let link_type = parse_include_path("arbitrary:5:");
848        assert_eq!(
849            link_type,
850            LinkType::Include(
851                PathBuf::from("arbitrary"),
852                RangeOrAnchor::Range(LineRange::from(4..))
853            )
854        );
855    }
856
857    #[test]
858    fn parse_start_with_garbage_interpreted_as_start_only_range() {
859        let link_type = parse_include_path("arbitrary:5:NaN");
860        assert_eq!(
861            link_type,
862            LinkType::Include(
863                PathBuf::from("arbitrary"),
864                RangeOrAnchor::Range(LineRange::from(4..))
865            )
866        );
867    }
868
869    #[test]
870    fn parse_end_only_range() {
871        let link_type = parse_include_path("arbitrary::5");
872        assert_eq!(
873            link_type,
874            LinkType::Include(
875                PathBuf::from("arbitrary"),
876                RangeOrAnchor::Range(LineRange::from(..5))
877            )
878        );
879    }
880
881    #[test]
882    fn parse_start_and_end_range() {
883        let link_type = parse_include_path("arbitrary:5:10");
884        assert_eq!(
885            link_type,
886            LinkType::Include(
887                PathBuf::from("arbitrary"),
888                RangeOrAnchor::Range(LineRange::from(4..10))
889            )
890        );
891    }
892
893    #[test]
894    fn parse_with_negative_interpreted_as_anchor() {
895        let link_type = parse_include_path("arbitrary:-5");
896        assert_eq!(
897            link_type,
898            LinkType::Include(
899                PathBuf::from("arbitrary"),
900                RangeOrAnchor::Anchor("-5".to_string())
901            )
902        );
903    }
904
905    #[test]
906    fn parse_with_floating_point_interpreted_as_anchor() {
907        let link_type = parse_include_path("arbitrary:-5.7");
908        assert_eq!(
909            link_type,
910            LinkType::Include(
911                PathBuf::from("arbitrary"),
912                RangeOrAnchor::Anchor("-5.7".to_string())
913            )
914        );
915    }
916
917    #[test]
918    fn parse_with_anchor_followed_by_colon() {
919        let link_type = parse_include_path("arbitrary:some-anchor:this-gets-ignored");
920        assert_eq!(
921            link_type,
922            LinkType::Include(
923                PathBuf::from("arbitrary"),
924                RangeOrAnchor::Anchor("some-anchor".to_string())
925            )
926        );
927    }
928
929    #[test]
930    fn parse_with_more_than_three_colons_ignores_everything_after_third_colon() {
931        let link_type = parse_include_path("arbitrary:5:10:17:anything:");
932        assert_eq!(
933            link_type,
934            LinkType::Include(
935                PathBuf::from("arbitrary"),
936                RangeOrAnchor::Range(LineRange::from(4..10))
937            )
938        );
939    }
940}