Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions docs/_snippets/applies_to-version.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
`applies_to` accepts the following version formats:

* `Major.Minor`
* `Major.Minor.Patch`
* **Greater than or equal to**: `x.x+`, `x.x`, `x.x.x+`, `x.x.x` (default behavior when no operator specified)
* **Range (inclusive)**: `x.x-y.y`, `x.x.x-y.y.y`, `x.x-y.y.y`, `x.x.x-y.y`
* **Exact version**: `=x.x`, `=x.x.x`

Regardless of the version format used in the source file, the version number is always rendered in the `Major.Minor.Patch` format.
**Version Display:**

- Versions are always displayed as **Major.Minor** (e.g., `9.1`) in badges, regardless of the format used in source files.
- Each version represents the **latest patch** of that minor version (e.g., `9.1` means 9.1.0, 9.1.1, 9.1.6, etc.).
- The `+` symbol indicates "this version and later" (e.g., `9.1+` means 9.1.0 and all subsequent releases).
- Ranges show both versions (e.g., `9.0-9.2`) when both are released, or convert to `+` format if the end version is unreleased.

:::{note}
**Automatic Version Sorting**: When you specify multiple versions for the same product, the build system automatically sorts them in descending order (highest version first) regardless of the order you write them in the source file. For example, `stack: ga 8.18.6, ga 9.1.2, ga 8.19.2, ga 9.0.6` will be displayed as `stack: ga 9.1.2, ga 9.0.6, ga 8.19.2, ga 8.18.6`. Items without versions (like `ga` without a version or `all`) are sorted last.
**Automatic Version Sorting**: When you specify multiple versions for the same product, the build system automatically sorts them in descending order (highest version first) regardless of the order you write them in the source file. For example, `stack: ga 9.1, beta 9.0, preview 8.18` will be displayed with the highest priority lifecycle and version first. Items without versions are sorted last.
:::
91 changes: 83 additions & 8 deletions docs/syntax/applies.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,41 @@ Where:
- The lifecycle is mandatory.
- The version is optional.

### Version Syntax

Versions can be specified using several formats to indicate different applicability scenarios:

| Description | Syntax | Example | Badge Display |
|:------------|:-------|:--------|:--------------|
| **Greater than or equal to** (default) | `x.x+` `x.x` `x.x.x+` `x.x.x` | `ga 9.1` or `ga 9.1+` | `9.1+` |
| **Range** (inclusive) | `x.x-y.y` `x.x.x-y.y.y` | `preview 9.0-9.2` | `9.0-9.2` or `9.0+`* |
| **Exact version** | `=x.x` `=x.x.x` | `beta =9.1` | `9.1` |

\* Range display depends on release status of the second version.

**Important notes:**

- Versions are always displayed as **Major.Minor** (e.g., `9.1`) in badges, regardless of whether you specify patch versions in the source.
- Each version statement corresponds to the **latest patch** of the specified minor version (e.g., `9.1` represents 9.1.0, 9.1.1, 9.1.6, etc.).
- When critical patch-level differences exist, use plain text descriptions alongside the badge rather than specifying patch versions.

### Version Validation Rules

The build process enforces the following validation rules:

- **One version per lifecycle**: Each lifecycle (GA, Preview, Beta, etc.) can only have one version declaration.
- ✅ `stack: ga 9.2+, beta 9.0-9.1`
- ❌ `stack: ga 9.2, ga 9.3`
- **One "greater than" per key**: Only one lifecycle per product key can use the `+` (greater than or equal to) syntax.
- ✅ `stack: ga 9.2+, beta 9.0-9.1`
- ❌ `stack: ga 9.2+, beta 9.0+`
- **Valid range order**: In ranges, the first version must be less than or equal to the second version.
- ✅ `stack: preview 9.0-9.2`
- ❌ `stack: preview 9.2-9.0`
- **No version overlaps**: Versions for the same key cannot overlap (ranges are inclusive).
- ✅ `stack: ga 9.2+, beta 9.0-9.1`
- ❌ `stack: ga 9.2+, beta 9.0-9.2`

### Page level

