#![doc(test(
no_crate_inject,
attr(
deny(warnings, rust_2018_idioms, single_use_lifetimes),
allow(dead_code, unused_variables)
)
))]
#![forbid(unsafe_code)]
#![warn(
missing_debug_implementations,
missing_docs,
rust_2018_idioms,
single_use_lifetimes,
unreachable_pub
)]
#![warn(
clippy::default_trait_access,
clippy::exhaustive_enums,
clippy::exhaustive_structs,
clippy::wildcard_imports
)]
#[cfg(test)]
#[path = "gen/assert_impl.rs"]
mod assert_impl;
mod error;
use std::mem;
use indexmap::IndexMap;
use memchr::memmem;
use once_cell::sync::Lazy;
use regex::Regex;
pub use crate::error::Error;
use crate::error::Result;
pub type Changelog<'a> = IndexMap<&'a str, Release<'a>>;
pub fn parse(text: &str) -> Result<Changelog<'_>> {
Parser::new().parse(text)
}
pub fn parse_iter(text: &str) -> ParseIter<'_, 'static> {
ParseIter::new(text, None, None)
}
#[allow(single_use_lifetimes)] #[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde_crate::Serialize))]
#[cfg_attr(feature = "serde", serde(crate = "serde_crate"))]
#[non_exhaustive]
pub struct Release<'a> {
pub version: &'a str,
pub title: &'a str,
pub notes: &'a str,
}
#[derive(Debug, Default)]
pub struct Parser {
version: Option<Regex>,
prefix: Option<Regex>,
}
impl Parser {
pub fn new() -> Self {
Self::default()
}
pub fn version_format(&mut self, format: &str) -> Result<&mut Self> {
if format.trim().is_empty() {
return Err(Error::format("empty or whitespace version format"));
}
self.version = Some(Regex::new(format).map_err(Error::new)?);
Ok(self)
}
pub fn prefix_format(&mut self, format: &str) -> Result<&mut Self> {
self.prefix = Some(Regex::new(format).map_err(Error::new)?);
Ok(self)
}
pub fn parse<'a>(&self, text: &'a str) -> Result<Changelog<'a>> {
let mut map = IndexMap::new();
for release in self.parse_iter(text) {
if let Some(release) = map.insert(release.version, release) {
return Err(Error::parse(format!(
"multiple release notes for '{}'",
release.version
)));
}
}
if map.is_empty() {
return Err(Error::parse("no release was found"));
}
Ok(map)
}
pub fn parse_iter<'a, 'r>(&'r self, text: &'a str) -> ParseIter<'a, 'r> {
ParseIter::new(text, self.version.as_ref(), self.prefix.as_ref())
}
}
#[allow(missing_debug_implementations)]
#[must_use = "iterators are lazy and do nothing unless consumed"]
pub struct ParseIter<'a, 'r> {
version_format: &'r Regex,
prefix_format: &'r Regex,
find_open: memmem::Finder<'static>,
find_close: memmem::Finder<'static>,
lines: Lines<'a>,
level: Option<u8>,
}
const OPEN: &[u8] = b"<!--";
const CLOSE: &[u8] = b"-->";
impl<'a, 'r> ParseIter<'a, 'r> {
fn new(
text: &'a str,
version_format: Option<&'r Regex>,
prefix_format: Option<&'r Regex>,
) -> Self {
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());
Self {
version_format: version_format.unwrap_or(&DEFAULT_VERSION_FORMAT),
prefix_format: prefix_format.unwrap_or(&DEFAULT_PREFIX_FORMAT),
find_open: memmem::Finder::new(OPEN),
find_close: memmem::Finder::new(CLOSE),
lines: Lines::new(text),
level: None,
}
}
fn end_release(
&self,
mut cur_release: Release<'a>,
release_note_start: usize,
line_start: usize,
) -> Release<'a> {
assert!(!cur_release.version.is_empty());
if release_note_start < line_start {
cur_release.notes = self.lines.text[release_note_start..line_start - 1].trim_end();
}
cur_release
}
fn handle_comment(&self, on_comment: &mut bool, line: &'a str) {
let mut line = Some(line);
while let Some(l) = line {
match (self.find_open.find(l.as_bytes()), self.find_close.find(l.as_bytes())) {
(None, None) => {}
(Some(_), None) => *on_comment = true,
(None, Some(_)) => *on_comment = false,
(Some(open), Some(close)) => {
if open < close {
*on_comment = false;
line = l.get(close + CLOSE.len()..);
} else {
*on_comment = true;
line = l.get(open + OPEN.len()..);
}
continue;
}
}
break;
}
}
}
impl<'a> Iterator for ParseIter<'a, '_> {
type Item = Release<'a>;
fn next(&mut self) -> Option<Self::Item> {
let mut on_code_block = false;
let mut on_comment = false;
let mut release_note_start = None;
let mut cur_release = Release { version: "", title: "", notes: "" };
while let Some((line, line_start, line_end)) = self.lines.peek() {
let heading =
if on_code_block || on_comment { None } else { heading(line, &mut self.lines) };
if heading.is_none() {
self.lines.next();
if trim(line).starts_with("```") {
on_code_block = !on_code_block;
}
if !on_code_block {
self.handle_comment(&mut on_comment, line);
}
if line_end == self.lines.text.len() {
break;
}
continue;
}
let heading = heading.unwrap();
if let Some(release_level) = self.level {
if heading.level > release_level {
self.lines.next();
if line_end == self.lines.text.len() {
break;
}
continue;
}
if heading.level < release_level {
self.lines.next();
if let Some(release_note_start) = release_note_start {
return Some(self.end_release(cur_release, release_note_start, line_start));
}
if line_end == self.lines.text.len() {
break;
}
continue;
}
if let Some(release_note_start) = release_note_start {
return Some(self.end_release(cur_release, release_note_start, line_start));
}
}
debug_assert!(release_note_start.is_none());
let mut unlinked = unlink(heading.text);
if let Some(m) = self.prefix_format.find(unlinked) {
unlinked = &unlinked[m.end()..];
}
let version =
unlink(unlinked.split_once(char::is_whitespace).map_or(unlinked, |x| x.0));
if !self.version_format.is_match(version) {
self.lines.next();
if line_end == self.lines.text.len() {
break;
}
continue;
};
cur_release.version = version;
cur_release.title = heading.text;
self.level.get_or_insert(heading.level);
self.lines.next();
if heading.style == HeadingStyle::Setext {
self.lines.next();
}
while let Some((next, ..)) = self.lines.peek() {
if next.trim().is_empty() {
self.lines.next();
} else {
break;
}
}
if let Some((_, line_start, _)) = self.lines.peek() {
release_note_start = Some(line_start);
} else {
break;
}
}
if !cur_release.version.is_empty() {
if let Some(release_note_start) = release_note_start {
if let Some(nodes) = self.lines.text.get(release_note_start..) {
cur_release.notes = nodes.trim_end();
}
}
return Some(cur_release);
}
None
}
}
struct Lines<'a> {
text: &'a str,
iter: memchr::Memchr<'a>,
line_start: usize,
peeked: Option<(&'a str, usize, usize)>,
peeked2: Option<(&'a str, usize, usize)>,
}
impl<'a> Lines<'a> {
fn new(text: &'a str) -> Self {
Self {
text,
iter: memchr::memchr_iter(b'\n', text.as_bytes()),
line_start: 0,
peeked: None,
peeked2: None,
}
}
fn peek(&mut self) -> Option<(&'a str, usize, usize)> {
self.peeked = self.next();
self.peeked
}
fn peek2(&mut self) -> Option<(&'a str, usize, usize)> {
let peeked = self.next();
let peeked2 = self.next();
self.peeked = peeked;
self.peeked2 = peeked2;
self.peeked2
}
}
impl<'a> Iterator for Lines<'a> {
type Item = (&'a str, usize, usize);
fn next(&mut self) -> Option<Self::Item> {
if let Some(triple) = self.peeked.take() {
return Some(triple);
}
if let Some(triple) = self.peeked2.take() {
return Some(triple);
}
let (line, line_end) = match self.iter.next() {
Some(line_end) => (&self.text[self.line_start..line_end], line_end),
None => (self.text.get(self.line_start..)?, self.text.len()),
};
let line_start = mem::replace(&mut self.line_start, line_end + 1);
Some((line, line_start, line_end))
}
}
struct Heading<'a> {
text: &'a str,
level: u8,
style: HeadingStyle,
}
#[derive(Eq, PartialEq)]
enum HeadingStyle {
Atx,
Setext,
}
fn heading<'a>(line: &'a str, lines: &mut Lines<'a>) -> Option<Heading<'a>> {
let line = trim(line);
if line.starts_with('#') {
let mut level = 0usize;
while line.as_bytes().get(level) == Some(&b'#') {
level += 1;
}
if level <= 6 && line.as_bytes().get(level) == Some(&b' ') {
Some(Heading {
text: line[level..].trim(),
level: level as _,
style: HeadingStyle::Atx,
})
} else {
None
}
} else if let Some((next, ..)) = lines.peek2() {
let next = trim(next);
if next.is_empty() {
None
} else if next.as_bytes().iter().all(|&b| b == b'=') {
Some(Heading { text: line, level: 1, style: HeadingStyle::Setext })
} else if next.as_bytes().iter().all(|&b| b == b'-') {
Some(Heading { text: line, level: 2, style: HeadingStyle::Setext })
} else {
None
}
} else {
None
}
}
fn trim(s: &str) -> &str {
let mut count = 0;
while s.as_bytes().get(count) == Some(&b' ') {
count += 1;
}
if count < 4 {
s[count..].trim_end()
} else {
s.trim_end()
}
}
fn unlink(mut s: &str) -> &str {
s = s.strip_prefix('[').unwrap_or(s);
s.strip_suffix(']').unwrap_or(s)
}