Skip to main content

zerodds_xml_compose/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! DDS-XML type definitions into the common AST.
4//!
5//! Crate `zerodds-xml-compose`. Safety classification: **STANDARD**.
6//! Build-time only, std, `forbid(unsafe_code)`.
7//!
8//! One call — [`compose`] — takes a path to a DDS-XML document and
9//! produces the same [`Ast`] that `zerodds-idl-compose` produces from an
10//! `.idl` file, plus the type objects the model registers. Everything
11//! downstream of the model — `zerodds_types::from_ast`, the layout
12//! planner, `zerodds-wire-codec`, every code generator — reads the model
13//! and does not know or care which front end filled it.
14//!
15//! ## Why a crate of its own rather than more of `zerodds-xml`
16//!
17//! `zerodds-xml` reads DDS-XML already, and stops one layer short: it
18//! builds its own descriptive [`zerodds_xml::TypeLibrary`] and bridges
19//! that to a `MinimalTypeObject`. That layer is a published API with
20//! its own users, and it drops exactly what a model needs — a member
21//! carries no `tryConstruct`, no `hashid` and no `external`, a struct
22//! carries no `autoid`, an enum carries neither extensibility nor
23//! `defaultLiteral`, a union carries no extensibility and no
24//! discriminator key, and its primitive table has no `int8`/`uint8` at
25//! all. Widening those structs would change signatures every current
26//! caller compiles against.
27//!
28//! So this crate takes the same position against `zerodds-xml` that
29//! `zerodds-idl-compose` takes against `zerodds-idl-parse`: it reuses
30//! the front end's well-formedness layer — [`zerodds_xml::parse_xml_tree`]
31//! and its element tree, with the DDS-XML 1.0 §7.1 depth cap, element
32//! budget and namespace handling — and lowers that tree into the model
33//! itself. Nothing in `zerodds-xml` changes.
34//!
35//! ## Spec sources
36//!
37//! * OMG DDS-XML 1.0 (formal/18-10-01) §7.1 (representation syntax),
38//!   §7.3.3 (Types building block).
39//! * OMG DDS-XTypes 1.3 Annex A (XML Type Representation) for the type
40//!   grammar the `<types>` element carries, and §7.3.1.2.1 for what the
41//!   attributes mean.
42//! * OMG IDL 4.2 §7.4 where the two representations share a rule.
43
44#![forbid(unsafe_code)]
45#![warn(missing_docs)]
46
47mod expr;
48mod lower;
49
50use std::path::{Path, PathBuf};
51
52use zerodds_ast::Ast;
53use zerodds_types::resolve::TypeRegistry;
54
55pub use lower::{Diagnostic, Severity};
56
57/// Re-exported because [`ComposeOptions`] carries them in public fields,
58/// exactly as `zerodds-idl-compose` re-exports them: a caller that
59/// cannot name the type cannot construct the options.
60pub use zerodds_idl_lower::{HashedIdCounter, HashedNameSpelling, KeyOnUnionDiscriminator};
61
62/// What one composition produced.
63///
64/// Field for field what `zerodds_idl_compose::Composed` carries, so a
65/// caller that already loads IDL can add XML without a second shape to
66/// handle.
67pub struct Composed {
68    /// The model, built as far as the sources allowed.
69    pub ast: Ast,
70    /// Every type object the model produced, by hash.
71    pub registry: TypeRegistry,
72    /// Everything wrong, in the order it was found.
73    pub diagnostics: Vec<Diagnostic>,
74    /// Types no type object could be built for, with the reason.
75    pub without_type_objects: Vec<(String, String)>,
76}
77
78impl Composed {
79    /// Whether any stage reported an error.
80    #[must_use]
81    pub fn failed(&self) -> bool {
82        self.diagnostics
83            .iter()
84            .any(|d| d.severity == Severity::Error)
85    }
86}
87
88/// What the caller decides that the document does not.
89///
90/// The three fields are the three `zerodds_idl_compose::ComposeOptions`
91/// carries that a type document can be affected by. `defines` has no
92/// counterpart: DDS-XML has no preprocessor.
93#[derive(Debug, Clone, Default)]
94pub struct ComposeOptions {
95    /// What an aggregate of unspecified extensibility is taken to be.
96    ///
97    /// `None` leaves the facet absent, so "the document said appendable"
98    /// stays distinguishable from "the document said nothing" and
99    /// `Ast::effective_extensibility` applies XTypes 1.3 §7.3.1.2.1.8's
100    /// default. What the document *did* say still wins.
101    pub default_extensibility: Option<zerodds_ast::Extensibility>,
102    /// Whether a hashed member id advances the sequential id counter.
103    ///
104    /// The same dialect axis `zerodds-idl-compose` carries, and here for
105    /// the same reason: a type declared once in IDL and once in XML has
106    /// to reach the same member ids, and it cannot if the two load paths
107    /// answer this differently.
108    pub hashed_id_advances_the_counter: HashedIdCounter,
109    /// Which spelling of an escaped name a member-id hash reads.
110    ///
111    /// Carried for parity with the IDL path. DDS-XML has no escape
112    /// underscore — a `<member name="long">` writes the name as it is —
113    /// so no document reaches the axis today.
114    pub hashed_name_spelling: HashedNameSpelling,
115    /// Whether `key="true"` on a `<discriminator>` is carried.
116    ///
117    /// The same axis the IDL path carries for `@key` in that position,
118    /// and here for the reason the two above are here: one declaration
119    /// written twice has to reach one model. DDS-XML documents are the
120    /// only ones that reach it today — the OMG `dds-xtypes` suite's
121    /// `types/xml/unions_key_discriminator.xml` is the case.
122    pub key_on_union_discriminator: KeyOnUnionDiscriminator,
123    /// Directories searched for the files an `<include>` names, after the
124    /// directory of the document that includes them.
125    ///
126    /// DDS-XTypes 1.3 §7.5.2.2.13: "A collection of URLs to directories to
127    /// be searched for additional type description documents that may be
128    /// included, directly or indirectly [...] The directory in which the
129    /// target of document_url resides shall be considered on the
130    /// inclusion search path implicitly".
131    pub include_paths: Vec<PathBuf>,
132    /// Whether a collection bound of `0` means "unbounded".
133    ///
134    /// DDS-XTypes 1.3 §7.3.2.4: "The literal expression "-1" shall
135    /// indicate an unbounded collection; no other "negative" value is
136    /// permitted." Zero is not negative, but it is no bound either:
137    /// IDL 4.2 §7.4.1.4.4.3.1 takes a `<positive_int_const>` for the bound
138    /// of a sequence, a string and a map, so a bound of 0 is refused. The
139    /// OMG `dds-xtypes` suite writes `sequenceMaxLength="0"` and comments
140    /// it "unlimited (0 or -1)"; a caller that loads the suite sets this.
141    pub zero_bound_is_unbounded: bool,
142}
143
144/// Compose one DDS-XML document from a path.
145///
146/// # Errors
147/// Only the read of `path` itself: everything after it is reported
148/// through [`Composed::diagnostics`], because a document with one bad
149/// declaration still has the rest.
150pub fn compose(path: &str) -> Result<Composed, String> {
151    compose_with(path, ComposeOptions::default())
152}
153
154/// As [`compose`], deciding what the document leaves open.
155///
156/// # Errors
157/// As [`compose`].
158pub fn compose_with(path: &str, options: ComposeOptions) -> Result<Composed, String> {
159    let source = std::fs::read_to_string(path).map_err(|error| format!("{path}: {error}"))?;
160    Ok(compose_source_with(path, &source, options))
161}
162
163/// As [`compose`], over text the caller already has.
164#[must_use]
165pub fn compose_source(name: &str, source: &str) -> Composed {
166    compose_source_with(name, source, ComposeOptions::default())
167}
168
169/// As [`compose_source`], deciding what the document leaves open.
170#[must_use]
171pub fn compose_source_with(name: &str, source: &str, options: ComposeOptions) -> Composed {
172    compose_all_sources(&[(name.to_owned(), source.to_owned())], options)
173}
174
175/// Compose several documents into **one** model.
176///
177/// The units are lowered into a shared `Ast`, so a module two of them
178/// declare is one node — the same guarantee
179/// `zerodds_idl_compose::compose_all` gives, and for the same reason:
180/// two separate models emit the same module twice and the two collide
181/// in one Rust scope.
182///
183/// # Errors
184/// The read of any unit, naming it.
185pub fn compose_all(
186    paths: &[impl AsRef<Path>],
187    options: ComposeOptions,
188) -> Result<Composed, String> {
189    let mut sources = Vec::with_capacity(paths.len());
190    for path in paths {
191        let path: &Path = path.as_ref();
192        let name = path.to_string_lossy().into_owned();
193        let text = std::fs::read_to_string(path).map_err(|error| format!("{name}: {error}"))?;
194        sources.push((name, text));
195    }
196    Ok(compose_all_sources(&sources, options))
197}
198
199/// As [`compose_all`], over text the caller already has.
200///
201/// Each entry is `(name, source)`; the name is what a diagnostic points
202/// at.
203#[must_use]
204pub fn compose_all_sources(sources: &[(String, String)], options: ComposeOptions) -> Composed {
205    let mut ast = Ast::new();
206    let diagnostics = lower::lower_all(&mut ast, sources, &options);
207
208    let mut registry = TypeRegistry::new();
209    let mut diagnostics = diagnostics;
210    // A failure of the registration pass itself is reported, not turned
211    // into an empty list. An empty `without_type_objects` reads as
212    // "every type has an object", which is the opposite of what an error
213    // here means, and a caller that trusted it would announce a type it
214    // cannot serve.
215    let without_type_objects = match zerodds_types::from_ast::register_all(&ast, &mut registry) {
216        Ok(skipped) => skipped
217            .into_iter()
218            .map(|(name, error)| (name, format!("{error:?}")))
219            .collect(),
220        Err(error) => {
221            diagnostics.push(Diagnostic {
222                severity: Severity::Error,
223                message: format!("no type object could be built for this model: {error:?}"),
224                at: sources
225                    .first()
226                    .map_or_else(|| String::from("<bundle>"), |(name, _)| name.clone()),
227                spec_reference: "DDS-XTypes 1.3 §7.3.4",
228            });
229            Vec::new()
230        }
231    };
232
233    Composed {
234        ast,
235        registry,
236        diagnostics,
237        without_type_objects,
238    }
239}
240
241/// DDS-XTypes 1.3 §7.5.2.2.14 `create_type_w_document`: the type named
242/// `type_name` of the XML Type Representation in `document`.
243///
244/// `include_paths` are the directories searched for the files the
245/// document includes; a file is looked for beside the document that
246/// names it first (here the current directory, a document in memory has
247/// none of its own), then in each of them.
248///
249/// # Errors
250/// "If an error occurs, this method shall return a nil value": the
251/// diagnostics of the document if it has an error, or the name of the
252/// type if the document has no such type — "which will trivially be the
253/// case if the name is nil or the empty string".
254pub fn create_type_w_document(
255    document: &str,
256    type_name: &str,
257    include_paths: &[&str],
258) -> Result<zerodds_types::dynamic::DynamicType, String> {
259    let options = ComposeOptions {
260        include_paths: include_paths.iter().map(PathBuf::from).collect(),
261        ..ComposeOptions::default()
262    };
263    let composed = compose_source_with("<document>", document, options);
264    type_of_composed(composed, type_name)
265}
266
267/// DDS-XTypes 1.3 §7.5.2.2.13 `create_type_w_uri`: the type named
268/// `type_name` of the XML Type Representation at `document_url`.
269///
270/// "Implementations shall minimally support the file:// URL scheme": a
271/// `file://` URL, and a plain path, are read; another scheme is refused.
272/// The directory of the document is on the include search path
273/// implicitly.
274///
275/// # Errors
276/// As [`create_type_w_document`], and a URL of another scheme or a file
277/// that cannot be read.
278pub fn create_type_w_uri(
279    document_url: &str,
280    type_name: &str,
281    include_paths: &[&str],
282) -> Result<zerodds_types::dynamic::DynamicType, String> {
283    let path = match document_url.split_once("://") {
284        Some(("file", rest)) => rest,
285        Some((scheme, _)) => {
286            return Err(format!(
287                "`{scheme}://` is not supported; only file:// URLs are read (§7.5.2.2.13)"
288            ));
289        }
290        None => document_url,
291    };
292    let options = ComposeOptions {
293        include_paths: include_paths.iter().map(PathBuf::from).collect(),
294        ..ComposeOptions::default()
295    };
296    let composed = compose_with(path, options)?;
297    type_of_composed(composed, type_name)
298}
299
300fn type_of_composed(
301    composed: Composed,
302    type_name: &str,
303) -> Result<zerodds_types::dynamic::DynamicType, String> {
304    if composed.failed() {
305        return Err(composed
306            .diagnostics
307            .iter()
308            .filter(|d| d.severity == Severity::Error)
309            .map(ToString::to_string)
310            .collect::<Vec<_>>()
311            .join("; "));
312    }
313    let ast = std::sync::Arc::new(composed.ast);
314    zerodds_types::dynamic::DynamicType::find(&ast, type_name).map_err(|e| e.to_string())
315}
316
317/// The `<type-folder>/xml/<type-file>.xml` a caller names by its two
318/// halves, which is the layout the OMG `dds-xtypes` suite uses.
319///
320/// Here rather than in the tool because the same layout is what every
321/// other consumer of a DDS-XML type folder sees.
322#[must_use]
323pub fn xml_path(folder: &str, file: &str) -> PathBuf {
324    Path::new(folder).join("xml").join(format!("{file}.xml"))
325}