Skip to main content

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}