use std::fmt;
use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
use crate::parsers::{
histories,
markdown::{self, Block, Heading},
};
use crate::{EntryKind, ErrorKind, Format, MAX_INPUT_BYTES, ParseError, parse};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DetectionError {
Unrecognized,
Ambiguous {
candidates: Vec<Format>,
},
InvalidInput(ParseError),
}
impl fmt::Display for DetectionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unrecognized => {
f.write_str("no sufficiently specific changelog format signature found")
}
Self::Ambiguous { candidates } => {
f.write_str("ambiguous changelog format: ")?;
for (index, format) in candidates.iter().enumerate() {
if index != 0 {
f.write_str(", ")?;
}
f.write_str(format.as_str())?;
}
Ok(())
}
Self::InvalidInput(error) => error.fmt(f),
}
}
}
impl std::error::Error for DetectionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::InvalidInput(error) => Some(error),
_ => None,
}
}
}
impl From<ParseError> for DetectionError {
fn from(error: ParseError) -> Self {
Self::InvalidInput(error)
}
}
pub fn detect(input: &str) -> Result<Format, DetectionError> {
detect_internal(input, None)
}
pub fn detect_named(input: &str, name: &str) -> Result<Format, DetectionError> {
detect_internal(input, Some(name))
}
fn detect_internal(input: &str, name: Option<&str>) -> Result<Format, DetectionError> {
if input.len() > MAX_INPUT_BYTES {
return Err(ParseError::new(
ErrorKind::ResourceLimit,
None,
"input exceeds the 16 MiB limit",
)
.into());
}
let mut candidates = Vec::new();
if name.is_some_and(fragment_name) {
candidates.push(Format::Towncrier);
}
if input.trim().is_empty() {
if candidates.is_empty() {
return Err(ParseError::new(
ErrorKind::EmptyInput,
None,
"expected nonempty changelog content or a Towncrier fragment filename",
)
.into());
}
return finish(&candidates);
}
let trimmed = input.trim_start();
if trimmed.starts_with('{')
|| trimmed
.strip_prefix('[')
.is_some_and(|rest| rest.trim_start().starts_with(['{', '[', ']']))
{
let value: serde_json::Value = serde_json::from_str(input).map_err(|error| {
ParseError::new(
ErrorKind::MalformedInput,
Some(error.line()),
format!("cannot inspect JSON: {error}"),
)
})?;
if github_json(&value) {
candidates.push(Format::GitHubReleaseNotes);
}
return finish(&candidates);
}
if input
.lines()
.next()
.is_some_and(|line| line.trim() == "---")
&& parse(input, Format::Changesets).is_ok_and(|model| {
model.releases.iter().any(|release| {
release
.attributes
.get("fragment")
.is_some_and(|kind| kind == "changesets")
})
})
{
candidates.push(Format::Changesets);
}
let first = input
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or_default();
if debian(first) {
candidates.push(Format::DebianChangelog);
}
if dated_author(first) {
candidates.push(Format::GnuChangeLog);
}
let scanned = markdown::scan(input)?;
markdown_candidates(input, &scanned.blocks, &mut candidates);
finish(&candidates)
}
fn finish(candidates: &[Format]) -> Result<Format, DetectionError> {
let order = [
Format::KeepAChangelog,
Format::ConventionalChangelog,
Format::GenericMarkdown,
Format::GitHubReleaseNotes,
Format::Changesets,
Format::Towncrier,
Format::DebianChangelog,
Format::GnuNews,
Format::GnuChangeLog,
];
let candidates: Vec<_> = order
.into_iter()
.filter(|format| candidates.contains(format))
.collect();
match candidates.as_slice() {
[] => Err(DetectionError::Unrecognized),
[format] => Ok(*format),
_ => Err(DetectionError::Ambiguous { candidates }),
}
}
fn github_json(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Array(items) => items.iter().any(github_object),
_ => github_object(value),
}
}
fn github_object(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Object(object) => {
object.contains_key("tag_name") || object.contains_key("tagName")
}
_ => false,
}
}
fn fragment_name(name: &str) -> bool {
let filename = name.rsplit(['/', '\\']).next().unwrap_or(name);
let mut parts = filename.split('.');
let (Some(issue), Some(kind)) = (parts.next(), parts.next()) else {
return false;
};
!issue.is_empty()
&& kind.starts_with(char::is_alphabetic)
&& (issue.bytes().all(|byte| byte.is_ascii_digit())
|| (issue.starts_with('+') && issue.len() > 1))
&& !matches!(kind.to_ascii_lowercase().as_str(), "md" | "rst" | "txt")
}
fn debian(line: &str) -> bool {
line.split_once(" (").is_some_and(|(package, rest)| {
!package.is_empty()
&& !package.contains(char::is_whitespace)
&& rest.contains(") ")
&& rest.contains("; urgency=")
})
}
fn dated_author(line: &str) -> bool {
line.get(..10)
.zip(line.get(10..))
.is_some_and(|(date, author)| {
date.bytes().enumerate().all(|(index, byte)| {
if matches!(index, 4 | 7) {
byte == b'-'
} else {
byte.is_ascii_digit()
}
}) && author.starts_with(char::is_whitespace)
&& !author.trim().is_empty()
})
}
fn markdown_candidates(input: &str, blocks: &[Block], candidates: &mut Vec<Format>) {
let visible = visible_markers(input);
let headings: Vec<_> = blocks
.iter()
.filter_map(|block| match block {
Block::Heading(heading) => Some(heading),
Block::Entry(..) => None,
})
.collect();
let numeric_parenthesized = headings
.iter()
.any(|heading| heading.text.contains(" (") && histories::looks_like_release(&heading.text));
let conventional_categories = headings.iter().any(|heading| {
matches!(
heading.text.to_ascii_lowercase().as_str(),
"features"
| "bug fixes"
| "performance improvements"
| "âš breaking changes"
| "breaking changes"
)
});
for heading in &headings {
heading_candidates(heading, candidates);
}
if numeric_parenthesized && conventional_categories {
candidates.push(Format::ConventionalChangelog);
}
let github_heading = headings.iter().any(|heading| {
matches!(
heading.text.to_ascii_lowercase().as_str(),
"what's changed" | "new contributors"
)
});
if github_heading {
candidates.push(Format::GitHubReleaseNotes);
if !visible.github_url {
candidates.push(Format::GenericMarkdown);
}
}
for block in blocks {
if let Block::Entry(range, EntryKind::ListItem) = block
&& let Some(text) = input.get(range.clone())
&& (text.starts_with("* Noteworthy changes in release ")
|| text.starts_with("* Changes in version ")
|| text.starts_with("* Changes in release ")
|| text.starts_with("* Version "))
{
candidates.push(Format::GnuNews);
}
}
if input.starts_with("GNU ") && visible.gnu_version {
candidates.push(Format::GnuNews);
}
if candidates.is_empty() {
if numeric_parenthesized {
candidates.extend([
Format::ConventionalChangelog,
Format::Towncrier,
Format::GenericMarkdown,
]);
} else if !headings.is_empty()
|| blocks
.iter()
.any(|block| matches!(block, Block::Entry(_, EntryKind::ListItem)))
{
candidates.extend([
Format::GenericMarkdown,
Format::GitHubReleaseNotes,
Format::Towncrier,
]);
}
}
}
#[derive(Default)]
struct VisibleMarkers {
github_url: bool,
gnu_version: bool,
}
fn visible_markers(input: &str) -> VisibleMarkers {
let mut markers = VisibleMarkers::default();
let mut excluded: usize = 0;
for (event, range) in Parser::new(input).into_offset_iter() {
match event {
Event::Start(Tag::CodeBlock(_) | Tag::BlockQuote(_)) => {
excluded = excluded.saturating_add(1);
}
Event::End(TagEnd::CodeBlock | TagEnd::BlockQuote(_)) => {
excluded = excluded.saturating_sub(1);
}
Event::Text(text) if excluded == 0 => {
markers.github_url |= text.contains("github.com/");
markers.gnu_version |= text.starts_with("Version ")
&& (range.start == 0
|| input
.get(..range.start)
.is_some_and(|prefix| prefix.ends_with('\n')));
}
Event::Start(Tag::Link { dest_url, .. }) if excluded == 0 => {
markers.github_url |= dest_url.contains("github.com/");
}
_ => {}
}
}
markers
}
fn heading_candidates(heading: &Heading, candidates: &mut Vec<Format>) {
let text = heading.text.trim();
if heading.level == HeadingLevel::H2
&& !text.contains(" (")
&& (text.starts_with('[')
|| text.eq_ignore_ascii_case("unreleased")
|| text
.rsplit_once(" - ")
.is_some_and(|(name, _)| !name.contains(char::is_whitespace)))
{
candidates.push(Format::KeepAChangelog);
}
if heading.level == HeadingLevel::H3
&& matches!(text, "Major Changes" | "Minor Changes" | "Patch Changes")
{
candidates.push(Format::Changesets);
}
if let Some((name, _)) = text.rsplit_once(" (")
&& name.contains(char::is_whitespace)
&& let Some(version) = name.split_whitespace().last()
&& histories::looks_like_release(version)
{
candidates.push(Format::Towncrier);
}
if heading.level == HeadingLevel::H2
&& let Some((name, _)) = text.rsplit_once(" - ")
&& name.contains(char::is_whitespace)
&& !name.starts_with('[')
{
candidates.push(Format::GenericMarkdown);
}
}