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}