semver_php/
parser.rs

1use crate::{
2	constraint::{Constraint, MatchAllConstraint, MultiConstraint, Operator, SingleConstraint},
3	error::{Result, SemverError},
4	version::{expand_stability, Stability},
5};
6use regex::Regex;
7use std::{cmp, sync::LazyLock};
8
9/// Pre-release modifier regex pattern.
10/// Matches: -beta, -b, -RC, -alpha, -a, -patch, -pl, -p with optional numbers
11const MODIFIER_PATTERN: &str =
12	r"[._-]?(?:(stable|beta|b|RC|alpha|a|patch|pl|p)((?:[.-]?\d+)*)?)?([.-]?dev)?";
13
14/// Stability extraction regex (matches modifier at end of version string)
15static STABILITY_EXTRACT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
16	Regex::new(&format!(r"(?i){MODIFIER_PATTERN}(?:\+.*)?$"))
17		.expect("Invalid stability extract regex")
18});
19
20/// Classical versioning: v1.2.3.4-beta5
21static CLASSICAL_VERSION_REGEX: LazyLock<Regex> = LazyLock::new(|| {
22	Regex::new(&format!(
23		r"(?i)^v?(\d{{1,5}})(\.\d+)?(\.\d+)?(\.\d+)?{MODIFIER_PATTERN}$"
24	))
25	.expect("Invalid classical version regex")
26});
27
28/// Date-based versioning: 2010.01.02
29static DATE_VERSION_REGEX: LazyLock<Regex> = LazyLock::new(|| {
30	Regex::new(&format!(
31		r"(?i)^v?(\d{{4}}(?:[.:-]?\d{{2}}){{1,6}}(?:[.:-]?\d{{1,3}}){{0,2}}){MODIFIER_PATTERN}$"
32	))
33	.expect("Invalid date version regex")
34});
35
36/// Numeric branch pattern: v1.x, 2.0.*, etc.
37static NUMERIC_BRANCH_REGEX: LazyLock<Regex> = LazyLock::new(|| {
38	Regex::new(r"(?i)^v?(\d+)(\.(?:\d+|[xX*]))?(\.(?:\d+|[xX*]))?(\.(?:\d+|[xX*]))?$")
39		.expect("Invalid numeric branch regex")
40});
41
42/// Alias pattern: "1.0 as 2.0"
43static ALIAS_REGEX: LazyLock<Regex> =
44	LazyLock::new(|| Regex::new(r"^([^,\s]+)\s+as\s+([^,\s]+)$").expect("Invalid alias regex"));
45
46/// Stability flag pattern: "@dev", "@stable", etc.
47static STABILITY_FLAG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
48	Regex::new(r"(?i)@(stable|RC|beta|alpha|dev)$").expect("Invalid stability flag regex")
49});
50
51/// Build metadata pattern: "+build123"
52static BUILD_METADATA_REGEX: LazyLock<Regex> =
53	LazyLock::new(|| Regex::new(r"^([^,\s+]+)\+[^\s]+$").expect("Invalid build metadata regex"));
54
55/// OR constraint splitter
56static OR_SPLITTER: LazyLock<Regex> =
57	LazyLock::new(|| Regex::new(r"\s*\|\|?\s*").expect("Invalid OR splitter regex"));
58
59/// Match all wildcard pattern
60static MATCH_ALL_REGEX: LazyLock<Regex> =
61	LazyLock::new(|| Regex::new(r"(?i)^(v)?[xX*](\.[xX*])*$").expect("Invalid match all regex"));
62
63/// Version regex for constraint parsing (with capture groups for version parts)
64const VERSION_REGEX_PATTERN: &str = r"v?(\d++)(?:\.(\d++))?(?:\.(\d++))?(?:\.(\d++))?(?:[._-]?(?:(stable|beta|b|RC|alpha|a|patch|pl|p)((?:[.-]?\d+)*)?)?([.-]?dev)?|\.([xX*][.-]?dev))(?:\+[^\s]+)?";
65
66/// Tilde constraint regex
67static TILDE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
68	Regex::new(&format!(r"(?i)^~>?{VERSION_REGEX_PATTERN}$")).expect("Invalid tilde regex")
69});
70
71/// Caret constraint regex
72static CARET_REGEX: LazyLock<Regex> = LazyLock::new(|| {
73	Regex::new(&format!(r"(?i)^\^{VERSION_REGEX_PATTERN}($)")).expect("Invalid caret regex")
74});
75
76/// X-range constraint regex
77static XRANGE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
78	Regex::new(r"(?i)^v?(\d++)(?:\.(\d++))?(?:\.(\d++))?(?:\.[xX*])++$")
79		.expect("Invalid x-range regex")
80});
81
82/// Hyphen range constraint regex
83static HYPHEN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
84	Regex::new(&format!(
85		r"(?i)^(?P<from>{VERSION_REGEX_PATTERN}) +- +(?P<to>{VERSION_REGEX_PATTERN})($)"
86	))
87	.expect("Invalid hyphen regex")
88});
89
90/// Basic comparator regex
91static COMPARATOR_REGEX: LazyLock<Regex> = LazyLock::new(|| {
92	Regex::new(r"^(<>|!=|>=?|<=?|==?)?\s*(.*)").expect("Invalid comparator regex")
93});
94
95/// Stability flag in constraint
96static STABILITY_CONSTRAINT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
97	Regex::new(r"(?i)^([^,\s]*?)@(stable|RC|beta|alpha|dev)$")
98		.expect("Invalid stability constraint regex")
99});
100
101/// Ref stripping regex (for dev branches with #ref)
102static REF_STRIP_REGEX: LazyLock<Regex> = LazyLock::new(|| {
103	Regex::new(r"(?i)^(dev-[^,\s@]+?|[^,\s@]+?\.x-dev)#.+$").expect("Invalid ref strip regex")
104});
105
106/// Version parser for Composer semver.
107#[derive(Debug, Clone, Default)]
108pub struct VersionParser;
109
110impl VersionParser {
111	/// Parse and return the stability of a version string.
112	pub fn parse_stability(version: &str) -> Stability {
113		// Strip refs like #abcd123
114		let version = version.split('#').next().unwrap_or(version);
115
116		// dev-* prefix or *-dev suffix
117		if version.to_lowercase().starts_with("dev-") || version.ends_with("-dev") {
118			return Stability::Dev;
119		}
120
121		// Use regex to extract modifier from version
122		let lower = version.to_lowercase();
123		if let Some(caps) = STABILITY_EXTRACT_REGEX.captures(&lower) {
124			// Check for dev suffix (group 3)
125			if caps.get(3).is_some() {
126				return Stability::Dev;
127			}
128
129			// Check for stability marker (group 1)
130			if let Some(stability_match) = caps.get(1) {
131				return match stability_match.as_str() {
132					"rc" => Stability::RC,
133					"beta" | "b" => Stability::Beta,
134					"alpha" | "a" => Stability::Alpha,
135					_ => Stability::Stable,
136				};
137			}
138		}
139
140		Stability::Stable
141	}
142
143	/// Normalize a stability string.
144	///
145	/// # Errors
146	/// Returns `SemverError::InvalidStability` if the string is not a valid stability.
147	pub fn normalize_stability(stability: &str) -> Result<String> {
148		let lower = stability.to_lowercase();
149		match lower.as_str() {
150			"rc" => Ok("RC".to_string()),
151			"dev" => Ok("dev".to_string()),
152			"beta" => Ok("beta".to_string()),
153			"alpha" => Ok("alpha".to_string()),
154			"stable" => Ok("stable".to_string()),
155			_ => Err(SemverError::InvalidStability(stability.to_string())),
156		}
157	}
158
159	/// Check if a version string is valid.
160	#[must_use]
161	pub fn is_valid(version: &str) -> bool {
162		Self::normalize(version).is_ok()
163	}
164
165	/// Normalize a version string to Composer's canonical form.
166	///
167	/// # Errors
168	/// Returns an error if the version string is not a valid version format.
169	pub fn normalize(version: &str) -> Result<String> {
170		Self::normalize_full(version, None)
171	}
172
173	/// Normalize a version string with optional full version context for error messages.
174	///
175	/// # Errors
176	/// Returns an error if the version string is not a valid version format.
177	#[allow(
178		clippy::missing_panics_doc,
179		reason = "All unwraps are safe due to regex match checks"
180	)]
181	pub fn normalize_full(version: &str, full_version: Option<&str>) -> Result<String> {
182		let version = version.trim();
183		let orig_version = version;
184		let full_version = full_version.unwrap_or(version);
185
186		// Strip off aliasing: "1.0 as 2.0" -> "1.0"
187		let version = ALIAS_REGEX
188			.captures(version)
189			.map_or(version, |caps| caps.get(1).map_or(version, |m| m.as_str()));
190
191		// Strip off stability flag: "1.0@dev" -> "1.0"
192		let version = STABILITY_FLAG_REGEX
193			.captures(version)
194			.map_or(version, |caps| {
195				&version[..version.len() - caps.get(0).unwrap().as_str().len()]
196			});
197
198		// Normalize master/trunk/default branches to dev-name for BC
199		let version = match version.to_lowercase().as_str() {
200			"master" | "trunk" | "default" => {
201				return Ok(format!("dev-{}", version.to_lowercase()));
202			},
203			_ => version,
204		};
205
206		// If requirement is branch-like, use full name
207		if version.to_lowercase().starts_with("dev-") {
208			return Ok(format!("dev-{}", &version[4..]));
209		}
210
211		// Strip off build metadata: "1.0+build123" -> "1.0"
212		let version = BUILD_METADATA_REGEX
213			.captures(version)
214			.map_or(version, |caps| caps.get(1).map_or(version, |m| m.as_str()));
215
216		// Try classical versioning: v1.2.3.4-beta5
217		if let Some(normalized) = Self::try_classical_version(version) {
218			return Ok(normalized);
219		}
220
221		// Try date-based versioning: 2010.01.02
222		if let Some(normalized) = Self::try_date_version(version) {
223			return Ok(normalized);
224		}
225
226		// Try dev branches: 1.x-dev, foo-dev
227		if let Some(normalized) = Self::try_dev_branch(version) {
228			return Ok(normalized);
229		}
230
231		Err(Self::make_error(orig_version, full_version))
232	}
233
234	/// Try to parse as classical version (1.2.3.4-beta5)
235	fn try_classical_version(version: &str) -> Option<String> {
236		let caps = CLASSICAL_VERSION_REGEX.captures(version)?;
237
238		let major = caps.get(1)?.as_str();
239		let minor = caps
240			.get(2)
241			.map_or(".0", |m| m.as_str())
242			.trim_start_matches('.');
243		let patch = caps
244			.get(3)
245			.map_or(".0", |m| m.as_str())
246			.trim_start_matches('.');
247		let extra = caps
248			.get(4)
249			.map_or(".0", |m| m.as_str())
250			.trim_start_matches('.');
251
252		let mut result = format!(
253			"{}.{}.{}.{}",
254			major,
255			if minor.is_empty() { "0" } else { minor },
256			if patch.is_empty() { "0" } else { patch },
257			if extra.is_empty() { "0" } else { extra }
258		);
259
260		// Add stability modifier
261		if let Some(stability_match) = caps.get(5) {
262			let stability = stability_match.as_str();
263			if stability.to_lowercase() != "stable" {
264				let expanded = expand_stability(stability);
265				result.push('-');
266				result.push_str(expanded);
267
268				// Add stability number if present
269				if let Some(stability_num) = caps.get(6) {
270					let num = stability_num.as_str().trim_start_matches(['.', '-']);
271					if !num.is_empty() {
272						result.push_str(num);
273					}
274				}
275			}
276		}
277
278		// Add dev suffix if present
279		if caps.get(7).is_some() {
280			result.push_str("-dev");
281		}
282
283		Some(result)
284	}
285
286	/// Try to parse as date-based version (2010.01.02)
287	fn try_date_version(version: &str) -> Option<String> {
288		let caps = DATE_VERSION_REGEX.captures(version)?;
289
290		let date_part = caps.get(1)?.as_str();
291		// Replace non-digit separators with dots
292		let normalized_date: String = date_part
293			.chars()
294			.map(|c| if c.is_ascii_digit() { c } else { '.' })
295			.collect();
296
297		// Remove consecutive dots and trailing dots
298		let mut result = String::new();
299		let mut last_was_dot = false;
300		for c in normalized_date.chars() {
301			if c == '.' {
302				if !last_was_dot {
303					result.push(c);
304					last_was_dot = true;
305				}
306			} else {
307				result.push(c);
308				last_was_dot = false;
309			}
310		}
311
312		// Trim trailing dots
313		while result.ends_with('.') {
314			result.pop();
315		}
316
317		// Add stability modifier if present
318		if let Some(stability_match) = caps.get(2) {
319			let stability = stability_match.as_str();
320			if stability.to_lowercase() != "stable" {
321				let expanded = expand_stability(stability);
322				result.push('-');
323				result.push_str(expanded);
324
325				if let Some(stability_num) = caps.get(3) {
326					let num = stability_num.as_str().trim_start_matches(['.', '-']);
327					if !num.is_empty() {
328						result.push_str(num);
329					}
330				}
331			}
332		}
333
334		// Add dev suffix if present
335		if caps.get(4).is_some() {
336			result.push_str("-dev");
337		}
338
339		Some(result)
340	}
341
342	/// Try to parse as dev branch (1.x-dev, foo-dev)
343	#[allow(clippy::case_sensitive_file_extension_comparisons)]
344	fn try_dev_branch(version: &str) -> Option<String> {
345		let lower = version.to_lowercase();
346		if !lower.ends_with("-dev") && !lower.ends_with(".dev") && !lower.ends_with("_dev") {
347			return None;
348		}
349
350		// Extract the base part (without -dev suffix) - all endings are 4 chars
351		let base = &version[..version.len() - 4];
352
353		// Try to normalize as a branch
354		let normalized = Self::normalize_branch(base);
355
356		// Only return if it's a numeric branch (not dev-prefixed)
357		if normalized.starts_with("dev-") {
358			None
359		} else {
360			Some(normalized)
361		}
362	}
363
364	/// Normalize a branch name.
365	pub fn normalize_branch(name: &str) -> String {
366		let name = name.trim();
367
368		// Try numeric branch pattern: v1.x, 2.0.*, etc.
369		if let Some(caps) = NUMERIC_BRANCH_REGEX.captures(name) {
370			let mut parts = Vec::with_capacity(4);
371
372			for i in 1..=4 {
373				let part = caps
374					.get(i)
375					.map_or("x", |m| m.as_str().trim_start_matches('.'));
376
377				let lower = part.to_lowercase();
378				let normalized = match lower.as_str() {
379					"*" | "x" => "9999999".to_string(),
380					_ => part.to_string(),
381				};
382				parts.push(normalized);
383			}
384
385			// Fill remaining parts with 9999999
386			while parts.len() < 4 {
387				parts.push("9999999".to_string());
388			}
389
390			// Replace any remaining x/* with 9999999
391			let version = parts.join(".");
392			let version = version.replace(['x', 'X', '*'], "9999999");
393
394			return format!("{version}-dev");
395		}
396
397		// Non-numeric branch: feature-foo -> dev-feature-foo
398		format!("dev-{name}")
399	}
400
401	/// Normalize a default branch name (master, trunk, default) to 9999999-dev.
402	///
403	/// This is deprecated in Composer 2 but still needed for sorting versions.
404	#[deprecated(
405		note = "No longer needed in Composer 2, which doesn't normalize branch names to 9999999-dev"
406	)]
407	#[must_use]
408	pub fn normalize_default_branch(name: &str) -> String {
409		if name == "dev-master" || name == "dev-default" || name == "dev-trunk" {
410			"9999999-dev".to_string()
411		} else {
412			name.to_string()
413		}
414	}
415
416	/// Extract numeric alias prefix from a branch name.
417	#[must_use]
418	#[allow(clippy::missing_panics_doc)]
419	pub fn parse_numeric_alias_prefix(branch: &str) -> Option<String> {
420		let re = Regex::new(r"(?i)^(?P<version>(\d+\.)*\d+)(?:\.x)?-dev$").expect("Invalid regex");
421		if let Some(caps) = re.captures(branch) {
422			if let Some(version) = caps.name("version") {
423				return Some(format!("{}.", version.as_str()));
424			}
425		}
426		None
427	}
428
429	/// Generate an error with helpful context.
430	fn make_error(orig_version: &str, full_version: &str) -> SemverError {
431		let mut extra_message = String::new();
432
433		// Check if this is an alias issue
434		if full_version.contains(" as ") {
435			if full_version.ends_with(&format!(" as {orig_version}")) {
436				extra_message =
437					format!(" in \"{full_version}\", the alias must be an exact version");
438			} else if full_version.starts_with(&format!("{orig_version} as ")) {
439				extra_message = format!(
440                    " in \"{full_version}\", the alias source must be an exact version, if it is a branch name you should prefix it with dev-"
441                );
442			}
443		}
444
445		SemverError::InvalidVersionWithContext {
446			version: orig_version.to_string(),
447			context: extra_message,
448		}
449	}
450
451	/// Parse version constraints string into a Constraint.
452	///
453	/// # Errors
454	/// Returns an error if the constraint string is empty or malformed.
455	#[allow(clippy::missing_panics_doc)]
456	pub fn parse_constraints(constraints: &str) -> Result<Box<dyn Constraint>> {
457		let constraints = constraints.trim();
458		if constraints.is_empty() {
459			return Err(SemverError::EmptyConstraint);
460		}
461
462		let pretty_constraint = constraints.to_string();
463
464		// Check for consecutive OR operators (triple pipes)
465		if constraints.contains("|||") {
466			return Err(SemverError::ConstraintParseFailed(
467				constraints.to_string(),
468				"Consecutive OR operators".to_string(),
469			));
470		}
471
472		// Split on OR operators (|| or |)
473		let or_parts: Vec<&str> = OR_SPLITTER.split(constraints).collect();
474
475		// Check for leading/trailing operators
476		if or_parts.first().is_some_and(|s| s.is_empty()) {
477			return Err(SemverError::ConstraintParseFailed(
478				constraints.to_string(),
479				"Leading || or | operator".to_string(),
480			));
481		}
482		if or_parts.last().is_some_and(|s| s.is_empty()) {
483			return Err(SemverError::ConstraintParseFailed(
484				constraints.to_string(),
485				"Trailing || or | operator".to_string(),
486			));
487		}
488
489		let mut or_groups: Vec<Box<dyn Constraint>> = Vec::new();
490
491		for or_constraint in or_parts {
492			let or_constraint = or_constraint.trim();
493			if or_constraint.is_empty() {
494				return Err(SemverError::ConstraintParseFailed(
495					constraints.to_string(),
496					"Empty constraint in OR group".to_string(),
497				));
498			}
499
500			// Check for consecutive operators (double comma, triple pipe, etc.)
501			if or_constraint.contains(",,")
502				|| or_constraint.contains(", ,")
503				|| or_constraint.contains(" ,,")
504			{
505				return Err(SemverError::ConstraintParseFailed(
506					constraints.to_string(),
507					"Consecutive comma operators".to_string(),
508				));
509			}
510
511			// Check for trailing comma
512			if or_constraint.trim_end().ends_with(',') {
513				return Err(SemverError::ConstraintParseFailed(
514					constraints.to_string(),
515					"Trailing comma operator".to_string(),
516				));
517			}
518
519			// Check for leading comma
520			if or_constraint.trim_start().starts_with(',') {
521				return Err(SemverError::ConstraintParseFailed(
522					constraints.to_string(),
523					"Leading comma operator".to_string(),
524				));
525			}
526
527			// Split on AND operators (comma or space)
528			let and_parts: Vec<&str> = Self::split_and_constraints(or_constraint);
529
530			// Check for leading/trailing AND operators
531			if and_parts.first().is_some_and(|s| s.is_empty()) {
532				return Err(SemverError::ConstraintParseFailed(
533					constraints.to_string(),
534					"Leading comma operator".to_string(),
535				));
536			}
537			if and_parts.last().is_some_and(|s| s.is_empty()) {
538				return Err(SemverError::ConstraintParseFailed(
539					constraints.to_string(),
540					"Trailing comma operator".to_string(),
541				));
542			}
543
544			let mut constraint_objects: Vec<Box<dyn Constraint>> = Vec::new();
545
546			for and_constraint in and_parts {
547				let and_constraint = and_constraint.trim();
548				if and_constraint.is_empty() {
549					return Err(SemverError::ConstraintParseFailed(
550						constraints.to_string(),
551						"Empty constraint in AND group (possibly double comma)".to_string(),
552					));
553				}
554				let parsed = Self::parse_constraint(and_constraint)?;
555				for c in parsed {
556					constraint_objects.push(c);
557				}
558			}
559
560			let constraint: Box<dyn Constraint> = if constraint_objects.len() == 1 {
561				constraint_objects.pop().unwrap()
562			} else {
563				Box::new(MultiConstraint::new(constraint_objects, true))
564			};
565
566			or_groups.push(constraint);
567		}
568
569		let mut parsed_constraint = MultiConstraint::create(or_groups, false);
570		parsed_constraint.set_pretty_string(pretty_constraint);
571
572		Ok(parsed_constraint)
573	}
574
575	/// Split a constraint string on AND operators (comma or space).
576	fn split_and_constraints(constraint: &str) -> Vec<&str> {
577		// Simple split - we need to handle spaces that are not part of operators
578		let mut parts = Vec::new();
579		let mut current_start = 0;
580		let mut in_hyphen_range = false;
581		let chars: Vec<char> = constraint.chars().collect();
582		let len = chars.len();
583		let mut i = 0;
584
585		while i < len {
586			let c = chars[i];
587
588			// Check for hyphen range (contains " - ")
589			if c == ' ' && i + 2 < len && chars[i + 1] == '-' && chars[i + 2] == ' ' {
590				in_hyphen_range = true;
591				i += 3;
592				continue;
593			}
594
595			// Check for " as " (alias) - don't split on this
596			if c == ' ' && i + 4 < len {
597				let next_chars: String = chars[i..i + 4].iter().collect();
598				if next_chars == " as " {
599					i += 4;
600					continue;
601				}
602			}
603
604			// Check for comma or space separator
605			if (c == ',' || c == ' ') && !in_hyphen_range {
606				// Skip if this is after an operator or 'as'
607				let before = &constraint[current_start..i].trim_end();
608				let after_start = i + 1;
609				let after = if after_start < len {
610					constraint[after_start..].trim_start()
611				} else {
612					""
613				};
614
615				// Don't split if next part starts with "as "
616				if after.starts_with("as ") {
617					i += 1;
618					continue;
619				}
620
621				if !before.is_empty()
622					&& !before.ends_with('>')
623					&& !before.ends_with('<')
624					&& !before.ends_with('=')
625					&& !before.ends_with("as")
626				{
627					let part = constraint[current_start..i].trim();
628					if !part.is_empty() {
629						parts.push(part);
630					}
631
632					// Skip consecutive separators
633					while i < len && (chars[i] == ',' || chars[i] == ' ') {
634						i += 1;
635					}
636					current_start = i;
637					in_hyphen_range = false;
638					continue;
639				}
640			}
641
642			i += 1;
643		}
644
645		// Add remaining part
646		let remaining = constraint[current_start..].trim();
647		if !remaining.is_empty() {
648			parts.push(remaining);
649		}
650
651		if parts.is_empty() {
652			parts.push(constraint.trim());
653		}
654
655		parts
656	}
657
658	/// Parse a single constraint (after splitting on OR/AND).
659	fn parse_constraint(constraint: &str) -> Result<Vec<Box<dyn Constraint>>> {
660		let constraint = constraint.trim();
661
662		// Strip aliasing: "1.0 as 2.0" -> "1.0"
663		let constraint = ALIAS_REGEX.captures(constraint).map_or(constraint, |caps| {
664			caps.get(1).map_or(constraint, |m| m.as_str())
665		});
666
667		// Strip @stability flags and keep for later use
668		let (constraint, stability_modifier) = STABILITY_CONSTRAINT_REGEX
669			.captures(constraint)
670			.map_or((constraint, None), |caps| {
671				let base = caps.get(1).map_or("", |m| m.as_str());
672				let stability = caps.get(2).map_or("", |m| m.as_str());
673				let base = if base.is_empty() { "*" } else { base };
674				let modifier = if stability.to_lowercase() == "stable" {
675					None
676				} else {
677					Some(stability.to_string())
678				};
679				(base, modifier)
680			});
681
682		// Strip #refs
683		let constraint = REF_STRIP_REGEX
684			.captures(constraint)
685			.map_or(constraint, |caps| {
686				caps.get(1).map_or(constraint, |m| m.as_str())
687			});
688
689		// Match all wildcard: *, x.x, X.X.*, etc.
690		if let Some(caps) = MATCH_ALL_REGEX.captures(constraint) {
691			if caps.get(1).is_some() || caps.get(2).is_some() {
692				// v* or x.x -> >= 0.0.0.0-dev
693				return Ok(vec![Box::new(SingleConstraint::new(
694					Operator::Ge,
695					"0.0.0.0-dev",
696				))]);
697			}
698			// Just * -> MatchAll
699			return Ok(vec![Box::new(MatchAllConstraint::new())]);
700		}
701
702		// Tilde range: ~1.2.3
703		if let Some(caps) = TILDE_REGEX.captures(constraint) {
704			return Self::parse_tilde_constraint(constraint, &caps, stability_modifier.as_ref());
705		}
706
707		// Caret range: ^1.2.3
708		if let Some(caps) = CARET_REGEX.captures(constraint) {
709			return Self::parse_caret_constraint(constraint, &caps, stability_modifier.as_ref());
710		}
711
712		// X-range: 1.2.x, 2.*
713		if let Some(caps) = XRANGE_REGEX.captures(constraint) {
714			return Ok(Self::parse_xrange_constraint(&caps));
715		}
716
717		// Hyphen range: 1.0 - 2.0
718		if let Some(caps) = HYPHEN_REGEX.captures(constraint) {
719			return Self::parse_hyphen_constraint(constraint, &caps);
720		}
721
722		// Basic comparators: >=1.0, <2.0, =1.5, etc.
723		if let Some(caps) = COMPARATOR_REGEX.captures(constraint) {
724			return Self::parse_comparator_constraint(&caps, stability_modifier.as_ref());
725		}
726
727		Err(SemverError::ConstraintParseFailed(
728			constraint.to_string(),
729			"Unknown constraint format".to_string(),
730		))
731	}
732
733	/// Parse tilde constraint (~1.2.3)
734	fn parse_tilde_constraint(
735		constraint: &str,
736		caps: &regex::Captures,
737		_stability_modifier: Option<&String>,
738	) -> Result<Vec<Box<dyn Constraint>>> {
739		// Check for invalid ~> operator
740		if constraint.starts_with("~>") {
741			return Err(SemverError::ConstraintParseFailed(
742				constraint.to_string(),
743				"Invalid operator \"~>\", you probably meant to use the \"~\" operator".to_string(),
744			));
745		}
746
747		// Determine position based on how many version parts are present
748		let position = if caps.get(4).is_some_and(|m| !m.as_str().is_empty()) {
749			4
750		} else if caps.get(3).is_some_and(|m| !m.as_str().is_empty()) {
751			3
752		} else if caps.get(2).is_some_and(|m| !m.as_str().is_empty()) {
753			2
754		} else {
755			1
756		};
757
758		// When matching x-dev patterns, shift position
759		let position = if caps.get(8).is_some() {
760			position + 1
761		} else {
762			position
763		};
764
765		// Calculate stability suffix
766		let stability_suffix =
767			if caps.get(5).is_none() && caps.get(7).is_none() && caps.get(8).is_none() {
768				"-dev"
769			} else {
770				""
771			};
772
773		// Normalize the low version
774		let low_version = Self::normalize(&format!(
775			"{}{}",
776			&constraint[1..], // skip the ~
777			stability_suffix
778		))?;
779		let lower_bound = SingleConstraint::new(Operator::Ge, low_version);
780
781		// For upper bound, increment the position of one more significance
782		let high_position = cmp::max(1, position - 1);
783		let high_version = format!(
784			"{}-dev",
785			Self::manipulate_version_string(caps, high_position, 1)
786		);
787		let upper_bound = SingleConstraint::new(Operator::Lt, high_version);
788
789		Ok(vec![Box::new(lower_bound), Box::new(upper_bound)])
790	}
791
792	/// Parse caret constraint (^1.2.3)
793	fn parse_caret_constraint(
794		constraint: &str,
795		caps: &regex::Captures,
796		_stability_modifier: Option<&String>,
797	) -> Result<Vec<Box<dyn Constraint>>> {
798		let major = caps.get(1).map_or("0", |m| m.as_str());
799		let minor = caps.get(2).map(|m| m.as_str());
800		let patch = caps.get(3).map(|m| m.as_str());
801
802		// Determine position based on left-most non-zero digit
803		let position = if major != "0" || minor.is_none() || minor == Some("") {
804			1
805		} else if minor != Some("0") || patch.is_none() || patch == Some("") {
806			2
807		} else {
808			3
809		};
810
811		// Calculate stability suffix
812		let stability_suffix =
813			if caps.get(5).is_none() && caps.get(7).is_none() && caps.get(8).is_none() {
814				"-dev"
815			} else {
816				""
817			};
818
819		// Normalize the low version
820		let low_version = Self::normalize(&format!(
821			"{}{}",
822			&constraint[1..], // skip the ^
823			stability_suffix
824		))?;
825		let lower_bound = SingleConstraint::new(Operator::Ge, low_version);
826
827		// For upper bound
828		let high_version = format!("{}-dev", Self::manipulate_version_string(caps, position, 1));
829		let upper_bound = SingleConstraint::new(Operator::Lt, high_version);
830
831		Ok(vec![Box::new(lower_bound), Box::new(upper_bound)])
832	}
833
834	/// Parse X-range constraint (1.2.x, 2.*)
835	fn parse_xrange_constraint(caps: &regex::Captures) -> Vec<Box<dyn Constraint>> {
836		let position = if caps.get(3).is_some_and(|m| !m.as_str().is_empty()) {
837			3
838		} else if caps.get(2).is_some_and(|m| !m.as_str().is_empty()) {
839			2
840		} else {
841			1
842		};
843
844		let low_version = format!("{}-dev", Self::manipulate_version_string(caps, position, 0));
845		let high_version = format!("{}-dev", Self::manipulate_version_string(caps, position, 1));
846
847		if low_version == "0.0.0.0-dev" {
848			// 0.x -> only upper bound
849			return vec![Box::new(SingleConstraint::new(Operator::Lt, high_version))];
850		}
851
852		vec![
853			Box::new(SingleConstraint::new(Operator::Ge, low_version)),
854			Box::new(SingleConstraint::new(Operator::Lt, high_version)),
855		]
856	}
857
858	/// Parse hyphen range constraint (1.0 - 2.0)
859	fn parse_hyphen_constraint(
860		constraint: &str,
861		caps: &regex::Captures,
862	) -> Result<Vec<Box<dyn Constraint>>> {
863		// Check for wildcards in hyphen range (not allowed)
864		if constraint.contains('*') || constraint.to_lowercase().contains('x') {
865			// Only x-dev is allowed
866			let from_part = caps.name("from").map_or("", |m| m.as_str());
867			let to_part = caps.name("to").map_or("", |m| m.as_str());
868
869			let from_has_invalid_wildcard = (from_part.contains('*')
870				|| from_part.to_lowercase().contains('x'))
871				&& !from_part.to_lowercase().ends_with("-dev")
872				&& !from_part.to_lowercase().ends_with(".dev");
873
874			let to_has_invalid_wildcard = (to_part.contains('*')
875				|| to_part.to_lowercase().contains('x'))
876				&& !to_part.to_lowercase().ends_with("-dev")
877				&& !to_part.to_lowercase().ends_with(".dev");
878
879			if from_has_invalid_wildcard || to_has_invalid_wildcard {
880				return Err(SemverError::ConstraintParseFailed(
881					constraint.to_string(),
882					"Wildcards in hyphen ranges require -dev suffix".to_string(),
883				));
884			}
885		}
886
887		let from_str = caps.name("from").map_or("", |m| m.as_str());
888		let to_str = caps.name("to").map_or("", |m| m.as_str());
889
890		// Calculate low stability suffix
891		let low_stability_suffix =
892			if caps.get(6).is_none() && caps.get(8).is_none() && caps.get(9).is_none() {
893				"-dev"
894			} else {
895				""
896			};
897
898		let low_version = format!("{}{low_stability_suffix}", Self::normalize(from_str)?);
899		let lower_bound = SingleConstraint::new(Operator::Ge, low_version);
900
901		// For upper bound, check if it's a complete version or partial
902		// A version is "complete" if it has patch version OR any stability marker
903		// Partial versions like "1.2" get incremented (1.2 -> < 1.3.0.0-dev)
904		// Complete versions like "1.2.3" or "1.2-beta" use <= exact version
905		let is_complete_version = {
906			// Check if the "to" part has patch or fourth component
907			let has_patch = caps.get(13).is_some_and(|m| !m.as_str().is_empty());
908			let has_fourth = caps.get(14).is_some_and(|m| !m.as_str().is_empty());
909			// Check for any stability in the "to" part (groups 16, 17, 18 for stability, number, dev)
910			let has_stability = caps.get(16).is_some_and(|m| !m.as_str().is_empty())
911				|| caps.get(17).is_some_and(|m| !m.as_str().is_empty())
912				|| caps.get(18).is_some_and(|m| !m.as_str().is_empty());
913
914			// Also check if the to_str contains stability suffix explicitly
915			let to_lower = to_str.to_lowercase();
916			let to_has_stability = to_lower.contains("-dev")
917				|| to_lower.contains(".dev")
918				|| to_lower.contains("-rc")
919				|| to_lower.contains("-beta")
920				|| to_lower.contains("-alpha")
921				|| to_lower.contains("-b")
922				|| to_lower.contains("-a");
923
924			// Complete if: has patch OR has fourth OR has stability
925			has_patch || has_fourth || has_stability || to_has_stability
926		};
927
928		let upper_bound = if is_complete_version {
929			// Complete version: use <=
930			let high_version = Self::normalize(to_str)?;
931			SingleConstraint::new(Operator::Le, high_version)
932		} else {
933			// Partial version: increment and use <
934			// Need to figure out which position to increment
935			let minor_present = caps.get(12).is_some_and(|m| !m.as_str().is_empty());
936			let position = if minor_present { 2 } else { 1 };
937
938			// Build matches array from "to" groups (11-14 for to version)
939			let to_matches = [
940				"",
941				caps.get(11).map_or("0", |m| m.as_str()),
942				caps.get(12).map_or("", |m| m.as_str()),
943				caps.get(13).map_or("", |m| m.as_str()),
944				caps.get(14).map_or("", |m| m.as_str()),
945			];
946			let high_version = format!(
947				"{}-dev",
948				Self::manipulate_version_array(&to_matches, position, 1)
949			);
950			SingleConstraint::new(Operator::Lt, high_version)
951		};
952
953		Ok(vec![Box::new(lower_bound), Box::new(upper_bound)])
954	}
955
956	/// Parse basic comparator constraint (>=1.0, <2.0, =1.5)
957	fn parse_comparator_constraint(
958		caps: &regex::Captures,
959		stability_modifier: Option<&String>,
960	) -> Result<Vec<Box<dyn Constraint>>> {
961		let op_str = caps.get(1).map_or("=", |m| m.as_str());
962		let version_str = caps.get(2).map_or("", |m| m.as_str()).trim();
963
964		if version_str.is_empty() {
965			return Err(SemverError::ConstraintParseFailed(
966				format!("{op_str}{version_str}"),
967				"Missing version after operator".to_string(),
968			));
969		}
970
971		// Check if original version has stability marker (before normalization)
972		let original_lower = version_str.to_lowercase();
973		let has_explicit_stability = original_lower.contains("-stable")
974			|| original_lower.contains("-dev")
975			|| original_lower.contains("-alpha")
976			|| original_lower.contains("-beta")
977			|| original_lower.contains("-rc")
978			|| original_lower.contains(".dev")
979			|| original_lower.contains(".alpha")
980			|| original_lower.contains(".beta")
981			|| original_lower.contains(".rc")
982			|| version_str.starts_with("dev-");
983
984		// Try to normalize the version
985		let version = match Self::normalize(version_str) {
986			Ok(v) => v,
987			Err(_) => {
988				// Try to recover from invalid constraint like foobar-dev
989				if version_str.ends_with("-dev")
990					&& version_str
991						.chars()
992						.all(|c| c.is_alphanumeric() || c == '-' || c == '.' || c == '/')
993				{
994					// Convert foo-dev to dev-foo
995					Self::normalize(&format!("dev-{}", &version_str[..version_str.len() - 4]))?
996				} else {
997					return Err(SemverError::ConstraintParseFailed(
998						version_str.to_string(),
999						"Invalid version".to_string(),
1000					));
1001				}
1002			},
1003		};
1004
1005		let op = match op_str {
1006			"<>" | "!=" => Operator::Ne,
1007			">=" => Operator::Ge,
1008			"<=" => Operator::Le,
1009			">" => Operator::Gt,
1010			"<" => Operator::Lt,
1011			"==" | "=" | "" => Operator::Eq,
1012			_ => {
1013				return Err(SemverError::InvalidOperator(op_str.to_string()));
1014			},
1015		};
1016
1017		// Apply stability modifier or add -dev suffix for < and >=
1018		// But only if there was no explicit stability in the original version
1019		let version = if op == Operator::Eq {
1020			version
1021		} else if let Some(modifier) = stability_modifier {
1022			if Self::parse_stability(&version) == Stability::Stable {
1023				format!("{version}-{modifier}")
1024			} else {
1025				version
1026			}
1027		} else if (op == Operator::Lt || op == Operator::Ge) && !has_explicit_stability {
1028			// Add -dev only if no stability suffix was present in original
1029			format!("{version}-dev")
1030		} else {
1031			version
1032		};
1033
1034		Ok(vec![Box::new(SingleConstraint::new(op, version))])
1035	}
1036
1037	/// Manipulate version string from regex captures.
1038	fn manipulate_version_string(
1039		caps: &regex::Captures,
1040		position: usize,
1041		increment: i32,
1042	) -> String {
1043		let mut parts: [String; 4] = [
1044			caps.get(1).map_or("0", |m| m.as_str()).to_string(),
1045			caps.get(2).map_or("0", |m| m.as_str()).to_string(),
1046			caps.get(3).map_or("0", |m| m.as_str()).to_string(),
1047			caps.get(4).map_or("0", |m| m.as_str()).to_string(),
1048		];
1049
1050		// Fill empty parts with "0"
1051		for part in &mut parts {
1052			if part.is_empty() {
1053				*part = "0".to_string();
1054			}
1055		}
1056
1057		// Apply increment/padding
1058		for i in (0..4).rev() {
1059			if i + 1 > position {
1060				parts[i] = "0".to_string();
1061			} else if i + 1 == position && increment != 0 {
1062				let val: i64 = parts[i].parse().unwrap_or(0);
1063				parts[i] = (val + i64::from(increment)).to_string();
1064			}
1065		}
1066
1067		format!("{}.{}.{}.{}", parts[0], parts[1], parts[2], parts[3])
1068	}
1069
1070	/// Manipulate version from an array of parts.
1071	fn manipulate_version_array(parts: &[&str; 5], position: usize, increment: i32) -> String {
1072		let mut result: [String; 4] = [
1073			parts[1].to_string(),
1074			if parts[2].is_empty() {
1075				"0".to_string()
1076			} else {
1077				parts[2].to_string()
1078			},
1079			if parts[3].is_empty() {
1080				"0".to_string()
1081			} else {
1082				parts[3].to_string()
1083			},
1084			if parts[4].is_empty() {
1085				"0".to_string()
1086			} else {
1087				parts[4].to_string()
1088			},
1089		];
1090
1091		// Apply increment/padding
1092		for i in (0..4).rev() {
1093			if i + 1 > position {
1094				result[i] = "0".to_string();
1095			} else if i + 1 == position && increment != 0 {
1096				let val: i64 = result[i].parse().unwrap_or(0);
1097				result[i] = (val + i64::from(increment)).to_string();
1098			}
1099		}
1100
1101		format!("{}.{}.{}.{}", result[0], result[1], result[2], result[3])
1102	}
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107	use super::*;
1108
1109	#[test]
1110	fn test_parse_stability() {
1111		assert_eq!(VersionParser::parse_stability("1.0.0"), Stability::Stable);
1112		assert_eq!(VersionParser::parse_stability("dev-master"), Stability::Dev);
1113		assert_eq!(VersionParser::parse_stability("1.0.0-dev"), Stability::Dev);
1114		assert_eq!(
1115			VersionParser::parse_stability("1.0.0-alpha"),
1116			Stability::Alpha
1117		);
1118		assert_eq!(
1119			VersionParser::parse_stability("1.0.0-beta"),
1120			Stability::Beta
1121		);
1122		assert_eq!(VersionParser::parse_stability("1.0.0-RC"), Stability::RC);
1123	}
1124
1125	#[test]
1126	fn test_normalize_branch() {
1127		assert_eq!(
1128			VersionParser::normalize_branch("v1.x"),
1129			"1.9999999.9999999.9999999-dev"
1130		);
1131		assert_eq!(
1132			VersionParser::normalize_branch("v1.*"),
1133			"1.9999999.9999999.9999999-dev"
1134		);
1135		assert_eq!(
1136			VersionParser::normalize_branch("v1.0"),
1137			"1.0.9999999.9999999-dev"
1138		);
1139		assert_eq!(
1140			VersionParser::normalize_branch("2.0"),
1141			"2.0.9999999.9999999-dev"
1142		);
1143		assert_eq!(VersionParser::normalize_branch("master"), "dev-master");
1144		assert_eq!(
1145			VersionParser::normalize_branch("feature-a"),
1146			"dev-feature-a"
1147		);
1148	}
1149
1150	#[test]
1151	fn test_basic_normalization() {
1152		assert_eq!(VersionParser::normalize("1.0.0").unwrap(), "1.0.0.0");
1153		assert_eq!(VersionParser::normalize("1.2.3.4").unwrap(), "1.2.3.4");
1154		assert_eq!(VersionParser::normalize("v1.0.0").unwrap(), "1.0.0.0");
1155		assert_eq!(
1156			VersionParser::normalize("dev-master").unwrap(),
1157			"dev-master"
1158		);
1159	}
1160}