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