cargo-sync-rdme 0.5.0

Cargo subcommand to synchronize README with crate documentation
Documentation
use std::{ops::Range, sync::Arc};

use miette::{NamedSource, SourceSpan};
use pulldown_cmark::Event;

use crate::sync::ManifestFile;

use super::{super::MarkdownFile, Marker, ParseMarkerError, Replace};

pub(in super::super) fn find_all<'events>(
    readme: &MarkdownFile,
    manifest: &ManifestFile,
    events: impl IntoIterator<Item = (Event<'events>, Range<usize>)> + 'events,
) -> Result<Vec<(Replace, Range<usize>)>, FindAllError> {
    let events = events.into_iter();
    let it = Iter { manifest, events };
    let mut markers = vec![];
    let mut errors = vec![];
    for res in it {
        match res {
            Ok(marker) => markers.push(marker),
            Err(err) => errors.push(err),
        }
    }

    if !errors.is_empty() {
        let source_code = readme.to_named_source();
        return Err(FindAllError {
            source_code,
            errors,
        });
    }

    Ok(markers)
}

#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("failed to parse README")]
pub(in super::super) struct FindAllError {
    #[source_code]
    source_code: NamedSource<Arc<str>>,
    #[related]
    errors: Vec<FindError>,
}

#[derive(Debug, thiserror::Error, miette::Diagnostic)]
enum FindError {
    #[error(transparent)]
    #[diagnostic(transparent)]
    ParseMarker(#[from] ParseMarkerError),
    #[error("unexpected end marker")]
    UnexpectedEndMarker {
        #[label = "the end marker defined here"]
        span: SourceSpan,
    },
    #[error("corresponding end marker not found")]
    EndMarkerNotFound {
        #[label = "the start label defined here"]
        start_span: SourceSpan,
    },
    #[error("nested markers are not allowed")]
    NestedMarker {
        #[label = "the nested marker defined here"]
        nested_span: SourceSpan,
        #[label = "the previous marker starts here"]
        previous_span: SourceSpan,
    },
}

#[derive(Debug)]
struct Iter<'manifest, I> {
    manifest: &'manifest ManifestFile,
    events: I,
}

impl<'event, I> Iterator for Iter<'_, I>
where
    I: Iterator<Item = (Event<'event>, Range<usize>)>,
{
    type Item = Result<(Replace, Range<usize>), FindError>;

    fn next(&mut self) -> Option<Self::Item> {
        match itry!(self.next_marker())? {
            (Marker::Replace(replace), range) => Some(Ok((replace, range))),
            (Marker::Start(replace), start_range) => match itry!(self.next_marker()) {
                Some((Marker::End, end_range)) => {
                    Some(Ok((replace, start_range.start..end_range.end)))
                }
                Some((_, nested_range)) => Some(Err(FindError::NestedMarker {
                    nested_span: nested_range.into(),
                    previous_span: start_range.into(),
                })),
                None => Some(Err(FindError::EndMarkerNotFound {
                    start_span: start_range.into(),
                })),
            },
            (Marker::End, range) => {
                Some(Err(FindError::UnexpectedEndMarker { span: range.into() }))
            }
        }
    }
}

impl<'event, I> Iter<'_, I>
where
    I: Iterator<Item = (Event<'event>, Range<usize>)>,
{
    fn next_marker(&mut self) -> Result<Option<(Marker, Range<usize>)>, FindError> {
        for (event, range) in self.events.by_ref() {
            if let Event::Html(html) = &event
                && let Some(marker) = Marker::matches((html, range.clone().into()), self.manifest)?
            {
                return Ok(Some((marker, range)));
            }
        }
        Ok(None)
    }
}

#[cfg(test)]
mod tests {
    use pulldown_cmark::Parser;

    use super::*;

    fn line_ranges(lines: &[impl AsRef<str>]) -> Vec<Range<usize>> {
        lines
            .iter()
            .scan(0, |offset, line| {
                let line = line.as_ref();
                let range = *offset..*offset + line.len() + 1;
                *offset = range.end;
                Some(range)
            })
            .collect()
    }

    #[test]
    fn no_markers() {
        let input = "Hello, world!";
        let mut markers = Iter {
            manifest: &ManifestFile::dummy(Default::default()),
            events: Parser::new(input).into_offset_iter(),
        };
        assert!(markers.next().is_none());
    }

    #[test]
    fn replace_marker() {
        let lines = [
            "Good morning, world!".to_string(),
            Marker::Replace(Replace::Title).to_string(),
            "Good afternoon, world!".to_string(),
            Marker::Replace(Replace::Badge {
                name: "".into(),
                badges: vec![].into(),
            })
            .to_string(),
            "Good evening, world!".to_string(),
            Marker::Replace(Replace::Rustdoc).to_string(),
            "Good night, world!".to_string(),
        ];
        let ranges = line_ranges(&lines);
        let input = lines.join("\n");

        let config = indoc::indoc! {"
            [package.metadata.cargo-sync-rdme.badge.badges]
        "};

        let mut markers = Iter {
            manifest: &ManifestFile::dummy(toml::from_str(config).unwrap()),
            events: Parser::new(&input).into_offset_iter(),
        };
        assert_eq!(
            markers.next().unwrap().unwrap(),
            (Replace::Title, ranges[1].clone())
        );
        assert_eq!(
            markers.next().unwrap().unwrap(),
            (
                Replace::Badge {
                    name: "".into(),
                    badges: vec![].into()
                },
                ranges[3].clone()
            )
        );
        assert_eq!(
            markers.next().unwrap().unwrap(),
            (Replace::Rustdoc, ranges[5].clone())
        );
        assert!(markers.next().is_none());
    }

    #[test]
    fn replace_region() {
        let lines = [
            "Good morning, world!".to_string(),
            Marker::Start(Replace::Title).to_string(),
            "Good afternoon, world!".to_string(),
            "# Heading!".to_string(),
            Marker::End.to_string(),
            "Good evening, world!".to_string(),
        ];
        let ranges = line_ranges(&lines);
        let input = lines.join("\n");

        let config = indoc::indoc! {"
            [package.metadata.cargo-sync-rdme.badge.badges]
        "};

        let mut markers = Iter {
            manifest: &ManifestFile::dummy(toml::from_str(config).unwrap()),
            events: Parser::new(&input).into_offset_iter(),
        };
        assert_eq!(
            markers.next().unwrap().unwrap(),
            (Replace::Title, ranges[1].start..ranges[4].end)
        );
        assert!(markers.next().is_none());
    }
}