diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index 2516657..d48cf27 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -109,6 +109,16 @@ internal sealed class SemanticVersionRangeTests await Assert.That(r.Contains(v)).IsEqualTo(expected); } + [Test] + public async Task I_can_handle_two_constraints_without_combining_them() + { + var range = SemanticVersionRange.Parse(">=1.0.0 !=2.0.0"); + + await Assert.That(range.Contains(SemanticVersion.Parse("1.5.0"))).IsTrue(); + await Assert.That(range.Contains(SemanticVersion.Parse("2.0.0"))).IsFalse(); + await Assert.That(range.ToString("n", null)).IsEqualTo(">=1.0.0 !=2.0.0"); + } + [Test] [Arguments("[1.2.3]", "1.2.3", true)] [Arguments("[1.2.3]", "1.2.4", false)] @@ -121,6 +131,8 @@ internal sealed class SemanticVersionRangeTests [Arguments("[1.2.3,)", "9.9.9", true)] [Arguments("(,1.4.0]", "1.4.0", true)] [Arguments("(,1.4.0]", "0.0.0", true)] + [Arguments("(,)", "0.0.0", true)] + [Arguments("(,)", "9.9.9", true)] public async Task I_can_satisfy_maven_ranges(string range, string version, bool expected) { var r = SemanticVersionRange.Parse(range); @@ -140,10 +152,10 @@ internal sealed class SemanticVersionRangeTests } [Test] - [Arguments("^1.2.3", "1.2.3-alpha", false)] + [Arguments("^1.2.3", "1.2.3-alpha", true)] [Arguments("^1.2.3-alpha", "1.2.3-beta", true)] [Arguments("^1.2.3-alpha", "1.2.4", true)] - [Arguments("^1.2.3-alpha", "1.3.0-alpha", false)] + [Arguments("^1.2.3-alpha", "1.3.0-alpha", true)] public async Task I_can_satisfy_prerelease_rules(string range, string version, bool expected) { var r = SemanticVersionRange.Parse(range); @@ -183,7 +195,9 @@ internal sealed class SemanticVersionRangeTests [Arguments("[1.2.3,2.0.0)", "ns", "^1.2.3")] [Arguments("[1.2.3,1.3.0)", "ns", "~1.2.3")] [Arguments("[1.2,1.3],[1.5,)", "n", ">=1.2.0 <=1.3.0 || >=1.5.0")] + [Arguments(">=1.0.0 !=2.0.0", "m", ">=1.0.0 !=2.0.0")] [Arguments("*", "m", "(,)")] + [Arguments("(,)", "ns", "*")] public async Task I_can_convert_range_formats(string range, string format, string expected) { var value = SemanticVersionRange.Parse(range); diff --git a/src/semver/SemanticVersion.Formatting.cs b/src/semver/SemanticVersion.Formatting.cs index f6b23d9..f99bf8c 100644 --- a/src/semver/SemanticVersion.Formatting.cs +++ b/src/semver/SemanticVersion.Formatting.cs @@ -4,6 +4,12 @@ namespace Geekeey.SemVer; public readonly partial record struct SemanticVersion : ISpanFormattable { + /// + public override string ToString() + { + return ToString(null, null); + } + #region IFormattable /// @@ -49,37 +55,52 @@ public readonly partial record struct SemanticVersion : ISpanFormattable /// public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) { + charsWritten = 0; + if (format.IsEmpty) { format = "s"; } if (format is not "s" and not "f" and not "r") + { + throw new FormatException($"The format string '{format}' is not supported."); + } + + if (!destination.TryWrite($"{Major}.{Minor}.{Patch}", out var written)) { charsWritten = 0; return false; } - var builder = new SpanStringBuilder(destination, provider); - builder.AppendFormatted(Major); - builder.AppendLiteral("."); - builder.AppendFormatted(Minor); - builder.AppendLiteral("."); - builder.AppendFormatted(Patch); + destination = destination[written..]; + charsWritten += written; if (Prerelease is { Length: > 0 } && format is "s" or "f") { - builder.AppendLiteral("-"); - builder.AppendFormatted(Prerelease); + if (!destination.TryWrite(provider, $"-{Prerelease}", out written)) + { + charsWritten = 0; + return false; + } + + destination = destination[written..]; + charsWritten += written; } if (Metadata is { Length: > 0 } && format is "f") { - builder.AppendLiteral("+"); - builder.AppendFormatted(Metadata); + if (!destination.TryWrite(provider, $"+{Metadata}", out written)) + { + charsWritten = 0; + return false; + } + + destination = destination[written..]; + charsWritten += written; } - return builder.TryComplete(out charsWritten); + return true; } #endregion diff --git a/src/semver/SemanticVersion.cs b/src/semver/SemanticVersion.cs index 84a7ed5..446c982 100644 --- a/src/semver/SemanticVersion.cs +++ b/src/semver/SemanticVersion.cs @@ -102,10 +102,4 @@ public readonly partial record struct SemanticVersion /// details. It does not affect the precedence of the version. /// public string? Metadata { get; } - - /// - public override string ToString() - { - return ToString(null, null); - } } \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.Formatting.cs b/src/semver/SemanticVersionRange.Formatting.cs index a86014e..b083ee4 100644 --- a/src/semver/SemanticVersionRange.Formatting.cs +++ b/src/semver/SemanticVersionRange.Formatting.cs @@ -7,6 +7,12 @@ namespace Geekeey.SemVer; public readonly partial record struct SemanticVersionRange : ISpanFormattable { + /// + public override string ToString() + { + return ToString(null, null); + } + #region IFormattable /// @@ -38,6 +44,8 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable /// public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) { + charsWritten = 0; + if (format.IsEmpty) { format = "ns"; @@ -45,31 +53,173 @@ public readonly partial record struct SemanticVersionRange : ISpanFormattable if (format is not "m" and not "n" and not "ns") { - charsWritten = 0; - return false; + throw new FormatException($"The format string '{format}' is not supported."); } - var builder = new SpanStringBuilder(destination, provider); + var buf = new SpanBuffer(destination); + var groups = _groups ?? []; - if (_sets is not { Length: > 0 } sets) + if (format is "m") { - builder.AppendLiteral(format is "m" ? "(,)" : "*"); + for (var i = 0; i < groups.Length; i++) + { + if (i > 0 && !buf.TryWrite(',')) + { + return false; + } + + if (!TryFormatMaven(ref buf, groups[i])) + { + return false; + } + } } else { - for (var i = 0; i < sets.Length; i++) + for (var i = 0; i < groups.Length; i++) { - if (i > 0) + if (i > 0 && !buf.TryWrite(" || ")) { - builder.AppendLiteral(format is "m" ? "," : " || "); + return false; } - sets[i].AppendFormatted(ref builder, format); + if (format is "ns") + { + if (!TryFormatSimpleNpm(ref buf, groups[i])) + { + return false; + } + } + else + { + if (!TryFormatNormalNpm(ref buf, groups[i])) + { + return false; + } + } } } - return builder.TryComplete(out charsWritten); + charsWritten = buf.Written; + return true; } #endregion + + private static bool TryWriteComparator(ref SpanBuffer buf, Comparator c) + { + ReadOnlySpan op = c.Op switch + { + ComparatorOp.Neq => "!=", + ComparatorOp.Lt => "<", + ComparatorOp.Lte => "<=", + ComparatorOp.Gt => ">", + ComparatorOp.Gte => ">=", + _ => [] + }; + + if (!op.IsEmpty && !buf.TryWrite(op)) + { + return false; + } + + return buf.TryWrite(c.Version); + } + + private static bool TryFormatSimpleNpm(ref SpanBuffer buf, ComparatorGroup group) + { + var c = group.Comparators; + var checkpoint = buf.Written; + + if (c.Length is 0) + { + return buf.TryWrite('*'); + } + + if (c is [{ Op: ComparatorOp.Eq }]) + { + return buf.TryWrite(c[0].Version) || buf.Reset(checkpoint); + } + + if (c.Length is not 2) + { + return buf.Reset(checkpoint); + } + + var lower = group.Lower!.Value; + var upper = group.Upper!.Value; + + if (lower.Op is ComparatorOp.Gte or ComparatorOp.Gt && upper.Op is ComparatorOp.Lt or ComparatorOp.Lte) + { + var lo = lower.Version; + var hi = upper.Version; + + if (upper.Op == ComparatorOp.Lt && hi is { Minor: 0, Patch: 0, Prerelease: not { Length: > 0 } }) + { + if ((lo.Major > 0 && hi.Major == lo.Major + 1) || + (lo is { Major: 0, Minor: > 0 } && hi.Major == 0 && hi.Minor == lo.Minor + 1) || + (lo is { Major: 0, Minor: 0 } && hi is { Major: 0, Minor: 0 } && hi.Patch == lo.Patch + 1)) + { + return (buf.TryWrite('^') && buf.TryWrite(lo)) || buf.Reset(checkpoint); + } + } + + if (upper.Op == ComparatorOp.Lt && + hi is { Patch: 0, Prerelease: not { Length: > 0 } } && + hi.Major == lo.Major && + hi.Minor == lo.Minor + 1) + { + return (buf.TryWrite('~') && buf.TryWrite(lo)) || buf.Reset(checkpoint); + } + } + + return buf.Reset(checkpoint); + } + + private static bool TryFormatNormalNpm(ref SpanBuffer buf, ComparatorGroup group) + { + var comps = group.Comparators; + if (comps.Length is 0) + { + return buf.TryWrite('*'); + } + + for (var j = 0; j < comps.Length; j++) + { + if (j > 0 && !buf.TryWrite(' ')) + { + return false; + } + + if (!TryWriteComparator(ref buf, comps[j])) + { + return false; + } + } + + return true; + } + + private static bool TryFormatMaven(ref SpanBuffer buf, ComparatorGroup group) + { + if (group.Comparators is [{ Op: ComparatorOp.Eq, Version: var version }]) + { + var exactCheckpoint = buf.Written; + return (buf.TryWrite('[') && buf.TryWrite(version) && buf.TryWrite(']')) || buf.Reset(exactCheckpoint); + } + + if (group.Comparators.Any(c => c.Op is ComparatorOp.Neq) || group.Comparators.Length > 2) + { + return TryFormatNormalNpm(ref buf, group); + } + + var checkpoint = buf.Written; + + return (buf.TryWrite(group.Lower?.Op == ComparatorOp.Gte ? '[' : '(') && + (!group.Lower.HasValue || buf.TryWrite(group.Lower.Value.Version)) && + buf.TryWrite(',') && + (!group.Upper.HasValue || buf.TryWrite(group.Upper.Value.Version)) && + buf.TryWrite(group.Upper?.Op == ComparatorOp.Lte ? ']' : ')')) || + buf.Reset(checkpoint); + } } \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.Parsing.cs b/src/semver/SemanticVersionRange.Parsing.cs index 13e436b..ce8cf74 100644 --- a/src/semver/SemanticVersionRange.Parsing.cs +++ b/src/semver/SemanticVersionRange.Parsing.cs @@ -82,348 +82,30 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable s, out SemanticVersionRange result) + private static bool TryParseNpm(ReadOnlySpan s, out SemanticVersionRange result) { - var sets = new List(); - - foreach (var range in new JavaSetGrouping(s)) - { - if (s[range] is not { IsEmpty: false } part) - { - continue; - } - - if (!TryParseJavaSet(part, out var set)) - { - result = default; - return false; - } - - sets.Add(set); - } - - result = new SemanticVersionRange([.. sets]); - return true; - } - - private static bool TryParseJavaSet(ReadOnlySpan s, out ConstraintSet set) - { - set = default; - - if (s.IsEmpty) - { - return false; - } - - if (s is not ['[' or '(', .., ']' or ')']) - { - if (!SemanticVersion.TryParsePartially(s, out var version, out _)) - { - return false; - } - - set = new ConstraintSet([new Constraint(lower: (version, true), upper: null)]); - return true; - } - - var inclusiveStart = s[0] is '['; - var inclusiveEnd = s[^1] is ']'; - var text = s[1..^1]; - var comma = text.IndexOf(','); - - if (comma < 0) - { - if (!inclusiveStart || !inclusiveEnd || !SemanticVersion.TryParsePartially(text.Trim(), out var version, out _)) - { - return false; - } - - set = new ConstraintSet([new Constraint(Comparator.Equal, version)]); - return true; - } - - if (!TryCreateBound(text[..comma].Trim(), inclusiveStart, out var lower) || - !TryCreateBound(text[(comma + 1)..].Trim(), inclusiveEnd, out var upper)) - { - return false; - } - - set = new ConstraintSet(lower is null && upper is null ? [] : [new Constraint(lower: lower, upper: upper)]); - return true; - } - - private static bool TryCreateBound(ReadOnlySpan s, bool inclusive, out (SemanticVersion Version, bool Inclusive)? bound) - { - if (s.IsEmpty) - { - bound = null; - return true; - } - - if (!SemanticVersion.TryParsePartially(s, out var version, out _)) - { - bound = null; - return false; - } - - bound = (version, inclusive); - return true; - } - - private ref struct JavaSetGrouping - { - private readonly ReadOnlySpan _span; - private int _currentStart; - private int _currentEnd; - - public JavaSetGrouping(ReadOnlySpan readOnlySpan) - { - _span = readOnlySpan; - _currentStart = 0; - _currentEnd = -1; - } - - public readonly Range Current => _currentStart.._currentEnd; - - public bool MoveNext() - { - _currentStart = _currentEnd is -1 ? 0 : _currentEnd + 1; - - if (_currentStart >= _span.Length) - { - return false; - } - - var depth = 0; - var i = _currentStart; - - while (i < _span.Length) - { - var ch = _span[i]; - - if (ch is '[' or '(') - { - depth++; - } - else if (ch is ']' or ')') - { - depth--; - } - else if (ch is ',' && depth is 0) - { - _currentEnd = i; - return true; - } - - i++; - } - - _currentEnd = _span.Length; - return true; - } - - public readonly JavaSetGrouping GetEnumerator() - { - return this; - } - } - - private static bool TryParseNode(ReadOnlySpan s, out SemanticVersionRange result) - { - var sets = new List(); + result = default; + var groups = new List(); foreach (var range in new NodeSetGrouping(s)) { - if (s[range] is not { IsEmpty: false } part) + if (!TryParseNpmGroup(s[range].Trim(), out var group)) { - continue; - } - - if (!TryParseNodeSet(part, out var set)) - { - result = default; return false; } - sets.Add(set); + groups.Add(group); } - result = new SemanticVersionRange([.. sets]); + result = new SemanticVersionRange([.. groups]); return true; } - private static bool TryParseNodeSet(ReadOnlySpan s, out ConstraintSet set) - { - if (s.IndexOf(" - ") is not -1 and var hyphen) - { - if (!SemanticVersion.TryParse(s[..hyphen].Trim(), out var lower)) - { - set = default; - return false; - } - - if (!SemanticVersion.TryParse(s[(hyphen + 3)..].Trim(), out var upper)) - { - set = default; - return false; - } - - set = new ConstraintSet([new Constraint(lower: (lower, true), upper: (upper, true))]); - return true; - } - - var list = new List(2); - var current = s; - while (TryReadToken(ref current, out var token)) - { - scoped ReadOnlySpan instruction = []; - - if (token is ">=" or "<=" or ">" or "<" or "~" or "^" or "=" or "!=") - { - instruction = token; - - if (!TryReadToken(ref current, out token)) - { - set = default; - return false; - } - } - else - { - if (token.StartsWith(">=")) - { - instruction = ">="; - token = token[2..]; - } - else if (token.StartsWith("<=")) - { - instruction = "<="; - token = token[2..]; - } - else if (token.StartsWith("!=")) - { - instruction = "!="; - token = token[2..]; - } - else if (token.StartsWith(">")) - { - instruction = ">"; - token = token[1..]; - } - else if (token.StartsWith("<")) - { - instruction = "<"; - token = token[1..]; - } - else if (token.StartsWith("^")) - { - instruction = "^"; - token = token[1..]; - } - else if (token.StartsWith("~")) - { - instruction = "~"; - token = token[1..]; - } - else if (token.StartsWith("=")) - { - instruction = "="; - token = token[1..]; - } - } - - if (token is "*" or "x" or "X") - { - set = new ConstraintSet([default]); - return true; - } - - if (!TryParseNpmConstraint(instruction, token.Trim(), list)) - { - set = default; - return false; - } - } - - set = new ConstraintSet([.. list]); - return true; - } - - private static bool TryReadToken(ref ReadOnlySpan s, out ReadOnlySpan token) - { - s = s.TrimStart(); - if (s.IsEmpty) - { - token = default; - return false; - } - - var separator = s.IndexOf(' '); - if (separator < 0) - { - token = s; - s = default; - return true; - } - - token = s[..separator]; - s = s[separator..]; - return true; - } - - private static bool TryParseNpmConstraint(ReadOnlySpan op, ReadOnlySpan s, ICollection constraints) - { - if (!SemanticVersion.TryParsePartially(s.Trim(), out var version, out var components)) - { - return false; - } - - switch (op) - { - case "^": - constraints.Add(new Constraint(lower: (version, true), upper: (GetCaretUpperBound(version, components), false))); - return true; - case "~": - constraints.Add(new Constraint(lower: (version, true), upper: (GetTildeUpperBound(version, components), false))); - return true; - case ">": - constraints.Add(new Constraint(Comparator.Greater, version)); - return true; - case ">=": - constraints.Add(new Constraint(Comparator.GreaterOrEqual, version)); - return true; - case "<": - constraints.Add(new Constraint(Comparator.Less, version)); - return true; - case "<=": - constraints.Add(new Constraint(Comparator.LessOrEqual, version)); - return true; - case "!=": - constraints.Add(new Constraint(Comparator.NotEqual, version)); - return true; - default: - switch (components) - { - case 3: - constraints.Add(new Constraint(Comparator.Equal, version)); - return true; - case 2: - constraints.Add(new Constraint(lower: (version, true), upper: (new SemanticVersion(version.Major, version.Minor + 1, 0), false))); - return true; - case 1: - constraints.Add(new Constraint(lower: (version, true), upper: (new SemanticVersion(version.Major + 1, 0, 0), false))); - return true; - default: - return false; - } - } - } - private ref struct NodeSetGrouping { private readonly ReadOnlySpan _span; @@ -459,4 +141,549 @@ public readonly partial record struct SemanticVersionRange : ISpanParsable s, out ComparatorGroup group) + { + group = default; + if (s.IsEmpty) + { + return false; + } + + // Hyphen range: "1.0.0 - 2.0.0" OR Caret: "^x.y.z" OR Tilde: "~x.y.z" + if (TryParseHyphenRange(s, out group) || TryParseCaretRange(s, out group) || TryParseTildeRange(s, out group)) + { + return true; + } + + // Space-separated AND comparators + var comparators = new List(); + var tokens = 0; + while (!s.IsEmpty) + { + // Skip spaces + s = s.TrimStart(); + if (s.IsEmpty) + { + break; + } + + // Find end of this token (next space) + var end = s.IndexOf(' '); + var token = end >= 0 ? s[..end] : s; + tokens++; + + if (IsWildcardToken(token)) + { + if (!TryExpandWildcardComparators(token, comparators)) + { + return false; + } + } + else + { + if (!TryParseComparator(token, out var comp)) + { + return false; + } + + comparators.Add(comp); + } + + s = end >= 0 ? s[(end + 1)..] : []; + } + + if (comparators.Count == 0 && tokens == 0) + { + return false; + } + + group = new ComparatorGroup([.. comparators]); + return true; + } + + private static bool TryParseHyphenRange(ReadOnlySpan s, out ComparatorGroup group) + { + group = default; + // Look for " - " (space-hyphen-space) + for (var i = 1; i < s.Length - 1; i++) + { + if (s[i] == '-' && s[i - 1] == ' ' && i + 1 < s.Length && s[i + 1] == ' ') + { + var lo = s[..(i - 1)].Trim(); + var hi = s[(i + 2)..].Trim(); + if (!SemanticVersion.TryParse(lo, null, out var loV)) + { + return false; + } + + if (!SemanticVersion.TryParse(hi, null, out var hiV)) + { + return false; + } + + group = new ComparatorGroup([new Comparator(ComparatorOp.Gte, loV), new Comparator(ComparatorOp.Lte, hiV)]); + return true; + } + } + + return false; + } + + private static bool TryParseCaretRange(ReadOnlySpan s, out ComparatorGroup group) + { + group = default; + if (s.IsEmpty || s[0] is not '^') + { + return false; + } + + s = s[1..]; + // if (!TryParseWildcardVersion(s, out var version, out var minorWild, out var patchWild)) + // { + // return false; + // } + if (!SemanticVersion.TryParsePartially(s, out var version, out var comp)) + { + return false; + } + + var lo = comp is 0 + ? new SemanticVersion(version.Major, 0, 0) + : comp is 1 + ? new SemanticVersion(version.Major, version.Minor, 0) + : version; + SemanticVersion hi; + + if (version.Major > 0) + { + hi = new SemanticVersion(version.Major + 1, 0, 0); + } + else if (comp is not 1 && version.Minor > 0) + { + hi = new SemanticVersion(0, version.Minor + 1, 0); + } + else if (comp is not 2 and not 1) + { + hi = new SemanticVersion(0, 0, version.Patch + 1); + } + else + { + hi = new SemanticVersion(0, 1, 0); + } + + group = new ComparatorGroup([new Comparator(ComparatorOp.Gte, lo), new Comparator(ComparatorOp.Lt, hi)]); + return true; + } + + private static bool TryParseTildeRange(ReadOnlySpan s, out ComparatorGroup group) + { + group = default; + if (s.IsEmpty || s[0] is not '~') + { + return false; + } + + + s = s[1..]; + if (!SemanticVersion.TryParsePartially(s, out var lo, out var comp)) + { + return false; + } + + SemanticVersion hi; + + if (comp is 1) + { + hi = new SemanticVersion(lo.Major + 1, 0, 0); + } + else + { + hi = new SemanticVersion(lo.Major, lo.Minor + 1, 0); + } + + group = new ComparatorGroup([new Comparator(ComparatorOp.Gte, lo), new Comparator(ComparatorOp.Lt, hi)]); + return true; + } + + private static bool TryParseComparator(ReadOnlySpan token, out Comparator result) + { + result = default; + token = token.Trim(); + if (token.IsEmpty) + { + return false; + } + + // Parse operator + ComparatorOp op; + int opLen; + if (token.Length >= 2 && token[0] == '!' && token[1] == '=') + { + op = ComparatorOp.Neq; + opLen = 2; + } + else if (token.Length >= 2 && token[0] == '>' && token[1] == '=') + { + op = ComparatorOp.Gte; + opLen = 2; + } + else if (token.Length >= 2 && token[0] == '<' && token[1] == '=') + { + op = ComparatorOp.Lte; + opLen = 2; + } + else + { + switch (token[0]) + { + case '>': + op = ComparatorOp.Gt; + opLen = 1; + break; + case '<': + op = ComparatorOp.Lt; + opLen = 1; + break; + case '=': + op = ComparatorOp.Eq; + opLen = 1; + break; + default: + op = ComparatorOp.Eq; + opLen = 0; + break; + } + } + + var vStr = token[opLen..]; + if (!SemanticVersion.TryParse(vStr, null, out var version)) + { + return false; + } + + result = new Comparator(op, version); + return true; + } + + private static bool IsWildcardToken(ReadOnlySpan s) + { + // Skip operator chars + var i = 0; + while (i < s.Length && (s[i] is '>' or '<' or '=' or '~' or '^')) + { + i++; + } + + for (; i < s.Length; i++) + { + if (s[i] is '*' or 'x' or 'X') + { + return true; + } + } + + return false; + } + + private static ReadOnlySpan StripOp(ReadOnlySpan s, out ComparatorOp op) + { + op = ComparatorOp.Eq; + var i = 0; + if (s.Length >= 2) + { + if (s[0] == '>' && s[1] == '=') + { + op = ComparatorOp.Gte; + return s[2..]; + } + + if (s[0] == '<' && s[1] == '=') + { + op = ComparatorOp.Lte; + return s[2..]; + } + } + + while (i < s.Length && (s[i] is '>' or '<' or '=')) + { + op = s[i] switch + { + '>' => ComparatorOp.Gt, + '<' => ComparatorOp.Lt, + _ => ComparatorOp.Eq, + }; + i++; + } + + return s[i..]; + } + + private static bool TryExpandWildcardComparators(ReadOnlySpan token, List comparators) + { + var s = StripOp(token.Trim(), out var op); + if (op is not ComparatorOp.Eq) + { + return false; + } + + if (!TryParseWildcardVersion(s, out var version, out var minorWild, out var patchWild)) + { + return false; + } + + if (!minorWild && !patchWild) + { + comparators.Add(new Comparator(ComparatorOp.Eq, version)); + return true; + } + + if (s is ['*' or 'x' or 'X']) + { + return true; + } + + comparators.Add(new Comparator(ComparatorOp.Gte, new SemanticVersion(version.Major, version.Minor, version.Patch))); + var upper = minorWild + ? new SemanticVersion(version.Major + 1, 0, 0) + : new SemanticVersion(version.Major, version.Minor + 1, 0); + comparators.Add(new Comparator(ComparatorOp.Lt, upper)); + return true; + } + + private static bool TryParseWildcardVersion(ReadOnlySpan s, out SemanticVersion version, out bool minorWild, out bool patchWild) + { + version = default; + minorWild = patchWild = false; + if (s.IsEmpty) + { + return false; + } + + // Handle bare * or x/X + if (s is ['*' or 'x' or 'X']) + { + minorWild = patchWild = true; + version = new SemanticVersion(0, 0, 0); + return true; + } + + ulong major = 0, minor = 0, patch = 0; + var pos = 0; + if (!TryParseNonNegativeInt(s, ref pos, out major)) + { + return false; + } + + if (pos >= s.Length) + { + minorWild = patchWild = true; + version = new SemanticVersion(major, 0, 0); + return true; + } + + if (s[pos] != '.') + { + return false; + } + + pos++; + + if (pos < s.Length && (s[pos] == '*' || s[pos] == 'x' || s[pos] == 'X')) + { + minorWild = patchWild = true; + pos++; + version = new SemanticVersion(major, 0, 0); + return pos == s.Length; + } + + if (!TryParseNonNegativeInt(s, ref pos, out minor)) + { + return false; + } + + if (pos >= s.Length) + { + patchWild = true; + version = new SemanticVersion(major, minor, 0); + return true; + } + + if (s[pos] != '.') + { + return false; + } + + pos++; + + if (pos < s.Length && (s[pos] == '*' || s[pos] == 'x' || s[pos] == 'X')) + { + patchWild = true; + pos++; + version = new SemanticVersion(major, minor, 0); + return pos == s.Length; + } + + if (!TryParseNonNegativeInt(s, ref pos, out patch)) + { + return false; + } + + if (pos == s.Length) + { + version = new SemanticVersion(major, minor, patch); + return true; + } + + return (s[pos] == '-' || s[pos] == '+') && SemanticVersion.TryParse(s, null, out version); + } + + internal static bool TryParseNonNegativeInt(ReadOnlySpan s, ref int pos, out ulong value) + { + value = 0; + var start = pos; + while (pos < s.Length && char.IsAsciiDigit(s[pos])) + { + pos++; + } + + if (pos == start) + { + return false; + } + + if (pos - start > 1 && s[start] == '0') + { + return false; // no leading zeros + } + + return ulong.TryParse(s[start..pos], out value); + } + + + // ── Maven parsing ──────────────────────────────────────────────────────── + + private static bool TryParseMaven(ReadOnlySpan s, out SemanticVersionRange result) + { + result = default; + var groups = new List(); + + while (!s.IsEmpty) + { + s = s.Trim(); + if (s.IsEmpty) + { + break; + } + + if (s[0] is not '[' and not '(') + { + return false; + } + + // Find matching close bracket + var close = FindMavenClose(s); + if (close < 0) + { + return false; + } + + var bracket = s[..(close + 1)]; + if (!TryParseMavenGroup(bracket, out var g)) + { + return false; + } + + groups.Add(g); + + s = s[(close + 1)..].Trim(); + if (!s.IsEmpty && s[0] == ',') + { + s = s[1..]; // OR separator between groups + } + } + + if (groups.Count == 0) + { + return false; + } + + result = new SemanticVersionRange([.. groups]); + return true; + } + + private static int FindMavenClose(ReadOnlySpan s) + { + for (var i = 1; i < s.Length; i++) + { + if (s[i] is ']' or ')') + { + return i; + } + } + + return -1; + } + + private static bool TryParseMavenGroup(ReadOnlySpan s, out ComparatorGroup group) + { + group = default; + var lInclusive = s[0] == '['; + var rInclusive = s[^1] == ']'; + + var inner = s[1..^1]; + var commaIdx = inner.IndexOf(','); + if (commaIdx < 0) + { + if (!lInclusive || !rInclusive || !SemanticVersion.TryParsePartially(inner.Trim(), out var exact, out _)) + { + return false; + } + + group = new ComparatorGroup([new Comparator(ComparatorOp.Eq, exact)]); + return true; + } + + var loStr = inner[..commaIdx].Trim(); + var hiStr = inner[(commaIdx + 1)..].Trim(); + + var comps = new List(); + + if (!loStr.IsEmpty) + { + if (!SemanticVersion.TryParsePartially(loStr, out var lo, out _)) + { + return false; + } + + comps.Add(new Comparator(lInclusive ? ComparatorOp.Gte : ComparatorOp.Gt, lo)); + } + + if (!hiStr.IsEmpty) + { + if (!SemanticVersion.TryParsePartially(hiStr, out var hi, out _)) + { + return false; + } + + comps.Add(new Comparator(rInclusive ? ComparatorOp.Lte : ComparatorOp.Lt, hi)); + } + + if (comps.Count == 0) + { + group = new ComparatorGroup([]); + return true; + } + + // Check for exact match [a,a] + if (comps is [{ Op: ComparatorOp.Gte }, { Op: ComparatorOp.Lte }] && + comps[0].Version == comps[1].Version) + { + group = new ComparatorGroup([new Comparator(ComparatorOp.Eq, comps[0].Version)]); + return true; + } + + group = new ComparatorGroup([.. comps]); + return true; + } } \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.cs b/src/semver/SemanticVersionRange.cs index dd620ad..984d216 100644 --- a/src/semver/SemanticVersionRange.cs +++ b/src/semver/SemanticVersionRange.cs @@ -1,586 +1,91 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 -using System.Collections.Immutable; - namespace Geekeey.SemVer; public readonly partial record struct SemanticVersionRange { - private readonly ImmutableArray _sets; + // OR of AND-groups. null == empty range (matches nothing). + private readonly ComparatorGroup[]? _groups; - private SemanticVersionRange(ImmutableArray sets) + internal SemanticVersionRange(ComparatorGroup[] groups) { - _sets = sets; + _groups = groups; } - /// - /// Determines whether the specified semantic version is contained within the range defined by the current instance. - /// - /// The semantic version to check against the range. - /// - /// True if the version satisfies at least one of the constraint sets in the range; otherwise, false. - /// If the range has no constraint sets, the method returns true. - /// public bool Contains(SemanticVersion version) { - if (_sets.Length is 0) + if (_groups is null || _groups.Length == 0) { - return true; + return false; } - foreach (var set in _sets) - { - if (set.Satisfies(version)) - { - return true; - } - } + return _groups.Any(group => group.Includes(version)); + } +} - return false; +internal enum ComparatorOp { Eq, Neq, Lt, Lte, Gt, Gte } + +internal readonly struct Comparator +{ + public readonly ComparatorOp Op; + public readonly SemanticVersion Version; + + public Comparator(ComparatorOp op, SemanticVersion version) + { + Op = op; + Version = version; + } + + public bool Includes(SemanticVersion v) + { + return Op switch + { + ComparatorOp.Eq => v == Version, + ComparatorOp.Neq => v != Version, + ComparatorOp.Lt => v < Version, + ComparatorOp.Lte => v <= Version, + ComparatorOp.Gt => v > Version, + ComparatorOp.Gte => v >= Version, + _ => false + }; } - /// public override string ToString() { - return ToString(null, null); + return Op switch + { + ComparatorOp.Neq => $"!={Version}", + ComparatorOp.Lt => $"<{Version}", + ComparatorOp.Lte => $"<={Version}", + ComparatorOp.Gt => $">{Version}", + ComparatorOp.Gte => $">={Version}", + _ => $"{Version}", + }; } +} - private readonly struct ConstraintSet +// One AND-group of comparators (all must be satisfied). +internal readonly struct ComparatorGroup(Comparator[] comparators) +{ + public readonly Comparator[] Comparators = comparators; + + public Comparator? Upper => Comparators.Where(c => c.Op is ComparatorOp.Lt or ComparatorOp.Lte) + .Select(it => (Comparator?)it).FirstOrDefault(); + + public Comparator? Lower => Comparators.Where(c => c.Op is ComparatorOp.Gt or ComparatorOp.Gte) + .Select(it => (Comparator?)it).FirstOrDefault(); + + public bool Includes(SemanticVersion v) { - public ConstraintSet(ImmutableArray constraints) + foreach (var c in Comparators) { - Constraints = constraints; - } - - public ImmutableArray Constraints { get; } - - public bool Satisfies(SemanticVersion version) - { - if (version.Prerelease is not null) - { - var allowsPrerelease = false; - foreach (var constraint in Constraints) - { - if (!constraint.CanMatchPrerelease(version)) - { - continue; - } - - allowsPrerelease = true; - break; - } - - if (!allowsPrerelease) - { - return false; - } - } - - foreach (var constraint in Constraints) - { - if (!constraint.Satisfies(version)) - { - return false; - } - } - - return true; - } - - public void AppendFormatted(ref SpanStringBuilder builder, ReadOnlySpan format) - { - var effectiveCount = 0; - Constraint single = default; - - foreach (var constraint in Constraints.Where(constraint => !constraint.IsAny)) - { - effectiveCount++; - single = constraint; - } - - if (effectiveCount is 0) - { - builder.AppendLiteral(format is "m" ? "(,)" : "*"); - return; - } - - if (effectiveCount is 1) - { - var snapshot = builder; - if (single.TryFormat(ref snapshot, format is "m", format is "ns", bareEquals: format is not "m")) - { - builder = snapshot; - return; - } - - if (!snapshot.Success) - { - builder = snapshot; - return; - } - - AppendConstraints(ref builder, bareEquals: false); - return; - } - - if (format is "m" or "ns") - { - var snapshot = builder; - if (TryCombineBounds(out var combined) && - combined.TryFormat(ref snapshot, format is "m", format is "ns", bareEquals: format is not "m")) - { - builder = snapshot; - return; - } - - if (!snapshot.Success) - { - builder = snapshot; - return; - } - } - - AppendConstraints(ref builder, bareEquals: format is not "m"); - } - - private bool TryCombineBounds(out Constraint combined) - { - combined = default; - - (SemanticVersion Version, bool Inclusive)? lower = null; - (SemanticVersion Version, bool Inclusive)? upper = null; - var effectiveCount = 0; - - foreach (var constraint in Constraints) - { - if (constraint.IsAny) - { - continue; - } - - effectiveCount++; - - if (constraint.TryGetLowerBound(out var nextLower)) - { - if (lower is not null) - { - return false; - } - - lower = nextLower; - continue; - } - - if (constraint.TryGetUpperBound(out var nextUpper)) - { - if (upper is not null) - { - return false; - } - - upper = nextUpper; - continue; - } - - return false; - } - - if (effectiveCount is not 2 || lower is null && upper is null) + if (!c.Includes(v)) { return false; } - - combined = lower is null && upper is null - ? default - : new Constraint(lower: lower, upper: upper); - return true; } - private void AppendConstraints(ref SpanStringBuilder builder, bool bareEquals) - { - var written = 0; - foreach (var constraint in Constraints) - { - if (constraint.IsAny) - { - continue; - } - - if (written > 0) - { - builder.AppendFormatted(' '); - } - - constraint.AppendConstraints(ref builder, bareEquals); - - written++; - } - - if (written is 0) - { - builder.AppendLiteral("*"); - } - } - } - - private readonly struct Constraint - { - private readonly Comparator _comparator; - private readonly SemanticVersion _version; - private readonly (SemanticVersion Version, bool Inclusive)? _lower; - private readonly (SemanticVersion Version, bool Inclusive)? _upper; - - public Constraint(Comparator comparator, SemanticVersion version) - { - _comparator = comparator; - _version = version; - _lower = null; - _upper = null; - } - - public Constraint((SemanticVersion Version, bool Inclusive)? lower, (SemanticVersion Version, bool Inclusive)? upper) - { - _comparator = Comparator.Any; - _version = default; - _lower = lower; - _upper = upper; - } - - public bool IsAny => _comparator is Comparator.Any && _lower is null && _upper is null; - - public bool Satisfies(SemanticVersion version) - { - if (IsAny) - { - return true; - } - - if (_comparator is not Comparator.Any) - { - return _comparator switch - { - Comparator.Equal => version.CompareTo(_version) is 0, - Comparator.NotEqual => version.CompareTo(_version) is not 0, - Comparator.Greater => version.CompareTo(_version) > 0, - Comparator.GreaterOrEqual => version.CompareTo(_version) >= 0, - Comparator.Less => version.CompareTo(_version) < 0, - Comparator.LessOrEqual => version.CompareTo(_version) <= 0, - _ => false, - }; - } - - return SatisfiesInterval(version); - } - - public bool CanMatchPrerelease(SemanticVersion version) - { - if (_comparator is not Comparator.Any) - { - return HasMatchingPrerelease(_version, version); - } - - return (_lower is { } lower && HasMatchingPrerelease(lower.Version, version)) || - (_upper is { } upper && HasMatchingPrerelease(upper.Version, version)); - } - - public bool TryGetLowerBound(out (SemanticVersion Version, bool Inclusive) lower) - { - if (_lower is { } intervalLower) - { - lower = intervalLower; - return true; - } - - if (_comparator is not Comparator.Any) - { - switch (_comparator) - { - case Comparator.Greater: - lower = (_version, false); - return true; - case Comparator.GreaterOrEqual: - lower = (_version, true); - return true; - } - } - - lower = default; - return false; - } - - public bool TryGetUpperBound(out (SemanticVersion Version, bool Inclusive) upper) - { - if (_upper is { } intervalUpper) - { - upper = intervalUpper; - return true; - } - - if (_comparator is not Comparator.Any) - { - switch (_comparator) - { - case Comparator.Less: - upper = (_version, false); - return true; - case Comparator.LessOrEqual: - upper = (_version, true); - return true; - } - } - - upper = default; - return false; - } - - public bool TryFormat(ref SpanStringBuilder builder, bool maven, bool shortNpm, bool bareEquals) - { - if (IsAny) - { - builder.AppendLiteral(maven ? "(,)" : "*"); - return true; - } - - if (_comparator is not Comparator.Any) - { - return maven - ? TryAppendMaven(ref builder) - : AppendComparator(ref builder, bareEquals); - } - - if (maven) - { - AppendMavenRange(ref builder, _lower, _upper); - return true; - } - - return shortNpm && TryGetShortNpmOperator(out var shortOperator, out var shortVersion) - ? AppendShortNpm(ref builder, shortOperator, shortVersion) - : AppendIntervalConstraints(ref builder); - } - - public void AppendConstraints(ref SpanStringBuilder builder, bool bareEquals) - { - if (IsAny) - { - builder.AppendLiteral("*"); - return; - } - - if (_comparator is not Comparator.Any) - { - _ = AppendComparator(ref builder, bareEquals); - return; - } - - _ = AppendIntervalConstraints(ref builder); - } - - private bool SatisfiesInterval(SemanticVersion version) - { - if (_lower is { } lower) - { - var lowerComparison = version.CompareTo(lower.Version); - if (lowerComparison < 0 || lowerComparison is 0 && !lower.Inclusive) - { - return false; - } - } - - if (_upper is { } upper) - { - var upperComparison = version.CompareTo(upper.Version); - if (upperComparison > 0 || upperComparison is 0 && !upper.Inclusive) - { - return false; - } - } - - return true; - } - - private bool TryAppendMaven(ref SpanStringBuilder builder) - { - return _comparator switch - { - Comparator.Equal => AppendMavenExact(ref builder, _version), - Comparator.Greater => AppendMavenRange(ref builder, (_version, false), null), - Comparator.GreaterOrEqual => AppendMavenRange(ref builder, (_version, true), null), - Comparator.Less => AppendMavenRange(ref builder, null, (_version, false)), - Comparator.LessOrEqual => AppendMavenRange(ref builder, null, (_version, true)), - _ => false, - }; - } - - private bool AppendComparator(ref SpanStringBuilder builder, bool bareEquals) - { - switch (_comparator) - { - case Comparator.Equal when bareEquals: - builder.AppendFormatted(_version, "f"); - return true; - case Comparator.Equal: - builder.AppendLiteral("="); - builder.AppendFormatted(_version, "f"); - return true; - case Comparator.Greater: - builder.AppendLiteral(">"); - builder.AppendFormatted(_version, "f"); - return true; - case Comparator.GreaterOrEqual: - builder.AppendLiteral(">="); - builder.AppendFormatted(_version, "f"); - return true; - case Comparator.Less: - builder.AppendLiteral("<"); - builder.AppendFormatted(_version, "f"); - return true; - case Comparator.LessOrEqual: - builder.AppendLiteral("<="); - builder.AppendFormatted(_version, "f"); - return true; - case Comparator.NotEqual: - builder.AppendLiteral("!="); - builder.AppendFormatted(_version, "f"); - return true; - default: - return false; - } - } - - private bool AppendIntervalConstraints(ref SpanStringBuilder builder) - { - var written = 0; - - if (_lower is { } lower) - { - builder.AppendLiteral(lower.Inclusive ? ">=" : ">"); - builder.AppendFormatted(lower.Version, "f"); - - written++; - } - - if (_upper is { } upper) - { - if (written > 0) - { - builder.AppendFormatted(' '); - } - - builder.AppendLiteral(upper.Inclusive ? "<=" : "<"); - builder.AppendFormatted(upper.Version, "f"); - - written++; - } - - if (written > 0) - { - return true; - } - - builder.AppendLiteral("*"); - return true; - } - - private static bool AppendMavenExact(ref SpanStringBuilder builder, SemanticVersion version) - { - builder.AppendFormatted('['); - builder.AppendFormatted(version, "f"); - builder.AppendFormatted(']'); - return true; - } - - private static bool AppendMavenRange(ref SpanStringBuilder builder, (SemanticVersion Version, bool Inclusive)? lower, (SemanticVersion Version, bool Inclusive)? upper) - { - builder.AppendFormatted(lower is { Inclusive: true } ? '[' : '('); - - if (lower is { } lowerBound) - { - builder.AppendFormatted(lowerBound.Version, "f"); - } - - builder.AppendFormatted(','); - - if (upper is { } upperBound) - { - builder.AppendFormatted(upperBound.Version, "f"); - } - - builder.AppendFormatted(upper is { Inclusive: true } ? ']' : ')'); - return true; - } - - private static bool AppendShortNpm(ref SpanStringBuilder builder, char shortOperator, SemanticVersion shortVersion) - { - builder.AppendFormatted(shortOperator); - builder.AppendFormatted(shortVersion, "f"); - return true; - } - - private bool TryGetShortNpmOperator(out char shortOperator, out SemanticVersion shortVersion) - { - if (_lower is { Inclusive: true } lower && _upper is { Inclusive: false } upper) - { - if (upper.Version == GetCaretUpperBound(lower.Version)) - { - shortOperator = '^'; - shortVersion = lower.Version; - return true; - } - - if (upper.Version == GetTildeUpperBound(lower.Version)) - { - shortOperator = '~'; - shortVersion = lower.Version; - return true; - } - } - - shortOperator = default; - shortVersion = default; - return false; - } - - private static bool HasMatchingPrerelease(SemanticVersion candidate, SemanticVersion version) - { - return candidate.Prerelease is not null && - candidate.Major == version.Major && - candidate.Minor == version.Minor && - candidate.Patch == version.Patch; - } - } - - private static SemanticVersion GetCaretUpperBound(SemanticVersion version, int components = 3) - { - return components switch - { - 1 => new SemanticVersion(version.Major + 1, 0, 0), - 2 when version.Major is 0 => new SemanticVersion(0, version.Minor + 1, 0), - 2 => new SemanticVersion(version.Major + 1, 0, 0), - _ when version.Major > 0 => new SemanticVersion(version.Major + 1, 0, 0), - _ when version.Minor > 0 => new SemanticVersion(0, version.Minor + 1, 0), - _ => new SemanticVersion(0, 0, version.Patch + 1), - }; - } - - private static SemanticVersion GetTildeUpperBound(SemanticVersion version, int components = 3) - { - return components switch - { - >= 2 => new SemanticVersion(version.Major, version.Minor + 1, 0), - _ => new SemanticVersion(version.Major + 1, 0, 0), - }; - } - - private enum Comparator - { - Any, - Equal, - Greater, - GreaterOrEqual, - Less, - LessOrEqual, - NotEqual, + return true; } } \ No newline at end of file diff --git a/src/semver/SpanBuffer.cs b/src/semver/SpanBuffer.cs new file mode 100644 index 0000000..8c2c3fa --- /dev/null +++ b/src/semver/SpanBuffer.cs @@ -0,0 +1,50 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.SemVer; + +internal ref struct SpanBuffer(Span buffer) +{ + private readonly Span _buffer = buffer; + public int Written { get; private set; } + + public bool TryWrite(ReadOnlySpan value) + { + if (!value.TryCopyTo(_buffer[Written..])) + { + return false; + } + + Written += value.Length; + return true; + } + + public bool TryWrite(T v) where T : ISpanFormattable + { + if (!v.TryFormat(_buffer[Written..], out var n, [], null)) + { + return false; + } + + Written += n; + return true; + } + + public bool TryWrite(T v, ReadOnlySpan fmt) where T : ISpanFormattable + { + if (!v.TryFormat(_buffer[Written..], out var n, fmt, null)) + { + return false; + } + + Written += n; + return true; + } + + // Resets position to a saved checkpoint and returns false — useful in || chains. + public bool Reset(int checkpoint) + { + Written = checkpoint; + return false; + } +} \ No newline at end of file diff --git a/src/semver/SpanStringBuilder.cs b/src/semver/SpanStringBuilder.cs deleted file mode 100644 index 66f9afa..0000000 --- a/src/semver/SpanStringBuilder.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) The Geekeey Authors -// SPDX-License-Identifier: EUPL-1.2 - -namespace Geekeey.SemVer; - -internal ref struct SpanStringBuilder -{ - private Span _destination; - private readonly IFormatProvider? _provider; - - public SpanStringBuilder(Span destination, IFormatProvider? provider = null) - { - _destination = destination; - _provider = provider; - CharsWritten = 0; - Success = true; - } - - public int CharsWritten { get; private set; } - - public bool Success { get; private set; } - - public readonly bool TryComplete(out int charsWritten) - { - charsWritten = Success ? CharsWritten : 0; - return Success; - } - - public void AppendLiteral(string? value) - { - AppendLiteral(value.AsSpan()); - } - - public void AppendLiteral(ReadOnlySpan value) - { - if (!Success) - { - return; - } - - if (!value.TryCopyTo(_destination)) - { - Success = false; - return; - } - - _destination = _destination[value.Length..]; - CharsWritten += value.Length; - } - - public void AppendFormatted(string? value) - { - AppendLiteral(value.AsSpan()); - } - - public void AppendFormatted(ReadOnlySpan value) - { - AppendLiteral(value); - } - - public void AppendFormatted(T value) where T : ISpanFormattable - { - AppendFormatted(value, null); - } - - public void AppendFormatted(T value, string? format) where T : ISpanFormattable - { - if (!Success) - { - return; - } - - if (!value.TryFormat(_destination, out var written, format, _provider)) - { - Success = false; - return; - } - - _destination = _destination[written..]; - CharsWritten += written; - } -} \ No newline at end of file