Skip to content

Commit 9abe711

Browse files
committed
Introduce VersionSpec, a model meant for advanced version handling for applicabilities
1 parent 5a492db commit 9abe711

File tree

3 files changed

+334
-0
lines changed

3 files changed

+334
-0
lines changed

src/Elastic.Documentation/AppliesTo/ApplicableToYamlConverter.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,10 +256,102 @@ private static bool TryGetApplicabilityOverTime(Dictionary<object, object?> dict
256256
if (target is null || (target is string s && string.IsNullOrWhiteSpace(s)))
257257
availability = AppliesCollection.GenerallyAvailable;
258258
else if (target is string stackString)
259+
{
259260
availability = AppliesCollection.TryParse(stackString, diagnostics, out var a) ? a : null;
261+
262+
if (availability is not null)
263+
ValidateApplicabilityCollection(key, availability, diagnostics);
264+
}
260265
return availability is not null;
261266
}
262267

268+
private static void ValidateApplicabilityCollection(string key, AppliesCollection collection, List<(Severity, string)> diagnostics)
269+
{
270+
var items = collection.ToList();
271+
272+
// Rule: Only one version declaration per lifecycle
273+
var lifecycleGroups = items.GroupBy(a => a.Lifecycle).ToList();
274+
foreach (var group in lifecycleGroups)
275+
{
276+
var lifecycleVersionedItems = group.Where(a => a.Version is not null &&
277+
a.Version != AllVersionsSpec.Instance).ToList();
278+
if (lifecycleVersionedItems.Count > 1)
279+
{
280+
diagnostics.Add((Severity.Warning,
281+
$"Key '{key}': Multiple version declarations for {group.Key} lifecycle. Only one version per lifecycle is allowed."));
282+
}
283+
}
284+
285+
// Rule: Only one item per key can use greater-than syntax
286+
var greaterThanItems = items.Where(a =>
287+
a.Version is VersionSpec spec && spec.Kind == VersionSpecKind.GreaterThanOrEqual &&
288+
a.Version != AllVersionsSpec.Instance).ToList();
289+
290+
if (greaterThanItems.Count > 1)
291+
{
292+
diagnostics.Add((Severity.Warning,
293+
$"Key '{key}': Multiple items use greater-than-or-equal syntax. Only one item per key can use this syntax."));
294+
}
295+
296+
// Rule: In a range, the first version must be less than or equal the last version
297+
foreach (var item in items)
298+
{
299+
if (item.Version is { Kind: VersionSpecKind.Range } spec)
300+
{
301+
if (spec.Min.CompareTo(spec.Max!) > 0)
302+
{
303+
diagnostics.Add((Severity.Warning,
304+
$"Key '{key}', {item.Lifecycle}: Range has first version ({spec.Min.Major}.{spec.Min.Minor}) greater than last version ({spec.Max!.Major}.{spec.Max.Minor})."));
305+
}
306+
}
307+
}
308+
309+
// Rule: No overlapping version ranges for the same key
310+
var versionedItems = items.Where(a => a.Version is not null &&
311+
a.Version != AllVersionsSpec.Instance).ToList();
312+
313+
for (var i = 0; i < versionedItems.Count; i++)
314+
{
315+
for (var j = i + 1; j < versionedItems.Count; j++)
316+
{
317+
if (CheckVersionOverlap(versionedItems[i].Version!, versionedItems[j].Version!, out var overlapMsg))
318+
{
319+
diagnostics.Add((Severity.Warning,
320+
$"Key '{key}': Overlapping versions between {versionedItems[i].Lifecycle} and {versionedItems[j].Lifecycle}. {overlapMsg}"));
321+
}
322+
}
323+
}
324+
}
325+
326+
private static bool CheckVersionOverlap(VersionSpec v1, VersionSpec v2, out string message)
327+
{
328+
message = string.Empty;
329+
330+
// Get the effective ranges for each version spec
331+
// For GreaterThanOrEqual: [min, infinity)
332+
// For Range: [min, max]
333+
// For Exact: [exact, exact]
334+
335+
var (v1Min, v1Max) = GetEffectiveRange(v1);
336+
var (v2Min, v2Max) = GetEffectiveRange(v2);
337+
338+
var overlaps = v1Min.CompareTo(v2Max ?? new SemVersion(9999, 9999, 9999)) <= 0 &&
339+
v2Min.CompareTo(v1Max ?? new SemVersion(9999, 9999, 9999)) <= 0;
340+
341+
if (overlaps)
342+
message = $"Version ranges overlap.";
343+
344+
return overlaps;
345+
}
346+
347+
private static (SemVersion min, SemVersion? max) GetEffectiveRange(VersionSpec spec) => spec.Kind switch
348+
{
349+
VersionSpecKind.Exact => (spec.Min, spec.Min),
350+
VersionSpecKind.Range => (spec.Min, spec.Max),
351+
VersionSpecKind.GreaterThanOrEqual => (spec.Min, null),
352+
_ => throw new ArgumentOutOfRangeException(nameof(spec), spec.Kind, "Unknown VersionSpecKind")
353+
};
354+
263355
public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) =>
264356
serializer.Invoke(value, type);
265357
}

