aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-semver/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-17 01:31:51 +0900
committernsfisis <nsfisis@gmail.com>2026-05-17 01:31:51 +0900
commitedf934ba867fa917dd29e4fa2cc86bbef37da96b (patch)
tree2fb60ee5cf3186a60b5db5018e6f4cf6681ee9fd /crates/shirabe-semver/src
parentccc62145d5ca22d22ff01bf387d6fd3d27ab87b5 (diff)
downloadphp-shirabe-edf934ba867fa917dd29e4fa2cc86bbef37da96b.tar.gz
php-shirabe-edf934ba867fa917dd29e4fa2cc86bbef37da96b.tar.zst
php-shirabe-edf934ba867fa917dd29e4fa2cc86bbef37da96b.zip
feat(port): port VersionParser.php
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-semver/src')
-rw-r--r--crates/shirabe-semver/src/version_parser.rs738
1 files changed, 738 insertions, 0 deletions
diff --git a/crates/shirabe-semver/src/version_parser.rs b/crates/shirabe-semver/src/version_parser.rs
index c02e4b9..eefc65e 100644
--- a/crates/shirabe-semver/src/version_parser.rs
+++ b/crates/shirabe-semver/src/version_parser.rs
@@ -1 +1,739 @@
//! ref: composer/vendor/composer/semver/src/VersionParser.php
+
+use crate::constraint::constraint::Constraint;
+use crate::constraint::constraint_interface::ConstraintInterface;
+use crate::constraint::match_all_constraint::MatchAllConstraint;
+use crate::constraint::multi_constraint::MultiConstraint;
+use shirabe_php_shim as php;
+
+// Regex to match pre-release data (sort of).
+//
+// Due to backwards compatibility:
+// - Instead of enforcing hyphen, an underscore, dot or nothing at all are also accepted.
+// - Only stabilities as recognized by Composer are allowed to precede a numerical identifier.
+// - Numerical-only pre-release identifiers are not supported, see tests.
+//
+// |--------------|
+// [major].[minor].[patch] -[pre-release] +[build-metadata]
+const MODIFIER_REGEX: &str =
+ "[._-]?(?:(stable|beta|b|RC|alpha|a|patch|pl|p)((?:[.-]?\\d+)*+)?)?([.-]?dev)?";
+
+const STABILITIES_REGEX: &str = "stable|RC|beta|alpha|dev";
+
+#[derive(Debug)]
+pub struct VersionParser;
+
+impl VersionParser {
+ pub fn parse_stability(version: &str) -> String {
+ let version = php::preg_replace("{#.+$}", "", version).unwrap_or_default();
+
+ if version.starts_with("dev-") || version.ends_with("-dev") {
+ return "dev".to_string();
+ }
+
+ let pattern = format!("{{{}(?:\\+.*)?$}}i", MODIFIER_REGEX);
+ let lower = php::strtolower(&version);
+ let mut match_: Vec<Option<String>> = Vec::new();
+ php::preg_match(&pattern, &lower, &mut match_);
+
+ // match_[3] = the ([.-]?dev)? capture
+ if match_.get(3).and_then(|o| o.as_deref()).is_some_and(|s| !s.is_empty()) {
+ return "dev".to_string();
+ }
+
+ // match_[1] = the (stable|beta|b|RC|alpha|a|patch|pl|p) capture
+ let m1 = match_.get(1).and_then(|o| o.as_deref()).unwrap_or("");
+ if !m1.is_empty() {
+ if m1 == "beta" || m1 == "b" {
+ return "beta".to_string();
+ }
+ if m1 == "alpha" || m1 == "a" {
+ return "alpha".to_string();
+ }
+ if m1 == "rc" {
+ return "RC".to_string();
+ }
+ }
+
+ "stable".to_string()
+ }
+
+ pub fn normalize_stability(stability: &str) -> anyhow::Result<String> {
+ let stability = php::strtolower(stability);
+
+ if !["stable", "rc", "beta", "alpha", "dev"].contains(&stability.as_str()) {
+ anyhow::bail!(
+ "Invalid stability string \"{}\", expected one of stable, RC, beta, alpha or dev",
+ stability
+ );
+ }
+
+ Ok(if stability == "rc" {
+ "RC".to_string()
+ } else {
+ stability
+ })
+ }
+
+ pub fn normalize(&self, version: &str, full_version: Option<&str>) -> anyhow::Result<String> {
+ let version = php::trim(version, None);
+ let orig_version = version.clone();
+ let full_version = full_version.unwrap_or(&version).to_string();
+ let mut version = version;
+
+ // strip off aliasing
+ let mut match_: Vec<Option<String>> = Vec::new();
+ if php::preg_match("{^([^,\\s]++) ++as ++([^,\\s]++)$}", &version, &mut match_) > 0 {
+ version = match_[1].clone().unwrap_or_default();
+ }
+
+ // strip off stability flag
+ let stab_pattern = format!("{{@(?:{})$}}i", STABILITIES_REGEX);
+ let mut match_: Vec<Option<String>> = Vec::new();
+ if php::preg_match(&stab_pattern, &version, &mut match_) > 0 {
+ let match0_len = match_[0].as_deref().unwrap_or("").len();
+ version = version[..version.len() - match0_len].to_string();
+ }
+
+ // normalize master/trunk/default branches to dev-name for BC with 1.x as these used to
+ // be valid constraints
+ if ["master", "trunk", "default"].contains(&version.as_str()) {
+ version = format!("dev-{}", version);
+ }
+
+ // if requirement is branch-like, use full name
+ if version.to_ascii_lowercase().starts_with("dev-") {
+ return Ok(format!("dev-{}", &version[4..]));
+ }
+
+ // strip off build metadata
+ let mut match_: Vec<Option<String>> = Vec::new();
+ if php::preg_match("{^([^,\\s+]++)\\+[^\\s]++$}", &version, &mut match_) > 0 {
+ version = match_[1].clone().unwrap_or_default();
+ }
+
+ let mut index: Option<usize> = None;
+ let mut matches: Vec<Option<String>> = Vec::new();
+
+ // match classical versioning
+ let classical_pattern =
+ format!("{{^v?(\\d{{1,5}}+)(\\.\\d++)?(\\.\\d++)?(\\.\\d++)?{}$}}i", MODIFIER_REGEX);
+ if php::preg_match(&classical_pattern, &version, &mut matches) > 0 {
+ let m2 = matches[2].as_deref().unwrap_or("");
+ let m3 = matches[3].as_deref().unwrap_or("");
+ let m4 = matches[4].as_deref().unwrap_or("");
+ version = format!(
+ "{}{}{}{}",
+ matches[1].as_deref().unwrap_or(""),
+ if m2.is_empty() { ".0" } else { m2 },
+ if m3.is_empty() { ".0" } else { m3 },
+ if m4.is_empty() { ".0" } else { m4 },
+ );
+ index = Some(5);
+ } else {
+ // match date(time) based versioning
+ let datetime_pattern = format!(
+ "{{^v?(\\d{{4}}(?:[.:-]?\\d{{2}}){{1,6}}(?:[.:-]?\\d{{1,3}}){{0,2}}){}$}}i",
+ MODIFIER_REGEX
+ );
+ if php::preg_match(&datetime_pattern, &version, &mut matches) > 0 {
+ version = php::preg_replace(
+ "{\\D}",
+ ".",
+ matches[1].as_deref().unwrap_or(""),
+ )
+ .unwrap_or_default();
+ index = Some(2);
+ }
+ }
+
+ // add version modifiers if a version was matched
+ if let Some(idx) = index {
+ let mi = matches.get(idx).and_then(|o| o.as_deref()).unwrap_or("");
+ if !mi.is_empty() {
+ if mi == "stable" {
+ return Ok(version);
+ }
+ let mi1 = matches.get(idx + 1).and_then(|o| o.as_deref()).unwrap_or("");
+ version = format!(
+ "{}-{}{}",
+ version,
+ self.expand_stability(mi),
+ if !mi1.is_empty() {
+ mi1.trim_start_matches(['.', '-'])
+ } else {
+ ""
+ }
+ );
+ }
+
+ if !matches
+ .get(idx + 2)
+ .and_then(|o| o.as_deref())
+ .unwrap_or("")
+ .is_empty()
+ {
+ version = format!("{}-dev", version);
+ }
+
+ return Ok(version);
+ }
+
+ // match dev branches
+ let mut match_: Vec<Option<String>> = Vec::new();
+ if php::preg_match("{(.*?)[.-]?dev$}i", &version, &mut match_) > 0 {
+ let branch_name = match_[1].clone().unwrap_or_default();
+ // a branch ending with -dev is only valid if it is numeric
+ // if it gets prefixed with dev- it means the branch name should
+ // have had a dev- prefix already when passed to normalize
+ if let Ok(normalized) = self.normalize_branch(&branch_name) {
+ if !normalized.starts_with("dev-") {
+ return Ok(normalized);
+ }
+ }
+ }
+
+ let extra_message = if php::preg_match(
+ &format!(
+ "{{ +as +{}(?:@(?:{}))?$}}",
+ php::preg_quote(&version, None),
+ STABILITIES_REGEX
+ ),
+ &full_version,
+ &mut Vec::new(),
+ ) > 0
+ {
+ format!(" in \"{}\", the alias must be an exact version", full_version)
+ } else if php::preg_match(
+ &format!(
+ "{{^{}(?:@(?:{}))? +as +}}",
+ php::preg_quote(&version, None),
+ STABILITIES_REGEX
+ ),
+ &full_version,
+ &mut Vec::new(),
+ ) > 0
+ {
+ format!(
+ " in \"{}\", the alias source must be an exact version, if it is a branch name \
+ you should prefix it with dev-",
+ full_version
+ )
+ } else {
+ String::new()
+ };
+
+ anyhow::bail!("Invalid version string \"{}\"{}", orig_version, extra_message)
+ }
+
+ pub fn parse_numeric_alias_prefix(&self, branch: &str) -> Option<String> {
+ let mut matches: Vec<Option<String>> = Vec::new();
+ // matches['version'] == matches[1] ((?P<version>...) is group 1)
+ if php::preg_match(
+ "{^(?P<version>(\\d++\\.)*\\d++)(?:\\.x)?-dev$}i",
+ branch,
+ &mut matches,
+ ) > 0
+ {
+ let version = matches[1].clone().unwrap_or_default();
+ return Some(format!("{}.", version));
+ }
+
+ None
+ }
+
+ pub fn normalize_branch(&self, name: &str) -> anyhow::Result<String> {
+ let name = php::trim(name, None);
+
+ let mut matches: Vec<Option<String>> = Vec::new();
+ // Groups: 1=major, 2=".minor"(outer), 3=minor(inner), 4=".patch"(outer),
+ // 5=patch(inner), 6=".fourth"(outer), 7=fourth(inner).
+ // We use the outer groups [1,2,4,6] to replicate PHP's groups [1,2,3,4].
+ if php::preg_match(
+ "{^v?(\\d++)(\\.(\\d++|[xX*]))?(\\.(\\d++|[xX*]))?(\\.(\\d++|[xX*]))?$}i",
+ &name,
+ &mut matches,
+ ) > 0
+ {
+ let mut version = String::new();
+ for i in [1usize, 2, 4, 6] {
+ if let Some(Some(m)) = matches.get(i) {
+ version.push_str(&m.replace(['*', 'X'], "x"));
+ } else {
+ version.push_str(".x");
+ }
+ }
+
+ return Ok(format!("{}-dev", version.replace('x', "9999999")));
+ }
+
+ Ok(format!("dev-{}", name))
+ }
+
+ #[deprecated(
+ note = "No need to use this anymore in theory, Composer 2 does not normalize any \
+ branch names to 9999999-dev anymore"
+ )]
+ pub fn normalize_default_branch(&self, name: &str) -> String {
+ if name == "dev-master" || name == "dev-default" || name == "dev-trunk" {
+ return "9999999-dev".to_string();
+ }
+
+ name.to_string()
+ }
+
+ pub fn parse_constraints(
+ &self,
+ constraints: &str,
+ ) -> anyhow::Result<Box<dyn ConstraintInterface>> {
+ let pretty_constraint = constraints.to_string();
+
+ let or_constraints = php::preg_split(
+ "{\\s*\\|\\|?\\s*}",
+ &php::trim(constraints, None),
+ )
+ .ok_or_else(|| anyhow::anyhow!("Failed to preg_split string: {}", constraints))?;
+
+ let mut or_groups: Vec<Box<dyn ConstraintInterface>> = Vec::new();
+
+ for or_constraint in &or_constraints {
+ let and_constraints = php::preg_split(
+ "{(?<!^|as|[=>< ,]) *(?<!-)[, ](?!-) *(?!,|as|$)}",
+ or_constraint,
+ )
+ .ok_or_else(|| {
+ anyhow::anyhow!("Failed to preg_split string: {}", or_constraint)
+ })?;
+
+ let constraint_objects: Vec<Box<dyn ConstraintInterface>> =
+ if and_constraints.len() > 1 {
+ let mut objs: Vec<Box<dyn ConstraintInterface>> = Vec::new();
+ for and_constraint in &and_constraints {
+ for parsed in self.parse_constraint(and_constraint)? {
+ objs.push(parsed);
+ }
+ }
+ objs
+ } else {
+ self.parse_constraint(&and_constraints[0])?
+ };
+
+ let constraint: Box<dyn ConstraintInterface> = if constraint_objects.len() == 1 {
+ constraint_objects.into_iter().next().unwrap()
+ } else {
+ Box::new(MultiConstraint::new(constraint_objects, true)?)
+ };
+
+ or_groups.push(constraint);
+ }
+
+ let mut parsed_constraint = MultiConstraint::create(or_groups, false)?;
+
+ parsed_constraint.set_pretty_string(Some(pretty_constraint));
+
+ Ok(parsed_constraint)
+ }
+
+ fn parse_constraint(
+ &self,
+ constraint: &str,
+ ) -> anyhow::Result<Vec<Box<dyn ConstraintInterface>>> {
+ let mut constraint = constraint.to_string();
+
+ // strip off aliasing
+ let mut match_: Vec<Option<String>> = Vec::new();
+ if php::preg_match("{^([^,\\s]++) ++as ++([^,\\s]++)$}", &constraint, &mut match_) > 0 {
+ constraint = match_[1].clone().unwrap_or_default();
+ }
+
+ // strip @stability flags, and keep it for later use
+ let mut stability_modifier: Option<String> = None;
+ let mut match_: Vec<Option<String>> = Vec::new();
+ let stab_pattern = format!("{{^([^,\\s]*?)@({})$}}i", STABILITIES_REGEX);
+ if php::preg_match(&stab_pattern, &constraint, &mut match_) > 0 {
+ let m1 = match_[1].as_deref().unwrap_or("");
+ constraint = if !m1.is_empty() {
+ m1.to_string()
+ } else {
+ "*".to_string()
+ };
+ let m2 = match_[2].as_deref().unwrap_or("");
+ if m2 != "stable" {
+ stability_modifier = Some(m2.to_string());
+ }
+ }
+
+ // get rid of #refs as those are used by composer only
+ let mut match_: Vec<Option<String>> = Vec::new();
+ if php::preg_match(
+ "{^(dev-[^,\\s@]+?|[^,\\s@]+?\\.x-dev)#.+$}i",
+ &constraint,
+ &mut match_,
+ ) > 0
+ {
+ constraint = match_[1].clone().unwrap_or_default();
+ }
+
+ let mut match_: Vec<Option<String>> = Vec::new();
+ if php::preg_match("{^(v)?[xX*](\\.[xX*])*$}i", &constraint, &mut match_) > 0 {
+ let m1_nonempty = !match_.get(1).and_then(|o| o.as_deref()).unwrap_or("").is_empty();
+ let m2_nonempty = !match_.get(2).and_then(|o| o.as_deref()).unwrap_or("").is_empty();
+ if m1_nonempty || m2_nonempty {
+ return Ok(vec![Box::new(Constraint::new(
+ ">=".to_string(),
+ "0.0.0.0-dev".to_string(),
+ )?)]);
+ }
+
+ return Ok(vec![Box::new(MatchAllConstraint { pretty_string: None })]);
+ }
+
+ let version_regex = format!(
+ "v?(\\d++)(?:\\.(\\d++))?(?:\\.(\\d++))?(?:\\.(\\d++))?(?:{}|\\.([xX*][.-]?dev))(?:\\+[^\\s]+)?",
+ MODIFIER_REGEX
+ );
+
+ // Tilde Range
+ //
+ // Like wildcard constraints, unsuffixed tilde constraints say that they must be greater
+ // than the previous version, to ensure that unstable instances of the current version are
+ // allowed. However, if a stability suffix is added to the constraint, then a >= match on
+ // the current version is used instead.
+ let mut matches: Vec<Option<String>> = Vec::new();
+ let tilde_pattern = format!("{{^~>?{}$}}i", version_regex);
+ if php::preg_match(&tilde_pattern, &constraint, &mut matches) > 0 {
+ if constraint.starts_with("~>") {
+ anyhow::bail!(
+ "Could not parse version constraint {}: Invalid operator \"~>\", you probably \
+ meant to use the \"~\" operator",
+ constraint
+ );
+ }
+
+ // Work out which position in the version we are operating at
+ let mut position = if !matches[4].as_deref().unwrap_or("").is_empty() {
+ 4
+ } else if !matches[3].as_deref().unwrap_or("").is_empty() {
+ 3
+ } else if !matches[2].as_deref().unwrap_or("").is_empty() {
+ 2
+ } else {
+ 1
+ };
+
+ // when matching 2.x-dev or 3.0.x-dev we have to shift the second or third number,
+ // despite no second/third number matching above
+ if !matches[8].as_deref().unwrap_or("").is_empty() {
+ position += 1;
+ }
+
+ // Calculate the stability suffix
+ let stability_suffix =
+ if matches[5].as_deref().unwrap_or("").is_empty()
+ && matches[7].as_deref().unwrap_or("").is_empty()
+ && matches[8].as_deref().unwrap_or("").is_empty()
+ {
+ "-dev"
+ } else {
+ ""
+ };
+
+ let low_version = self.normalize(
+ &format!("{}{}", &constraint[1..], stability_suffix),
+ None,
+ )?;
+ let lower_bound = Constraint::new(">=".to_string(), low_version)?;
+
+ // For upper bound, we increment the position of one more significance,
+ // but highPosition = 0 would be illegal
+ let high_position = std::cmp::max(1, position - 1);
+ let high_version = format!(
+ "{}-dev",
+ self.manipulate_version_string(&matches, high_position, 1, "0")
+ .unwrap_or_default()
+ );
+ let upper_bound = Constraint::new("<".to_string(), high_version)?;
+
+ return Ok(vec![Box::new(lower_bound), Box::new(upper_bound)]);
+ }
+
+ // Caret Range
+ //
+ // Allows changes that do not modify the left-most non-zero digit in the [major, minor,
+ // patch] tuple. In other words, this allows patch and minor updates for versions 1.0.0
+ // and above, patch updates for versions 0.X >=0.1.0, and no updates for versions 0.0.X
+ let mut matches: Vec<Option<String>> = Vec::new();
+ let caret_pattern = format!("{{^\\^{}($)}}i", version_regex);
+ if php::preg_match(&caret_pattern, &constraint, &mut matches) > 0 {
+ // Work out which position in the version we are operating at
+ let m1 = matches[1].as_deref().unwrap_or("");
+ let m2 = matches[2].as_deref().unwrap_or("");
+ let m3 = matches[3].as_deref().unwrap_or("");
+ let position = if m1 != "0" || m2.is_empty() {
+ 1
+ } else if m2 != "0" || m3.is_empty() {
+ 2
+ } else {
+ 3
+ };
+
+ // Calculate the stability suffix
+ let stability_suffix =
+ if matches[5].as_deref().unwrap_or("").is_empty()
+ && matches[7].as_deref().unwrap_or("").is_empty()
+ && matches[8].as_deref().unwrap_or("").is_empty()
+ {
+ "-dev"
+ } else {
+ ""
+ };
+
+ let low_version = self.normalize(
+ &format!("{}{}", &constraint[1..], stability_suffix),
+ None,
+ )?;
+ let lower_bound = Constraint::new(">=".to_string(), low_version)?;
+
+ // For upper bound, we increment the position of one more significance,
+ // but highPosition = 0 would be illegal
+ let high_version = format!(
+ "{}-dev",
+ self.manipulate_version_string(&matches, position, 1, "0")
+ .unwrap_or_default()
+ );
+ let upper_bound = Constraint::new("<".to_string(), high_version)?;
+
+ return Ok(vec![Box::new(lower_bound), Box::new(upper_bound)]);
+ }
+
+ // X Range
+ //
+ // Any of X, x, or * may be used to "stand in" for one of the numeric values in the
+ // [major, minor, patch] tuple. A partial version range is treated as an X-Range, so the
+ // special character is in fact optional.
+ let mut matches: Vec<Option<String>> = Vec::new();
+ if php::preg_match(
+ "{^v?(\\d++)(?:\\.(\\d++))?(?:\\.(\\d++))?(?:\\.[xX*])++$}",
+ &constraint,
+ &mut matches,
+ ) > 0
+ {
+ let position = if !matches[3].as_deref().unwrap_or("").is_empty() {
+ 3
+ } else if !matches[2].as_deref().unwrap_or("").is_empty() {
+ 2
+ } else {
+ 1
+ };
+
+ let low_version = format!(
+ "{}-dev",
+ self.manipulate_version_string(&matches, position, 0, "0")
+ .unwrap_or_default()
+ );
+ let high_version = format!(
+ "{}-dev",
+ self.manipulate_version_string(&matches, position, 1, "0")
+ .unwrap_or_default()
+ );
+
+ if low_version == "0.0.0.0-dev" {
+ return Ok(vec![Box::new(Constraint::new("<".to_string(), high_version)?)]);
+ }
+
+ return Ok(vec![
+ Box::new(Constraint::new(">=".to_string(), low_version)?),
+ Box::new(Constraint::new("<".to_string(), high_version)?),
+ ]);
+ }
+
+ // Hyphen Range
+ //
+ // Specifies an inclusive set. If a partial version is provided as the first version in
+ // the inclusive range, then the missing pieces are replaced with zeroes. If a partial
+ // version is provided as the second version in the inclusive range, then all versions
+ // that start with the supplied parts of the tuple are accepted, but nothing that would
+ // be greater than the provided tuple parts.
+ let mut matches: Vec<Option<String>> = Vec::new();
+ let hyphen_pattern = format!(
+ "{{^(?P<from>{}) +- +(?P<to>{})($)}}i",
+ version_regex, version_regex
+ );
+ if php::preg_match(&hyphen_pattern, &constraint, &mut matches) > 0 {
+ // matches[1]='from' string, matches[2..9]=from captures, matches[10]='to' string,
+ // matches[11..18]=to captures, matches[19]='($)'
+ // matches[6]=from stability, matches[8]=from dev, matches[9]=from wildcard-dev
+ let low_stability_suffix =
+ if matches[6].as_deref().unwrap_or("").is_empty()
+ && matches[8].as_deref().unwrap_or("").is_empty()
+ && matches[9].as_deref().unwrap_or("").is_empty()
+ {
+ "-dev"
+ } else {
+ ""
+ };
+
+ let from_str = matches[1].clone().unwrap_or_default(); // matches['from']
+ let low_version = self.normalize(&from_str, None)?;
+ let lower_bound = Constraint::new(
+ ">=".to_string(),
+ format!("{}{}", low_version, low_stability_suffix),
+ )?;
+
+ // PHP's empty() on "0" returns true, but here we only check for truly empty/missing
+ let empty = |x: &Option<String>| -> bool {
+ x.as_deref().map_or(true, |s| s.is_empty())
+ };
+
+ // matches[12]=to minor, matches[13]=to patch, matches[15]=to stability,
+ // matches[17]=to dev, matches[18]=to wildcard-dev
+ let upper_bound: Constraint = if (!empty(&matches[12]) && !empty(&matches[13]))
+ || !matches[15].as_deref().unwrap_or("").is_empty()
+ || !matches[17].as_deref().unwrap_or("").is_empty()
+ || !matches[18].as_deref().unwrap_or("").is_empty()
+ {
+ let to_str = matches[10].clone().unwrap_or_default(); // matches['to']
+ let hv = self.normalize(&to_str, None)?;
+ Constraint::new("<=".to_string(), hv)?
+ } else {
+ // matches[11]=to major, matches[12]=to minor, matches[13]=to patch,
+ // matches[14]=to fourth
+ let high_match = vec![
+ Some(String::new()),
+ matches[11].clone(),
+ matches[12].clone(),
+ matches[13].clone(),
+ matches[14].clone(),
+ ];
+
+ // validate to version
+ let to_str = matches[10].clone().unwrap_or_default(); // matches['to']
+ self.normalize(&to_str, None)?;
+
+ let position = if empty(&matches[12]) { 1 } else { 2 };
+ let hv = format!(
+ "{}-dev",
+ self.manipulate_version_string(&high_match, position, 1, "0")
+ .unwrap_or_default()
+ );
+ Constraint::new("<".to_string(), hv)?
+ };
+
+ return Ok(vec![Box::new(lower_bound), Box::new(upper_bound)]);
+ }
+
+ // Basic Comparators
+ let mut match_: Vec<Option<String>> = Vec::new();
+ if php::preg_match("{^(<>|!=|>=?|<=?|==?)?\\s*(.*)}", &constraint, &mut match_) > 0 {
+ let version_str = match_[2].clone().unwrap_or_default();
+ let op_str = match_[1].clone().unwrap_or_default();
+
+ let version_result: anyhow::Result<String> = (|| {
+ match self.normalize(&version_str, None) {
+ Ok(v) => Ok(v),
+ Err(e) => {
+ // recover from an invalid constraint like foobar-dev which should be
+ // dev-foobar except if the constraint uses a known operator, in which
+ // case it must be a parse error
+ if version_str.ends_with("-dev")
+ && php::preg_match(
+ "{^[0-9a-zA-Z-./]+$}",
+ &version_str,
+ &mut Vec::new(),
+ ) > 0
+ {
+ self.normalize(
+ &format!("dev-{}", &version_str[..version_str.len() - 4]),
+ None,
+ )
+ } else {
+ Err(e)
+ }
+ }
+ }
+ })();
+
+ if let Ok(mut version) = version_result {
+ let op = if op_str.is_empty() { "=" } else { &op_str };
+
+ if op != "==" && op != "=" {
+ if let Some(ref stab_mod) = stability_modifier {
+ if Self::parse_stability(&version) == "stable" {
+ version = format!("{}-{}", version, stab_mod);
+ }
+ }
+ }
+ if op == "<" || op == ">=" {
+ let modifier_pattern = format!("{{-{}$}}", MODIFIER_REGEX);
+ if php::preg_match(
+ &modifier_pattern,
+ &php::strtolower(&version_str),
+ &mut Vec::new(),
+ ) == 0
+ && !version_str.starts_with("dev-")
+ {
+ version = format!("{}-dev", version);
+ }
+ }
+
+ let final_op = if op_str.is_empty() {
+ "=".to_string()
+ } else {
+ op_str
+ };
+ return Ok(vec![Box::new(Constraint::new(final_op, version)?)]);
+ }
+ }
+
+ anyhow::bail!("Could not parse version constraint {}", constraint)
+ }
+
+ fn manipulate_version_string(
+ &self,
+ matches: &[Option<String>],
+ position: usize,
+ increment: i64,
+ pad: &str,
+ ) -> Option<String> {
+ let mut parts: [i64; 5] = [
+ 0,
+ matches.get(1).and_then(|o| o.as_deref()).unwrap_or("0").parse().unwrap_or(0),
+ matches.get(2).and_then(|o| o.as_deref()).unwrap_or("0").parse().unwrap_or(0),
+ matches.get(3).and_then(|o| o.as_deref()).unwrap_or("0").parse().unwrap_or(0),
+ matches.get(4).and_then(|o| o.as_deref()).unwrap_or("0").parse().unwrap_or(0),
+ ];
+ let pad_val: i64 = pad.parse().unwrap_or(0);
+ let mut position = position;
+
+ for i in (1..=4).rev() {
+ if i > position {
+ parts[i] = pad_val;
+ } else if i == position && increment != 0 {
+ parts[i] += increment;
+ // If $matches[$i] was 0, carry the decrement
+ if parts[i] < 0 {
+ parts[i] = pad_val;
+ // Return null on a carry overflow
+ if i == 1 {
+ return None;
+ }
+ position -= 1;
+ }
+ }
+ }
+
+ Some(format!("{}.{}.{}.{}", parts[1], parts[2], parts[3], parts[4]))
+ }
+
+ fn expand_stability(&self, stability: &str) -> String {
+ let stability = stability.to_ascii_lowercase();
+
+ match stability.as_str() {
+ "a" => "alpha".to_string(),
+ "b" => "beta".to_string(),
+ "p" | "pl" => "patch".to_string(),
+ "rc" => "RC".to_string(),
+ _ => stability,
+ }
+ }
+}