zerodds_build/lib.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Crate `zerodds-build`. Safety classification: **TOOLING**.
4//!
5//! A `build.rs` helper for ZeroDDS IDL.
6//!
7//! Build-time only (`[build-dependencies]`), std, `forbid(unsafe_code)`.
8//!
9//! ```no_run
10//! fn main() -> Result<(), zerodds_build::Error> {
11//! zerodds_build::Config::new()
12//! .include_dir("idl")
13//! .compile(&["idl/Robot.idl"])
14//! }
15//! ```
16//!
17//! Each `.idl` becomes one `.rs` in `OUT_DIR`, named after the file, to
18//! be pulled in with `include!`:
19//!
20//! ```ignore
21//! include!(concat!(env!("OUT_DIR"), "/Robot.rs"));
22//! ```
23//!
24//! ## Topic types
25//!
26//! What that writes is the Rust type and its codec — a pair of inherent
27//! functions over `zerodds_wire`. `DomainParticipant::create_topic`
28//! takes a `T: zerodds_dcps::DdsType`, which is a different surface, and
29//! [`Config::dds_type_impls`] writes it:
30//!
31//! ```no_run
32//! fn main() -> Result<(), zerodds_build::Error> {
33//! zerodds_build::Config::new()
34//! .dds_type_impls(true)
35//! .compile(&["idl/Robot.idl"])
36//! }
37//! ```
38//!
39//! ```ignore
40//! participant.create_topic::<Robot>("entity.robot")?;
41//! ```
42//!
43//! It is off by default because it puts `zerodds-dcps`, `zerodds-types`
44//! and `zerodds-wire` into the consumer's `[dependencies]`; the option's
45//! own documentation says why that is not a default worth taking.
46//!
47//! ## Where it goes
48//!
49//! **At the crate root.** The generated modules name types across module
50//! boundaries with paths relative to where they sit, and each `pub mod`
51//! carries `use super::*` so the file's own imports reach into it. Both
52//! hold wherever the file is included; what does not hold is splitting
53//! one `.idl`'s output across two places.
54//!
55//! ## Rebuilds
56//!
57//! Every `.idl` passed here **and every file it `#include`s** is reported
58//! to cargo as `rerun-if-changed`. Without the second half, an edit to an
59//! included file leaves the generated code stale and nothing says so:
60//! cargo does not re-run a build script for a path it was not told about.
61
62#![forbid(unsafe_code)]
63#![allow(clippy::print_stdout)] // cargo's build-script protocol *is* stdout.
64#![warn(missing_docs)]
65
66use std::path::{Path, PathBuf};
67
68mod dds_type;
69
70use zerodds_ast::facet::Extensibility;
71use zerodds_ast::{Ast, TypeKind, TypeNode};
72use zerodds_ast_core::Id;
73use zerodds_ast_layout::key::{Encoding, Endianness, LayoutKey, Purpose};
74use zerodds_ast_layout::{AxisFlag, Dialect, DialectLowering, DialectSettings, PlCdr1Reading};
75use zerodds_idl_compose::{ComposeOptions, PathLoader, compose_with};
76use zerodds_lang_rules::adapter::cst::{RustMapFormScope, nest_by_module, rust_item_pairs};
77
78/// How the generated Rust holds an IDL `map` ([`Config::rust_map`]).
79pub use zerodds_lang_rules::adapter::cst::RustMapForm;
80use zerodds_lang_rules::encode::{rust_codec, rust_codec_pair};
81use zerodds_lang_rules::rule::rust;
82use zerodds_lang_rules::{ModelGraph, apply};
83
84/// What went wrong.
85#[derive(Debug)]
86#[non_exhaustive]
87pub enum Error {
88 /// A unit could not be read.
89 Read {
90 /// The path as given.
91 path: PathBuf,
92 /// What the filesystem said.
93 detail: String,
94 },
95 /// A unit did not compose: it holds an error the front end reported.
96 ///
97 /// Every diagnostic is carried, not just the first — a build that
98 /// reports one defect per run costs a round trip per defect.
99 Compose {
100 /// The path as given.
101 path: PathBuf,
102 /// Every error-severity diagnostic, rendered.
103 diagnostics: Vec<String>,
104 },
105 /// The model composed and the emitter refused a type in it.
106 Codegen {
107 /// The path as given.
108 path: PathBuf,
109 /// What the emitter said, naming the construct.
110 detail: String,
111 },
112 /// The generated module could not be written.
113 Write {
114 /// Where it was going.
115 path: PathBuf,
116 /// What the filesystem said.
117 detail: String,
118 },
119 /// `OUT_DIR` was not set and no output directory was chosen.
120 ///
121 /// Cargo sets `OUT_DIR` for a build script. Reaching this means the
122 /// caller is not one, and has to say where the output goes.
123 NoOutDir,
124 /// A value given to [`Config::axis`] is not one its axis recognises.
125 ///
126 /// `axis` is infallible — it only appends to a list — so this is
127 /// where a typo in the value string surfaces, the same way an
128 /// unknown axis value surfaces at `zerodds-idlc generate`'s command
129 /// line, just later: there is no command line here to refuse it at.
130 Axis {
131 /// `AxisFlag::flag_name`, so the offending call is easy to find
132 /// among several `.axis(...)` calls on the same `Config`.
133 flag: &'static str,
134 /// The value given.
135 value: String,
136 /// Why it was refused, from the axis's own policy.
137 detail: String,
138 },
139}
140
141impl core::fmt::Display for Error {
142 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
143 match self {
144 Self::Read { path, detail } => write!(f, "{}: {detail}", path.display()),
145 Self::Compose { path, diagnostics } => {
146 write!(f, "{}: {}", path.display(), diagnostics.join("; "))
147 }
148 Self::Codegen { path, detail } => write!(f, "{}: {detail}", path.display()),
149 Self::Write { path, detail } => write!(f, "{}: {detail}", path.display()),
150 Self::NoOutDir => write!(f, "OUT_DIR is not set and no out_dir() was given"),
151 Self::Axis {
152 flag,
153 value: _,
154 detail,
155 } => write!(f, "{flag}: {detail}"),
156 }
157 }
158}
159
160impl std::error::Error for Error {}
161
162/// What an aggregate of unspecified extensibility is taken to be.
163///
164/// XTypes 1.3 §7.3.1.2.1.8 makes it appendable and then says in as many
165/// words that a compiler may let the user choose. Nothing here overrules
166/// a source that *did* say.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum DefaultExt {
169 /// `@final`.
170 Final,
171 /// `@appendable` — the specification's own default.
172 Appendable,
173 /// `@mutable`.
174 Mutable,
175}
176
177impl From<DefaultExt> for Extensibility {
178 fn from(value: DefaultExt) -> Self {
179 match value {
180 DefaultExt::Final => Self::Final,
181 DefaultExt::Appendable => Self::Appendable,
182 DefaultExt::Mutable => Self::Mutable,
183 }
184 }
185}
186
187impl From<Extensibility> for DefaultExt {
188 fn from(value: Extensibility) -> Self {
189 match value {
190 Extensibility::Final => Self::Final,
191 Extensibility::Appendable => Self::Appendable,
192 Extensibility::Mutable => Self::Mutable,
193 }
194 }
195}
196
197/// An IDL-to-Rust compilation, configured the way `zerodds-idlc`'s own
198/// flags configure it.
199pub struct Config {
200 include_dirs: Vec<PathBuf>,
201 defines: Vec<(String, String)>,
202 out_dir: Option<PathBuf>,
203 default_extensibility: Option<DefaultExt>,
204 header_comment: Option<String>,
205 emit_cargo_directives: bool,
206 typeobject: bool,
207 type_identifier_constants: bool,
208 dds_type_impls: bool,
209 dialect: Dialect,
210 axis_overrides: Vec<(AxisFlag, String)>,
211 pl_cdr1_reading: PlCdr1Reading,
212 rust_map: RustMapForm,
213}
214
215impl Default for Config {
216 fn default() -> Self {
217 Self::new()
218 }
219}
220
221impl Config {
222 /// A new configuration.
223 #[must_use]
224 pub fn new() -> Self {
225 Self {
226 include_dirs: Vec::new(),
227 defines: Vec::new(),
228 out_dir: None,
229 default_extensibility: None,
230 header_comment: None,
231 emit_cargo_directives: true,
232 typeobject: true,
233 type_identifier_constants: false,
234 dds_type_impls: false,
235 dialect: Dialect::default(),
236 axis_overrides: Vec::new(),
237 pl_cdr1_reading: PlCdr1Reading::default(),
238 rust_map: RustMapForm::default(),
239 }
240 }
241
242 /// Add a directory to the `#include` search list — the CLI's `-I`.
243 pub fn include_dir(&mut self, dir: impl Into<PathBuf>) -> &mut Self {
244 self.include_dirs.push(dir.into());
245 self
246 }
247
248 /// Define a macro — the CLI's `-D`. `None` is `-DNAME`.
249 pub fn define(&mut self, name: impl AsRef<str>, value: Option<&str>) -> &mut Self {
250 self.defines.push((
251 name.as_ref().to_string(),
252 value.unwrap_or_default().to_string(),
253 ));
254 self
255 }
256
257 /// Where the generated modules go. Defaults to `OUT_DIR`.
258 pub fn out_dir(&mut self, dir: impl Into<PathBuf>) -> &mut Self {
259 self.out_dir = Some(dir.into());
260 self
261 }
262
263 /// What an aggregate of unspecified extensibility is taken to be.
264 pub fn default_extensibility(&mut self, ext: DefaultExt) -> &mut Self {
265 self.default_extensibility = Some(ext);
266 self
267 }
268
269 /// Reproduce one peer's answers to every wire axis at once — the
270 /// same preset `zerodds-idlc generate --dialect` takes, over the
271 /// same [`Dialect`] `zerodds_ast_layout` defines.
272 ///
273 /// The default, [`Dialect::default`] (`specification`), is what
274 /// every axis-affecting field already defaulted to before this
275 /// existed — a generated module built with no `dialect` call reads
276 /// and writes exactly what it did before. What changes when this is
277 /// called is both halves the dialect settles: the front end's three
278 /// lowering options (`@hashid`'s counter, an escaped name's
279 /// spelling, a declaration named after its own scope), which used
280 /// to be the specification's unconditionally, and the `LayoutKey`
281 /// [`Self::compile`] plans the codec under. [`Self::axis`] overrides
282 /// one answer of whichever dialect is in effect.
283 pub fn dialect(&mut self, dialect: Dialect) -> &mut Self {
284 self.dialect = dialect;
285 self
286 }
287
288 /// Override one wire axis's answer, after the dialect's.
289 ///
290 /// `axis` is one of [`zerodds_ast_layout::AXES`] — the same table
291 /// `zerodds-idlc generate`'s `--<axis>` flags and
292 /// `wireprobe-zerodds`'s read — and `value` is one of the spellings
293 /// [`AxisFlag::help_values`] lists for it. Several calls apply in
294 /// the order given, each overriding one answer of the dialect
295 /// [`Self::dialect`] named (or the default dialect, if it was not
296 /// called).
297 ///
298 /// Infallible, and applied lazily: a value the axis does not
299 /// recognise is refused by [`Self::compile`] and
300 /// [`Self::compile_bundle`], as [`Error::Axis`], because there is no
301 /// command line to refuse it at here — a `build.rs` that supplied a
302 /// typo learns about it the way it learns about a `.idl` that does
303 /// not parse, from the build failing with a message that names it.
304 ///
305 /// [`AxisFlag::DefaultExtensibility`] also sets what
306 /// [`Self::default_extensibility`] sets, because that one axis is
307 /// two settings and not one: `LayoutKey::default_extensibility`,
308 /// which `Self::layout_key` answers for, decides the framing of a
309 /// member whose *own* type has no resolved facet, but a top-level
310 /// type's own facet is resolved once, during composition
311 /// (`ComposeOptions::default_extensibility`), before any
312 /// `LayoutKey` exists — and `zerodds_ast::Ast::effective_extensibility`,
313 /// which the generated `DdsType::EXTENSIBILITY` and the evolution-
314 /// tolerant decode pattern both read, only ever sees the composed
315 /// answer. An `axis` call that touched the `LayoutKey` alone would
316 /// leave those two reading different extensibilities for the same
317 /// type — a member framed as `@final` decoded as if it were
318 /// `@appendable`, silently, because nothing before this call site
319 /// caught it. `wireprobe-zerodds` and `zerodds-idlc generate`'s own
320 /// `--default-extensibility` do not have this split: the first
321 /// never resolves a facet at compose time at all, so its `LayoutKey`
322 /// fallback is the only answer to agree with; the second's
323 /// `--default-extensibility` is composition's own flag and was
324 /// never routed through the axis table.
325 pub fn axis(&mut self, axis: AxisFlag, value: impl Into<String>) -> &mut Self {
326 let value = value.into();
327 if axis == AxisFlag::DefaultExtensibility
328 && let Some(extensibility) = Extensibility::parse(&value)
329 {
330 self.default_extensibility = Some(extensibility.into());
331 }
332 self.axis_overrides.push((axis, value));
333 self
334 }
335
336 /// Whether the generated PL_CDR1 reader takes a stream another
337 /// implementation framed, or reports it.
338 ///
339 /// [`PlCdr1Reading::Strict`] is the default. Under
340 /// [`PlCdr1Reading::Tolerant`] the reader accepts any flags on
341 /// `PID_EXTENDED` and `PID_LIST_END` (XTypes 1.3 §7.4.1.2.1), the short
342 /// and the extended header of a parameter (§7.4.1.2.2) and a declared
343 /// length that is the value's own or that length rounded up to four
344 /// (§7.4.1.2). It changes the generated Rust reader only; the writer
345 /// and the generators for other languages are not affected, and there
346 /// is no `--<axis>` flag for it.
347 pub fn pl_cdr1_reading(&mut self, reading: PlCdr1Reading) -> &mut Self {
348 self.pl_cdr1_reading = reading;
349 self
350 }
351
352 /// The `LayoutKey` this configuration's dialect and axis overrides
353 /// plan `encoding`/`endianness` under.
354 ///
355 /// One fold and not [`zerodds_ast_layout::dialect_settings_from_overrides`]
356 /// directly: that function's error names the value alone, and this
357 /// keeps [`AxisFlag::flag_name`] beside it so [`Error::Axis`] can
358 /// name which `.axis(...)` call on this `Config` was wrong, the way
359 /// `zerodds-idlc`'s own command-line errors name the flag.
360 ///
361 /// # Errors
362 /// [`Error::Axis`] for the first override whose value its axis does
363 /// not recognise.
364 fn layout_key(&self, encoding: Encoding, endianness: Endianness) -> Result<LayoutKey, Error> {
365 let mut settings =
366 DialectSettings::in_dialect(self.dialect, encoding, endianness, Purpose::Data);
367 for (axis, value) in &self.axis_overrides {
368 settings = axis
369 .parse_and_apply(settings, value)
370 .map_err(|detail| Error::Axis {
371 flag: axis.flag_name(),
372 value: value.clone(),
373 detail,
374 })?;
375 }
376 Ok(settings.key.with_pl_cdr1_reading(self.pl_cdr1_reading))
377 }
378
379 /// The front-end lowering options this configuration's dialect
380 /// settles — before any `LayoutKey` exists, so not something
381 /// [`Self::layout_key`] answers for.
382 fn lowering(&self) -> DialectLowering {
383 DialectLowering::for_dialect(self.dialect)
384 }
385
386 /// What composition should resolve an unannotated aggregate to —
387 /// `ComposeOptions::default_extensibility`.
388 ///
389 /// [`Self::default_extensibility`] wins when given (explicitly, or
390 /// through [`Self::axis`]'s own write of the same field — see that
391 /// method's doc). Absent that, and only when [`Self::dialect`] named
392 /// something other than the default: that dialect's own answer, not
393 /// `None`. `None` is composition's own signal to leave a type's
394 /// facet unresolved rather than writing the rule's default in — see
395 /// `zerodds_idl_lower::complete`'s own comment on why — and
396 /// `Ast::effective_extensibility`'s fallback for an unresolved facet
397 /// is a fixed constant, not [`Self::layout_key`]'s dialect. Left as
398 /// `None` here whenever the dialect did not move, the two answer the
399 /// same way by construction and a run with neither `dialect()` nor
400 /// `axis()` called reads exactly as it always did. Moved by the
401 /// dialect and left `None` here, they would not: the codec's
402 /// `DHEADER` follows the `LayoutKey` (correctly, the dialect's), and
403 /// the generated `DdsType::EXTENSIBILITY` constant and the
404 /// evolution-tolerant decode pattern — both `effective_extensibility`
405 /// readers — would still answer the specification's, so a decoder
406 /// built from the file would default a truncated member of a type
407 /// planned `@final` instead of refusing the stream, silently.
408 fn compose_default_extensibility(&self) -> Option<Extensibility> {
409 self.default_extensibility.map(Into::into).or_else(|| {
410 (self.dialect != Dialect::default()).then(|| self.dialect.default_extensibility())
411 })
412 }
413
414 /// A comment placed at the top of each generated module.
415 pub fn header_comment(&mut self, comment: impl Into<String>) -> &mut Self {
416 self.header_comment = Some(comment.into());
417 self
418 }
419
420 /// Whether to emit the `type_objects` module.
421 ///
422 /// On by default. It holds one XCDR2-LE serialised minimal
423 /// `TypeObject` per named type, which is what a participant hands to
424 /// a peer that asks over the TypeLookup service — a type with no
425 /// object cannot be resolved remotely, and the peer finds that out
426 /// at discovery time rather than here.
427 pub fn typeobject(&mut self, enabled: bool) -> &mut Self {
428 self.typeobject = enabled;
429 self
430 }
431
432 /// Whether to emit the `type_identifiers` module.
433 ///
434 /// Off by default, because the module's constants are spelled in
435 /// `zerodds_types` terms and pull that crate into the consumer's
436 /// dependencies. A crate that implements `zerodds_dcps::DdsType` by
437 /// hand over the generated codec turns this on to get
438 /// `TYPE_IDENTIFIER` from the same computation discovery uses,
439 /// instead of pinning a hash nothing re-checks.
440 pub fn type_identifier_constants(&mut self, enabled: bool) -> &mut Self {
441 self.type_identifier_constants = enabled;
442 self
443 }
444
445 /// Whether to emit `impl zerodds_dcps::DdsType` for each generated
446 /// type.
447 ///
448 /// With this on, a generated type is a topic type:
449 /// `participant.create_topic::<Robot>("entity.robot")` takes it, and
450 /// the four codec pairs, the KeyHash holder, the `TypeIdentifier`
451 /// and the `TypeObject` all come from the model rather than from a
452 /// bridge somebody wrote by hand beside the generated file.
453 ///
454 /// **Off by default**, for the reason
455 /// [`Self::type_identifier_constants`] is off: the impl is spelled
456 /// in `zerodds_dcps`, `zerodds_types` and `zerodds_wire` terms and
457 /// puts all three into the consumer's dependencies. That is the
458 /// whole DCPS runtime, and this crate is also used by consumers that
459 /// want the codec and nothing else — `crates/wire-conformance`
460 /// compiles the generated modules with no DDS in the graph at all,
461 /// and would stop compiling the day a default turned this on. A
462 /// default that breaks a build nobody changed is worse than a line
463 /// in a `build.rs`.
464 ///
465 /// Turning it on implies nothing else: the identifier and the object
466 /// are written into the impl, not read out of the
467 /// [`Self::type_identifier_constants`] or [`Self::typeobject`]
468 /// modules, whose constants are keyed by a type's **simple** name
469 /// and would collide between two modules declaring one name.
470 pub fn dds_type_impls(&mut self, enabled: bool) -> &mut Self {
471 self.dds_type_impls = enabled;
472 self
473 }
474
475 /// Whether to print `cargo:rerun-if-changed` lines.
476 ///
477 /// On by default and worth leaving on: cargo does not re-run a build
478 /// script for a path it was not told about, so turning this off means
479 /// an edit to an `.idl` produces no rebuild and no message.
480 pub fn emit_cargo_directives(&mut self, enabled: bool) -> &mut Self {
481 self.emit_cargo_directives = enabled;
482 self
483 }
484
485 /// How the generated Rust holds an IDL `map`, as `zerodds-idlc
486 /// --rust-map` selects it.
487 ///
488 /// [`RustMapForm::Entries`] is the default: `Vec<zerodds_wire::MapEntry<K, V>>`,
489 /// the sequence of `MapEntry` that XTypes 1.3 §7.5.1.3.6 binds a map to
490 /// where a language has no mapping of its own. [`RustMapForm::BTreeMap`]
491 /// is the option: a `BTreeMap<K, V>`, whose entries are in ascending key
492 /// order and not in the order they were put in.
493 pub fn rust_map(&mut self, form: RustMapForm) -> &mut Self {
494 self.rust_map = form;
495 self
496 }
497
498 /// Compile several units into **one** Rust module.
499 ///
500 /// The units are lowered into a **shared model**, so a module two of
501 /// them declare is one node and the generator emits one `pub mod`
502 /// for it. `compile` gives each unit its own `.rs`, and two of those
503 /// pulled into one Rust scope with `include!` both open `pub mod
504 /// common` — `error[E0428]: the name `common` is defined multiple
505 /// times`, which is issue #39.
506 ///
507 /// That is a structural answer and not a textual one. Concatenating
508 /// the units, or writing a synthetic file full of `#include` lines,
509 /// reaches the same output for IDL and buys nothing anywhere else:
510 /// the model is shared with DDS-XML, ROS and a `TypeObject` off the
511 /// wire, and node identity is what merges those. `zerodds-idl-compose`
512 /// holds the merge; this only names the output.
513 ///
514 /// `name` is the output's stem — `world` writes `world.rs`.
515 ///
516 /// # Errors
517 /// As [`Self::compile`].
518 pub fn compile_bundle(&self, idls: &[impl AsRef<Path>], name: &str) -> Result<(), Error> {
519 let out_dir = self.resolve_out_dir()?;
520 let loader = PathLoader::new(self.include_dirs.clone());
521 let paths: Vec<PathBuf> = idls.iter().map(|p| p.as_ref().to_path_buf()).collect();
522
523 if self.emit_cargo_directives {
524 for path in &paths {
525 println!("cargo:rerun-if-changed={}", path.display());
526 }
527 }
528
529 let mut composed = zerodds_idl_compose::compose_all(
530 &paths,
531 &loader,
532 ComposeOptions {
533 default_extensibility: self.compose_default_extensibility(),
534 default_nested: None,
535 defines: self.defines.clone(),
536 // The specification's readings by default — Cyclone's
537 // and Fast DDS's for the counter, IDL 4.2 §7.5.2's
538 // refusal for the enclosing scope — and `self.dialect`'s
539 // once [`Self::dialect`] has been called: a build script
540 // has no reason to take RTI's or Fast DDS's reading
541 // unless it is talking to that peer, and `Self::dialect`
542 // is the knob for saying so.
543 hashed_id_advances_the_counter: if self.lowering().hashed_id_advances_the_counter {
544 zerodds_idl_compose::HashedIdCounter::Advances
545 } else {
546 zerodds_idl_compose::HashedIdCounter::Skipped
547 },
548 hashed_name_spelling: if self.lowering().hashid_reads_the_escaped_spelling {
549 zerodds_idl_compose::HashedNameSpelling::AsWritten
550 } else {
551 zerodds_idl_compose::HashedNameSpelling::Stripped
552 },
553 name_of_the_enclosing_scope: if self.lowering().admits_name_of_enclosing_scope {
554 zerodds_idl_compose::NameOfTheEnclosingScope::Admitted
555 } else {
556 zerodds_idl_compose::NameOfTheEnclosingScope::Refused
557 },
558 // The front end carries `@key` on a union discriminator by
559 // default (DDS-XTypes 1.3 Table 21). This build asks for the
560 // refusal: the Rust union codec has no key holder to emit
561 // for a keyed discriminator (`dds_type.rs`), so carrying it
562 // would fail later, in the generator, with a message about
563 // methods instead of about the declaration.
564 key_on_union_discriminator: zerodds_idl_compose::KeyOnUnionDiscriminator::Refused,
565 // A build reads the IDL it was pointed at, which may be a
566 // vendor's. Refusing an extension here would fail the
567 // build over a portability question nobody asked;
568 // `zerodds-idlc --strict` is where that question is put.
569 dialect: zerodds_idl_compose::ComposeOptions::default().dialect,
570 },
571 )
572 .map_err(|detail| Error::Read {
573 path: paths.first().cloned().unwrap_or_default(),
574 detail,
575 })?;
576
577 if self.emit_cargo_directives {
578 for opened in loader.opened() {
579 println!("cargo:rerun-if-changed={opened}");
580 }
581 }
582 let bundle = PathBuf::from(format!("{name}.idl"));
583 if composed.failed() {
584 return Err(Error::Compose {
585 path: bundle,
586 diagnostics: composed
587 .diagnostics
588 .iter()
589 .filter(|d| d.severity == zerodds_idl_parse::Severity::Error)
590 // A bundle's resolve diagnostics name no unit: their
591 // spans could be in any of them, and a line read from
592 // the first would be a guess.
593 .map(|d| located(d, None))
594 .collect(),
595 });
596 }
597
598 self.leave_out_what_xtypes_has_no_type_for(&bundle, &mut composed.ast)?;
599 let code = self.render_with_stem(&bundle, &composed.ast, name)?;
600 let target = out_dir.join(format!("{name}.rs"));
601 std::fs::write(&target, code).map_err(|error| Error::Write {
602 path: target,
603 detail: error.to_string(),
604 })
605 }
606
607 fn resolve_out_dir(&self) -> Result<PathBuf, Error> {
608 match &self.out_dir {
609 Some(dir) => Ok(dir.clone()),
610 None => Ok(PathBuf::from(
611 std::env::var("OUT_DIR").map_err(|_| Error::NoOutDir)?,
612 )),
613 }
614 }
615
616 /// Compile each unit into one Rust module.
617 ///
618 /// # Errors
619 /// [`Error`], naming the unit. A unit that fails stops the run: a
620 /// build that carried on would write a module that does not match
621 /// the IDL beside it.
622 pub fn compile(&self, idls: &[impl AsRef<Path>]) -> Result<(), Error> {
623 let out_dir = self.resolve_out_dir()?;
624 for idl in idls {
625 self.compile_one(idl.as_ref(), &out_dir)?;
626 }
627 Ok(())
628 }
629
630 fn compile_one(&self, idl: &Path, out_dir: &Path) -> Result<(), Error> {
631 let loader = PathLoader::new(self.include_dirs.clone());
632 let path = idl.to_string_lossy().into_owned();
633 let mut composed = compose_with(
634 &path,
635 &loader,
636 ComposeOptions {
637 default_extensibility: self.compose_default_extensibility(),
638 default_nested: None,
639 defines: self.defines.clone(),
640 // The specification's readings by default — Cyclone's
641 // and Fast DDS's for the counter, IDL 4.2 §7.5.2's
642 // refusal for the enclosing scope — and `self.dialect`'s
643 // once [`Self::dialect`] has been called: a build script
644 // has no reason to take RTI's or Fast DDS's reading
645 // unless it is talking to that peer, and `Self::dialect`
646 // is the knob for saying so.
647 hashed_id_advances_the_counter: if self.lowering().hashed_id_advances_the_counter {
648 zerodds_idl_compose::HashedIdCounter::Advances
649 } else {
650 zerodds_idl_compose::HashedIdCounter::Skipped
651 },
652 hashed_name_spelling: if self.lowering().hashid_reads_the_escaped_spelling {
653 zerodds_idl_compose::HashedNameSpelling::AsWritten
654 } else {
655 zerodds_idl_compose::HashedNameSpelling::Stripped
656 },
657 name_of_the_enclosing_scope: if self.lowering().admits_name_of_enclosing_scope {
658 zerodds_idl_compose::NameOfTheEnclosingScope::Admitted
659 } else {
660 zerodds_idl_compose::NameOfTheEnclosingScope::Refused
661 },
662 // The front end carries `@key` on a union discriminator by
663 // default (DDS-XTypes 1.3 Table 21). This build asks for the
664 // refusal: the Rust union codec has no key holder to emit
665 // for a keyed discriminator (`dds_type.rs`), so carrying it
666 // would fail later, in the generator, with a message about
667 // methods instead of about the declaration.
668 key_on_union_discriminator: zerodds_idl_compose::KeyOnUnionDiscriminator::Refused,
669 // A build reads the IDL it was pointed at, which may be a
670 // vendor's. Refusing an extension here would fail the
671 // build over a portability question nobody asked;
672 // `zerodds-idlc --strict` is where that question is put.
673 dialect: zerodds_idl_compose::ComposeOptions::default().dialect,
674 },
675 )
676 .map_err(|detail| Error::Read {
677 path: idl.to_path_buf(),
678 detail,
679 })?;
680
681 if self.emit_cargo_directives {
682 // The unit itself, and every file it pulled in. The second
683 // half is the one that is easy to leave out and impossible
684 // to notice: the build simply stops re-running.
685 println!("cargo:rerun-if-changed={path}");
686 for opened in loader.opened() {
687 println!("cargo:rerun-if-changed={opened}");
688 }
689 }
690
691 if composed.failed() {
692 return Err(Error::Compose {
693 path: idl.to_path_buf(),
694 diagnostics: composed
695 .diagnostics
696 .iter()
697 .filter(|d| d.severity == zerodds_idl_parse::Severity::Error)
698 .map(|d| located(d, Some(idl)))
699 .collect(),
700 });
701 }
702
703 let stem = idl
704 .file_stem()
705 .and_then(|s| s.to_str())
706 .unwrap_or("generated");
707 self.leave_out_what_xtypes_has_no_type_for(idl, &mut composed.ast)?;
708 let code = self.render_with_stem(idl, &composed.ast, stem)?;
709 let target = out_dir.join(format!("{stem}.rs"));
710 std::fs::write(&target, code).map_err(|error| Error::Write {
711 path: target,
712 detail: error.to_string(),
713 })
714 }
715
716 /// Leave out the declarations DDS-XTypes 1.3 has no type for, and say
717 /// so.
718 ///
719 /// §7.3.1.1.1: constructs outside the building blocks are legal in
720 /// the file and "compilers that do not support them shall ignore them
721 /// or issue a warning rather than halting with an error". A struct
722 /// that holds an `any`, a `ValueBase`, a `native` or an object
723 /// reference has no type identifier and no wire form, so it gets no
724 /// Rust type and no codec; the build goes on with the rest of the
725 /// file, and `cargo:warning` names each declaration left out when
726 /// cargo directives are on.
727 fn leave_out_what_xtypes_has_no_type_for(
728 &self,
729 idl: &Path,
730 ast: &mut Ast,
731 ) -> Result<(), Error> {
732 let left_out = ast
733 .drop_outside_profile(zerodds_ast::Profile::XTypes13)
734 .map_err(|error| Error::Codegen {
735 path: idl.to_path_buf(),
736 detail: error.to_string(),
737 })?;
738 if self.emit_cargo_directives {
739 for dropped in &left_out {
740 println!(
741 "cargo:warning={}: {} reaches `{}`, for which DDS-XTypes 1.3 has no type \
742 ({}); no code is generated for it ({})",
743 idl.display(),
744 dropped.name,
745 dropped.construct,
746 dropped.detail,
747 dropped.spec_reference
748 );
749 }
750 }
751 Ok(())
752 }
753
754 /// The Rust module for one model: the types and their codecs, folded
755 /// into the module tree once.
756 fn render_with_stem(&self, idl: &Path, ast: &Ast, stem: &str) -> Result<String, Error> {
757 let _map_form = RustMapFormScope::enter(self.rust_map);
758 let graph = ModelGraph::of(ast);
759 let applied = apply(&graph, &rust::catalogue()).map_err(|error| Error::Codegen {
760 path: idl.to_path_buf(),
761 detail: format!("{error:?}"),
762 })?;
763
764 // Types and codecs go through **one** fold. Folding them
765 // separately and concatenating declares every shared ancestor
766 // twice, and Rust allows a name once per scope.
767 let mut pairs = rust_item_pairs(ast, &applied).map_err(|error| Error::Codegen {
768 path: idl.to_path_buf(),
769 detail: format!("{error:?}"),
770 })?;
771 // The types that got a second, XCDR1 codec. Only the `DdsType`
772 // impl needs it, so it is only planned when that is asked for:
773 // an XCDR1 plan is a second walk of every type, and a consumer
774 // that wants the XCDR2 codec alone should not pay for it.
775 let mut xcdr1: Vec<Id<TypeNode>> = Vec::new();
776 for (id, node) in ast.types() {
777 if node.name.is_empty() || !has_codec(&node.kind) {
778 continue;
779 }
780 let key = self.layout_key(encoding_for(ast, id), Endianness::Little)?;
781 for item in rust_codec(ast, id, key).map_err(|error| Error::Codegen {
782 path: idl.to_path_buf(),
783 detail: format!("{error:?}"),
784 })? {
785 pairs.push((node.module, item));
786 }
787 if !self.dds_type_impls {
788 continue;
789 }
790 // A refusal here is **not** fatal and **not** silent: XCDR1
791 // cannot frame every type XCDR2 can, and the `DdsType` impl
792 // answers for the gap by leaving the four XCDR1 methods at
793 // the trait's refusing defaults and naming the type in a
794 // comment. Failing the whole build instead would refuse a
795 // type whose XCDR2 wire — the one a DDS 1.4 `XCDR2`
796 // representation asks for — is perfectly good.
797 let v1 = self.layout_key(xcdr1_encoding_for(ast, id), Endianness::Little)?;
798 if let Ok(items) = rust_codec_pair(ast, id, v1) {
799 for item in items {
800 pairs.push((node.module, item));
801 }
802 xcdr1.push(id);
803 }
804 }
805 let items = nest_by_module(ast, pairs).map_err(|error| Error::Codegen {
806 path: idl.to_path_buf(),
807 detail: format!("{error:?}"),
808 })?;
809 // `@verbatim(placement = BEGIN_FILE | END_FILE)`, OMG IDL 4.2
810 // §8.3.5.1, around everything this file holds.
811 let items = zerodds_lang_rules::verbatim::weave_file(
812 ast,
813 zerodds_lang_rules::verbatim::aliases::RUST,
814 items,
815 );
816
817 let mut doc = vec![
818 format!(
819 "Generated by zerodds-build from {}.",
820 idl.file_name()
821 .and_then(|n| n.to_str())
822 .unwrap_or("the IDL")
823 ),
824 String::new(),
825 String::from("Do not edit: the point of this file is that nobody did."),
826 ];
827 if let Some(comment) = &self.header_comment {
828 doc.push(String::new());
829 doc.push(comment.clone());
830 }
831 let doc: Vec<&str> = doc.iter().map(String::as_str).collect();
832
833 // Only the runtime names this file actually mentions.
834 //
835 // The five were imported unconditionally, and a type whose codec
836 // needs none of `Narrowed`, `TryConstruct` or `WireError` — a
837 // struct of plain scalars is one — carried them anyway. Under
838 // `-D warnings`, which a `build.rs` inherits from the crate it
839 // builds for, `unused_imports` is then an error and the
840 // generated file does not compile. The consumer sees a build
841 // failure in code they did not write.
842 //
843 // Which names appear is only knowable once the items are
844 // rendered, so they are rendered twice: once to look, once to
845 // keep. Both passes are string building over a tree that is
846 // already built.
847 let probe = zerodds_lang_render::rust::file(&zerodds_lang_cst::rust::File {
848 inner_attrs: Vec::new(),
849 doc: zerodds_lang_cst::common::Doc::lines(&[]),
850 uses: Vec::new(),
851 items: items.clone(),
852 });
853 let names: Vec<zerodds_lang_cst::common::Ident> = RUNTIME_IMPORTS
854 .iter()
855 .filter(|name| mentions_word(&probe, name))
856 .map(|name| zerodds_lang_cst::common::Ident::new(name))
857 .collect();
858 // The file is included at the crate root; every nested module
859 // reaches these through its own `use super::*`.
860 let mut uses = Vec::new();
861 if !names.is_empty() {
862 uses.push(zerodds_lang_cst::rust::Use {
863 path: zerodds_lang_cst::common::Path::single("zerodds_wire"),
864 names,
865 visibility: zerodds_lang_cst::common::Visibility::Private,
866 });
867 }
868 // Under [`RustMapForm::BTreeMap`] `adapter::cst` renders an IDL
869 // `map` as a bare `BTreeMap<K, V>`, so a file that carries one has
870 // to bring the name in; the default form names
871 // `zerodds_wire::MapEntry` by its path. Conditional for the
872 // same reason the runtime names are: an unused import is an
873 // error under the `-D warnings` a `build.rs` inherits, and the
874 // consumer would see it in code they did not write.
875 if mentions_word(&probe, "BTreeMap") {
876 uses.push(zerodds_lang_cst::rust::Use {
877 path: zerodds_lang_cst::common::Path::new(&["std", "collections"]),
878 names: vec![zerodds_lang_cst::common::Ident::new("BTreeMap")],
879 visibility: zerodds_lang_cst::common::Visibility::Private,
880 });
881 }
882 let file = zerodds_lang_cst::rust::File {
883 inner_attrs: Vec::new(),
884 doc: zerodds_lang_cst::common::Doc::lines(&doc),
885 uses,
886 items,
887 };
888 let mut code = zerodds_lang_render::rust::file(&file);
889 if self.typeobject {
890 code.push_str(&type_objects_module(ast, stem));
891 }
892 if self.type_identifier_constants {
893 code.push_str(&type_identifiers_module(ast, stem));
894 }
895 if self.dds_type_impls {
896 let key_for = |encoding: Encoding| {
897 self.layout_key(encoding, Endianness::Little)
898 .map_err(|error| error.to_string())
899 };
900 code.push_str(
901 &dds_type::dds_type_impls(ast, &xcdr1, &key_for).map_err(|detail| {
902 Error::Codegen {
903 path: idl.to_path_buf(),
904 detail,
905 }
906 })?,
907 );
908 }
909 Ok(code)
910 }
911}
912
913/// The type-identifier module: one `zerodds_types::TypeIdentifier`
914/// constant per named type, in the **complete** form — the one a
915/// hand-written `DdsType` impl carries as `TYPE_IDENTIFIER`.
916///
917/// Named after the unit for the same reason `type_objects_module` is:
918/// two units in one scope must not collide.
919///
920/// A type whose identifier this module cannot spell as a constant —
921/// today that is everything but an equivalence hash and a strongly
922/// connected component — is **named in a comment** rather than left
923/// out silently, so a reader who misses a constant finds the reason in
924/// the same place the constant would be.
925fn type_identifiers_module(ast: &Ast, stem: &str) -> String {
926 use core::fmt::Write as _;
927
928 let mut body = String::new();
929 for (id, node) in ast.types() {
930 if node.name.is_empty() || !has_codec(&node.kind) {
931 continue;
932 }
933 let Ok(identifier) = zerodds_types::from_ast::type_identifier(
934 ast,
935 id,
936 zerodds_types::from_ast::Form::Complete,
937 ) else {
938 let _ = writeln!(
939 body,
940 " // `{}`: its identifier could not be computed from the model.",
941 node.name
942 );
943 continue;
944 };
945 match identifier {
946 zerodds_types::TypeIdentifier::EquivalenceHashComplete(hash) => {
947 let octets: Vec<String> = hash.0.iter().map(|b| format!("0x{b:02x}")).collect();
948 let _ = write!(
949 body,
950 " /// Complete equivalence hash of `{}`, XTypes 1.3 \u{a7}7.3.4.6.\n \
951 pub const {}: zerodds_types::TypeIdentifier =\n \
952 zerodds_types::TypeIdentifier::EquivalenceHashComplete(\
953 zerodds_types::EquivalenceHash([{}]));\n",
954 node.name,
955 constant_name(&node.name),
956 octets.join(", ")
957 );
958 }
959 // A type in a strongly connected component is identified by the
960 // component's hash and its own position (§7.3.4.9.3).
961 zerodds_types::TypeIdentifier::StronglyConnectedComponent(scc) => {
962 let octets: Vec<String> = scc.hash.0.iter().map(|b| format!("0x{b:02x}")).collect();
963 let _ = write!(
964 body,
965 " /// Complete component identifier of `{}`, XTypes 1.3 \u{a7}7.3.4.9.3.\n \
966 pub const {}: zerodds_types::TypeIdentifier =\n \
967 zerodds_types::TypeIdentifier::StronglyConnectedComponent(\n \
968 zerodds_types::StronglyConnectedComponentId {{\n \
969 equivalence_kind: 0x{:02X},\n hash: \
970 zerodds_types::EquivalenceHash([{}]),\n scc_length: {},\n \
971 scc_index: {},\n }});\n",
972 node.name,
973 constant_name(&node.name),
974 scc.equivalence_kind,
975 octets.join(", "),
976 scc.scc_length,
977 scc.scc_index
978 );
979 }
980 other => {
981 let _ = writeln!(
982 body,
983 " // `{}`: {other:?} is not an equivalence hash and this module \
984 does not spell it as a constant yet.",
985 node.name
986 );
987 }
988 }
989 }
990 if body.is_empty() {
991 return String::new();
992 }
993 format!(
994 "\n#[allow(dead_code, non_upper_case_globals)]\npub mod type_identifiers_{} {{\n{body}}}\n",
995 constant_name(stem).to_lowercase()
996 )
997}
998
999/// The type-object module: one XCDR2-LE minimal `TypeObject` per named
1000/// type, as a byte slice.
1001///
1002/// **Named after the unit**, `type_objects_robot` and not
1003/// `type_objects`. Two `.idl` files pulled into one scope with
1004/// `include!` is the ordinary way to use this crate, and a fixed name
1005/// makes the second one `error[E0428]: the name `type_objects` is
1006/// defined multiple times` — reported as issue #25, fixed once, and
1007/// reintroduced here when this module was rebuilt. The stem is what
1008/// distinguishes them, because it is what already distinguishes the two
1009/// output files.
1010///
1011/// A type whose object cannot be built is left out rather than emitted
1012/// empty. An empty blob is a `TypeObject` that resolves to nothing, and
1013/// a peer that received one would report a type mismatch instead of a
1014/// missing type — the second is true and the first is not.
1015fn type_objects_module(ast: &Ast, stem: &str) -> String {
1016 use core::fmt::Write as _;
1017
1018 let mut body = String::new();
1019 for (id, node) in ast.types() {
1020 if node.name.is_empty() || !has_codec(&node.kind) {
1021 continue;
1022 }
1023 let Ok(object) = zerodds_types::from_ast::minimal_type_object(ast, id) else {
1024 continue;
1025 };
1026 let Ok(bytes) = zerodds_types::type_object::TypeObject::Minimal(object).to_bytes_le()
1027 else {
1028 continue;
1029 };
1030 let octets: Vec<String> = bytes.iter().map(|b| format!("0x{b:02x}")).collect();
1031 let _ = write!(
1032 body,
1033 " /// Minimal TypeObject for `{}`, XCDR2-LE.\n pub const {}: &[u8] = &[{}];\n",
1034 node.name,
1035 constant_name(&node.name),
1036 octets.join(", ")
1037 );
1038 }
1039 if body.is_empty() {
1040 return String::new();
1041 }
1042 format!(
1043 "\n#[allow(dead_code, non_upper_case_globals)]\npub mod type_objects_{} {{\n{body}}}\n",
1044 constant_name(stem).to_lowercase()
1045 )
1046}
1047
1048/// A type name as a Rust constant name.
1049///
1050/// The source case is kept rather than shouted: `Robot_sPose` and
1051/// `ROBOT_S_POSE` are not the same name to a reader looking for the type
1052/// they wrote, and the module allows `non_upper_case_globals` for that
1053/// reason.
1054fn constant_name(name: &str) -> String {
1055 name.chars()
1056 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1057 .collect()
1058}
1059
1060/// Whether a type gets a generated codec.
1061///
1062/// The kinds that carry members or values. A `typedef` and a bare
1063/// `sequence` do not: what they alias does.
1064pub(crate) const fn has_codec(kind: &TypeKind) -> bool {
1065 matches!(
1066 kind,
1067 TypeKind::Structure { .. }
1068 | TypeKind::Union { .. }
1069 | TypeKind::Enumeration { .. }
1070 | TypeKind::Bitmask { .. }
1071 | TypeKind::Bitset { .. }
1072 )
1073}
1074
1075/// The encoding a type's own extensibility asks for, under XCDR1.
1076fn xcdr1_encoding_for(ast: &Ast, id: Id<TypeNode>) -> Encoding {
1077 dds_type::xcdr1_encoding(ast.effective_extensibility(id))
1078}
1079
1080/// The encoding a type's own extensibility asks for, under XCDR2.
1081pub(crate) fn encoding_for(ast: &Ast, id: Id<TypeNode>) -> Encoding {
1082 match ast.effective_extensibility(id) {
1083 Extensibility::Final => Encoding::PlainCdr2,
1084 Extensibility::Appendable => Encoding::DelimitedCdr,
1085 Extensibility::Mutable => Encoding::PlCdr2,
1086 }
1087}
1088
1089/// A diagnostic as `file:line:column: message`.
1090///
1091/// The file is the one the diagnostic names, or `unit` when it names
1092/// none; line and column are read from that file as it is on disk, and
1093/// left out when it cannot be read.
1094fn located(diagnostic: &zerodds_idl_parse::Diagnostic, unit: Option<&Path>) -> String {
1095 let path = match (diagnostic.file.as_deref(), unit) {
1096 (Some(file), _) => PathBuf::from(file),
1097 (None, Some(unit)) => unit.to_path_buf(),
1098 (None, None) => return diagnostic.message.clone(),
1099 };
1100 let place = std::fs::read_to_string(&path).ok().map(|text| {
1101 let offset = (diagnostic.primary.start as usize).min(text.len());
1102 let before = text.get(..offset).unwrap_or(&text);
1103 let line = before.matches('\n').count() + 1;
1104 let column = before
1105 .rsplit('\n')
1106 .next()
1107 .map_or(0, |last| last.chars().count())
1108 + 1;
1109 format!("{line}:{column}")
1110 });
1111 match place {
1112 Some(place) => format!("{}:{place}: {}", path.display(), diagnostic.message),
1113 None => format!("{}: {}", path.display(), diagnostic.message),
1114 }
1115}
1116
1117/// Compile with the defaults — the one-liner form.
1118///
1119/// # Errors
1120/// As [`Config::compile`].
1121pub fn compile(idls: &[impl AsRef<Path>]) -> Result<(), Error> {
1122 Config::new().compile(idls)
1123}
1124
1125/// The runtime names a generated codec can reach for.
1126///
1127/// Kept as a list rather than derived from the emitter, because the
1128/// emitter names them in the expressions it builds and has no place to
1129/// report them from. A name added there and not here would compile
1130/// (`use super::*` reaches nothing that is not imported, so it would be
1131/// an unresolved-name error, loudly) — the failure mode is the safe
1132/// direction.
1133///
1134/// `ParameterRole` was the one that took that direction. The `@mutable`
1135/// codec writes every member behind `Writer::write_pid(id, role, must_understand)`
1136/// (XTypes 1.3 §7.4.3.4.1 — the EMHEADER's member id and flags), so the
1137/// emitter names it, and it was not in this list. The consequence was
1138/// not a stray warning: no `@mutable` struct compiled through
1139/// `zerodds-build` at all, because the generated file named a type it
1140/// had not imported. `interop/shapes/omg-dds-xtypes.idl` is the first
1141/// IDL in this tree to carry one, and it found it.
1142const RUNTIME_IMPORTS: [&str; 6] = [
1143 "Narrowed",
1144 "ParameterRole",
1145 "Reader",
1146 "TryConstruct",
1147 "WireError",
1148 "Writer",
1149];
1150
1151/// Does `haystack` mention `word` as a word of its own?
1152///
1153/// A substring test would count `Reader` inside `ReaderProxy` and keep
1154/// an import nothing uses. Rust identifiers are ASCII alphanumeric plus
1155/// `_`, so the neighbours decide it.
1156fn mentions_word(haystack: &str, word: &str) -> bool {
1157 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
1158 let bytes = haystack.as_bytes();
1159 let mut from = 0;
1160 while let Some(found) = haystack[from..].find(word) {
1161 let at = from + found;
1162 let before_ok = at == 0 || !is_ident(char::from(bytes[at - 1]));
1163 let after = at + word.len();
1164 let after_ok = after >= bytes.len() || !is_ident(char::from(bytes[after]));
1165 if before_ok && after_ok {
1166 return true;
1167 }
1168 from = at + 1;
1169 }
1170 false
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 #![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1176 use super::*;
1177
1178 fn scratch(name: &str) -> PathBuf {
1179 let dir = std::env::temp_dir().join(format!("zerodds-build-{name}-{}", std::process::id()));
1180 let _ = std::fs::remove_dir_all(&dir);
1181 std::fs::create_dir_all(&dir).expect("scratch dir");
1182 dir
1183 }
1184
1185 #[test]
1186 fn one_unit_becomes_one_module_named_after_it() {
1187 let dir = scratch("basic");
1188 let idl = dir.join("Robot.idl");
1189 std::fs::write(
1190 &idl,
1191 "module robot { @appendable struct Pose { @key long id; double x; }; };",
1192 )
1193 .expect("write idl");
1194
1195 Config::new()
1196 .out_dir(&dir)
1197 .emit_cargo_directives(false)
1198 .compile(&[&idl])
1199 .expect("compile");
1200
1201 let generated = std::fs::read_to_string(dir.join("Robot.rs")).expect("the module");
1202 assert!(generated.contains("pub mod robot"), "{generated}");
1203 assert!(generated.contains("pub struct Pose"), "{generated}");
1204 assert!(generated.contains("fn encode"), "{generated}");
1205 assert!(generated.contains("use super::*"), "{generated}");
1206 let _ = std::fs::remove_dir_all(&dir);
1207 }
1208
1209 /// DDS-XTypes 1.3 §7.3.1.1.1: constructs outside the building blocks
1210 /// are ignored or warned about "rather than halting with an error". A
1211 /// struct that holds an `any` has no type identifier and no wire form;
1212 /// the rest of the unit still builds.
1213 #[test]
1214 fn a_struct_holding_an_any_is_left_out_and_the_rest_of_the_unit_builds() {
1215 let dir = scratch("any");
1216 let idl = dir.join("Mixed.idl");
1217 std::fs::write(
1218 &idl,
1219 "module m { struct Plain { long a; }; struct Holds { any a; }; \
1220 struct Deep { Holds h; }; struct Beside { Plain p; string s; }; };",
1221 )
1222 .expect("write idl");
1223
1224 Config::new()
1225 .out_dir(&dir)
1226 .emit_cargo_directives(false)
1227 .compile(&[&idl])
1228 .expect("a unit with an `any` member builds");
1229
1230 let generated = std::fs::read_to_string(dir.join("Mixed.rs")).expect("the module");
1231 assert!(generated.contains("pub struct Plain"), "{generated}");
1232 assert!(generated.contains("pub struct Beside"), "{generated}");
1233 assert!(!generated.contains("Holds"), "{generated}");
1234 assert!(!generated.contains("Deep"), "{generated}");
1235 let _ = std::fs::remove_dir_all(&dir);
1236 }
1237
1238 /// A unit that does not compose fails the build and carries every
1239 /// error, not the first.
1240 #[test]
1241 fn a_broken_unit_fails_and_names_what_is_wrong() {
1242 let dir = scratch("broken");
1243 let idl = dir.join("Bad.idl");
1244 std::fs::write(&idl, "module m { struct S { long ; }; };").expect("write idl");
1245
1246 let Err(error) = Config::new()
1247 .out_dir(&dir)
1248 .emit_cargo_directives(false)
1249 .compile(&[&idl])
1250 else {
1251 panic!("a unit with a syntax error must not compile")
1252 };
1253 assert!(matches!(error, Error::Compose { .. }), "{error}");
1254 assert!(!dir.join("Bad.rs").exists(), "nothing was written");
1255 let _ = std::fs::remove_dir_all(&dir);
1256 }
1257
1258 /// The default fills silence and does not overrule the source.
1259 #[test]
1260 fn the_default_extensibility_reaches_the_generated_codec() {
1261 let dir = scratch("ext");
1262 let idl = dir.join("Ext.idl");
1263 std::fs::write(&idl, "module m { struct Silent { long a; }; };").expect("write idl");
1264
1265 Config::new()
1266 .out_dir(&dir)
1267 .emit_cargo_directives(false)
1268 .default_extensibility(DefaultExt::Final)
1269 .compile(&[&idl])
1270 .expect("compile");
1271 let with_final = std::fs::read_to_string(dir.join("Ext.rs")).expect("the module");
1272
1273 Config::new()
1274 .out_dir(&dir)
1275 .emit_cargo_directives(false)
1276 .compile(&[&idl])
1277 .expect("compile");
1278 let with_default = std::fs::read_to_string(dir.join("Ext.rs")).expect("the module");
1279
1280 assert_ne!(
1281 with_final, with_default,
1282 "@final and the specification's @appendable are different wire plans"
1283 );
1284 let _ = std::fs::remove_dir_all(&dir);
1285 }
1286
1287 /// An `#include` resolves against the search list, and the header
1288 /// comment reaches the file.
1289 #[test]
1290 fn an_include_dir_is_searched_and_the_comment_is_written() {
1291 let dir = scratch("include");
1292 let headers = dir.join("headers");
1293 std::fs::create_dir_all(&headers).expect("headers");
1294 std::fs::write(headers.join("common.idl"), "struct Shared { long a; };").expect("common");
1295 let idl = dir.join("Main.idl");
1296 std::fs::write(&idl, "#include \"common.idl\"\nstruct Main { long b; };").expect("main");
1297
1298 Config::new()
1299 .out_dir(&dir)
1300 .include_dir(&headers)
1301 .header_comment("built for the test")
1302 .emit_cargo_directives(false)
1303 .compile(&[&idl])
1304 .expect("compile");
1305
1306 let generated = std::fs::read_to_string(dir.join("Main.rs")).expect("the module");
1307 assert!(generated.contains("Shared"), "the include came in");
1308 assert!(generated.contains("built for the test"), "{generated}");
1309 let _ = std::fs::remove_dir_all(&dir);
1310 }
1311
1312 #[test]
1313 fn without_out_dir_and_without_cargo_it_says_so() {
1314 // `OUT_DIR` is set while the test suite itself is built by cargo,
1315 // so this asserts the mapping rather than the environment.
1316 let error = Error::NoOutDir;
1317 assert!(error.to_string().contains("OUT_DIR"), "{error}");
1318 }
1319}