diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index f87225a..2516657 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -19,7 +19,7 @@ internal sealed class SemanticVersionRangeTests { var r = SemanticVersionRange.Parse(range); var v = SemanticVersion.Parse(version); - await Assert.That(r.Satisfies(v)).IsEqualTo(expected); + await Assert.That(r.Contains(v)).IsEqualTo(expected); } [Test] @@ -31,7 +31,7 @@ internal sealed class SemanticVersionRangeTests { var r = SemanticVersionRange.Parse(range); var v = SemanticVersion.Parse(version); - await Assert.That(r.Satisfies(v)).IsEqualTo(expected); + await Assert.That(r.Contains(v)).IsEqualTo(expected); } [Test] @@ -47,7 +47,7 @@ internal sealed class SemanticVersionRangeTests { var r = SemanticVersionRange.Parse(range); var v = SemanticVersion.Parse(version); - await Assert.That(r.Satisfies(v)).IsEqualTo(expected); + await Assert.That(r.Contains(v)).IsEqualTo(expected); } [Test] @@ -64,7 +64,7 @@ internal sealed class SemanticVersionRangeTests { var r = SemanticVersionRange.Parse(range); var v = SemanticVersion.Parse(version); - await Assert.That(r.Satisfies(v)).IsEqualTo(expected); + await Assert.That(r.Contains(v)).IsEqualTo(expected); } [Test] @@ -80,7 +80,7 @@ internal sealed class SemanticVersionRangeTests { var r = SemanticVersionRange.Parse(range); var v = SemanticVersion.Parse(version); - await Assert.That(r.Satisfies(v)).IsEqualTo(expected); + await Assert.That(r.Contains(v)).IsEqualTo(expected); } [Test] @@ -94,7 +94,7 @@ internal sealed class SemanticVersionRangeTests { var r = SemanticVersionRange.Parse(range); var v = SemanticVersion.Parse(version); - await Assert.That(r.Satisfies(v)).IsEqualTo(expected); + await Assert.That(r.Contains(v)).IsEqualTo(expected); } [Test] @@ -106,7 +106,7 @@ internal sealed class SemanticVersionRangeTests { var r = SemanticVersionRange.Parse(range); var v = SemanticVersion.Parse(version); - await Assert.That(r.Satisfies(v)).IsEqualTo(expected); + await Assert.That(r.Contains(v)).IsEqualTo(expected); } [Test] @@ -125,7 +125,7 @@ internal sealed class SemanticVersionRangeTests { var r = SemanticVersionRange.Parse(range); var v = SemanticVersion.Parse(version); - await Assert.That(r.Satisfies(v)).IsEqualTo(expected); + await Assert.That(r.Contains(v)).IsEqualTo(expected); } [Test] @@ -136,7 +136,7 @@ internal sealed class SemanticVersionRangeTests { var r = SemanticVersionRange.Parse(range); var v = SemanticVersion.Parse(version); - await Assert.That(r.Satisfies(v)).IsEqualTo(expected); + await Assert.That(r.Contains(v)).IsEqualTo(expected); } [Test] @@ -148,7 +148,7 @@ internal sealed class SemanticVersionRangeTests { var r = SemanticVersionRange.Parse(range); var v = SemanticVersion.Parse(version); - await Assert.That(r.Satisfies(v)).IsEqualTo(expected); + await Assert.That(r.Contains(v)).IsEqualTo(expected); } [Test] @@ -165,7 +165,7 @@ internal sealed class SemanticVersionRangeTests var json = "\"^1.2.3\""; var r = System.Text.Json.JsonSerializer.Deserialize(json); await Assert.That(r.ToString()).IsEqualTo("^1.2.3"); - await Assert.That(r.Satisfies(new SemanticVersion(1, 2, 4))).IsTrue(); + await Assert.That(r.Contains(new SemanticVersion(1, 2, 4))).IsTrue(); } [Test] @@ -200,4 +200,15 @@ internal sealed class SemanticVersionRangeTests await Assert.That(success).IsTrue(); await Assert.That(new string(destination[..charsWritten])).IsEqualTo("^1.2.3"); } + + [Test] + public async Task I_fail_formatting_when_the_tentative_short_form_overflows() + { + var value = SemanticVersionRange.Parse("[1.2.3,2.0.0)"); + var destination = new char[5]; + var success = value.TryFormat(destination, out var charsWritten, "ns", null); + + await Assert.That(success).IsFalse(); + await Assert.That(charsWritten).IsEqualTo(0); + } } \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.Formatting.cs b/src/semver/SemanticVersionRange.Formatting.cs new file mode 100644 index 0000000..a86014e --- /dev/null +++ b/src/semver/SemanticVersionRange.Formatting.cs @@ -0,0 +1,75 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Runtime.CompilerServices; + +namespace Geekeey.SemVer; + +public readonly partial record struct SemanticVersionRange : ISpanFormattable +{ + #region IFormattable + + /// + public string ToString(string? format, IFormatProvider? formatProvider) + { + if (format is not null and not "m" and not "n" and not "ns") + { + throw new FormatException($"The format string '{format}' is not supported."); + } + + var handler = new DefaultInterpolatedStringHandler(0, 1, formatProvider); + handler.AppendFormatted(this, format); + return handler.ToStringAndClear(); + } + + #endregion + + #region ISpanFormattable + + /// + /// Tries to format the semantic version range into the specified span of characters. + /// + /// ISpanFormattable.TryFormat + public bool TryFormat(Span destination, out int charsWritten) + { + return TryFormat(destination, out charsWritten, default, null); + } + + /// + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + if (format.IsEmpty) + { + format = "ns"; + } + + if (format is not "m" and not "n" and not "ns") + { + charsWritten = 0; + return false; + } + + var builder = new SpanStringBuilder(destination, provider); + + if (_sets is not { Length: > 0 } sets) + { + builder.AppendLiteral(format is "m" ? "(,)" : "*"); + } + else + { + for (var i = 0; i < sets.Length; i++) + { + if (i > 0) + { + builder.AppendLiteral(format is "m" ? "," : " || "); + } + + sets[i].AppendFormatted(ref builder, format); + } + } + + return builder.TryComplete(out charsWritten); + } + + #endregion +} \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.Parsing.cs b/src/semver/SemanticVersionRange.Parsing.cs new file mode 100644 index 0000000..13e436b --- /dev/null +++ b/src/semver/SemanticVersionRange.Parsing.cs @@ -0,0 +1,462 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.CodeAnalysis; + +namespace Geekeey.SemVer; + +public readonly partial record struct SemanticVersionRange : ISpanParsable +{ + #region IParsable + + /// + /// Parses a string into a . + /// + /// + public static SemanticVersionRange Parse(string s) + { + return Parse(s.AsSpan(), null); + } + + /// + public static SemanticVersionRange Parse(string s, IFormatProvider? provider) + { + return Parse(s.AsSpan(), provider); + } + + /// + /// Tries to parse a string into a . + /// + /// + public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out SemanticVersionRange result) + { + return TryParse(s, null, out result); + } + + /// + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result) + { + return TryParse(s.AsSpan(), provider, out result); + } + + #endregion + + #region ISpanParsable + + /// + /// Parses a span of characters into a . + /// + /// + public static SemanticVersionRange Parse(ReadOnlySpan s) + { + return Parse(s, null); + } + + /// + public static SemanticVersionRange Parse(ReadOnlySpan s, IFormatProvider? provider) + { + if (!TryParse(s, provider, out var result)) + { + throw new FormatException($"The input string '{s}' was not in a correct format."); + } + + return result; + } + + /// + /// Tries to parse a span of characters into a . + /// + /// + public static bool TryParse(ReadOnlySpan s, [MaybeNullWhen(false)] out SemanticVersionRange result) + { + return TryParse(s, null, out result); + } + + /// + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result) + { + result = default; + + if (s.IsEmpty) + { + return false; + } + + return s[0] is '[' or '(' ? TryParseJava(s, out result) : TryParseNode(s, out result); + } + + #endregion + + private static bool TryParseJava(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(); + + foreach (var range in new NodeSetGrouping(s)) + { + if (s[range] is not { IsEmpty: false } part) + { + continue; + } + + if (!TryParseNodeSet(part, out var set)) + { + result = default; + return false; + } + + sets.Add(set); + } + + result = new SemanticVersionRange([.. sets]); + 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; + private int _currentStart; + private int _currentEnd; + + public NodeSetGrouping(ReadOnlySpan span) + { + _span = span; + _currentStart = 0; + _currentEnd = -1; + } + + public readonly Range Current => _currentStart.._currentEnd; + + public bool MoveNext() + { + _currentStart = _currentEnd is -1 ? 0 : _currentEnd + 2; + + if (_currentStart >= _span.Length) + { + return false; + } + + var index = _span[_currentStart..].IndexOf("||"); + _currentEnd = index >= 0 ? _currentStart + index : _span.Length; + + return true; + } + + public readonly NodeSetGrouping GetEnumerator() + { + return this; + } + } +} \ No newline at end of file diff --git a/src/semver/SemanticVersionRange.cs b/src/semver/SemanticVersionRange.cs index 20df5c2..dd620ad 100644 --- a/src/semver/SemanticVersionRange.cs +++ b/src/semver/SemanticVersionRange.cs @@ -2,51 +2,36 @@ // SPDX-License-Identifier: EUPL-1.2 using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; namespace Geekeey.SemVer; public readonly partial record struct SemanticVersionRange { - private readonly ImmutableArray> _constraints; + private readonly ImmutableArray _sets; - private SemanticVersionRange(ImmutableArray> constraints) + private SemanticVersionRange(ImmutableArray sets) { - _constraints = constraints; + _sets = sets; } - public bool Satisfies(SemanticVersion version) + /// + /// 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 (_constraints.Length is 0) + if (_sets.Length is 0) { return true; } - foreach (var set in _constraints) + foreach (var set in _sets) { - if (version.Prerelease is not null) - { - if (!set.Any(constraint => constraint.Version.Prerelease is not null && - constraint.Version.Major == version.Major && - constraint.Version.Minor == version.Minor && - constraint.Version.Patch == version.Patch)) - { - continue; - } - } - - if (set.All(constraint => constraint.Comparator switch - { - Comparator.Any => true, - Comparator.Equal => version.CompareTo(constraint.Version) is 0, - Comparator.NotEqual => version.CompareTo(constraint.Version) is not 0, - Comparator.Greater => version.CompareTo(constraint.Version) > 0, - Comparator.GreaterOrEqual => version.CompareTo(constraint.Version) >= 0, - Comparator.Less => version.CompareTo(constraint.Version) < 0, - Comparator.LessOrEqual => version.CompareTo(constraint.Version) <= 0, - _ => false, - })) + if (set.Satisfies(version)) { return true; } @@ -61,17 +46,531 @@ public readonly partial record struct SemanticVersionRange return ToString(null, null); } - private readonly struct Constraint + private readonly struct ConstraintSet { - public Constraint(Comparator comparator, SemanticVersion version) + public ConstraintSet(ImmutableArray constraints) { - Comparator = comparator; - Version = version; + Constraints = constraints; } - public Comparator Comparator { get; } + public ImmutableArray Constraints { get; } - public SemanticVersion Version { 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) + { + 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 @@ -84,795 +583,4 @@ public readonly partial record struct SemanticVersionRange LessOrEqual, NotEqual, } -} - -public readonly partial record struct SemanticVersionRange : ISpanFormattable -{ - #region IFormattable - - /// - public string ToString(string? format, IFormatProvider? formatProvider) - { - var handler = new DefaultInterpolatedStringHandler(0, 1, formatProvider); - handler.AppendFormatted(this, format); - return handler.ToStringAndClear(); - } - - #endregion - - #region ISpanFormattable - - /// - /// Tries to format the semantic version range into the specified span of characters. - /// - /// ISpanFormattable.TryFormat - public bool TryFormat(Span destination, out int charsWritten) - { - return TryFormat(destination, out charsWritten, default, null); - } - - /// - public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) - { - return TryFormatCore(destination, out charsWritten, format); - } - - #endregion - - private bool TryFormatCore(Span destination, out int charsWritten, ReadOnlySpan format) - { - charsWritten = 0; - - if (_constraints is not { Length: > 0 } sets) - { - return TryWriteLiteral(ref destination, ref charsWritten, format is RangeStringFormat.Maven ? "(,)" : "*"); - } - - for (var i = 0; i < sets.Length; i++) - { - if (i > 0 && !TryWriteLiteral(ref destination, ref charsWritten, format is RangeStringFormat.Maven ? "," : " || ")) - { - charsWritten = 0; - return false; - } - - if (!TryWriteSet(ref destination, ref charsWritten, sets[i], format)) - { - charsWritten = 0; - return false; - } - } - - return true; - } - - private static bool TryWriteSet(ref Span destination, ref int charsWritten, ImmutableArray set, RangeStringFormat format) - { - AnalyzeSet(set, out var effectiveCount, out var hasExact, out var exact, out var hasLower, out var lower, out var hasUpper, out var upper, out var hasUnsupported); - - if (hasUnsupported) - { - return TryWriteConstraints(ref destination, ref charsWritten, set, false); - } - - if (effectiveCount is 0) - { - return TryWriteLiteral(ref destination, ref charsWritten, format is RangeStringFormat.Maven ? "(,)" : "*"); - } - - if (hasExact && effectiveCount is 1) - { - if (format is RangeStringFormat.Maven && !TryWriteLiteral(ref destination, ref charsWritten, "[")) - { - return false; - } - - if (!TryWriteVersion(ref destination, ref charsWritten, exact.Version)) - { - return false; - } - - return format is not RangeStringFormat.Maven || TryWriteLiteral(ref destination, ref charsWritten, "]"); - } - - if (format is RangeStringFormat.NpmShort && hasLower && hasUpper && effectiveCount is 2 && TryGetShortNpmOperator(lower, upper, out var shortOperator)) - { - return TryWriteChar(ref destination, ref charsWritten, shortOperator) && - TryWriteVersion(ref destination, ref charsWritten, lower.Version); - } - - if (format is RangeStringFormat.Maven) - { - return TryWriteMavenSet(ref destination, ref charsWritten, hasLower, lower, hasUpper, upper); - } - - return TryWriteConstraints(ref destination, ref charsWritten, set, true); - } - - private static bool TryWriteMavenSet(ref Span destination, ref int charsWritten, bool hasLower, Constraint lower, bool hasUpper, Constraint upper) - { - if (!TryWriteChar(ref destination, ref charsWritten, hasLower && lower.Comparator is Comparator.GreaterOrEqual ? '[' : '(')) - { - return false; - } - - if (hasLower && !TryWriteVersion(ref destination, ref charsWritten, lower.Version)) - { - return false; - } - - if (!TryWriteChar(ref destination, ref charsWritten, ',')) - { - return false; - } - - if (hasUpper && !TryWriteVersion(ref destination, ref charsWritten, upper.Version)) - { - return false; - } - - return TryWriteChar(ref destination, ref charsWritten, hasUpper && upper.Comparator is Comparator.LessOrEqual ? ']' : ')'); - } - - private static bool TryWriteConstraints(ref Span destination, ref int charsWritten, ImmutableArray set, bool bareEquals) - { - var written = 0; - foreach (var constraint in set) - { - if (constraint.Comparator is Comparator.Any) - { - continue; - } - - if (written > 0 && !TryWriteChar(ref destination, ref charsWritten, ' ')) - { - return false; - } - - if (!TryWriteConstraint(ref destination, ref charsWritten, constraint, bareEquals)) - { - return false; - } - - written++; - } - - return written > 0 || TryWriteLiteral(ref destination, ref charsWritten, "*"); - } - - private static bool TryWriteConstraint(ref Span destination, ref int charsWritten, Constraint constraint, bool bareEquals) - { - switch (constraint.Comparator) - { - case Comparator.Equal when bareEquals: - return TryWriteVersion(ref destination, ref charsWritten, constraint.Version); - case Comparator.Equal: - return TryWriteLiteral(ref destination, ref charsWritten, "=") && - TryWriteVersion(ref destination, ref charsWritten, constraint.Version); - case Comparator.Greater: - return TryWriteLiteral(ref destination, ref charsWritten, ">") && - TryWriteVersion(ref destination, ref charsWritten, constraint.Version); - case Comparator.GreaterOrEqual: - return TryWriteLiteral(ref destination, ref charsWritten, ">=") && - TryWriteVersion(ref destination, ref charsWritten, constraint.Version); - case Comparator.Less: - return TryWriteLiteral(ref destination, ref charsWritten, "<") && - TryWriteVersion(ref destination, ref charsWritten, constraint.Version); - case Comparator.LessOrEqual: - return TryWriteLiteral(ref destination, ref charsWritten, "<=") && - TryWriteVersion(ref destination, ref charsWritten, constraint.Version); - case Comparator.NotEqual: - return TryWriteLiteral(ref destination, ref charsWritten, "!=") && - TryWriteVersion(ref destination, ref charsWritten, constraint.Version); - default: - return false; - } - } - - private static bool TryGetShortNpmOperator(Constraint lower, Constraint upper, out char shortOperator) - { - if (lower.Comparator is Comparator.GreaterOrEqual && upper.Comparator is Comparator.Less) - { - if (upper.Version == GetCaretUpperBound(lower.Version)) - { - shortOperator = '^'; - return true; - } - - if (upper.Version == GetTildeUpperBound(lower.Version)) - { - shortOperator = '~'; - return true; - } - } - - shortOperator = default; - return false; - } - - private static SemanticVersion GetCaretUpperBound(SemanticVersion version) - { - return version.Major > 0 - ? new SemanticVersion(version.Major + 1, 0, 0) - : version.Minor > 0 - ? new SemanticVersion(0, version.Minor + 1, 0) - : new SemanticVersion(0, 0, version.Patch + 1); - } - - private static SemanticVersion GetTildeUpperBound(SemanticVersion version) - { - return new SemanticVersion(version.Major, version.Minor + 1, 0); - } - - private static void AnalyzeSet( - ImmutableArray set, - out int effectiveCount, - out bool hasExact, - out Constraint exact, - out bool hasLower, - out Constraint lower, - out bool hasUpper, - out Constraint upper, - out bool hasUnsupported) - { - effectiveCount = 0; - hasExact = false; - exact = default; - hasLower = false; - lower = default; - hasUpper = false; - upper = default; - hasUnsupported = false; - - foreach (var constraint in set) - { - if (constraint.Comparator is Comparator.Any) - { - continue; - } - - effectiveCount++; - switch (constraint.Comparator) - { - case Comparator.Equal: - hasExact = true; - exact = constraint; - break; - case Comparator.Greater: - case Comparator.GreaterOrEqual: - hasLower = true; - lower = constraint; - break; - case Comparator.Less: - case Comparator.LessOrEqual: - hasUpper = true; - upper = constraint; - break; - default: - hasUnsupported = true; - break; - } - } - } - - private static bool TryWriteVersion(ref Span destination, ref int charsWritten, SemanticVersion version) - { - if (!version.TryFormat(destination, out var written, "f", null)) - { - return false; - } - - destination = destination[written..]; - charsWritten += written; - return true; - } - - private static bool TryWriteChar(ref Span destination, ref int charsWritten, char value) - { - if (destination.IsEmpty) - { - return false; - } - - destination[0] = value; - destination = destination[1..]; - charsWritten++; - return true; - } - - private static bool TryWriteLiteral(ref Span destination, ref int charsWritten, ReadOnlySpan value) - { - if (!value.TryCopyTo(destination)) - { - return false; - } - - destination = destination[value.Length..]; - charsWritten += value.Length; - return true; - } -} - -public readonly partial record struct SemanticVersionRange : ISpanParsable -{ - #region IParsable - - /// - /// Parses a string into a . - /// - /// - public static SemanticVersionRange Parse(string s) - { - return Parse(s.AsSpan(), null); - } - - /// - public static SemanticVersionRange Parse(string s, IFormatProvider? provider) - { - return Parse(s.AsSpan(), provider); - } - - /// - /// Tries to parse a string into a . - /// - /// - public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out SemanticVersionRange result) - { - return TryParse(s, null, out result); - } - - /// - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result) - { - return TryParse(s.AsSpan(), provider, out result); - } - - #endregion - - #region ISpanParsable - - /// - /// Parses a span of characters into a . - /// - /// - public static SemanticVersionRange Parse(ReadOnlySpan s) - { - return Parse(s, null); - } - - /// - public static SemanticVersionRange Parse(ReadOnlySpan s, IFormatProvider? provider) - { - if (!TryParse(s, provider, out var result)) - { - throw new FormatException($"The input string '{s}' was not in a correct format."); - } - - return result; - } - - /// - /// Tries to parse a span of characters into a . - /// - /// - public static bool TryParse(ReadOnlySpan s, [MaybeNullWhen(false)] out SemanticVersionRange result) - { - return TryParse(s, null, out result); - } - - /// - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result) - { - result = default; - - if (s.IsEmpty) - { - return false; - } - - return s[0] is '[' or '(' ? TryParseJava(s, out result) : TryParseNode(s, out result); - } - - #endregion - - private static bool TryParseJava(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 constraints)) - { - result = default; - return false; - } - - sets.Add(constraints); - } - - result = new SemanticVersionRange([..sets]); - return true; - } - - private static bool TryParseJavaSet(ReadOnlySpan s, [NotNullWhen(true)] out ImmutableArray constraints) - { - constraints = []; - - if (s.IsEmpty) - { - return false; - } - - if (s is not ['[' or '(', .., ']' or ')']) - { - if (!SemanticVersion.TryParsePartially(s, out var version, out _)) - { - return false; - } - - constraints = [new Constraint(Comparator.GreaterOrEqual, version)]; - 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; - } - - constraints = [new Constraint(Comparator.Equal, version)]; - return true; - } - - var result = new List(2); - - var lowerComparator = inclusiveStart ? Comparator.GreaterOrEqual : Comparator.Greater; - if (!TryAddConstraint(text[..comma].Trim(), lowerComparator, result)) - { - return false; - } - - var upperComparator = inclusiveEnd ? Comparator.LessOrEqual : Comparator.Less; - if (!TryAddConstraint(text[(comma + 1)..].Trim(), upperComparator, result)) - { - return false; - } - - constraints = [.. result]; - return true; - - static bool TryAddConstraint(ReadOnlySpan text, Comparator comparator, ICollection result) - { - if (text.IsEmpty) - { - return true; - } - - if (!SemanticVersion.TryParsePartially(text, out var version, out _)) - { - return false; - } - - result.Add(new Constraint(comparator, version)); - 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>(); - - foreach (var range in new NodeSetGrouping(s)) - { - if (s[range] is not { IsEmpty: false } part) - { - continue; - } - - if (!TryParseNodeSet(part, out var constraints)) - { - result = default; - return false; - } - - sets.Add(constraints); - } - - result = new SemanticVersionRange([..sets]); - return true; - } - - private static bool TryParseNodeSet(ReadOnlySpan s, [NotNullWhen(true)] out ImmutableArray constraints) - { - if (s.IndexOf(" - ") is not -1 and var hyphen) - { - if (!SemanticVersion.TryParse(s[..hyphen].Trim(), out var lowerVersion)) - { - constraints = default; - return false; - } - - if (!SemanticVersion.TryParse(s[(hyphen + 3)..].Trim(), out var upperVersion)) - { - constraints = default; - return false; - } - - constraints = - [ - new Constraint(Comparator.GreaterOrEqual, lowerVersion), - new Constraint(Comparator.LessOrEqual, upperVersion), - ]; - 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 "=") - { - instruction = token; - - if (!TryReadToken(ref current, out token)) - { - constraints = 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[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") - { - constraints = [new Constraint(Comparator.Any, default)]; - return true; - } - - if (!TryParseNpmConstraint(instruction, token.Trim(), list)) - { - constraints = default; - return false; - } - } - - constraints = [.. 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(Comparator.GreaterOrEqual, version)); - constraints.Add(new Constraint(Comparator.Less, GetCaretUpperBound(version, components))); - return true; - case "~": - constraints.Add(new Constraint(Comparator.GreaterOrEqual, version)); - constraints.Add(new Constraint(Comparator.Less, GetTildeUpperBound(version, components))); - 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; - default: - switch (components) - { - case 3: - constraints.Add(new Constraint(Comparator.Equal, version)); - return true; - case 2: - constraints.Add(new Constraint(Comparator.GreaterOrEqual, version)); - constraints.Add(new Constraint(Comparator.Less, new SemanticVersion(version.Major, version.Minor + 1, 0))); - return true; - case 1: - constraints.Add(new Constraint(Comparator.GreaterOrEqual, version)); - constraints.Add(new Constraint(Comparator.Less, new SemanticVersion(version.Major + 1, 0, 0))); - return true; - default: - return false; - } - } - - static SemanticVersion GetCaretUpperBound(SemanticVersion version, int components) - { - 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), - }; - } - - static SemanticVersion GetTildeUpperBound(SemanticVersion version, int components) - { - return components switch - { - >= 2 => new SemanticVersion(version.Major, version.Minor + 1, 0), - _ => new SemanticVersion(version.Major + 1, 0, 0), - }; - } - } - - private ref struct NodeSetGrouping - { - private readonly ReadOnlySpan _span; - private int _currentStart; - private int _currentEnd; - - public NodeSetGrouping(ReadOnlySpan span) - { - _span = span; - _currentStart = 0; - _currentEnd = -1; - } - - public readonly Range Current => _currentStart.._currentEnd; - - public bool MoveNext() - { - _currentStart = _currentEnd is -1 ? 0 : _currentEnd + 2; - - if (_currentStart >= _span.Length) - { - return false; - } - - var index = _span[_currentStart..].IndexOf("||"); - _currentEnd = index >= 0 ? _currentStart + index : _span.Length; - - return true; - } - - public readonly NodeSetGrouping GetEnumerator() - { - return this; - } - } } \ No newline at end of file