Skip to main content

zerodds_idl_compose/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! The shared IDL composition pipeline.
4//!
5//! Crate `zerodds-idl-compose`. Safety classification: **STANDARD**.
6//! Build-time only, std, `forbid(unsafe_code)`.
7//!
8//! One call — [`compose`] — takes a path and produces the model plus its
9//! registered type objects. Every build-time consumer goes through it, so
10//! a `build.rs` and the command-line tool see the same thing from the
11//! same file.
12//!
13//! ## What this crate no longer does
14//!
15//! It used to do five things, and it existed because only the CLI did all
16//! five: the `build.rs` scripts called the parser directly and got none
17//! of them. Four have since moved into the front end, where they belong:
18//!
19//! | step | where it is now |
20//! |---|---|
21//! | `#include` / `#define` expansion | [`mod@zerodds_idl_parse::preprocess`] |
22//! | parse | [`zerodds_idl_parse::parse_preprocessed`] |
23//! | vendor key pragmas → `@key` | `zerodds-idl-lower`'s `apply_keylists` |
24//! | default extensibility | `Ast::effective_extensibility` |
25//! | type objects | [`zerodds_types::from_ast`] |
26//!
27//! The fourth is the one worth naming. It used to **write** an explicit
28//! `@final`/`@appendable` onto every unannotated aggregate so that each
29//! backend would agree. The model answers the question instead, in one
30//! place, and nothing is written into the tree — which is what stops two
31//! backends from disagreeing without either of them being wrong.
32//!
33//! What is left here is the composition itself: resolving include paths
34//! against a search list, and running the four stages in order.
35
36#![forbid(unsafe_code)]
37#![warn(missing_docs)]
38
39use std::cell::RefCell;
40use std::path::{Path, PathBuf};
41
42use zerodds_ast::Ast;
43use zerodds_idl_parse::{Diagnostic, FileLoader, Severity};
44use zerodds_types::resolve::TypeRegistry;
45
46/// A loader over the filesystem, with a search list for `#include`.
47///
48/// A quoted include resolves against the including file's own directory
49/// first, which is what every C preprocessor does and what an IDL author
50/// expects when they write `#include "common.idl"` next to their file.
51/// Only then does the search list apply. Getting that order wrong makes
52/// a file compile against a header from somewhere else, and the two are
53/// indistinguishable in the output.
54pub struct PathLoader {
55    roots: Vec<PathBuf>,
56    /// Every file this loader actually opened, in the order it opened
57    /// them.
58    ///
59    /// A build has to know: an `#include` that changed must trigger a
60    /// rebuild, and cargo only re-runs a build script for paths it was
61    /// told about. Without this the generated code goes stale after an
62    /// edit to a header, and nothing reports it — the build is simply
63    /// not re-run.
64    opened: RefCell<Vec<String>>,
65}
66
67impl PathLoader {
68    /// A loader over `roots`, searched in the order given.
69    #[must_use]
70    pub fn new(roots: Vec<PathBuf>) -> Self {
71        Self {
72            roots,
73            opened: RefCell::new(Vec::new()),
74        }
75    }
76
77    /// A loader with no search list — quoted includes still resolve
78    /// beside the including file.
79    #[must_use]
80    pub fn bare() -> Self {
81        Self::new(Vec::new())
82    }
83
84    /// Every file opened so far, in order.
85    #[must_use]
86    pub fn opened(&self) -> Vec<String> {
87        self.opened.borrow().clone()
88    }
89}
90
91impl FileLoader for PathLoader {
92    fn load(&self, path: &str, from: Option<&str>) -> Option<(String, String)> {
93        let mut candidates: Vec<PathBuf> = Vec::new();
94        // Beside the including file first.
95        if let Some(from) = from {
96            if let Some(parent) = Path::new(from).parent() {
97                candidates.push(parent.join(path));
98            }
99        }
100        candidates.extend(self.roots.iter().map(|root| root.join(path)));
101        // A path that is already absolute, or that resolves from the
102        // working directory, is the last resort rather than the first:
103        // an include that only works because of where the build ran is
104        // the one that breaks on another machine.
105        candidates.push(PathBuf::from(path));
106
107        for candidate in candidates {
108            if let Ok(text) = std::fs::read_to_string(&candidate) {
109                let resolved = candidate.to_string_lossy().into_owned();
110                self.opened.borrow_mut().push(resolved.clone());
111                return Some((resolved, text));
112            }
113        }
114        None
115    }
116}
117
118/// What one composition produced.
119pub struct Composed {
120    /// The model, built as far as the sources allowed.
121    pub ast: Ast,
122    /// Every type object the model produced, by hash.
123    pub registry: TypeRegistry,
124    /// Everything wrong, from every stage, in the order the stages ran.
125    pub diagnostics: Vec<Diagnostic>,
126    /// Types no type object could be built for, with the reason.
127    ///
128    /// Not a diagnostic: a type that has no `TypeObject` is still a type
129    /// a backend can generate. It is listed so a caller that needs the
130    /// objects — discovery, a type lookup service — finds out which ones
131    /// it will not have, instead of discovering it at run time.
132    pub without_type_objects: Vec<(String, String)>,
133    /// The unit as the parser saw it, for placing a diagnostic raised on
134    /// the expanded text afterwards in the file a reader has. The model's
135    /// nodes are in file offsets already: their `SourceFacet` names the
136    /// file and the range in it. `None` for a bundle of units.
137    pub expanded: Option<zerodds_idl_parse::Preprocessed>,
138}
139
140impl Composed {
141    /// Moves a diagnostic raised on this model's spans into the file it
142    /// points at; see `Preprocessed::locate`. Leaves it alone where the
143    /// model came from several units.
144    pub fn locate(&self, diagnostic: &mut Diagnostic) {
145        if let Some(expanded) = &self.expanded {
146            expanded.locate(diagnostic);
147        }
148    }
149
150    /// Whether any stage reported an error.
151    #[must_use]
152    pub fn failed(&self) -> bool {
153        self.diagnostics
154            .iter()
155            .any(|d| d.severity == Severity::Error)
156    }
157}
158
159/// What the caller decides that the source does not.
160#[derive(Debug, Clone)]
161pub struct ComposeOptions {
162    /// What an aggregate of unspecified extensibility is taken to be.
163    ///
164    /// `None` leaves the facet absent, so "the source said appendable"
165    /// stays distinguishable from "the source said nothing" and
166    /// `Ast::effective_extensibility` applies XTypes 1.3 §7.3.1.2.1.8's
167    /// default. `Some` writes it into the model, which is what that same
168    /// paragraph provides for:
169    ///
170    /// > Implementations may provide a mechanism to override this
171    /// > default behavior; for example, IDL compilers may provide
172    /// > configuration options to allow users to specify whether types
173    /// > of unspecified extensibility are to be considered final,
174    /// > appendable, or mutable.
175    ///
176    /// What the source *did* say still wins. This fills silence only,
177    /// which is why it is an option here and not a rewrite of the tree.
178    pub default_extensibility: Option<zerodds_ast::Extensibility>,
179    /// The `@default_nested` of the top-level modules that carry none of
180    /// their own — the compiler option DDS-XTypes 1.3 §7.3.1.2.1.7
181    /// requires ("IDL compilers shall provide the means to change the
182    /// default value for non-annotated top-level modules"). `None` is the
183    /// specification's FALSE. See
184    /// [`zerodds_idl_lower::Options::default_nested`].
185    pub default_nested: Option<bool>,
186    /// Macros defined before the first line is read — a build system's
187    /// `-D`. An empty body is `-DNAME`: defined, expanding to nothing.
188    pub defines: Vec<(String, String)>,
189    /// Whether a `@hashid` member advances the sequential id counter.
190    ///
191    /// A dialect axis and not a preference: vendors disagree, and the
192    /// answer changes the id of every member *after* a `@hashid` one.
193    /// It is here because the wire corpus varies it, and the corpus and
194    /// a build have to be able to load a file the same way — two load
195    /// paths with the same intent is how a production path came to skip
196    /// the resolve pass entirely.
197    pub hashed_id_advances_the_counter: zerodds_idl_lower::HashedIdCounter,
198    /// Which spelling of an escaped name a member-id hash reads. The
199    /// same kind of axis as the counter above — RTI hashes `_long` as
200    /// written where the clause hashes `long` — and here for the same
201    /// reason: the corpus and a build have to load a file the same way.
202    pub hashed_name_spelling: zerodds_idl_lower::HashedNameSpelling,
203    /// Whether `@key` on a union discriminator is carried.
204    ///
205    /// The same axis `zerodds-xml-compose` carries, and here for the
206    /// reason the two above are here: a type declared once in IDL and
207    /// once in XML has to reach the same model, and the key bit is part
208    /// of its `TypeIdentifier`.
209    pub key_on_union_discriminator: zerodds_idl_lower::KeyOnUnionDiscriminator,
210    /// Whether a declaration may carry the name of the scope it sits in.
211    ///
212    /// The same kind of axis as the three above, and here for the same
213    /// reason: the corpus and a build have to load a file the same way.
214    /// The measurement behind it is the corpus case
215    /// `resolve-module-named-as-type`, which Cyclone DDS and OpenDDS
216    /// refuse and Fast DDS and RTI Connext take.
217    pub name_of_the_enclosing_scope: zerodds_idl_lower::NameOfTheEnclosingScope,
218    /// Which vendors' extensions this composition admits. Spec §3.5.
219    ///
220    /// Reaches two stages, because vendor constructs live in two: the
221    /// parser sees `keylist` as grammar, the lowering sees `#pragma
222    /// keylist` and OpenDDS's `DCPS_*` as directives that change the
223    /// model. `Dialect::STRICT` turns both from a warning into an error.
224    ///
225    /// Not part of a derived `Default`: see the `impl Default` below.
226    pub dialect: zerodds_idl_parse::Dialect,
227}
228
229/// `ComposeOptions::default()` admits every vendor extension.
230///
231/// Written out rather than derived, because `Dialect::default()` is
232/// `STRICT` and the two defaults are deliberately opposite. A tool
233/// pointed at a file somebody else wrote should read it before it judges
234/// it; `--strict` is the caller who asked the other question.
235impl Default for ComposeOptions {
236    fn default() -> Self {
237        Self {
238            default_extensibility: None,
239            default_nested: None,
240            defines: Vec::new(),
241            hashed_id_advances_the_counter: zerodds_idl_lower::HashedIdCounter::default(),
242            hashed_name_spelling: zerodds_idl_lower::HashedNameSpelling::default(),
243            key_on_union_discriminator: zerodds_idl_lower::KeyOnUnionDiscriminator::default(),
244            name_of_the_enclosing_scope: zerodds_idl_lower::NameOfTheEnclosingScope::default(),
245            dialect: zerodds_idl_parse::Dialect::ALL_VENDORS,
246        }
247    }
248}
249
250/// Re-exported because [`ComposeOptions`] carries it in a public field:
251/// a caller that cannot name the type cannot construct the options, and
252/// `zerodds-build` could not until this was here.
253pub use zerodds_idl_lower::HashedIdCounter;
254pub use zerodds_idl_lower::HashedNameSpelling;
255pub use zerodds_idl_lower::KeyOnUnionDiscriminator;
256pub use zerodds_idl_lower::NameOfTheEnclosingScope;
257
258/// Preprocess, parse, lower and register, from a path.
259///
260/// # Errors
261/// Only the read of `path` itself: everything after it is reported
262/// through [`Composed::diagnostics`], because a file with one bad
263/// declaration still has the rest, and a build that stops at the first
264/// one costs a round trip per defect.
265pub fn compose(path: &str, loader: &dyn FileLoader) -> Result<Composed, String> {
266    compose_with(path, loader, ComposeOptions::default())
267}
268
269/// As [`compose`], deciding what the source leaves open.
270///
271/// # Errors
272/// As [`compose`].
273pub fn compose_with(
274    path: &str,
275    loader: &dyn FileLoader,
276    options: ComposeOptions,
277) -> Result<Composed, String> {
278    let source = std::fs::read_to_string(path).map_err(|error| format!("{path}: {error}"))?;
279    Ok(compose_source_with(path, &source, loader, options))
280}
281
282/// As [`compose`], over text the caller already has.
283#[must_use]
284pub fn compose_source(path: &str, source: &str, loader: &dyn FileLoader) -> Composed {
285    compose_source_with(path, source, loader, ComposeOptions::default())
286}
287
288/// As [`compose_source`], deciding what the source leaves open.
289#[must_use]
290pub fn compose_source_with(
291    path: &str,
292    source: &str,
293    loader: &dyn FileLoader,
294    options: ComposeOptions,
295) -> Composed {
296    let preprocessed = zerodds_idl_parse::preprocess_with(path, source, loader, &options.defines);
297    let parsed = zerodds_idl_parse::parse_preprocessed_with(&preprocessed, options.dialect);
298    let mut diagnostics = parsed.diagnostics().to_vec();
299    // The preprocessor's come first and are in file offsets already; the
300    // parser's are on the expanded text; everything raised on the model
301    // after them is on the tree (`Parse::source_span`).
302    let expanded_from = preprocessed.diagnostics.len();
303    let tree_from = diagnostics.len();
304
305    let lowered = zerodds_idl_lower::lower_with(&parsed.syntax(), path, options.lowering());
306    diagnostics.extend(lowered.diagnostics);
307    let mut ast = lowered.ast;
308    diagnostics.extend(resolve(&mut ast, path));
309    // After resolve, because a cycle is a property of the resolved type
310    // graph: an unresolved reference has no edge to close one.
311    diagnostics.extend(inadmissible_cycles(&ast));
312    for diagnostic in diagnostics.iter_mut().skip(tree_from) {
313        on_the_expanded_text(&parsed, diagnostic);
314    }
315    for diagnostic in diagnostics.iter_mut().skip(expanded_from) {
316        preprocessed.locate(diagnostic);
317    }
318    // The diagnostics above are in file offsets now; the nodes are next.
319    // Lowering gave each the range of the tree it was lowered from,
320    // under the unit's path, and an empty macro chain. A reader of a
321    // node's source — a lint, a generator naming a declaration, a
322    // snapshot — wants the file and the range a person can open, and
323    // the macros that wrote the text.
324    place_nodes_in_their_files(&mut ast, &parsed, &preprocessed, |_| true);
325
326    // Type objects are built from whatever the model holds. A model with
327    // errors in it still registers the types that came out whole, which
328    // is what lets a caller see how far a broken file got.
329    let mut registry = TypeRegistry::new();
330    let without_type_objects = zerodds_types::from_ast::register_all(&ast, &mut registry)
331        .map(|skipped| {
332            skipped
333                .into_iter()
334                .map(|(name, error)| (name, format!("{error:?}")))
335                .collect()
336        })
337        .unwrap_or_default();
338
339    Composed {
340        ast,
341        registry,
342        diagnostics,
343        without_type_objects,
344        expanded: Some(preprocessed),
345    }
346}
347
348/// The source facet `any` holds now.
349fn ast_source(ast: &Ast, any: zerodds_ast::AnyId) -> Option<&zerodds_ast::SourceFacet> {
350    ast.facets(any).map(|facets| &facets.source)
351}
352
353/// A diagnostic raised on the tree, moved to the expanded text the
354/// preprocessor's map reads ([`zerodds_idl_parse::Parse::source_span`]).
355fn on_the_expanded_text(parsed: &zerodds_idl_parse::Parse, diagnostic: &mut Diagnostic) {
356    diagnostic.primary = parsed.source_span(diagnostic.primary);
357    for (span, _) in &mut diagnostic.labels {
358        *span = parsed.source_span(*span);
359    }
360}
361
362/// The `SourceFacet` of every node `which` selects moved from the tree
363/// to the file it was written in: the tree's range as a range of the
364/// expanded text ([`zerodds_idl_parse::Parse::source_span`]), and that
365/// as the file, the range in it and the macros that wrote it
366/// ([`zerodds_idl_parse::Preprocessed::provenance`]). A node with no
367/// source range, or one before the first token, keeps what it has.
368///
369/// Every placement is computed before the first is written, so a node
370/// two handles reach is placed once from the range lowering gave it and
371/// not again from the range the first placement left.
372fn place_nodes_in_their_files(
373    ast: &mut Ast,
374    parsed: &zerodds_idl_parse::Parse,
375    preprocessed: &zerodds_idl_parse::Preprocessed,
376    which: impl Fn(zerodds_ast::AnyId) -> bool,
377) {
378    let mut placements = Vec::new();
379    for any in ast.all_nodes().filter(|any| which(*any)) {
380        let Some(facets) = ast.facets(any) else {
381            continue;
382        };
383        let Some(span) = facets.source.span else {
384            continue;
385        };
386        if !matches!(facets.source.origin, zerodds_ast::SourceOrigin::File { .. }) {
387            continue;
388        }
389        let expanded = parsed.source_span(zerodds_idl_cst::Span::new(span.start, span.end));
390        if let Some(placed) = preprocessed.provenance(expanded) {
391            placements.push((any, placed));
392        }
393    }
394    for (any, placed) in placements {
395        let Some(facets) = ast.facets_mut(any) else {
396            continue;
397        };
398        facets.source.origin = zerodds_ast::SourceOrigin::File { path: placed.path };
399        facets.source.span = Some(zerodds_ast::ByteSpan {
400            start: placed.span.start,
401            end: placed.span.end,
402        });
403        facets.source.macro_chain = placed
404            .macro_chain
405            .into_iter()
406            .map(|used| zerodds_ast::MacroExpansion {
407                name: used.name,
408                invoked_at: Some(zerodds_ast::ByteSpan {
409                    start: used.at.start,
410                    end: used.at.end,
411                }),
412                path: used.path,
413            })
414            .collect();
415    }
416}
417
418impl ComposeOptions {
419    /// The lowering options these map to.
420    #[must_use]
421    pub fn lowering(&self) -> zerodds_idl_lower::Options {
422        zerodds_idl_lower::Options {
423            default_extensibility: self.default_extensibility,
424            default_nested: self.default_nested,
425            hashed_id_advances_the_counter: self.hashed_id_advances_the_counter,
426            hashed_name_spelling: self.hashed_name_spelling,
427            key_on_union_discriminator: self.key_on_union_discriminator,
428            name_of_the_enclosing_scope: self.name_of_the_enclosing_scope,
429            dialect: self.dialect,
430        }
431    }
432}
433
434/// The recursion IDL 4.2 §7.4.1.4.4.4.4 does not admit.
435///
436/// > A type may only contain itself through a sequence, a map, or an
437/// > `@optional` member — something on the cycle at which a *value* can
438/// > stop.
439///
440/// The rule already existed and already cited the clause: the layout
441/// stage raises `LayoutError::RecursionOutsideCollection`. It sat one
442/// stage past the front end, so `zerodds-idlc check` reported nothing
443/// for `struct Sa { Sa m; };` and only `generate` refused it — somebody
444/// using `check` to ask whether a file is valid IDL did not find out.
445///
446/// It runs here rather than in the CLI so that every caller of the front
447/// end gets the same answer: `check`, a `build.rs` through
448/// `zerodds-build`, and a wire probe all compose the same way, and a
449/// rule that only one of them applies is a rule that surprises the other
450/// two.
451///
452/// **Not a second implementation.**
453/// [`zerodds_ast_layout::types_that_close_an_inadmissible_cycle`] runs
454/// the planner's own walk and keeps only that one error. The barrier
455/// bookkeeping — which of sequence, map or `@optional` lies on the cycle
456/// — lives in the descent, and a rule written twice drifts.
457fn inadmissible_cycles(ast: &Ast) -> Vec<Diagnostic> {
458    // The plain XCDR2 data key. The answer does not depend on it: every
459    // arm that raises this error is about the shape of the type graph
460    // and not about an encoding. A key is required only because `plan`
461    // takes one.
462    let key = zerodds_ast_layout::LayoutKey::data(
463        zerodds_ast_layout::Encoding::PlainCdr2,
464        zerodds_ast_layout::Endianness::Little,
465    );
466    let closing = zerodds_ast_layout::types_that_close_an_inadmissible_cycle(ast, key);
467    // An anonymous type closes the same cycle as the declaration that
468    // contains it, and the walk returns both: `Node pair[2]` is an
469    // `Array { element: Node }` node with no name and no source position,
470    // so `recursive-array` produced two errors, the second reading
471    // `` `::` closes a type cycle `` at offset 0. One cycle is one error,
472    // and the one worth reporting is the one a reader can find in the file.
473    //
474    // Filtered on the source span rather than the name: a scoped name for
475    // an anonymous type is not absent, it is empty, and a rule that reads
476    // an empty string as "anonymous" also swallows a declaration whose
477    // name failed to resolve.
478    //
479    // Kept whole when *no* entry carries a span. A cycle reported without
480    // a position is still a cycle, and reporting none of them would be
481    // exactly the silence this rule exists to break.
482    let declared: Vec<_> = closing
483        .iter()
484        .copied()
485        .filter(|(id, _)| {
486            ast.type_(*id)
487                .is_ok_and(|node| node.facets.source.span.is_some())
488        })
489        .collect();
490    let reported = if declared.is_empty() {
491        closing
492    } else {
493        declared
494    };
495    reported
496        .into_iter()
497        .map(|(id, node_id)| {
498            let name = ast
499                .scoped_name_of(zerodds_ast::AnyId::Type(id, node_id))
500                .map_or_else(|| String::from("a type"), |scoped| scoped.to_string());
501            let span = ast
502                .type_(id)
503                .ok()
504                .and_then(|node| node.facets.source.span)
505                .map_or_else(
506                    || zerodds_idl_cst::Span::new(0, 0),
507                    |span| zerodds_idl_cst::Span::new(span.start, span.end),
508                );
509            Diagnostic::error(
510                zerodds_idl_parse::DiagnosticCode::InvalidBound,
511                span,
512                format!(
513                    "`{name}` closes a type cycle with nothing on it that terminates a value \
514                     — no collection, @optional member or productive @external indirection — \
515                     which OMG IDL 4.2 §7.4.1.4.4.4.4 and XTypes recursion rules do not admit"
516                ),
517                "OMG IDL 4.2 §7.4.1.4.4.4.4",
518            )
519        })
520        .collect()
521}
522
523/// Resolve every pending name, and report each one that does not.
524///
525/// Lowering leaves a name pending rather than failing on it — a forward
526/// declaration depends on that, and so does a name declared later in the
527/// same unit. Something has to run the pass afterwards, and until now
528/// nothing in a production path did: `Ast::resolve_all_by_name` was
529/// called only from tests and from one build script that had found this
530/// out for itself.
531///
532/// What that cost is not subtle. A member whose type lives in another
533/// module stayed unresolved, the generator emitted the type without it,
534/// and the sample went onto the wire missing a field — reported as a
535/// deserialisation error by the peer, which is where it first became
536/// visible. `module common { module util { struct Location …; }; struct
537/// Robot { ::common::util::Location location; }; };` is the shape, and
538/// it is ordinary IDL.
539///
540/// A name that cannot be resolved is an **error** here. It used to be
541/// nothing at all.
542fn resolve(ast: &mut Ast, path: &str) -> Vec<Diagnostic> {
543    let Ok(report) = ast.resolve_all_by_name() else {
544        return vec![Diagnostic {
545            severity: Severity::Error,
546            code: zerodds_idl_parse::DiagnosticCode::UnresolvedName,
547            message: format!("{path}: the model could not run a resolve pass"),
548            primary: zerodds_idl_cst::Span::new(0, 0),
549            labels: Vec::new(),
550            file: None,
551            spec_reference: "OMG IDL 4.2 §7.5",
552        }];
553    };
554    report
555        .broken
556        .iter()
557        .map(|(node, cause)| {
558            // Where the node that names it was written: the range its
559            // `SourceFacet` holds, and its file. In a unit that is the
560            // expanded text still, which the caller then places; in a
561            // bundle every unit's nodes are in their files already.
562            let facets = ast.resolve_node_id(*node).and_then(|any| ast.facets(any));
563            let primary = facets
564                .and_then(|f| f.source.span)
565                .map_or(zerodds_idl_cst::Span::new(0, 0), |span| {
566                    zerodds_idl_cst::Span::new(span.start, span.end)
567                });
568            let file = facets.and_then(|f| match &f.source.origin {
569                zerodds_ast::SourceOrigin::File { path } => Some(path.clone()),
570                _ => None,
571            });
572            Diagnostic {
573                severity: Severity::Error,
574                code: zerodds_idl_parse::DiagnosticCode::UnresolvedName,
575                message: format!("a name does not resolve: {cause:?} (node {node:?})"),
576                primary,
577                labels: Vec::new(),
578                file,
579                spec_reference: "OMG IDL 4.2 §7.5",
580            }
581        })
582        .collect()
583}
584
585/// Compose several units into **one** model.
586///
587/// The units are lowered into a shared `Ast`, so a module two of them
588/// declare is **one node** — and everything downstream sees one. Two
589/// separate compositions produce two models that know nothing of each
590/// other, and a generator run over each of them emits `pub mod common`
591/// twice; two `include!` calls in one Rust scope then collide, which is
592/// what issue #39 reported.
593///
594/// This is the model-level answer and not a textual one. Writing a
595/// synthetic unit full of `#include` lines reaches the same output for
596/// IDL and buys nothing anywhere else: the `Ast` is the common model for
597/// IDL, DDS-XML, ROS and a `TypeObject` off the wire, and only a source
598/// that has an include directive could take part. Node identity is what
599/// merges them here, so a module declared by three sources is one node
600/// whatever those sources were.
601///
602/// Each unit keeps its own diagnostics, in the order given.
603///
604/// # Errors
605/// The read of any unit, naming it.
606pub fn compose_all(
607    paths: &[impl AsRef<std::path::Path>],
608    loader: &dyn FileLoader,
609    options: ComposeOptions,
610) -> Result<Composed, String> {
611    let mut sources = Vec::with_capacity(paths.len());
612    for path in paths {
613        let path = path.as_ref();
614        let name = path.to_string_lossy().into_owned();
615        let text = std::fs::read_to_string(path).map_err(|error| format!("{name}: {error}"))?;
616        sources.push((name, text));
617    }
618    Ok(compose_all_sources(&sources, loader, options))
619}
620
621/// As [`compose_all`], over text the caller already has.
622///
623/// Each entry is `(name, source)`; the name is what a diagnostic points
624/// at.
625#[must_use]
626pub fn compose_all_sources(
627    sources: &[(String, String)],
628    loader: &dyn FileLoader,
629    options: ComposeOptions,
630) -> Composed {
631    let mut ast = Ast::new();
632    let mut diagnostics = Vec::new();
633    for (name, source) in sources {
634        let preprocessed =
635            zerodds_idl_parse::preprocess_with(name, source, loader, &options.defines);
636        let parsed = zerodds_idl_parse::parse_preprocessed(&preprocessed);
637        let mut unit = parsed.diagnostics().to_vec();
638        let expanded_from = preprocessed.diagnostics.len();
639        let tree_from = unit.len();
640        // What every node held before this unit: a node the unit adds is
641        // new, and one it completes — a forward declaration an earlier
642        // unit made — holds a range of this unit's tree afterwards.
643        let before: std::collections::HashMap<zerodds_ast::AnyId, zerodds_ast::SourceFacet> = ast
644            .all_nodes()
645            .filter_map(|any| ast.facets(any).map(|facets| (any, facets.source.clone())))
646            .collect();
647        unit.extend(zerodds_idl_lower::lower_into_with(
648            &mut ast,
649            &parsed.syntax(),
650            name,
651            options.lowering(),
652        ));
653        // This unit's nodes into its files, while its map is in hand: the
654        // nodes of the next unit index another tree.
655        let lowered_here = |any: zerodds_ast::AnyId| match before.get(&any) {
656            None => true,
657            Some(source) => ast_source(&ast, any).is_some_and(|now| now != source),
658        };
659        let here: std::collections::HashSet<zerodds_ast::AnyId> =
660            ast.all_nodes().filter(|any| lowered_here(*any)).collect();
661        place_nodes_in_their_files(&mut ast, &parsed, &preprocessed, |any| here.contains(&any));
662        // Each unit's own diagnostics are placed in its own files and
663        // name the unit, since a bundle has no one file to be "the" unit.
664        for (index, diagnostic) in unit.iter_mut().enumerate() {
665            if index >= tree_from {
666                on_the_expanded_text(&parsed, diagnostic);
667            }
668            if index >= expanded_from {
669                preprocessed.locate(diagnostic);
670            }
671            if diagnostic.file.is_none() {
672                diagnostic.file = Some(name.clone());
673            }
674        }
675        diagnostics.extend(unit);
676    }
677    // One pass over the merged model, after every unit is in it. A name
678    // that reaches across units resolves only here.
679    let path = sources
680        .first()
681        .map_or_else(|| String::from("<bundle>"), |(name, _)| name.clone());
682    diagnostics.extend(resolve(&mut ast, &path));
683
684    let mut registry = TypeRegistry::new();
685    let without_type_objects = zerodds_types::from_ast::register_all(&ast, &mut registry)
686        .map(|skipped| {
687            skipped
688                .into_iter()
689                .map(|(name, error)| (name, format!("{error:?}")))
690                .collect()
691        })
692        .unwrap_or_default();
693
694    Composed {
695        ast,
696        registry,
697        diagnostics,
698        without_type_objects,
699        expanded: None,
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    #![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
706    use super::*;
707    use zerodds_idl_parse::MemoryLoader;
708
709    #[test]
710    fn a_clean_unit_composes_and_registers_its_types() {
711        let composed = compose_source(
712            "test.idl",
713            "module m { @appendable struct S { @key long id; }; };",
714            &MemoryLoader::new(),
715        );
716        assert!(!composed.failed(), "{:?}", composed.diagnostics);
717        assert!(
718            composed.ast.types().any(|(_, node)| node.name == "S"),
719            "the type is in the model"
720        );
721        assert!(
722            composed.without_type_objects.is_empty(),
723            "{:?}",
724            composed.without_type_objects
725        );
726    }
727
728    /// A file with one bad declaration keeps the rest. A pipeline that
729    /// stopped at the first error would cost one build per defect.
730    #[test]
731    fn a_broken_declaration_does_not_discard_the_good_ones() {
732        let composed = compose_source(
733            "test.idl",
734            "module m { struct Good { long a; }; struct Bad { long ; }; };",
735            &MemoryLoader::new(),
736        );
737        assert!(composed.failed(), "the bad one is reported");
738        assert!(
739            composed.ast.types().any(|(_, node)| node.name == "Good"),
740            "the good one survived"
741        );
742    }
743
744    /// A quoted include resolves beside the including file before the
745    /// search list. Two files of the same name, one next to the source
746    /// and one on the search path, must not be interchangeable.
747    #[test]
748    fn a_quoted_include_prefers_the_including_files_directory() {
749        let dir = std::env::temp_dir().join(format!("idl-compose-{}", std::process::id()));
750        let beside = dir.join("beside");
751        let elsewhere = dir.join("elsewhere");
752        std::fs::create_dir_all(&beside).expect("beside");
753        std::fs::create_dir_all(&elsewhere).expect("elsewhere");
754        std::fs::write(beside.join("common.idl"), "struct Near { long a; };").expect("near");
755        std::fs::write(elsewhere.join("common.idl"), "struct Far { long a; };").expect("far");
756        let main = beside.join("main.idl");
757        std::fs::write(&main, "#include \"common.idl\"\nstruct Main { long b; };").expect("main");
758
759        let loader = PathLoader::new(vec![elsewhere.clone()]);
760        let composed = compose(main.to_str().expect("utf8"), &loader).expect("read");
761        assert!(
762            composed.ast.types().any(|(_, node)| node.name == "Near"),
763            "the include beside the file wins"
764        );
765        assert!(
766            !composed.ast.types().any(|(_, node)| node.name == "Far"),
767            "the one on the search path must not"
768        );
769
770        let _ = std::fs::remove_dir_all(&dir);
771    }
772
773    /// The option fills silence and does not overrule the source. This
774    /// is the distinction the whole option rests on: a file that says
775    /// `@final` still says it.
776    #[test]
777    fn the_default_only_reaches_types_the_source_left_open() {
778        use zerodds_ast::Extensibility;
779        let source = "module m { @final struct Said { long a; }; struct Silent { long a; }; };";
780        let composed = compose_source_with(
781            "test.idl",
782            source,
783            &MemoryLoader::new(),
784            ComposeOptions {
785                default_extensibility: Some(Extensibility::Mutable),
786                ..ComposeOptions::default()
787            },
788        );
789        let of = |name: &str| {
790            let (id, _) = composed
791                .ast
792                .types()
793                .find(|(_, node)| node.name == name)
794                .expect("the type");
795            composed.ast.effective_extensibility(id)
796        };
797        assert_eq!(of("Said"), Extensibility::Final, "the source said final");
798        assert_eq!(of("Silent"), Extensibility::Mutable, "the option filled it");
799    }
800
801    /// Without the option, silence stays silence and the specification's
802    /// own default applies.
803    #[test]
804    fn without_the_option_the_specification_default_applies() {
805        use zerodds_ast::Extensibility;
806        let composed = compose_source(
807            "test.idl",
808            "module m { struct Silent { long a; }; };",
809            &MemoryLoader::new(),
810        );
811        let (id, _) = composed
812            .ast
813            .types()
814            .find(|(_, node)| node.name == "Silent")
815            .expect("the type");
816        assert_eq!(
817            composed.ast.effective_extensibility(id),
818            Extensibility::Appendable,
819            "XTypes 1.3 §7.3.1.2.1.8"
820        );
821    }
822
823    /// A define reaches the unit before its first line, so a guard at
824    /// the top sees it.
825    #[test]
826    fn a_define_is_in_place_before_the_first_line() {
827        let composed = compose_source_with(
828            "test.idl",
829            "#ifdef WITH_EXTRA\nstruct Extra { long a; };\n#endif\nstruct Always { long b; };",
830            &MemoryLoader::new(),
831            ComposeOptions {
832                defines: vec![(String::from("WITH_EXTRA"), String::new())],
833                ..ComposeOptions::default()
834            },
835        );
836        assert!(
837            composed.ast.types().any(|(_, node)| node.name == "Extra"),
838            "the guarded declaration is in"
839        );
840
841        let without = compose_source(
842            "test.idl",
843            "#ifdef WITH_EXTRA\nstruct Extra { long a; };\n#endif\nstruct Always { long b; };",
844            &MemoryLoader::new(),
845        );
846        assert!(
847            !without.ast.types().any(|(_, node)| node.name == "Extra"),
848            "and out without the define"
849        );
850    }
851
852    /// The loader records what it opened. A build that is not told about
853    /// an include does not re-run when that include changes, and nothing
854    /// reports the stale output.
855    #[test]
856    fn the_loader_records_every_file_it_opened() {
857        let dir = std::env::temp_dir().join(format!("idl-compose-deps-{}", std::process::id()));
858        std::fs::create_dir_all(&dir).expect("dir");
859        std::fs::write(dir.join("common.idl"), "struct Shared { long a; };").expect("common");
860        let main = dir.join("main.idl");
861        std::fs::write(&main, "#include \"common.idl\"\nstruct Main { long b; };").expect("main");
862
863        let loader = PathLoader::bare();
864        let _ = compose(main.to_str().expect("utf8"), &loader).expect("read");
865        let opened = loader.opened();
866        assert!(
867            opened.iter().any(|p| p.ends_with("common.idl")),
868            "the include is in the list: {opened:?}"
869        );
870
871        let _ = std::fs::remove_dir_all(&dir);
872    }
873
874    #[test]
875    fn a_missing_file_is_an_error_and_names_itself() {
876        let Err(error) = compose("/nonexistent/nope.idl", &PathLoader::bare()) else {
877            panic!("a path that does not exist must not compose")
878        };
879        assert!(error.contains("nope.idl"), "{error}");
880    }
881}