logloom 1.0.0

Parse, validate, and export changelogs with a CLI and Rust library
Documentation
use crate::{Category, Changelog, Entry, EntryKind, ErrorKind, Format, ParseError, Section};

use super::{
    document, histories, markdown, release,
    source::Source,
    text::{self, Entries},
};

pub fn parse(input: &str, name: Option<&str>) -> Result<Changelog, ParseError> {
    if name.is_some_and(fragment_name) {
        return fragment(input, name);
    }
    let source = Source::new(input);
    let lines: Vec<_> = source.lines().collect();
    let rst = lines.windows(2).any(|pair| match pair {
        [(_, title), (_, underline)] => adornment(underline) && is_release(title),
        _ => false,
    });
    if rst {
        return rst_history(input);
    }
    match histories::parse(input, Format::Towncrier) {
        Ok(history) => Ok(history),
        Err(error) if error.kind == ErrorKind::NoReleases => fragment(input, name),
        Err(error) => Err(error),
    }
}

fn fragment_name(name: &str) -> bool {
    let filename = name.rsplit(['/', '\\']).next().unwrap_or(name);
    let mut parts = filename.split('.');
    let (Some(_), Some(kind)) = (parts.next(), parts.next()) else {
        return false;
    };
    !matches!(kind.to_ascii_lowercase().as_str(), "md" | "rst" | "txt")
}

fn is_release(title: &str) -> bool {
    (histories::looks_like_release(title) || title.contains(" ("))
        && histories::label(title, Format::Towncrier).is_some()
}

fn adornment(text: &str) -> bool {
    text.len() >= 3
        && text.bytes().next().is_some_and(|first| {
            matches!(first, b'=' | b'-' | b'~' | b'^') && text.bytes().all(|byte| byte == first)
        })
}

fn rst_history(input: &str) -> Result<Changelog, ParseError> {
    let source = Source::new(input);
    let lines: Vec<_> = source.lines().collect();
    let mut changelog = document(input);
    let mut entries = Entries::default();
    let mut first = None;
    let mut index = 0usize;
    while let Some((span, line)) = lines.get(index).copied() {
        if let Some((underline_span, underline)) = index
            .checked_add(1)
            .and_then(|index| lines.get(index))
            .copied()
            && !line.trim().is_empty()
            && !line.starts_with(char::is_whitespace)
            && adornment(underline)
        {
            if let Some(current) = changelog.releases.last_mut() {
                entries.finish(&source, current, span.start)?;
            }
            let heading_span = source.span(span.start..underline_span.end);
            if is_release(line) {
                let label = histories::label(line, Format::Towncrier).ok_or_else(|| {
                    ParseError::new(
                        ErrorKind::MalformedHeading,
                        Some(span.line),
                        "invalid Towncrier release heading",
                    )
                })?;
                let mut current = release(
                    label.version,
                    source.text(text::range(heading_span))?.trim().to_owned(),
                    heading_span,
                );
                current.date = label.date;
                first.get_or_insert(span.start);
                changelog.releases.push(current);
            } else if let Some(current) = changelog.releases.last_mut() {
                current.sections.push(Section {
                    component: None,
                    category: Some(histories::category(line, Format::Towncrier)),
                    heading: Some(source.text(text::range(heading_span))?.trim().to_owned()),
                    entries: Vec::new(),
                    span: heading_span,
                });
            } else if changelog.metadata.title.is_none() {
                changelog.metadata.title = Some(line.to_owned());
            }
            index = index.saturating_add(2);
        } else {
            if let Some(current) = changelog.releases.last_mut() {
                entries.line(
                    &source,
                    current,
                    span,
                    line,
                    line.starts_with("- ") || line.starts_with("* "),
                )?;
            }
            index = index.saturating_add(1);
        }
    }
    if let Some(current) = changelog.releases.last_mut() {
        entries.finish(&source, current, input.len())?;
    }
    let first = first.ok_or_else(|| {
        ParseError::new(
            ErrorKind::NoReleases,
            None,
            "no Towncrier release headings found",
        )
    })?;
    source
        .text(0..first)?
        .trim()
        .clone_into(&mut changelog.metadata.description);
    Ok(changelog)
}

fn fragment(input: &str, name: Option<&str>) -> Result<Changelog, ParseError> {
    let source = Source::new(input);
    let mut changelog = if name.is_some_and(|name| {
        std::path::Path::new(name)
            .extension()
            .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
    }) {
        markdown::body(input)?
    } else {
        let mut changelog = document(input);
        let mut current = release(None, String::new(), source.span(0..input.len()));
        if !input.trim().is_empty() {
            super::push_entry(
                &mut current,
                Entry {
                    markdown: input.trim().to_owned(),
                    kind: EntryKind::Text,
                    span: source.span(0..input.len()),
                },
            );
        }
        changelog.releases.push(current);
        changelog
    };
    if let Some(current) = changelog.releases.first_mut() {
        current.attributes.remove("version-status");
        current
            .attributes
            .insert("fragment".to_owned(), "towncrier".to_owned());
        if let Some(name) = name {
            let filename = name.rsplit(['/', '\\']).next().unwrap_or(name);
            let mut parts = filename.split('.');
            let (Some(issue), Some(kind)) = (parts.next(), parts.next()) else {
                return Err(ParseError::new(
                    ErrorKind::MalformedInput,
                    Some(1),
                    "Towncrier fragment name must be ISSUE.TYPE[.suffix]",
                ));
            };
            if issue.is_empty() || kind.is_empty() {
                return Err(ParseError::new(
                    ErrorKind::MalformedInput,
                    Some(1),
                    "Towncrier fragment issue and type must not be empty",
                ));
            }
            if !fragment_name(name) {
                if input.trim().is_empty() {
                    return Err(ParseError::new(
                        ErrorKind::EmptyInput,
                        Some(1),
                        "only misc fragments may have an empty body",
                    ));
                }
                return Ok(changelog);
            }
            current
                .attributes
                .insert("fragment-name".to_owned(), filename.to_owned());
            current
                .attributes
                .insert("type".to_owned(), kind.to_owned());
            if !issue.starts_with('+') {
                current
                    .attributes
                    .insert("issue".to_owned(), issue.to_owned());
            }
            let category = match kind {
                "feature" => Category::Added,
                "bugfix" => Category::Fixed,
                "removal" => Category::Removed,
                _ => Category::Custom(kind.to_owned()),
            };
            for section in &mut current.sections {
                if section.category.is_none() {
                    section.category = Some(category.clone());
                }
            }
            if input.trim().is_empty() && kind != "misc" {
                return Err(ParseError::new(
                    ErrorKind::EmptyInput,
                    Some(1),
                    "only misc fragments may have an empty body",
                ));
            }
        }
    }
    Ok(changelog)
}