use crate::normalize_version_words::{
replace_ignore_case, replace_unusual_characters, words_to_operators,
};
use itertools::Itertools;
use regex::Regex;
use semver::{Comparator, Op, VersionReq};
pub fn normalize_version_range(range: impl Into<String>) -> String {
let range = range.into();
let original = range.clone();
if parses(&range) {
return upper_bound_lists_to_branches(&range);
}
let range = replace_unusual_characters(&range);
let range = drop_notes_in_parentheses(&range);
let range = drop_v_prefixes(&range);
if is_every_version(&range) {
return "*".to_string();
}
let range = words_to_operators(&range);
let normalized = range
.split("||")
.map(normalize_alternative)
.filter(|alternative| !alternative.is_empty())
.join(" || ");
if normalized.is_empty() {
return original.to_string();
}
normalized
}
fn parses(range: &str) -> bool {
range
.split("||")
.all(|alternative| VersionReq::parse(alternative.trim()).is_ok())
}
fn drop_notes_in_parentheses(range: &str) -> String {
let without_note = match (range.find('('), range.rfind(')')) {
(Some(open), Some(close)) if open < close => {
format!("{}{}", &range[..open], &range[close + 1..])
}
(Some(open), _) => range[..open].to_string(),
_ => range.to_string(),
};
without_note.trim().to_string()
}
fn drop_v_prefixes(range: &str) -> String {
let v_before_digit = Regex::new(r"\b[vV](\d)").expect("the pattern is a constant");
v_before_digit.replace_all(range, "$1").to_string()
}
fn is_every_version(range: &str) -> bool {
let lowercase = range.trim().to_ascii_lowercase();
lowercase == "all" || lowercase == "all versions"
}
pub fn bound_every_version(versions: &str, operator: &str, repeated_phrase: &str) -> String {
let versions = if repeated_phrase.is_empty() {
versions.to_string()
} else {
replace_ignore_case(versions, &format!(" {repeated_phrase}"), "")
};
let versions = replace_ignore_case(&versions, " and ", ",");
versions
.split(',')
.map(str::trim)
.filter(|version| !version.is_empty())
.map(|version| format!("{operator} {version}"))
.join(", ")
}
pub fn interval_to_requirement(range: &str) -> Option<String> {
let words = range.split_whitespace().collect::<Vec<_>>();
let looks_like_version =
|word: &str| word.starts_with(|c: char| c.is_ascii_digit()) && !word.contains(',');
let looks_like_variable = |word: &str| word.starts_with(|c: char| c.is_ascii_alphabetic());
let is_less_than = |word: &str| word == "<" || word == "<=";
let flipped = |operator: &str| if operator == "<" { ">" } else { ">=" };
match words.as_slice() {
[from, lower_operator, variable, upper_operator, to]
if looks_like_version(from)
&& looks_like_version(to)
&& looks_like_variable(variable)
&& is_less_than(lower_operator)
&& is_less_than(upper_operator) =>
{
Some(format!(
"{} {from}, {upper_operator} {to}",
flipped(lower_operator)
))
}
[from, operator, to]
if looks_like_version(from) && looks_like_version(to) && is_less_than(operator) =>
{
Some(format!(">= {from}, {operator} {to}"))
}
_ => None,
}
}
fn normalize_alternative(alternative: &str) -> String {
let items = alternative
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.collect::<Vec<_>>();
let has_digit = |item: &&str| item.chars().any(|c| c.is_ascii_digit());
let some_item_has_a_digit = items.iter().any(has_digit);
let requirements = items
.iter()
.filter(|item| !some_item_has_a_digit || has_digit(item))
.map(|item| item_to_comparators(item))
.collect::<Vec<_>>();
let every_item_is_one_comparator = requirements
.iter()
.all(|comparators| comparators.len() == 1 && !comparators[0].starts_with("= "));
if every_item_is_one_comparator {
return group_into_branches(requirements.into_iter().flatten().collect());
}
requirements
.into_iter()
.map(group_into_branches)
.join(" || ")
}
fn item_to_comparators(item: &str) -> Vec<String> {
let item = drop_label(item);
if let Some((from, to)) = item.split_once(" - ") {
let (from, to) = (from.trim(), to.trim());
let both_are_versions = [from, to]
.iter()
.all(|side| side.starts_with(|c: char| c.is_ascii_digit()));
if both_are_versions {
return vec![
format!(">= {}", fix_version(from)),
format!("<= {}", fix_version(to)),
];
}
}
split_at_operators(&item)
.iter()
.map(|piece| format_comparator(piece))
.collect()
}
fn drop_label(item: &str) -> String {
let start = item
.find(|c: char| c.is_ascii_digit() || is_operator_char(c))
.unwrap_or(0);
let label = &item[..start];
let label_is_separate_words =
label.ends_with(' ') && !label.chars().any(|c| c.is_ascii_digit());
if label_is_separate_words {
item[start..].to_string()
} else {
item.to_string()
}
}
fn split_at_operators(item: &str) -> Vec<String> {
let mut pieces: Vec<String> = vec![];
let mut previous_is_operator = true;
for c in item.chars() {
let starts_operator = is_operator_char(c) && !previous_is_operator;
if starts_operator || pieces.is_empty() {
pieces.push(String::new());
}
pieces.last_mut().unwrap().push(c);
previous_is_operator = is_operator_char(c);
}
pieces
.iter()
.map(|piece| piece.trim().to_string())
.filter(|piece| !piece.is_empty())
.collect()
}
fn format_comparator(piece: &str) -> String {
let operator_end = piece
.find(|c: char| !is_operator_char(c))
.unwrap_or(piece.len());
let (operator, version) = piece.split_at(operator_end);
let version = fix_version(version.trim());
if operator.is_empty() {
format!("= {version}")
} else {
format!("{operator} {version}")
}
}
fn is_operator_char(c: char) -> bool {
matches!(c, '<' | '>' | '=' | '~' | '^')
}
fn fix_version(version: &str) -> String {
let version = version.split('+').next().unwrap_or(version);
let already_accepted = VersionReq::parse(version).is_ok();
let has_hyphen = version.contains('-');
if already_accepted || has_hyphen {
return version.to_string();
}
let build_stamp = Regex::new(r"^(\d+\.\d+\.\d+)\.v\d+$").expect("the pattern is a constant");
let without_stamp = build_stamp.replace(version, "$1");
match lenient_semver::parse(&without_stamp) {
Ok(parsed) => {
let mut fixed = format!("{}.{}.{}", parsed.major, parsed.minor, parsed.patch);
if !parsed.pre.is_empty() {
fixed = format!("{fixed}-{}", parsed.pre);
}
fixed
}
Err(_) => version.to_string(),
}
}
fn group_into_branches(comparators: Vec<String>) -> String {
let mut branches: Vec<Vec<String>> = vec![];
for comparator in comparators {
let starts_new_branch = comparator.starts_with('>') && !branches.is_empty();
if starts_new_branch || branches.is_empty() {
branches.push(vec![]);
}
branches.last_mut().unwrap().push(comparator);
}
branches
.iter()
.map(|branch| {
let requirement = branch.join(", ");
upper_bounds_to_branches(&requirement).unwrap_or(requirement)
})
.join(" || ")
}
fn upper_bound_lists_to_branches(range: &str) -> String {
let alternatives = range.split("||").map(str::trim).collect::<Vec<_>>();
let has_upper_bound_list = alternatives
.iter()
.any(|alternative| upper_bounds_to_branches(alternative).is_some());
if !has_upper_bound_list {
return range.to_string();
}
alternatives
.iter()
.map(|alternative| {
upper_bounds_to_branches(alternative).unwrap_or_else(|| alternative.to_string())
})
.join(" || ")
}
fn upper_bounds_to_branches(requirement: &str) -> Option<String> {
let parsed = VersionReq::parse(requirement).ok()?;
let comparators = &parsed.comparators;
let all_upper_bounds = comparators
.iter()
.all(|c| matches!(c.op, Op::Less | Op::LessEq));
let all_full_versions = comparators
.iter()
.all(|c| c.minor.is_some() && c.patch.is_some() && c.pre.is_empty());
let all_different_branches = comparators
.iter()
.map(|c| (c.major, c.minor))
.unique()
.count()
== comparators.len();
let is_upper_bound_list =
comparators.len() >= 2 && all_upper_bounds && all_full_versions && all_different_branches;
if !is_upper_bound_list {
return None;
}
Some(comparators.iter().map(upper_bound_to_branch).join(" || "))
}
fn upper_bound_to_branch(comparator: &Comparator) -> String {
let operator = if comparator.op == Op::LessEq {
"<="
} else {
"<"
};
let (major, minor) = (comparator.major, comparator.minor.unwrap());
let patch = comparator.patch.unwrap();
format!(">= {major}.{minor}.0, {operator} {major}.{minor}.{patch}")
}