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#[derive(Default)]
30pub struct LinkPreprocessor;
31
32impl LinkPreprocessor {
33 pub(crate) const NAME: &'static str = "links";
34
35 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 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 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#[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 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 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 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 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}