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}