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