cargo_rdme/
inject_doc.rs

1use crate::utils::{ItemOrOther, MarkdownItemIterator, Span};
2use crate::{Doc, Readme};
3use thiserror::Error;
4
5pub const MARKER_RDME: &str = "<!-- cargo-rdme -->";
6const MARKER_RDME_START: &str = "<!-- cargo-rdme start -->";
7const MARKER_RDME_END: &str = "<!-- cargo-rdme end -->";
8
9#[derive(PartialEq, Eq, Clone, Debug)]
10struct Heading<'a> {
11    level: u8,
12    text: &'a str,
13}
14
15#[derive(PartialEq, Eq, Clone, Debug)]
16enum ReadmeLine<'a> {
17    Heading(Heading<'a>, Span),
18    MarkerCargoRdme(Span),
19    MarkerCargoRdmeStart(Span),
20    MarkerCargoRdmeEnd(Span),
21}
22
23fn readme_line_iterator(readme: &Readme) -> MarkdownItemIterator<'_, ReadmeLine<'_>> {
24    use pulldown_cmark::{Event, Options, Parser, Tag};
25
26    let source = readme.as_string();
27    let parser = Parser::new_ext(source, Options::all());
28
29    let is_line_start =
30        |start| start == 0 || source[0..start].chars().rev().find(|&c| c != ' ') == Some('\n');
31    let mut depth = 0;
32
33    let iter = parser.into_offset_iter().filter_map(move |(event, range)| match event {
34        Event::Start(Tag::Heading { level, .. }) => Some((
35            range.clone().into(),
36            ReadmeLine::Heading(
37                Heading { level: level as u8, text: &source[range.start..range.end] },
38                range.into(),
39            ),
40        )),
41        Event::Html(ref html) if is_line_start(range.start) => {
42            let trimmed_line = html.strip_suffix('\r').unwrap_or_else(|| html.as_ref()).trim();
43
44            match trimmed_line {
45                MARKER_RDME if depth == 0 => {
46                    Some((range.clone().into(), ReadmeLine::MarkerCargoRdme(range.into())))
47                }
48                MARKER_RDME_START if depth == 0 => {
49                    depth += 1;
50                    Some((range.clone().into(), ReadmeLine::MarkerCargoRdmeStart(range.into())))
51                }
52                MARKER_RDME_END if depth <= 1 => {
53                    depth -= 1;
54                    Some((range.clone().into(), ReadmeLine::MarkerCargoRdmeEnd(range.into())))
55                }
56                MARKER_RDME_START => {
57                    depth += 1;
58                    None
59                }
60                MARKER_RDME_END => {
61                    depth -= 1;
62                    None
63                }
64                _ => None,
65            }
66        }
67        _ => None,
68    });
69
70    MarkdownItemIterator::new(source, iter)
71}
72
73fn doc_heading_iterator(doc: &Doc) -> MarkdownItemIterator<'_, Heading<'_>> {
74    use pulldown_cmark::{Event, Options, Parser, Tag};
75
76    let source = doc.as_string();
77    let parser = Parser::new_ext(source, Options::all());
78
79    let iter = parser.into_offset_iter().filter_map(move |(event, range)| match event {
80        Event::Start(Tag::Heading { level, .. }) => Some((
81            range.clone().into(),
82            Heading { level: level as u8, text: &source[range.start..range.end] },
83        )),
84        _ => None,
85    });
86
87    MarkdownItemIterator::new(source, iter)
88}
89
90#[derive(Error, Eq, PartialEq, Debug)]
91pub enum InjectDocError {
92    #[error("unexpected end marker at line {line_number}")]
93    UnexpectedMarkerCargoRdmeEnd { line_number: usize },
94    #[error("unmatched start marker")]
95    UnmatchedMarkerCargoRdmeStart,
96}
97
98fn bump_heading_level(doc: &Doc, level_bump: u8) -> Doc {
99    let mut new_doc = String::with_capacity(doc.as_string().len() + 256);
100
101    for item in doc_heading_iterator(doc).complete() {
102        match item {
103            ItemOrOther::Item(Heading { text, .. }) => {
104                (0..level_bump).for_each(|_| new_doc.push('#'));
105                new_doc.push_str(text);
106            }
107            ItemOrOther::Other(other) => {
108                new_doc.push_str(other);
109            }
110        }
111    }
112
113    Doc::from_str(new_doc)
114}
115
116pub struct NewReadme {
117    pub readme: Readme,
118    /// Weather the README had a cargo-rdme marker or not.
119    pub had_marker: bool,
120}
121
122pub fn inject_doc_in_readme(
123    readme: &Readme,
124    doc: &Doc,
125    heading_base_level: Option<u8>,
126) -> Result<NewReadme, InjectDocError> {
127    fn inject(new_readme: &mut String, doc: &Doc) {
128        new_readme.push_str(MARKER_RDME_START);
129        new_readme.push_str("\n\n");
130        doc.lines().for_each(|line| {
131            new_readme.push_str(line);
132            new_readme.push('\n');
133        });
134        new_readme.push('\n');
135        new_readme.push_str(MARKER_RDME_END);
136        new_readme.push('\n');
137    }
138
139    let mut new_readme: String =
140        String::with_capacity(readme.as_string().len() + doc.as_string().len() + 1024);
141    let mut inside_markers = false;
142    let mut last_heading_level: u8 = 0;
143    let mut had_marker = false;
144
145    for item in readme_line_iterator(readme).complete() {
146        match (inside_markers, item) {
147            (true, ItemOrOther::Item(ReadmeLine::MarkerCargoRdmeEnd(_))) => {
148                inside_markers = false;
149            }
150            (true, _) => (),
151
152            (false, ItemOrOther::Item(ReadmeLine::MarkerCargoRdmeEnd(span))) => {
153                let line_number =
154                    1 + readme.as_string()[0..span.start].chars().filter(|&c| c == '\n').count();
155
156                return Err(InjectDocError::UnexpectedMarkerCargoRdmeEnd { line_number });
157            }
158            (false, ItemOrOther::Item(ReadmeLine::Heading(Heading { level, text }, _))) => {
159                new_readme.push_str(text);
160                last_heading_level = level;
161            }
162            (false, ItemOrOther::Other(other)) => new_readme.push_str(other),
163            (false, ItemOrOther::Item(ReadmeLine::MarkerCargoRdme(_))) => {
164                let level_bump = heading_base_level.unwrap_or(last_heading_level);
165                let doc = bump_heading_level(doc, level_bump);
166                inject(&mut new_readme, &doc);
167                had_marker = true;
168            }
169            (false, ItemOrOther::Item(ReadmeLine::MarkerCargoRdmeStart(_))) => {
170                let level_bump = heading_base_level.unwrap_or(last_heading_level);
171                let doc = bump_heading_level(doc, level_bump);
172                inject(&mut new_readme, &doc);
173                inside_markers = true;
174                had_marker = true;
175            }
176        }
177    }
178
179    match inside_markers {
180        true => Err(InjectDocError::UnmatchedMarkerCargoRdmeStart),
181        false => {
182            let new_readme = NewReadme { readme: Readme::from_str(new_readme), had_marker };
183
184            Ok(new_readme)
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use indoc::indoc;
193    use pretty_assertions::assert_eq;
194
195    #[test]
196    fn test_readme_line_iterator() {
197        let str = indoc! { "
198            marker test <!-- cargo-rdme -->.
199             Starting with whitespace.
200
201            <!-- cargo-rdme start -->
202            <!-- cargo-rdme end -->
203            <!-- cargo-rdme -->
204            <!-- cargo-rdme end --> <- Does not count.
205             <!-- cargo-rdme start -->
206            <!-- cargo-rdme end -->\r
207            <!-- cargo-rdme start -->
208            <!-- cargo-rdme end --> \r
209            <!-- cargo-rdme start -->
210            <!-- cargo-rdme end --> "
211        };
212
213        let readme = Readme::from_str(str);
214        let mut iter = readme_line_iterator(&readme).items();
215
216        // TODO Replace by `assert_matches!()` once https://github.com/rust-lang/rust/issues/82775
217        // stabilizes.
218        assert!(matches!(iter.next(), Some(ReadmeLine::MarkerCargoRdmeStart(_))));
219        assert!(matches!(iter.next(), Some(ReadmeLine::MarkerCargoRdmeEnd(_))));
220        assert!(matches!(iter.next(), Some(ReadmeLine::MarkerCargoRdme(_))));
221        assert!(matches!(iter.next(), Some(ReadmeLine::MarkerCargoRdmeStart(_))));
222        assert!(matches!(iter.next(), Some(ReadmeLine::MarkerCargoRdmeEnd(_))));
223        assert!(matches!(iter.next(), Some(ReadmeLine::MarkerCargoRdmeStart(_))));
224        assert!(matches!(iter.next(), Some(ReadmeLine::MarkerCargoRdmeEnd(_))));
225        assert!(matches!(iter.next(), Some(ReadmeLine::MarkerCargoRdmeStart(_))));
226        assert!(matches!(iter.next(), Some(ReadmeLine::MarkerCargoRdmeEnd(_))));
227        assert_eq!(iter.next(), None);
228    }
229
230    #[test]
231    fn test_readme_line_iterator_nested() {
232        let str = indoc! { "
233            A
234            <!-- cargo-rdme start -->
235            B
236            <!-- cargo-rdme -->
237            C
238            <!-- cargo-rdme start -->
239            D
240            <!-- cargo-rdme end -->
241            E
242            <!-- cargo-rdme end -->
243            F"
244        };
245
246        let readme = Readme::from_str(str);
247        let mut iter = readme_line_iterator(&readme).items();
248
249        // TODO Replace by `assert_matches!()` once https://github.com/rust-lang/rust/issues/82775
250        // stabilizes.
251        assert!(matches!(iter.next(), Some(ReadmeLine::MarkerCargoRdmeStart(_))));
252        assert!(matches!(iter.next(), Some(ReadmeLine::MarkerCargoRdmeEnd(_))));
253        assert_eq!(iter.next(), None);
254    }
255
256    #[test]
257    fn test_inject_doc_single_marker() {
258        let readme_str = indoc! { r#"
259            This is a really nice crate.
260
261            <!-- cargo-rdme -->
262
263            Hope you enjoy!
264            "#
265        };
266        let doc_str = indoc! { r#"
267            # The crate
268
269            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
270            incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
271            exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
272            "#
273        };
274
275        let expected = indoc! { r#"
276            This is a really nice crate.
277
278            <!-- cargo-rdme start -->
279
280            # The crate
281
282            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
283            incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
284            exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
285
286            <!-- cargo-rdme end -->
287
288            Hope you enjoy!
289            "#
290        };
291
292        let readme = Readme::from_str(readme_str);
293        let doc = Doc::from_str(doc_str);
294
295        let new_readme = inject_doc_in_readme(&readme, &doc, None).unwrap();
296
297        assert_eq!(new_readme.readme.markdown.as_string(), expected);
298        assert!(new_readme.had_marker);
299    }
300
301    #[test]
302    fn test_inject_doc_start_end_marker() {
303        let readme_str = indoc! { r#"
304            This is a really nice crate.
305
306            <!-- cargo-rdme start -->
307
308            Li Europan lingues es membres del sam familie. Lor separat existentie es un myth.
309            Por scientie, musica, sport etc, litot Europa usa li sam vocabular.
310
311            <!-- cargo-rdme end -->
312
313            Hope you enjoy!
314            "#
315        };
316        let doc_str = indoc! { r#"
317            # The crate
318
319            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
320            incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
321            exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
322            "#
323        };
324
325        let expected = indoc! { r#"
326            This is a really nice crate.
327
328            <!-- cargo-rdme start -->
329
330            # The crate
331
332            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
333            incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
334            exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
335
336            <!-- cargo-rdme end -->
337
338            Hope you enjoy!
339            "#
340        };
341
342        let readme = Readme::from_str(readme_str);
343        let doc = Doc::from_str(doc_str);
344
345        let new_readme = inject_doc_in_readme(&readme, &doc, None).unwrap();
346
347        assert_eq!(new_readme.readme.markdown.as_string(), expected);
348        assert!(new_readme.had_marker);
349    }
350
351    #[test]
352    fn test_inject_doc_unmatched_start_marker() {
353        let readme_str = indoc! { r#"
354            This is a really nice crate.
355
356            <!-- cargo-rdme -->
357
358            <!-- cargo-rdme start -->
359
360            Hope you enjoy!
361            "#
362        };
363        let doc_str = indoc! { r#"
364            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
365            incididunt ut labore et dolore magna aliqua.
366            "#
367        };
368
369        let readme = Readme::from_str(readme_str);
370        let doc = Doc::from_str(doc_str);
371
372        let result = inject_doc_in_readme(&readme, &doc, None);
373
374        assert_eq!(result.err(), Some(InjectDocError::UnmatchedMarkerCargoRdmeStart));
375    }
376
377    #[test]
378    fn test_inject_doc_unexpected_end_marker() {
379        let readme_str = indoc! { r#"
380            This is a really nice crate.
381
382            <!-- cargo-rdme -->
383
384            <!-- cargo-rdme end -->
385
386            Hope you enjoy!
387            "#
388        };
389        let doc_str = indoc! { r#"
390            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
391            incididunt ut labore et dolore magna aliqua.
392            "#
393        };
394
395        let readme = Readme::from_str(readme_str);
396        let doc = Doc::from_str(doc_str);
397
398        let result = inject_doc_in_readme(&readme, &doc, None);
399
400        assert_eq!(
401            result.err(),
402            Some(InjectDocError::UnexpectedMarkerCargoRdmeEnd { line_number: 5 })
403        );
404    }
405
406    #[test]
407    fn test_bump_heading_level() {
408        let doc_str = indoc! { r#"
409            # Foo
410            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
411            incididunt ut labore et dolore magna aliqua.
412
413            ## Bar
414            Aenean dictum in nisi eu rutrum. Suspendisse vulputate tristique turpis eu vestibulum.
415            "#
416        };
417        let doc = Doc::from_str(doc_str);
418
419        let new_readme = bump_heading_level(&doc, 0);
420
421        assert_eq!(new_readme.markdown.as_string(), doc_str);
422
423        let expected = indoc! { r#"
424            ### Foo
425            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
426            incididunt ut labore et dolore magna aliqua.
427
428            #### Bar
429            Aenean dictum in nisi eu rutrum. Suspendisse vulputate tristique turpis eu vestibulum.
430            "#
431        };
432
433        let new_readme = bump_heading_level(&doc, 2);
434
435        assert_eq!(new_readme.markdown.as_string(), expected);
436    }
437
438    #[test]
439    fn test_inject_doc_bump_heading_level() {
440        let readme_str = indoc! { r#"
441            # The crate
442
443            This is a really nice crate.
444
445            <!-- cargo-rdme -->
446
447            Hope you enjoy!
448            "#
449        };
450        let doc_str = indoc! { r#"
451            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
452            incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
453            exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
454
455            # Foo
456
457            Aenean dictum in nisi eu rutrum. Suspendisse vulputate tristique turpis eu vestibulum.
458            "#
459        };
460
461        let expected = indoc! { r#"
462            # The crate
463
464            This is a really nice crate.
465
466            <!-- cargo-rdme start -->
467
468            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
469            incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
470            exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
471
472            ## Foo
473
474            Aenean dictum in nisi eu rutrum. Suspendisse vulputate tristique turpis eu vestibulum.
475
476            <!-- cargo-rdme end -->
477
478            Hope you enjoy!
479            "#
480        };
481
482        let readme = Readme::from_str(readme_str);
483        let doc = Doc::from_str(doc_str);
484
485        let new_readme = inject_doc_in_readme(&readme, &doc, None).unwrap();
486
487        assert_eq!(new_readme.readme.markdown.as_string(), expected);
488        assert!(new_readme.had_marker);
489    }
490
491    #[test]
492    fn test_inject_doc_bump_heading_level_ignore_within_markers() {
493        let readme_str = indoc! { r#"
494            # The crate
495
496            This is a really nice crate.
497
498            <!-- cargo-rdme start -->
499
500            ### The crate
501
502            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
503            incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
504            exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
505
506            <!-- cargo-rdme end -->
507
508            Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
509
510            <!-- cargo-rdme -->
511
512            Hope you enjoy!
513            "#
514        };
515        let doc_str = indoc! { r#"
516            # Foo
517
518            Aenean dictum in nisi eu rutrum. Suspendisse vulputate tristique turpis eu vestibulum.
519            "#
520        };
521
522        let expected = indoc! { r#"
523            # The crate
524
525            This is a really nice crate.
526
527            <!-- cargo-rdme start -->
528
529            ## Foo
530
531            Aenean dictum in nisi eu rutrum. Suspendisse vulputate tristique turpis eu vestibulum.
532
533            <!-- cargo-rdme end -->
534
535            Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
536
537            <!-- cargo-rdme start -->
538
539            ## Foo
540
541            Aenean dictum in nisi eu rutrum. Suspendisse vulputate tristique turpis eu vestibulum.
542
543            <!-- cargo-rdme end -->
544
545            Hope you enjoy!
546            "#
547        };
548
549        let readme = Readme::from_str(readme_str);
550        let doc = Doc::from_str(doc_str);
551
552        let new_readme = inject_doc_in_readme(&readme, &doc, None).unwrap();
553
554        assert_eq!(new_readme.readme.markdown.as_string(), expected);
555        assert!(new_readme.had_marker);
556    }
557
558    #[test]
559    fn test_inject_doc_bump_heading_level_ignore_code_blocks() {
560        let readme_str = indoc! { r#"
561            # The crate
562
563            This is a really nice crate.
564            You should try it!
565
566            ```
567            ### This is code
568            ```
569
570            <!-- cargo-rdme -->
571            "#
572        };
573        let doc_str = indoc! { r#"
574            # Foo
575
576            Aenean dictum in nisi eu rutrum. Suspendisse vulputate tristique turpis eu vestibulum.
577            "#
578        };
579
580        let expected = indoc! { r#"
581            # The crate
582
583            This is a really nice crate.
584            You should try it!
585
586            ```
587            ### This is code
588            ```
589
590            <!-- cargo-rdme start -->
591
592            ## Foo
593
594            Aenean dictum in nisi eu rutrum. Suspendisse vulputate tristique turpis eu vestibulum.
595
596            <!-- cargo-rdme end -->
597            "#
598        };
599
600        let readme = Readme::from_str(readme_str);
601        let doc = Doc::from_str(doc_str);
602
603        let new_readme = inject_doc_in_readme(&readme, &doc, None).unwrap();
604
605        assert_eq!(new_readme.readme.markdown.as_string(), expected);
606        assert!(new_readme.had_marker);
607    }
608
609    #[test]
610    fn test_inject_doc_with_zero_heading_base_level() {
611        let readme_str = indoc! { r#"
612            # The crate
613
614            This is a really nice crate.
615
616            <!-- cargo-rdme -->
617
618            Hope you enjoy!
619            "#
620        };
621        let doc_str = indoc! { r#"
622            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
623            incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
624            exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
625
626            # Foo
627
628            Aenean dictum in nisi eu rutrum. Suspendisse vulputate tristique turpis eu vestibulum.
629            "#
630        };
631
632        let expected = indoc! { r#"
633            # The crate
634
635            This is a really nice crate.
636
637            <!-- cargo-rdme start -->
638
639            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
640            incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
641            exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
642
643            # Foo
644
645            Aenean dictum in nisi eu rutrum. Suspendisse vulputate tristique turpis eu vestibulum.
646
647            <!-- cargo-rdme end -->
648
649            Hope you enjoy!
650            "#
651        };
652
653        let readme = Readme::from_str(readme_str);
654        let doc = Doc::from_str(doc_str);
655
656        let new_readme = inject_doc_in_readme(&readme, &doc, Some(0)).unwrap();
657
658        assert_eq!(new_readme.readme.markdown.as_string(), expected);
659        assert!(new_readme.had_marker);
660    }
661}