#![forbid(unsafe_code)]
#![warn(future_incompatible, rust_2018_idioms, single_use_lifetimes, unreachable_pub)]
#![warn(missing_debug_implementations, missing_docs)]
#![warn(clippy::all, clippy::default_trait_access)]
mod error;
use indexmap::IndexMap;
use once_cell::sync::Lazy;
use regex::Regex;
use std::{iter::Peekable, mem, str::Lines};
pub use error::Error;
type Result<T, E = Error> = std::result::Result<T, E>;
pub type Changelog<'a> = IndexMap<&'a str, Release<'a>>;
pub fn parse(text: &str) -> Result<Changelog<'_>> {
Parser::new().parse(text)
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Release<'a> {
pub version: &'a str,
pub title: &'a str,
pub notes: String,
}
impl Release<'_> {
fn new() -> Self {
Self { version: "", title: "", notes: String::new() }
}
}
#[derive(Debug, Default)]
pub struct Parser {
version: Option<Regex>,
prefix: Option<Regex>,
}
static DEFAULT_PREFIX_FORMAT: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^(v|Version |Release )?").unwrap());
static DEFAULT_VERSION_FORMAT: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^\d+\.\d+\.\d+(-[\w\.-]+)?(\+[\w\.-]+)?").unwrap());
impl Parser {
pub fn new() -> Self {
Self::default()
}
pub fn version_format(&mut self, version_format: &str) -> Result<&mut Self> {
if version_format.trim().is_empty() {
return Err(Error::Format("empty or whitespace version format".into()));
}
self.version = Some(Regex::new(version_format)?);
Ok(self)
}
pub fn prefix_format(&mut self, prefix_format: &str) -> Result<&mut Self> {
if prefix_format.trim().is_empty() {
return Err(Error::Format("empty or whitespace version format".into()));
}
self.prefix = Some(Regex::new(prefix_format)?);
Ok(self)
}
fn get_version_format(&self) -> &Regex {
self.version.as_ref().unwrap_or(&DEFAULT_VERSION_FORMAT)
}
fn get_prefix_format(&self) -> &Regex {
self.prefix.as_ref().unwrap_or(&DEFAULT_PREFIX_FORMAT)
}
pub fn parse<'a>(&self, text: &'a str) -> Result<Changelog<'a>> {
parse_inner(self, text)
}
}
fn parse_inner<'a>(parser: &Parser, text: &'a str) -> Result<Changelog<'a>> {
const LN: char = '\n';
let version_format = parser.get_version_format();
let prefix_format = parser.get_prefix_format();
let mut map = IndexMap::new();
let mut insert_release = |mut cur_release: Release<'a>| {
debug_assert!(!cur_release.version.is_empty());
while cur_release.notes.ends_with(LN) {
cur_release.notes.pop();
}
if let Some(release) = map.insert(cur_release.version, cur_release) {
return Err(Error::Parse(format!("multiple release notes for '{}'", release.version)));
}
Ok(())
};
let lines = &mut text.lines().peekable();
let mut cur_release = Release::new();
let mut on_release = false;
let mut on_code_block = false;
let mut on_comment = false;
let mut level = None;
while let Some(line) = lines.next() {
let heading = heading(line, lines);
if heading.is_none() || on_code_block || on_comment {
if trim(line).starts_with("```") {
on_code_block = !on_code_block;
}
if !on_code_block {
const OPEN: &str = "<!--";
const CLOSE: &str = "-->";
let mut ll = Some(line);
while let Some(l) = ll {
match (l.find(OPEN), l.find(CLOSE)) {
(None, None) => {}
(Some(_), None) => on_comment = true,
(None, Some(_)) => on_comment = false,
(Some(open), Some(close)) => {
if open < close {
on_comment = false;
ll = l.get(close + CLOSE.len()..);
} else {
on_comment = true;
ll = l.get(open + OPEN.len()..);
}
continue;
}
}
break;
}
}
if on_release {
cur_release.notes.push_str(line);
cur_release.notes.push(LN);
}
continue;
}
let heading = heading.unwrap();
let mut unlinked = unlink(heading.text);
if let Some(m) = prefix_format.find(unlinked) {
unlinked = unlink(&unlinked[m.end()..]);
}
let version = match version_format.find(unlinked) {
Some(m) => &unlinked[m.start()..m.end()],
None => {
if level.map_or(true, |l| heading.level <= l) {
on_release = false;
} else if on_release {
cur_release.notes.push_str(line);
cur_release.notes.push(LN);
}
continue;
}
};
if mem::replace(&mut on_release, true) {
insert_release(mem::replace(&mut cur_release, Release::new()))?;
}
cur_release.version = version;
cur_release.title = heading.text;
level.get_or_insert(heading.level);
if heading.style == HeadingStyle::Setext {
lines.next();
}
while let Some(next) = lines.peek() {
if next.trim().is_empty() {
lines.next();
} else {
break;
}
}
}
if !cur_release.version.is_empty() {
insert_release(cur_release)?;
}
if map.is_empty() {
return Err(Error::Parse("no release was found".into()));
}
Ok(map)
}
struct Heading<'a> {
text: &'a str,
level: usize,
style: HeadingStyle,
}
#[derive(Eq, PartialEq)]
enum HeadingStyle {
Atx,
Setext,
}
fn heading<'a>(line: &'a str, lines: &mut Peekable<Lines<'_>>) -> Option<Heading<'a>> {
static ALL_EQUAL_SIGNS: Lazy<Regex> = Lazy::new(|| Regex::new("^=+$").unwrap());
static ALL_DASHES: Lazy<Regex> = Lazy::new(|| Regex::new("^-+$").unwrap());
let line = trim(line);
if line.starts_with('#') {
let mut level = 0;
while line[level..].starts_with('#') {
level += 1;
}
if level <= 6 {
Some(Heading { text: line[level..].trim(), level, style: HeadingStyle::Atx })
} else {
None
}
} else if let Some(next) = lines.peek() {
let next = trim(next);
if ALL_EQUAL_SIGNS.is_match(next) {
Some(Heading { text: line, level: 1, style: HeadingStyle::Setext })
} else if ALL_DASHES.is_match(next) {
Some(Heading { text: line, level: 2, style: HeadingStyle::Setext })
} else {
None
}
} else {
None
}
}
fn trim(s: &str) -> &str {
let mut cnt = 0;
while s[cnt..].starts_with(' ') {
cnt += 1;
}
if cnt < 4 { s[cnt..].trim_end() } else { s.trim_end() }
}
fn unlink(s: &str) -> &str {
s.strip_prefix('[').unwrap_or(s)
}