Skip to main content

xidl_parser/versioning/
mod.rs

1//! API versioning: one `@version` enum, `@added`/`@removed` lifecycle annotations
2//! and per-version HIR projection.
3//!
4//! Phase 1 is deliberately small. A specification is versioned by marking one
5//! enum with `@version`; its members, in declaration order, are the version
6//! history. `@added(Versions::v1)` and `@removed(Versions::v2)` mark when a
7//! declaration exists; annotation order never matters. Renames are expressed by
8//! declaring a new member with a disjoint interval plus `@rename`, exactly like
9//! the desugaring `versioned idl -> multi idl` describes.
10//!
11//! Projection is a pure HIR rewrite: each version is wrapped in
12//! `module <version-ident> { ... }`, lifecycle annotations and the version enum
13//! are removed, and the document version pragma carries the version value. The
14//! merged form keeps one module per selected version so generators can stay
15//! version-free.
16
17mod claims;
18mod error;
19mod lifecycle;
20pub mod map;
21mod paths;
22mod project;
23mod timeline;
24
25#[cfg(test)]
26mod tests;
27
28pub use error::VersioningError;
29pub(crate) use timeline::VersionSelection;
30pub use timeline::{VersionDef, VersionId, VersionTimeline};
31
32use lifecycle::VersionVisitor as _;
33
34use crate::error::{ParseError, ParserResult};
35use crate::hir::{ParserProperties, Specification, VersionParameter};
36use crate::rest_hir::ProjectedHir;
37
38/// Generation property that selects one API version by member ident or value.
39pub const API_VERSION_PROPERTY: &str = "api_version";
40/// Generation property that selects several API versions (comma separated, or `*`).
41pub const API_VERSIONS_PROPERTY: &str = "api_versions";
42/// Generation property that selects the version output layout.
43pub const VERSION_LAYOUT_PROPERTY: &str = "version_layout";
44/// Generation property that lists the API version modules of a projection.
45///
46/// The HIR stage sets it for every versioned contract; targets that cannot
47/// render module-scoped output yet use it to fail loudly instead of emitting
48/// code that does not compile.
49pub const API_VERSION_MODULES_PROPERTY: &str = "api_version_modules";
50/// Layout value: one specification holding one module per version.
51pub const LAYOUT_MERGED: &str = "merged";
52/// Layout value: one projection per version.
53pub const LAYOUT_PER_VERSION: &str = "per_version";
54/// What kind of declaration a [`MemberInterval`] describes.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum IntervalKind {
58    /// A struct, exception, union, enum or typedef declaration.
59    Type,
60    /// A member of a struct, exception or union.
61    Member,
62    /// A member of the version enum's own timeline is not reported; this
63    /// covers enumerators of ordinary enums.
64    Enumerator,
65    /// A value of a bitmask.
66    BitmaskValue,
67    /// An interface operation.
68    Operation,
69    /// One parameter of an operation.
70    Parameter,
71    /// An interface attribute (expanded into operations for HTTP).
72    Attribute,
73}
74
75/// The existence interval of one declaration, as half-open timeline indices.
76#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
77pub struct MemberInterval {
78    /// Declaration path from the specification root, `::`-joined, without
79    /// any version module prefix.
80    pub path: String,
81    /// What the declaration is.
82    pub kind: IntervalKind,
83    /// First timeline index where the declaration exists (inclusive).
84    pub from: usize,
85    /// First timeline index where it no longer exists (exclusive).
86    pub until: usize,
87}
88
89/// One HIR projection produced from a specification.
90#[derive(Debug)]
91pub struct VersionedProjection {
92    /// API versions this projection contains; empty for unversioned contracts.
93    pub versions: Vec<VersionDef>,
94    /// Target version of a single-version projection; `None` for merged and
95    /// unversioned projections.
96    pub version: Option<VersionDef>,
97    /// The projected HIR.
98    pub hir: ProjectedHir,
99}
100
101/// Result of analysing the API versioning annotations of a specification.
102#[derive(Debug, Clone)]
103pub struct VersioningRecord {
104    timeline: VersionTimeline,
105    parameter: Option<VersionParameter>,
106}
107
108impl VersioningRecord {
109    /// Validates every API versioning annotation of a specification.
110    ///
111    /// Validation always runs, even when no version is projected. Returns
112    /// `None` for specifications without a `@version` enum, and fails when
113    /// lifecycle annotations appear without one.
114    pub fn analyze(spec: &Specification) -> ParserResult<Option<VersioningRecord>> {
115        let discovery = lifecycle::discover(&spec.0)?;
116        let Some((ident, members)) = discovery.version_enum else {
117            if let Some((annotation, decl)) = discovery.saw_lifecycle {
118                return Err(VersioningError::MissingVersionEnum { annotation, decl }.into());
119            }
120            claims::check(spec, None)?;
121            return Ok(None);
122        };
123        let timeline = VersionTimeline::from_members(ident.clone(), &members)?;
124        if let Some(parameter) = &discovery.version_parameter {
125            for (_, version) in timeline.iter() {
126                parameter.validate_value(&version.value).map_err(|reason| {
127                    VersioningError::MalformedVersionParameter {
128                        decl: format!("{ident}::{}", version.ident),
129                        reason,
130                    }
131                })?;
132            }
133        }
134        let mut validator = lifecycle::LifecycleValidator {
135            timeline: &timeline,
136        };
137        validator.walk(&spec.0, "", lifecycle::Availability::full())?;
138        claims::check(spec, Some(&timeline))?;
139        Ok(Some(VersioningRecord {
140            timeline,
141            parameter: discovery.version_parameter,
142        }))
143    }
144
145    /// The wire location and name carried by clients of this version timeline.
146    pub fn parameter(&self) -> Option<&VersionParameter> {
147        self.parameter.as_ref()
148    }
149
150    /// The version history declared by the `@version` enum.
151    pub fn timeline(&self) -> &VersionTimeline {
152        &self.timeline
153    }
154
155    /// The existence interval of every versioned declaration, read from the
156    /// unprojected specification. Version modules carry no lifecycle
157    /// annotations after projection, so this is the only view the adapter
158    /// stage can pair versions from.
159    pub fn member_intervals(&self, spec: &Specification) -> ParserResult<Vec<MemberInterval>> {
160        lifecycle::collect_intervals(&spec.0, &self.timeline)
161    }
162
163    /// Resolves a version member name or display value for a CLI or plugin.
164    pub fn resolve_version(&self, raw: &str) -> ParserResult<VersionId> {
165        self.timeline.find(raw).map_err(Into::into)
166    }
167
168    /// Projects `version` into an ordinary specification.
169    pub fn project(&self, spec: &Specification, version: VersionId) -> ParserResult<Specification> {
170        project::project_version(spec, &self.timeline, version)
171    }
172
173    /// Projects every selected version into one specification with one module
174    /// per version, in timeline order.
175    pub fn merge(
176        &self,
177        spec: &Specification,
178        versions: &[VersionId],
179    ) -> ParserResult<Specification> {
180        project::merge_versions(spec, &self.timeline, versions)
181    }
182}
183
184/// How versioned output is laid out for the target.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub(crate) enum VersionLayout {
187    /// One specification with one module per selected version.
188    Merged,
189    /// One projection per selected version.
190    PerVersion,
191}
192
193impl VersionLayout {
194    /// Reads [`VERSION_LAYOUT_PROPERTY`] from generation properties.
195    pub(crate) fn from_properties(properties: &ParserProperties) -> ParserResult<Self> {
196        let Some(value) = properties
197            .get(VERSION_LAYOUT_PROPERTY)
198            .and_then(serde_json::Value::as_str)
199        else {
200            return Ok(Self::Merged);
201        };
202        match value {
203            LAYOUT_MERGED => Ok(Self::Merged),
204            LAYOUT_PER_VERSION => Ok(Self::PerVersion),
205            other => Err(VersioningError::UnknownVersionLayout {
206                value: other.to_string(),
207            }
208            .into()),
209        }
210    }
211}
212
213/// Reads the requested version selection from generation properties.
214///
215/// A selection property on a specification without a `@version` enum is an
216/// error, so build flags cannot silently do nothing.
217pub(crate) fn selection_from_properties(
218    properties: &ParserProperties,
219) -> ParserResult<Option<VersionSelection>> {
220    VersionSelection::from_properties(properties)
221}
222
223/// Resolves the requested versions, defaulting to every declared version.
224pub(crate) fn selected_versions(
225    selection: Option<&VersionSelection>,
226    record: &VersioningRecord,
227) -> ParserResult<Vec<VersionId>> {
228    match selection {
229        Some(selection) => selection.resolve(&record.timeline),
230        None => Ok(record.timeline.iter().map(|(id, _)| id).collect()),
231    }
232}
233
234/// Reports a selection requested for an unversioned specification.
235pub(crate) fn unversioned_selection_error(selection: &VersionSelection) -> ParseError {
236    VersioningError::UnversionedSpecification {
237        selection: selection.describe(),
238    }
239    .into()
240}