Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for arbitrary separators, non-semver versions, disabling wrapping, and customizing wrapping character count #23

Merged
merged 9 commits into from
Oct 24, 2023
5 changes: 5 additions & 0 deletions .cl/22/arbitary-separators-and-non-semver-support/changes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
- added: "Add support for using an arbitrary separator character between the version and date of a release heading using the `--separator` flag"
- added: Add support for parsing non-semver versions
- added: "Add support for disabling wrapping of changelog release entries using the `--no-wrap` flag"
- added: "Add support for wrapping at a custom character count using the `--wrap-at` option"
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "A command line tool for parsing CHANGELOG.md files that use the K
keywords = ["changelog", "parser", "keepachangelog"]
categories = ["command-line-utilities", "development-tools", "text-processing"]
homepage = "https://github.com/marcaddeo/clparse"
readme = "README.md"
repository = "https://github.com/marcaddeo/clparse"
documentation = "https://github.com/marcaddeo/clparse#clparse"
authors = ["Marc Addeo <hi@marc.cx>"]
Expand All @@ -25,7 +26,6 @@ name = "clparse"
path = "src/main.rs"

[dependencies]
semver = { version = "0.10.0", features = ["serde"] }
pulldown-cmark = "0.5.3"
chrono = { version = "0.4.7", features = ["serde"] }
derive_builder = "0.7.2"
Expand All @@ -39,3 +39,4 @@ anyhow = "1.0.3"
err-derive = "0.2.4"
derive-getters = "0.1.0"
textwrap = "0.11.0"
versions = { version = "5.0.1", features = ["serde"] }
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,20 @@ Marc Addeo <hi@marc.cx>
A command line tool for parsing CHANGELOG.md files that use the Keep A Changelog format.

USAGE:
clparse [OPTIONS] <FILE>
clparse [FLAGS] [OPTIONS] <FILE>

FLAGS:
-h, --help Prints help information
-n, --no-wrap Disable wrapping of change entries of a release. By default, change entries are wrapped at 80
characters.
-V, --version Prints version information

OPTIONS:
-f, --format <format> Sets the output format of the parsed CHANGELOG [default: markdown] [possible values: json,
yaml, yml, markdown, md]
-f, --format <format> Sets the output format of the parsed CHANGELOG [default: markdown] [possible values:
json, yaml, yml, markdown, md]
-s, --separator <separator> Sets the separator character used between version and date in a release heading
[default: -]
-w, --wrap-at <wrap-at> Specify how many characters to wrap change entries at [default: 80]

ARGS:
<FILE> The CHANGELOG file to parse. This should be either a Markdown, JSON, or Yaml representation of a
Expand Down
69 changes: 58 additions & 11 deletions src/changelog.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use anyhow::Result;
use err_derive::Error;
use chrono::NaiveDate;
use derive_builder::Builder;
use derive_getters::Getters;
use err_derive::Error;
use indexmap::indexmap;
use semver::Version;
use versions::Version;
use serde::ser::Serializer;
use serde_derive::{Deserialize, Serialize};
use std::fmt;
use textwrap::wrap;
Expand All @@ -26,9 +27,20 @@ pub enum ChangeError {
InvalidChangeType(String),
}

fn version_serialize<S>(x: &Option<Version>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match x {
Some(ref version) => s.serialize_str(&version.to_string()),
None => s.serialize_none(),
}
}