src/Elastic.Documentation/Serialization/SourceGenerationContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,6 @@ namespace Elastic.Documentation.Serialization;
2828
[JsonSerializable(typeof(Applicability))]
2929
[JsonSerializable(typeof(ProductLifecycle))]
3030
[JsonSerializable(typeof(SemVersion))]
31+
[JsonSerializable(typeof(VersionSpec))]
3132
[JsonSerializable(typeof(string[]))]
3233
public sealed partial class SourceGenerationContext : JsonSerializerContext;
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Diagnostics.CodeAnalysis;
6+
7+
namespace Elastic.Documentation;
8+
9+
public sealed class AllVersionsSpec : VersionSpec
10+
{
11+
private static readonly SemVersion AllVersionsSemVersion = new(9999, 9999, 9999);
12+
13+
private AllVersionsSpec() : base(AllVersionsSemVersion, null, VersionSpecKind.GreaterThanOrEqual)
14+
{
15+
}
16+
17+
public static AllVersionsSpec Instance { get; } = new();
18+
19+
public override string ToString() => "all";
20+
}
21+
22+
public enum VersionSpecKind
23+
{
24+
GreaterThanOrEqual, // x.x, x.x+, x.x.x, x.x.x+
25+
Range, // x.x-y.y, x.x.x-y.y.y
26+
Exact // =x.x, =x.x.x
27+
}
28+
29+
/// <summary>
30+
/// Represents a version specification that can be a single version with greater-than-or-equal semantics,
31+
/// a range of versions, or an exact version match.
32+
/// </summary>
33+
public class VersionSpec : IComparable<VersionSpec>, IEquatable<VersionSpec>
34+
{
35+
/// <summary>
36+
/// The minimum version (or the exact version for Exact kind).
37+
/// </summary>
38+
public SemVersion Min { get; }
39+
40+
/// <summary>
41+
/// The maximum version for ranges. Null for GreaterThanOrEqual and Exact kinds.
42+
/// </summary>
43+
public SemVersion? Max { get; }
44+
45+
/// <summary>
46+
/// The kind of version specification.
47+
/// </summary>
48+
public VersionSpecKind Kind { get; }
49+
50+
// Internal constructor to prevent direct instantiation outside of TryParse
51+
// except for AllVersionsSpec which needs to inherit from this class
52+
protected VersionSpec(SemVersion min, SemVersion? max, VersionSpecKind kind)
53+
{
54+
Min = min;
55+
Max = max;
56+
Kind = kind;
57+
}
58+
59+
/// <summary>
60+
/// Tries to parse a version specification string.
61+
/// Supports: x.x, x.x+, x.x.x, x.x.x+ (gte), x.x-y.y (range), =x.x (exact)
62+
/// </summary>
63+
public static bool TryParse(string? input, [NotNullWhen(true)] out VersionSpec? spec)
64+
{
65+
spec = null;
66+
67+
if (string.IsNullOrWhiteSpace(input))
68+
return false;
69+
70+
var trimmed = input.Trim();
71+
72+
// Check for exact syntax: =x.x or =x.x.x
73+
if (trimmed.StartsWith('='))
74+
{
75+
var versionPart = trimmed[1..];
76+
if (!TryParseVersion(versionPart, out var version))
77+
return false;
78+
79+
spec = new(version, null, VersionSpecKind.Exact);
80+
return true;
81+
}
82+
83+
// Check for range syntax: x.x-y.y or x.x.x-y.y.y
84+
var dashIndex = FindRangeSeparator(trimmed);
85+
if (dashIndex > 0)
86+
{
87+
var minPart = trimmed[..dashIndex];
88+
var maxPart = trimmed[(dashIndex + 1)..];
89+
90+
if (!TryParseVersion(minPart, out var minVersion) ||
91+
!TryParseVersion(maxPart, out var maxVersion))
92+
return false;
93+
94+
spec = new(minVersion, maxVersion, VersionSpecKind.Range);
95+
return true;
96+
}
97+
98+
// Otherwise, it's greater-than-or-equal syntax
99+
// Strip trailing + if present
100+
var versionString = trimmed.EndsWith('+') ? trimmed[..^1] : trimmed;
101+
102+
if (!TryParseVersion(versionString, out var gteVersion))
103+
return false;
104+
105+
spec = new(gteVersion, null, VersionSpecKind.GreaterThanOrEqual);
106+
return true;
107+
}
108+
109+
/// <summary>
110+
/// Finds the position of the dash separator in a range specification.
111+
/// Returns -1 if no valid range separator is found.
112+
/// </summary>
113+
private static int FindRangeSeparator(string input)
114+
{
115+
// Look for a dash that's not part of a prerelease version
116+
// We need to distinguish between "9.0-9.1" (range) and "9.0-alpha" (prerelease)
117+
// Strategy: Find dashes and check if what follows looks like a version number
118+
119+
for (var i = 0; i < input.Length; i++)
120+
{
121+
if (input[i] == '-')
122+
{
123+
// Check if there's content before and after the dash
124+
if (i == 0 || i == input.Length - 1)
125+
continue;
126+
127+
// Check if the character after dash is a digit (indicating a version)
128+
if (i + 1 < input.Length && char.IsDigit(input[i + 1]))
129+
{
130+
// Also verify that what comes before looks like a version
131+
var beforeDash = input[..i];
132+
if (TryParseVersion(beforeDash, out _))
133+
return i;
134+
}
135+
}
136+
}
137+
138+
return -1;
139+
}
140+
141+
/// <summary>
142+
/// Tries to parse a version string, normalizing minor versions to include patch 0.
143+
/// </summary>
144+
private static bool TryParseVersion(string input, [NotNullWhen(true)] out SemVersion? version)
145+
{
146+
version = null;
147+
148+
if (string.IsNullOrWhiteSpace(input))
149+
return false;
150+
151+
var trimmed = input.Trim();
152+
153+
// Try to parse as-is first
154+
if (SemVersion.TryParse(trimmed, out version))
155+
return true;
156+
157+
// If that fails, try appending .0 to support minor version format (e.g., "9.2" -> "9.2.0")
158+
if (SemVersion.TryParse(trimmed + ".0", out version))
159+
return true;
160+
161+
return false;
162+
}
163+
164+
/// <summary>
165+
/// Returns the canonical string representation of this version spec.
166+
/// Format: "9.2+" for GreaterThanOrEqual, "9.0-9.1" for Range, "=9.2" for Exact
167+
/// </summary>
168+
public override string ToString() => Kind switch
169+
{
170+
VersionSpecKind.Exact => $"={Min.Major}.{Min.Minor}",
171+
VersionSpecKind.Range => $"{Min.Major}.{Min.Minor}-{Max!.Major}.{Max.Minor}",
172+
VersionSpecKind.GreaterThanOrEqual => $"{Min.Major}.{Min.Minor}+",
173+
_ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null)
174+
};
175+
176+
/// <summary>
177+
/// Compares this VersionSpec to another for sorting.
178+
/// Uses Max for ranges, otherwise uses Min.
179+
/// </summary>
180+
public int CompareTo(VersionSpec? other)
181+
{
182+
if (other is null)
183+
return 1;
184+
185+
// For sorting, we want to compare the "highest" version in each spec
186+
var thisCompareVersion = Kind == VersionSpecKind.Range && Max is not null ? Max : Min;
187+
var otherCompareVersion = other.Kind == VersionSpecKind.Range && other.Max is not null ? other.Max : other.Min;
188+
189+
return thisCompareVersion.CompareTo(otherCompareVersion);
190+
}
191+
192+
/// <summary>
193+
/// Checks if this VersionSpec is equal to another.
194+
/// </summary>
195+
public bool Equals(VersionSpec? other)
196+
{
197+
if (other is null)
198+
return false;
199+
200+
if (ReferenceEquals(this, other))
201+
return true;
202+
203+
return Kind == other.Kind && Min.Equals(other.Min) &&
204+
(Max?.Equals(other.Max) ?? (other.Max is null));
205+
}
206+
207+
public override bool Equals(object? obj) => obj is VersionSpec other && Equals(other);
208+
209+
public override int GetHashCode() => HashCode.Combine(Kind, Min, Max);
210+
211+
public static bool operator ==(VersionSpec? left, VersionSpec? right)
212+
{
213+
if (left is null)
214+
return right is null;
215+
return left.Equals(right);
216+
}
217+
218+
public static bool operator !=(VersionSpec? left, VersionSpec? right) => !(left == right);
219+
220+
public static bool operator <(VersionSpec? left, VersionSpec? right) =>
221+
left is null ? right is not null : left.CompareTo(right) < 0;
222+
223+
public static bool operator <=(VersionSpec? left, VersionSpec? right) =>
224+
left is null || left.CompareTo(right) <= 0;
225+
226+
public static bool operator >(VersionSpec? left, VersionSpec? right) =>
227+
left is not null && left.CompareTo(right) > 0;
228+
229+
public static bool operator >=(VersionSpec? left, VersionSpec? right) =>
230+
left is null ? right is null : left.CompareTo(right) >= 0;
231+
232+
/// <summary>
233+
/// Explicit conversion from string to VersionSpec
234+
/// </summary>
235+
public static explicit operator VersionSpec(string s)
236+
{
237+
if (TryParse(s, out var spec))
238+
return spec!;
239+
throw new ArgumentException($"'{s}' is not a valid version specification string.");
240+
}
241+
}

0 commit comments

Comments
 (0)