From 43eba56766e697a0fcdb43e63835bbc608552d22 Mon Sep 17 00:00:00 2001 From: Paul Delafosse Date: Sat, 2 Oct 2021 06:52:26 +0200 Subject: [PATCH] fix(scope): add support for multiple version placeholder and buidmetatata in hooks [#117] --- Cargo.lock | 2 + Cargo.toml | 2 + src/hook/mod.rs | 140 ++++++++++++--- src/hook/parser.rs | 349 +++++++++++--------------------------- src/hook/version_dsl.pest | 33 ++++ src/lib.rs | 2 + 6 files changed, 262 insertions(+), 266 deletions(-) create mode 100644 src/hook/version_dsl.pest diff --git a/Cargo.lock b/Cargo.lock index 18f94c5e..bf5db86d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,6 +171,8 @@ dependencies = [ "indoc", "itertools", "lazy_static", + "pest", + "pest_derive", "predicates 1.0.8", "rand 0.7.3", "semver", diff --git a/Cargo.toml b/Cargo.toml index bcff74fa..08ddc289 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ lazy_static = "^1" toml = "^0" structopt = { version = "^0", optional = true } conventional_commit_parser = "^0" +pest = "2.1.3" +pest_derive = "2.1.0" [dev-dependencies] assert_cmd = "1.0.3" diff --git a/src/hook/mod.rs b/src/hook/mod.rs index 67672762..86b7701b 100644 --- a/src/hook/mod.rs +++ b/src/hook/mod.rs @@ -1,24 +1,86 @@ +use std::collections::VecDeque; use std::fmt; +use std::ops::Range; use std::process::Command; use std::str::FromStr; use anyhow::Result; use semver::Version; -use crate::hook::parser::HookExpr; +use parser::Token; mod parser; #[derive(Debug, Eq, PartialEq)] -enum Token { - Version, - LatestVersion, - Amount(u32), - Add, - Major, - Minor, - Patch, - AlphaNumeric(String), +pub struct VersionSpan { + range: Range, + tokens: VecDeque, +} + +impl VersionSpan { + pub fn build_version_str( + &mut self, + version: &Version, + latest: Option<&Version>, + ) -> Result { + // According to the pest grammar, a `version` or `latest_version` token is expected first + let mut version = match self.tokens.pop_front() { + Some(Token::Version) => Ok(version), + Some(Token::LatestVersion) => { + latest.ok_or_else(|| anyhow!("No previous tag found to replace {{latest}} version")) + } + _ => unreachable!("Unexpected parsing error"), + }? + .clone(); + + let mut amount = 1; + + while let Some(token) = self.tokens.pop_front() { + match token { + // reset the increment amount to default whenever we encounter a `+` token + Token::Add => amount = 1, + // set the desired amount + Token::Amount(amt) => amount = amt, + // increments ... + Token::Major => { + version.major += amount; + version.minor = 0; + version.patch = 0; + } + Token::Minor => { + version.minor += amount; + version.patch = 0; + } + Token::Patch => version.patch += amount, + // set build metadata and prerelease + Token::PreRelease(pre_release) => version.pre = pre_release, + Token::BuildMetadata(build) => version.build = build, + _ => unreachable!("Unexpected parsing error"), + } + } + + Ok(version.to_string()) + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct HookSpan { + version_spans: Vec, + content: String, +} + +impl HookSpan { + fn replace_versions(&mut self, version: &Version, latest: Option<&Version>) -> Result { + let mut output = self.content.clone(); + if let Some(mut span) = self.version_spans.pop() { + let version_str = span.build_version_str(version, latest)?; + let version_str = version_str.as_str(); + output.replace_range(span.range.clone(), version_str); + output = parser::parse(&output)?.replace_versions(version, latest)?; + } + + Ok(output) + } } #[derive(Debug)] @@ -52,13 +114,16 @@ impl Hook { .map(Result::ok) .flatten(); - for i in 0..self.0.len() { - if let Some((range, version)) = - HookExpr::parse_version(&self.0[i], current_version.clone(), next_version.clone()) - { - self.0[i].replace_range(range, &version); - } - } + let parts = self + .0 + .iter() + .map(|part| parser::parse(part)) + .map(Result::unwrap) + .map(|mut span| span.replace_versions(&next_version, current_version.as_ref())) + .map(Result::unwrap) + .collect(); + + self.0 = parts; Ok(()) } @@ -76,10 +141,12 @@ impl Hook { #[cfg(test)] mod test { + use std::str::FromStr; + + use speculoos::prelude::*; + use crate::Hook; use crate::Result; - use speculoos::prelude::*; - use std::str::FromStr; #[test] fn parse_empty_string() { @@ -163,4 +230,39 @@ mod test { assert_eq!(&hook.0, &["coco", "chore", "bump snapshot to 1.1.0-pre"]); Ok(()) } + + #[test] + fn replace_version_with_multiple_placeholders() -> Result<()> { + let mut hook = Hook::from_str("echo \"the latest {{latest}}, the greatest {{version}}\"")?; + hook.insert_versions(Some("0.5.9".to_string()), "1.0.0") + .unwrap(); + + assert_eq!(&hook.0, &["echo", "the latest 0.5.9, the greatest 1.0.0"]); + Ok(()) + } + + #[test] + fn replace_version_with_multiple_placeholders_and_increments() -> Result<()> { + let mut hook = Hook::from_str( + "echo \"the latest {{latest+3major+1minor}}, the greatest {{version+2patch}}\"", + )?; + hook.insert_versions(Some("0.5.9".to_string()), "1.0.0") + .unwrap(); + + assert_eq!(&hook.0, &["echo", "the latest 3.1.0, the greatest 1.0.2"]); + Ok(()) + } + + #[test] + fn replace_version_with_pre_and_build_metadata() -> Result<()> { + let mut hook = + Hook::from_str("echo \"the latest {{version+1major-pre.alpha-bravo+build.42}}\"")?; + hook.insert_versions(None, "1.0.0").unwrap(); + + assert_eq!( + &hook.0, + &["echo", "the latest 2.0.0-pre.alpha-bravo+build.42"] + ); + Ok(()) + } } diff --git a/src/hook/parser.rs b/src/hook/parser.rs index c87d1a6e..66850886 100644 --- a/src/hook/parser.rs +++ b/src/hook/parser.rs @@ -1,286 +1,141 @@ use std::collections::VecDeque; -use anyhow::Result; -use semver::Version; +use pest::iterators::{Pair, Pairs}; +use semver::{BuildMetadata, Prerelease}; -use crate::hook::Token; -use std::ops::Range; +use crate::hook::{HookSpan, VersionSpan}; +use pest::Parser; -pub const DELIMITER_START: &str = "{{"; -pub const DELIMITER_END: &str = "}}"; +#[doc(hidden)] +#[derive(Parser)] +#[grammar = "hook/version_dsl.pest"] +struct HookDslParser; #[derive(Debug, Eq, PartialEq)] -pub struct HookExpr { - src: String, - tokens: VecDeque, +pub enum Token { + Version, + LatestVersion, + Amount(u64), + Add, + Major, + Minor, + Patch, + PreRelease(semver::Prerelease), + BuildMetadata(semver::BuildMetadata), } -impl Token { - fn parse(src: &str) -> Result<(Token, &str)> { - if let Some(remains) = src.strip_prefix("version") { - Ok((Token::Version, remains)) - } else if let Some(remains) = src.strip_prefix("latest") { - Ok((Token::LatestVersion, remains)) - } else if let Some(remains) = src.strip_prefix('+') { - Ok((Token::Add, remains)) - } else if let Some(remains) = src.strip_prefix("major") { - Ok((Token::Major, remains)) - } else if let Some(remains) = src.strip_prefix("minor") { - Ok((Token::Minor, remains)) - } else if let Some(remains) = src.strip_prefix("patch") { - Ok((Token::Patch, remains)) - } else if src[0..1].parse::().is_ok() { - let mut position = 1; - while src[position..position + 1].parse::().is_ok() { - position += 1; - } - match src[0..position].parse::() { - Ok(amount) => Ok((Token::Amount(amount), &src[position..])), - Err(e) => Err(anyhow!("{}", e)), - } - } else { - Ok((Token::AlphaNumeric(src.to_string()), "")) - } - } -} +pub fn parse(hook: &str) -> anyhow::Result { + let pairs = HookDslParser::parse(Rule::version_dsl, hook)? + .next() + .unwrap(); -impl HookExpr { - pub fn parse_version( - src: &str, - current_version: Option, - next_version: Version, - ) -> Option<(Range, String)> { - if let Some((range, mut expression)) = HookExpr::scan_hook_entry(src) { - expression.tokenize(); - expression - .calculate_version(current_version, next_version) - .ok() - .map(|exp| (range, exp)) - } else { - None - } - } + let mut span = HookSpan { + version_spans: vec![], + content: pairs.as_str().to_string(), + }; - fn from_str(src: &str) -> Self { - HookExpr { - src: src.to_string(), - tokens: VecDeque::new(), + for pair in pairs.into_inner() { + if pair.as_rule() == Rule::version { + let version_span = parse_version(pair)?; + span.version_spans.push(version_span); } } - fn scan_hook_entry(hook_entry: &str) -> Option<(Range, HookExpr)> { - match hook_entry.find(DELIMITER_START) { - Some(start) => hook_entry.find(DELIMITER_END).map(|end| { - let range = start..end + DELIMITER_END.len(); - let expression = - HookExpr::from_str(&hook_entry[start + DELIMITER_START.len()..end]); - (range, expression) - }), - None => None, - } - } + Ok(span) +} - fn tokenize(&mut self) { - let mut src = self.src.as_str(); - while !src.is_empty() { - if let Ok((token, remains)) = Token::parse(src) { - self.tokens.push_back(token); - src = remains +fn parse_version(pair: Pair) -> anyhow::Result { + let mut tokens = VecDeque::new(); + + let start = pair.as_span().start(); + let end = pair.as_span().end(); + + for pair in pair.into_inner() { + match pair.as_rule() { + Rule::current_version => tokens.push_back(Token::Version), + Rule::latest_version => tokens.push_back(Token::LatestVersion), + Rule::ops => parse_operator(&mut tokens, pair.into_inner())?, + Rule::pre_release => { + let identifiers = pair.into_inner().next().unwrap(); + let semver_pre_release = Prerelease::new(identifiers.as_str())?; + tokens.push_back(Token::PreRelease(semver_pre_release)); } + Rule::build_metadata => { + let identifiers = pair.into_inner().next().unwrap(); + let semver_build_meta = BuildMetadata::new(identifiers.as_str())?; + tokens.push_back(Token::BuildMetadata(semver_build_meta)); + } + _ => (), } } - fn increment_major(version: Version, amt: u32) -> Version { - let mut version = version; - for _ in 0..amt { - version.major += 1 - } - - version - } - - fn increment_patch(version: Version, amt: u32) -> Version { - let mut version = version; - for _ in 0..amt { - version.patch += 1 - } - - version - } - - fn increment_minor(version: Version, amt: u32) -> Version { - let mut version = version; - for _ in 0..amt { - version.minor += 1 - } - - version - } - - fn calculate_version( - &mut self, - current_version: Option, - next_version: Version, - ) -> Result { - ensure!(!self.tokens.is_empty(), "Hook expression must not be empty"); - - let mut version = match self.tokens.pop_front() { - Some(Token::Version) => Ok(next_version), - Some(Token::LatestVersion) => current_version - .ok_or_else(|| anyhow!("Not previous tag found to replace {{latest}} version")), - _ => Err(anyhow!( - "Hook expression must start with \"version\" or \"latest\"" - )), - }?; + Ok(VersionSpan { + range: start..end, + tokens, + }) +} - while let Some(token) = self.tokens.pop_front() { - match token { - Token::Add => version = self.calculate_increment(version)?, - Token::AlphaNumeric(string) => { - let output = [version.to_string(), string].join(""); - return Ok(output); - } - _ => return Err(anyhow!("Unexpected token in hook expression:{:?}", token)), - }; +fn parse_operator(tokens: &mut VecDeque, pairs: Pairs<'_, Rule>) -> anyhow::Result<()> { + for pair in pairs { + match pair.as_rule() { + Rule::add => tokens.push_back(Token::Add), + Rule::amt => tokens.push_back(Token::Amount(str::parse::(pair.as_str()).unwrap())), + Rule::major => tokens.push_back(Token::Major), + Rule::minor => tokens.push_back(Token::Minor), + Rule::patch => tokens.push_back(Token::Patch), + _ => (), } - - Ok(version.to_string()) } - fn parse_amount(&mut self) -> u32 { - let amt = if let Some(Token::Amount(amt)) = self.tokens.get(0) { - *amt - } else { - 1 - }; - - self.tokens.pop_front(); - amt - } - - fn calculate_increment(&mut self, version: Version) -> Result { - let amt = self.parse_amount(); - let token = self.tokens.pop_front(); - match token { - None => Err(anyhow!("Missing token after operator")), - Some(token) => match token { - Token::Major => Ok(HookExpr::increment_major(version, amt)), - Token::Minor => Ok(HookExpr::increment_minor(version, amt)), - Token::Patch => Ok(HookExpr::increment_patch(version, amt)), - _ => Err(anyhow!("Unexpected token in hook expression:{:?}", token)), - }, - } - } + Ok(()) } #[cfg(test)] -mod tests { - use std::collections::VecDeque; - - use semver::Version; - - use crate::hook::parser::HookExpr; - use crate::hook::Token; - use anyhow::Result; +mod test { + use crate::hook::parser::Token; + use crate::hook::{parser, VersionSpan}; + use semver::Prerelease; use speculoos::prelude::*; + use std::collections::VecDeque; #[test] - fn scan_exp() { - let entry = "echo {{version+1major}}"; - - let (range, expr) = HookExpr::scan_hook_entry(entry).unwrap(); - assert_that!(range).is_equal_to(5..23); - - assert_that!(expr).is_equal_to(HookExpr { - src: "version+1major".to_string(), - tokens: VecDeque::new(), - }) - } - - #[test] - fn tokenize_exp() { - let entry = "echo {{version+minor}}"; - - let (range, mut expr) = HookExpr::scan_hook_entry(entry).unwrap(); - expr.tokenize(); - assert_that!(range).is_equal_to(5..22); - assert_that!(expr.tokens) - .is_equal_to(&vec![Token::Version, Token::Add, Token::Minor].into()) - } - - #[test] - fn tokenize_latest_version() { - let entry = "echo {{latest}}"; - - let (range, mut expr) = HookExpr::scan_hook_entry(entry).unwrap(); - expr.tokenize(); - assert_that!(range).is_equal_to(5..15); - assert_that!(expr.tokens).is_equal_to(&vec![Token::LatestVersion].into()) - } - - #[test] - fn tokenize_exp_with_amount() { - let entry = "echo {{version+2major}}"; - - let (range, mut expr) = HookExpr::scan_hook_entry(entry).unwrap(); - expr.tokenize(); - - assert_that!(range).is_equal_to(5..23); - assert_that!(expr.tokens) - .is_equal_to(&vec![Token::Version, Token::Add, Token::Amount(2), Token::Major].into()) - } - - #[test] - fn tokenize_exp_with_alpha() { - let entry = "echo {{version+33patch-rc}}"; - - let (range, mut expr) = HookExpr::scan_hook_entry(entry).unwrap(); - expr.tokenize(); - - assert_that!(range).is_equal_to(5..27); - assert_that!(expr.tokens).is_equal_to( - &vec![ - Token::Version, - Token::Add, - Token::Amount(33), - Token::Patch, - Token::AlphaNumeric("-rc".to_string()), - ] - .into(), - ) + fn parse_version_and_latest() { + let result = parser::parse("the latest {{latest+1minor}}, the greatest {{version+patch}}"); + assert_that!(result) + .is_ok() + .map(|span| &span.version_spans) + .contains(&VersionSpan { + range: 11..28, + tokens: VecDeque::from(vec![ + Token::LatestVersion, + Token::Add, + Token::Amount(1), + Token::Minor, + ]), + }); } #[test] - fn calculate_version() { - let mut hookexpr = HookExpr { - src: "echo {{version+33patch-rc}}".to_string(), - tokens: VecDeque::from(vec![ - Token::Version, - Token::Add, - Token::Amount(33), - Token::Patch, - Token::AlphaNumeric("-rc".to_string()), - ]), - }; - - let result = hookexpr.calculate_version(None, Version::new(1, 0, 0)); + fn parse_version_with_pre_release() { + let result = parser::parse("the greatest {{version+patch-pre.alpha0}}"); assert_that!(result) .is_ok() - .is_equal_to("1.0.33-rc".to_string()); + .map(|span| &span.version_spans) + .contains(&VersionSpan { + range: 13..41, + tokens: VecDeque::from(vec![ + Token::Version, + Token::Add, + Token::Patch, + Token::PreRelease(Prerelease::new("pre.alpha0").unwrap()), + ]), + }); } #[test] - fn increment_version() -> Result<()> { - let version = Version::parse("0.0.0")?; - let version = HookExpr::increment_major(version, 1); - assert_that!(version.major).is_equal_to(1); - - let version = HookExpr::increment_minor(version, 2); - assert_that!(version.minor).is_equal_to(2); - - let version = HookExpr::increment_patch(version, 5); - assert_that!(version.patch).is_equal_to(5); + fn invalid_dsl_is_err() { + let result = parser::parse("the greatest {{+patch-pre.alpha0}}"); - Ok(()) + assert_that!(result).is_err(); } } diff --git a/src/hook/version_dsl.pest b/src/hook/version_dsl.pest new file mode 100644 index 00000000..8b16127d --- /dev/null +++ b/src/hook/version_dsl.pest @@ -0,0 +1,33 @@ +// +// Created by intellij-pest on 2021-10-01 +// version-dsl +// Author: okno +// + +delimiter_start = _{ "{{" } +delimiter_end = _{ "}}" } +current_version = { "version" } +latest_version = { "latest" } + +add = { "+" } + +major = { "major" } +minor = { "minor" } +patch = { "patch" } + +amt = { NUMBER } +ops = { add ~ amt? ~ ( major | minor | patch ) } +ASCII_ALPHA_OR_HYPHEN = { ASCII_ALPHA | NUMBER | "-" } + +pre_release_separator = _{ "-" } +build_metadata_separator = _{ "+" } +dot = _{ "." } + +identifier = { ASCII_ALPHA_OR_HYPHEN+ } +identifiers = { identifier ~ (dot ~ identifier)* } +pre_release = { pre_release_separator ~ identifiers } +build_metadata = { build_metadata_separator ~ identifiers } + + +version = { delimiter_start ~ (current_version | latest_version) ~ ops* ~ pre_release? ~ build_metadata? ~ delimiter_end} +version_dsl = { SOI ~ (version | (!delimiter_start ~ ANY) )* ~ EOI } diff --git a/src/lib.rs b/src/lib.rs index f8a06336..f005daf4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,8 @@ extern crate anyhow; extern crate serde_derive; #[macro_use] extern crate lazy_static; +#[macro_use] +extern crate pest_derive; pub mod error;