logloom 0.1.2

Parse, validate, and export changelogs with a CLI and Rust library
Documentation
use std::ops::Range;

use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};

use crate::{
    Category, Changelog, Entry, EntryKind, ErrorKind, LinkDefinition, ParseError, Section,
};

use super::{document, push_entry, release, source::Source};

pub struct Heading {
    pub level: HeadingLevel,
    pub text: String,
    pub link: Option<String>,
    pub range: Range<usize>,
}

pub enum Block {
    Heading(Heading),
    Entry(Range<usize>, EntryKind),
}

pub struct Markdown {
    pub blocks: Vec<Block>,
    pub links: Vec<LinkDefinition>,
}

pub fn scan(input: &str) -> Result<Markdown, ParseError> {
    let source = Source::new(input);
    let options =
        Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS;
    let parser = Parser::new_ext(input, options);
    let mut links: Vec<_> = parser
        .reference_definitions()
        .iter()
        .map(|(label, definition)| LinkDefinition {
            label: label.to_owned(),
            destination: definition.dest.to_string(),
            title: definition.title.as_ref().map(ToString::to_string),
            span: source.span(definition.span.clone()),
        })
        .collect();
    links.sort_by_key(|link| link.span.start);
    let mut blocks = Vec::new();
    let mut stack = Vec::new();
    let mut heading: Option<Heading> = None;
    for (event, range) in parser.into_offset_iter() {
        match event {
            Event::Start(tag) => {
                if stack.len() >= 128 {
                    return Err(ParseError::new(
                        ErrorKind::ResourceLimit,
                        Some(source.span(range).line),
                        "Markdown nesting exceeds 128 containers",
                    ));
                }
                if stack.is_empty() {
                    match &tag {
                        Tag::Heading { level, .. } => {
                            heading = Some(Heading {
                                level: *level,
                                text: String::new(),
                                link: None,
                                range: range.clone(),
                            });
                        }
                        Tag::List(_) => {}
                        _ => blocks.push(Block::Entry(range.clone(), EntryKind::Markdown)),
                    }
                } else if matches!(&tag, Tag::Item) && matches!(stack.as_slice(), [TagEnd::List(_)])
                {
                    blocks.push(Block::Entry(range.clone(), EntryKind::ListItem));
                }
                if let (Some(heading), Tag::Link { dest_url, .. }) = (&mut heading, &tag) {
                    heading.link.get_or_insert_with(|| dest_url.to_string());
                }
                stack.push(tag.to_end());
            }
            Event::End(tag) => {
                stack.pop();
                if stack.is_empty()
                    && matches!(tag, TagEnd::Heading(_))
                    && let Some(heading) = heading.take()
                {
                    blocks.push(Block::Heading(heading));
                }
            }
            Event::Text(text) | Event::Code(text) => {
                if let Some(heading) = &mut heading {
                    heading.text.push_str(&text);
                }
            }
            Event::SoftBreak | Event::HardBreak => {
                if let Some(heading) = &mut heading {
                    heading.text.push(' ');
                }
            }
            Event::Rule if stack.is_empty() => {
                blocks.push(Block::Entry(range, EntryKind::Markdown));
            }
            _ => {}
        }
    }
    Ok(Markdown { blocks, links })
}

pub fn body(input: &str) -> Result<Changelog, ParseError> {
    let source = Source::new(input);
    let markdown = scan(input)?;
    let mut changelog = document(input);
    changelog.links = markdown.links;
    let mut current = release(None, String::new(), source.span(0..input.len()));
    current
        .attributes
        .insert("version-status".to_owned(), "unknown".to_owned());
    for block in markdown.blocks {
        match block {
            Block::Heading(heading) => current.sections.push(Section {
                component: None,
                category: Some(Category::from_name(heading.text.trim())),
                heading: Some(source.text(heading.range.clone())?.trim().to_owned()),
                entries: Vec::new(),
                span: source.span(heading.range),
            }),
            Block::Entry(range, kind) => push_entry(
                &mut current,
                Entry {
                    markdown: source.text(range.clone())?.trim().to_owned(),
                    kind,
                    span: source.span(range),
                },
            ),
        }
    }
    changelog.releases.push(current);
    Ok(changelog)
}