Page level annotations are added in the YAML frontmatter, starting with the `applies_to` key and following the [key-value reference](#key-value-reference). For example:
Expand Down Expand Up @@ -134,6 +169,22 @@ Use the following key-value reference to find the appropriate key and value for

## Examples

### Version Syntax Examples

The following table demonstrates the various version syntax options and their rendered output:

| Source Syntax | Description | Badge Display | Notes |
|:-------------|:------------|:--------------|:------|
| `stack: ga 9.1` | Greater than or equal to 9.1 | `Stack│9.1+` | Default behavior, equivalent to `9.1+` |
| `stack: ga 9.1+` | Explicit greater than or equal to | `Stack│9.1+` | Explicit `+` syntax |
| `stack: preview 9.0-9.2` | Range from 9.0 to 9.2 (inclusive) | `Stack│Preview 9.0-9.2` | Shows range if 9.2.0 is released |
| `stack: preview 9.0-9.3` | Range where end is unreleased | `Stack│Preview 9.0+` | Shows `+` if 9.3.0 is not released |
| `stack: beta =9.1` | Exact version 9.1 only | `Stack│Beta 9.1` | No `+` symbol for exact versions |
| `stack: ga 9.2+, beta 9.0-9.1` | Multiple lifecycles | `Stack│9.2+` | Only highest priority lifecycle shown |
| `stack: ga 9.3, beta 9.1+` | Unreleased GA with Preview | `Stack│Beta 9.1+` | Shows Beta when GA unreleased with 2+ lifecycles |
| `serverless: ga` | No version (base 99999) | `Serverless` | No version badge for unversioned products |
| `deployment:`<br/>` ece: ga 9.0+` | Nested deployment syntax | `ECE│9.0+` | Deployment products shown separately |

### Versioning examples

Versioned products require a `version` tag to be used with the `lifecycle` tag:
Expand Down Expand Up @@ -240,22 +291,46 @@ applies_to:

## Look and feel

### Version Syntax Demonstrations

:::::{dropdown} New version syntax examples

The following examples demonstrate the new version syntax capabilities:

**Greater than or equal to:**
- {applies_to}`stack: ga 9.1` (implicit `+`)
- {applies_to}`stack: ga 9.1+` (explicit `+`)
- {applies_to}`stack: preview 9.0+`

**Ranges:**
- {applies_to}`stack: preview 9.0-9.2` (range display when both released)
- {applies_to}`stack: beta 9.1-9.3` (converts to `+` if end unreleased)

**Exact versions:**
- {applies_to}`stack: beta =9.1` (no `+` symbol)
- {applies_to}`stack: deprecated =9.0`

**Multiple lifecycles:**
- {applies_to}`stack: ga 9.2+, beta 9.0-9.1` (shows highest priority)

:::::

### Block

:::::{dropdown} Block examples

```{applies_to}
stack: preview 9.1
stack: preview 9.1+
serverless: ga

apm_agent_dotnet: ga 1.0.0
apm_agent_java: beta 1.0.0
edot_dotnet: preview 1.0.0
apm_agent_dotnet: ga 1.0+
apm_agent_java: beta 1.0+
edot_dotnet: preview 1.0+
edot_python:
edot_node: ga 1.0.0
elasticsearch: preview 9.0.0
security: removed 9.0.0
observability: deprecated 9.0.0
edot_node: ga 1.0+
elasticsearch: preview 9.0+
security: removed 9.0
observability: deprecated 9.0+
```
:::::

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ private bool ShouldIncludeOperation(OpenApiOperation operation, string product)
return true; // Could not parse version, safe to include

// Get current version for the product
var versioningSystemId = product == "elasticsearch"
var versioningSystemId = product.Equals("elasticsearch", StringComparison.OrdinalIgnoreCase)
? VersioningSystemId.Stack
: VersioningSystemId.Stack; // Both use Stack for now

Expand Down Expand Up @@ -294,14 +294,14 @@ private static ProductLifecycle ParseLifecycle(string stateValue)
/// <summary>
/// Parses the version from "Added in X.Y.Z" pattern in the x-state string.
/// </summary>
private static SemVersion? ParseVersion(string stateValue)
private static VersionSpec? ParseVersion(string stateValue)
{
var match = AddedInVersionRegex().Match(stateValue);
if (!match.Success)
return null;

var versionString = match.Groups[1].Value;
return SemVersion.TryParse(versionString, out var version) ? version : null;
return VersionSpec.TryParse(versionString, out var version) ? version : null;
}

/// <summary>
Expand Down
20 changes: 10 additions & 10 deletions src/Elastic.Documentation/AppliesTo/Applicability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public static bool TryParse(string? value, IList<(Severity, string)> diagnostics
return false;

// Sort by version in descending order (the highest version first)
// Items without versions (AllVersions.Instance) are sorted last
// Items without versions (AllVersionsSpec.Instance) are sorted last
var sortedApplications = applications.OrderDescending().ToArray();
availability = new AppliesCollection(sortedApplications);
return true;
Expand Down Expand Up @@ -98,12 +98,12 @@ public override string ToString()
public record Applicability : IComparable<Applicability>, IComparable
{
public ProductLifecycle Lifecycle { get; init; }
public SemVersion? Version { get; init; }
public VersionSpec? Version { get; init; }

public static Applicability GenerallyAvailable { get; } = new()
{
Lifecycle = ProductLifecycle.GenerallyAvailable,
Version = AllVersions.Instance
Version = AllVersionsSpec.Instance
};


Expand All @@ -126,8 +126,8 @@ public string GetLifeCycleName() =>
/// <inheritdoc />
public int CompareTo(Applicability? other)
{
var xIsNonVersioned = Version is null || ReferenceEquals(Version, AllVersions.Instance);
var yIsNonVersioned = other?.Version is null || ReferenceEquals(other.Version, AllVersions.Instance);
var xIsNonVersioned = Version is null || ReferenceEquals(Version, AllVersionsSpec.Instance);
var yIsNonVersioned = other?.Version is null || ReferenceEquals(other.Version, AllVersionsSpec.Instance);

if (xIsNonVersioned && yIsNonVersioned)
return 0;
Expand Down Expand Up @@ -158,7 +158,7 @@ public override string ToString()
_ => throw new ArgumentOutOfRangeException()
};
_ = sb.Append(lifecycle);
if (Version is not null && Version != AllVersions.Instance)
if (Version is not null && Version != AllVersionsSpec.Instance)
_ = sb.Append(' ').Append(Version);
return sb.ToString();
}
Expand Down Expand Up @@ -224,10 +224,10 @@ public static bool TryParse(string? value, IList<(Severity, string)> diagnostics
? null
: tokens[1] switch
{
null => AllVersions.Instance,
"all" => AllVersions.Instance,
"" => AllVersions.Instance,
var t => SemVersionConverter.TryParse(t, out var v) ? v : null
null => AllVersionsSpec.Instance,
"all" => AllVersionsSpec.Instance,
"" => AllVersionsSpec.Instance,
var t => VersionSpec.TryParse(t, out var v) ? v : null
};
availability = new Applicability { Version = version, Lifecycle = lifecycle };
return true;
Expand Down
10 changes: 6 additions & 4 deletions src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,27 @@ public static Applicability GetPrimaryApplicability(IEnumerable<Applicability> a
};

var availableApplicabilities = applicabilityList
.Where(a => a.Version is null || a.Version is AllVersions || a.Version <= currentVersion)
.Where(a => a.Version is null || a.Version is AllVersionsSpec ||
(a.Version is VersionSpec vs && vs.Min <= currentVersion))
.ToList();

if (availableApplicabilities.Count != 0)
{
return availableApplicabilities
.OrderByDescending(a => a.Version ?? new SemVersion(0, 0, 0))
.OrderByDescending(a => a.Version?.Min ?? new SemVersion(0, 0, 0))
.ThenBy(a => lifecycleOrder.GetValueOrDefault(a.Lifecycle, 999))
.First();
}

var futureApplicabilities = applicabilityList
.Where(a => a.Version is not null && a.Version is not AllVersions && a.Version > currentVersion)
.Where(a => a.Version is not null && a.Version is not AllVersionsSpec &&
a.Version is VersionSpec vs && vs.Min > currentVersion)
.ToList();

if (futureApplicabilities.Count != 0)
{
return futureApplicabilities
.OrderBy(a => a.Version!.CompareTo(currentVersion))
.OrderBy(a => a.Version!.Min.CompareTo(currentVersion))
.ThenBy(a => lifecycleOrder.GetValueOrDefault(a.Lifecycle, 999))
.First();
}
Expand Down
4 changes: 3 additions & 1 deletion src/Elastic.Documentation/AppliesTo/ApplicableTo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,11 @@ public record ApplicableTo
Product = AppliesCollection.GenerallyAvailable
};

private static readonly VersionSpec DefaultVersion = VersionSpec.TryParse("9.0", out var v) ? v! : AllVersionsSpec.Instance;

public static ApplicableTo Default { get; } = new()
{
Stack = new AppliesCollection([new Applicability { Version = new SemVersion(9, 0, 0), Lifecycle = ProductLifecycle.GenerallyAvailable }]),
Stack = new AppliesCollection([new Applicability { Version = DefaultVersion, Lifecycle = ProductLifecycle.GenerallyAvailable }]),
Serverless = ServerlessProjectApplicability.All
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public class ApplicableToJsonConverter : JsonConverter<ApplicableTo>
string? type = null;
string? subType = null;
var lifecycle = ProductLifecycle.GenerallyAvailable;
SemVersion? version = null;
VersionSpec? version = null;

while (reader.Read())
{
Expand Down Expand Up @@ -72,7 +72,7 @@ public class ApplicableToJsonConverter : JsonConverter<ApplicableTo>
break;
case "version":
var versionStr = reader.GetString();
if (versionStr != null && SemVersionConverter.TryParse(versionStr, out var v))
if (versionStr != null && VersionSpec.TryParse(versionStr, out var v))
version = v;
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,102 @@ private static bool TryGetApplicabilityOverTime(Dictionary<object, object?> dict
if (target is null || (target is string s && string.IsNullOrWhiteSpace(s)))
availability = AppliesCollection.GenerallyAvailable;
else if (target is string stackString)
{
availability = AppliesCollection.TryParse(stackString, diagnostics, out var a) ? a : null;

if (availability is not null)
ValidateApplicabilityCollection(key, availability, diagnostics);
}
return availability is not null;
}

private static void ValidateApplicabilityCollection(string key, AppliesCollection collection, List<(Severity, string)> diagnostics)
{
var items = collection.ToList();

// Rule: Only one version declaration per lifecycle
var lifecycleGroups = items.GroupBy(a => a.Lifecycle).ToList();
foreach (var group in lifecycleGroups)
{
var lifecycleVersionedItems = group.Where(a => a.Version is not null &&
a.Version != AllVersionsSpec.Instance).ToList();
if (lifecycleVersionedItems.Count > 1)
{
diagnostics.Add((Severity.Warning,
$"Key '{key}': Multiple version declarations for {group.Key} lifecycle. Only one version per lifecycle is allowed."));
}
}

// Rule: Only one item per key can use greater-than syntax
var greaterThanItems = items.Where(a =>
a.Version is { Kind: VersionSpecKind.GreaterThanOrEqual } &&
a.Version != AllVersionsSpec.Instance).ToList();

if (greaterThanItems.Count > 1)
{
diagnostics.Add((Severity.Warning,
$"Key '{key}': Multiple items use greater-than-or-equal syntax. Only one item per key can use this syntax."));
}

// Rule: In a range, the first version must be less than or equal the last version
foreach (var item in items)
{
if (item.Version is { Kind: VersionSpecKind.Range } spec)
{
if (spec.Min.CompareTo(spec.Max!) > 0)
{
diagnostics.Add((Severity.Warning,
$"Key '{key}', {item.Lifecycle}: Range has first version ({spec.Min.Major}.{spec.Min.Minor}) greater than last version ({spec.Max!.Major}.{spec.Max.Minor})."));
}
}
}

// Rule: No overlapping version ranges for the same key
var versionedItems = items.Where(a => a.Version is not null &&
a.Version != AllVersionsSpec.Instance).ToList();

for (var i = 0; i < versionedItems.Count; i++)
{
for (var j = i + 1; j < versionedItems.Count; j++)
{
if (CheckVersionOverlap(versionedItems[i].Version!, versionedItems[j].Version!, out var overlapMsg))
{
diagnostics.Add((Severity.Warning,
$"Key '{key}': Overlapping versions between {versionedItems[i].Lifecycle} and {versionedItems[j].Lifecycle}. {overlapMsg}"));
}
}
}
}

private static bool CheckVersionOverlap(VersionSpec v1, VersionSpec v2, out string message)
{
message = string.Empty;

// Get the effective ranges for each version spec
// For GreaterThanOrEqual: [min, infinity)
// For Range: [min, max]
// For Exact: [exact, exact]

var (v1Min, v1Max) = GetEffectiveRange(v1);
var (v2Min, v2Max) = GetEffectiveRange(v2);

var overlaps = v1Min.CompareTo(v2Max ?? new SemVersion(99999, 99999, 99999)) <= 0 &&
v2Min.CompareTo(v1Max ?? new SemVersion(99999, 99999, 99999)) <= 0;

if (overlaps)
message = $"Version ranges overlap.";

return overlaps;
}

private static (SemVersion min, SemVersion? max) GetEffectiveRange(VersionSpec spec) => spec.Kind switch
{
VersionSpecKind.Exact => (spec.Min, spec.Min),
VersionSpecKind.Range => (spec.Min, spec.Max),
VersionSpecKind.GreaterThanOrEqual => (spec.Min, null),
_ => throw new ArgumentOutOfRangeException(nameof(spec), spec.Kind, "Unknown VersionSpecKind")
};

public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) =>
serializer.Invoke(value, type);
}
Loading
Loading