#[derive(Debug, Clone, Builder, Getters, Serialize, Deserialize, PartialEq)]
pub struct Release {
#[builder(setter(strip_option), default)]
#[serde(serialize_with = "version_serialize")]
version: Option<Version>,
#[builder(setter(strip_option, into), default)]
link: Option<String>,
Expand All @@ -38,6 +50,18 @@ pub struct Release {
changes: Vec<Change>,
#[builder(default = "false")]
yanked: bool,
#[serde(skip)]
#[builder(default = "self.default_separator()")]
separator: String,
#[serde(skip)]
#[builder(default = "80.into()")]
wrap: Option<usize>,
}

impl ReleaseBuilder {
fn default_separator(&self) -> String {
"-".into()
}
}

impl Release {
Expand Down Expand Up @@ -98,7 +122,8 @@ pub struct Changelog {

impl Changelog {
pub fn unreleased_changes(&self) -> Vec<Change> {
self.releases.clone()
self.releases
.clone()
.into_iter()
.filter(|r| r.version.is_none())
.map(|r| r.changes.clone())
Expand All @@ -107,12 +132,12 @@ impl Changelog {
}

pub fn unreleased_mut(&mut self) -> Option<&mut Release> {
self.releases.iter_mut()
.find(|r| r.version == None)
self.releases.iter_mut().find(|r| r.version == None)
}

pub fn release_mut(&mut self, release: Version) -> Option<&mut Release> {
self.releases.iter_mut()
self.releases
.iter_mut()
.find(|r| r.version == Some(release.clone()))
}
}
Expand Down Expand Up @@ -145,8 +170,6 @@ impl fmt::Display for Change {
Fixed(description) => description,
Security(description) => description,
};
let description = description.as_str().replace("\n", " ");
let description = wrap(&description, 77).join("\n ");

fmt.write_str(&format!("- {}\n", description))?;

Expand All @@ -164,9 +187,9 @@ impl fmt::Display for Release {
// Release Version.
if let (Some(version), Some(date)) = (self.version.as_ref(), self.date) {
if self.yanked {
fmt.write_str(&format!("{} - {} [YANKED]\n", version, date))?;
fmt.write_str(&format!("{} {} {} [YANKED]\n", version, self.separator, date))?;
} else {
fmt.write_str(&format!("[{}] - {}\n", version, date))?;
fmt.write_str(&format!("[{}] {} {}\n", version, self.separator, date))?;
}
} else {
fmt.write_str("[Unreleased]\n")?;
Expand All @@ -177,6 +200,30 @@ impl fmt::Display for Release {
}

// Release changes.
let mut changes = self.changes.clone();

// If wrapping is enabled, we regenerate the list of changes and wrap
// them.
if let Some(wrap_at) = self.wrap {
changes = changes.into_iter().map(|change| {
let (change_type, mut description) = match change {
Added(description) => ("added", description),
Changed(description) => ("changed", description),
Deprecated(description) => ("deprecated", description),
Removed(description) => ("removed", description),
Fixed(description) => ("fixed", description),
Security(description) => ("security", description),
};

description = description.replace("\n", " ");
// The first 3 characters are not included in this change description,
// so we need to wrap at 3 less characters than expected.
description = wrap(&description, wrap_at - 3).join("\n ");

Change::new(change_type, description.to_string()).unwrap()
}).collect();
}

let mut changesets = indexmap! {
"Added" => Vec::new(),
"Changed" => Vec::new(),
Expand All @@ -185,7 +232,7 @@ impl fmt::Display for Release {
"Fixed" => Vec::new(),
"Security" => Vec::new(),
};
self.changes.iter().for_each(|change| match change {
changes.iter().for_each(|change| match change {
Added(_) => changesets.get_mut("Added").unwrap().push(change),
Changed(_) => changesets.get_mut("Changed").unwrap().push(change),
Deprecated(_) => changesets.get_mut("Deprecated").unwrap().push(change),
Expand Down
101 changes: 61 additions & 40 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use anyhow::Result;
use err_derive::Error;
use changelog::{Change, Changelog, ChangelogBuilder, Release, ReleaseBuilder};
use chrono::NaiveDate;
use err_derive::Error;
use pulldown_cmark::{Event, LinkType, Parser, Tag};
use semver::Version;
use versions::Version;
use std::fs::File;
use std::io::prelude::*;
use std::path::PathBuf;
Expand Down Expand Up @@ -35,26 +35,37 @@ pub enum ChangelogParserError {
ErrorBuildingRelease(String),
}

pub struct ChangelogParser;
pub struct ChangelogParser {
separator: String,
wrap: Option<usize>,
}

impl ChangelogParser {
pub fn parse(path: PathBuf) -> Result<Changelog> {
pub fn new(separator: String, wrap: Option<usize>) -> Self {
Self {
separator,
wrap,
}
}

pub fn parse(&self, path: PathBuf) -> Result<Changelog> {
let mut document = String::new();
File::open(path.clone())?.read_to_string(&mut document)?;
Self::parse_buffer(document)
self.parse_buffer(document)
}

pub fn parse_buffer(buffer: String) -> Result<Changelog> {
pub fn parse_buffer(&self, buffer: String) -> Result<Changelog> {
match Self::get_format_from_buffer(buffer.clone()) {
Ok(format) => match format {
ChangelogFormat::Markdown => Self::parse_markdown(buffer),
ChangelogFormat::Markdown => self.parse_markdown(buffer),
ChangelogFormat::Json => Self::parse_json(buffer),
ChangelogFormat::Yaml => Self::parse_yaml(buffer),
},
_ => Err(ChangelogParserError::UnableToDetermineFormat.into()),
}
}

fn parse_markdown(markdown: String) -> Result<Changelog> {
fn parse_markdown(&self, markdown: String) -> Result<Changelog> {
let parser = Parser::new(&markdown);

let mut section = ChangelogSection::None;
Expand All @@ -81,11 +92,8 @@ impl ChangelogParser {
accumulator = String::new();
}
ChangelogSection::Changeset(_) | ChangelogSection::ReleaseHeader => {
release.changes(changeset.clone());
releases.push(release.build().map_err(ChangelogParserError::ErrorBuildingRelease)?);

changeset = Vec::new();
release = ReleaseBuilder::default();
self.parse_release_header(&mut release, &mut accumulator);
self.build_release(&mut releases, &mut release, &mut changeset)?;
}
_ => (),
}
Expand Down Expand Up @@ -148,42 +156,19 @@ impl ChangelogParser {
link_accumulator.push_str(&text);
}
}
ChangelogSection::ReleaseHeader => {
let text = text.trim();

if text == "YANKED" {
release.yanked(true);
}

let mut date_format = "- %Y-%m-%d";
let split: Vec<&str> = text.split(" - ").collect();

if split.iter().count() > 1 {
date_format = "%Y-%m-%d";
}

for string in split {
if let Ok(date) = NaiveDate::parse_from_str(&string, date_format) {
release.date(date);
}

if let Ok(version) = Version::parse(&string) {
release.version(version);
}
}
}
ChangelogSection::ChangesetHeader => {
self.parse_release_header(&mut release, &mut accumulator);

section = ChangelogSection::Changeset(text.to_string())
}
ChangelogSection::Changeset(_) => accumulator.push_str(&text),
ChangelogSection::Changeset(_) | ChangelogSection::ReleaseHeader => accumulator.push_str(&text),
_ => (),
},
_ => (),
};
}

release.changes(changeset.clone());
releases.push(release.build().map_err(ChangelogParserError::ErrorBuildingRelease)?);
self.build_release(&mut releases, &mut release, &mut changeset)?;

if !description_links.is_empty() {
description = format!("{}{}\n", description, description_links);
Expand All @@ -199,6 +184,42 @@ impl ChangelogParser {
Ok(changelog)
}

fn parse_release_header(&self, release: &mut ReleaseBuilder, accumulator: &mut String) {
let delimiter = format!(" {} ", self.separator);
if let Some((left, right)) = accumulator.trim().split_once(&delimiter) {
if right.contains("YANKED") {
release.yanked(true);
}

let right = &right.replace(" [YANKED]", "");
if let Ok(date) = NaiveDate::parse_from_str(&right, "%Y-%m-%d") {
release.date(date);
}

if let Some(version) = Version::new(&left) {
release.version(version);
}
}

*accumulator = String::new();
}

fn build_release(&self, releases: &mut Vec<Release>, release: &mut ReleaseBuilder, changeset: &mut Vec<Change>) -> Result<()> {
release.changes(changeset.clone());
release.separator(self.separator.clone());
release.wrap(self.wrap);
releases.push(
release
.build()
.map_err(ChangelogParserError::ErrorBuildingRelease)?
);

*changeset = Vec::new();
*release = ReleaseBuilder::default();

Ok(())
}

fn parse_json(json: String) -> Result<Changelog> {
let changelog: Changelog = serde_json::from_str(&json)?;

Expand Down
Loading