mdbook_driver/builtin_preprocessors/
links.rs

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