Skip to main content

zerodds_ast/profile/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Profile validation. Spec §8.2.
4//!
5//! The same model may be valid under one profile and invalid under
6//! another — `map<>` exists in XTypes but not in CORBA 3.3, and ROS 2
7//! forbids constructs IDL 4.2 permits. That asymmetry is the point.
8//!
9//! A violation is reported with its node, its rule and the paragraph the
10//! rule comes from. It is never normalised away.
11
12pub mod outside;
13pub mod verdict;
14
15pub use outside::Dropped;
16pub use verdict::{
17    ALL_PROFILES, ConstructKind, FacetProperty, Verdict, construct_verdict, facet_verdict,
18};
19
20use crate::facet::VendorKind;
21use crate::model::Ast;
22use crate::node::AnyId;
23use crate::node::type_::TypeKind;
24use alloc::string::{String, ToString};
25use alloc::vec::Vec;
26use core::fmt;
27use zerodds_ast_core::NodeId;
28
29/// A specification a model can be measured against.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum Profile {
32    /// OMG IDL 4.2 core.
33    Idl42Core,
34    /// DDS-XTypes 1.3.
35    XTypes13,
36    /// CORBA 3.3.
37    Corba33,
38    /// ROS 2 interface definitions.
39    Ros2,
40    /// DDS-XML 1.0.
41    DdsXml,
42    /// The FlatData zero-copy model.
43    FlatData,
44    /// A vendor dialect.
45    Vendor(VendorKind),
46}
47
48impl Profile {
49    /// Every non-vendor profile.
50    pub const ALL: [Self; 6] = [
51        Self::Idl42Core,
52        Self::XTypes13,
53        Self::Corba33,
54        Self::Ros2,
55        Self::DdsXml,
56        Self::FlatData,
57    ];
58
59    /// Profile name, for diagnostics.
60    #[must_use]
61    pub const fn as_str(self) -> &'static str {
62        match self {
63            Self::Idl42Core => "IDL 4.2",
64            Self::XTypes13 => "DDS-XTypes 1.3",
65            Self::Corba33 => "CORBA 3.3",
66            Self::Ros2 => "ROS 2",
67            Self::DdsXml => "DDS-XML 1.0",
68            Self::FlatData => "FlatData",
69            Self::Vendor(_) => "vendor dialect",
70        }
71    }
72}
73
74/// A rule of a profile that a node breaks.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ProfileViolation {
77    /// The offending node.
78    pub node: NodeId,
79    /// Which profile the rule belongs to.
80    pub profile: Profile,
81    /// Short rule name.
82    pub rule: &'static str,
83    /// The specification paragraph the rule comes from.
84    pub spec_reference: &'static str,
85    /// What specifically was wrong.
86    pub detail: String,
87}
88
89impl fmt::Display for ProfileViolation {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(
92            f,
93            "{}: {} ({}) at {:?}: {}",
94            self.profile.as_str(),
95            self.rule,
96            self.spec_reference,
97            self.node,
98            self.detail
99        )
100    }
101}
102
103impl core::error::Error for ProfileViolation {}
104
105impl Ast {
106    /// Measure the model against a profile.
107    ///
108    /// Drives the construct table in [`verdict`], so every type kind and
109    /// every facet property has an explicit ruling. Profile-specific
110    /// checks that are not a simple yes/no on a construct — bit bounds,
111    /// topic resolvability, plain-type reachability — run alongside it.
112    ///
113    /// # Errors
114    /// Every violation found, never only the first.
115    pub fn validate(&self, profile: Profile) -> Result<(), Vec<ProfileViolation>> {
116        let mut out = Vec::new();
117
118        for (id, node) in self.types() {
119            let node_id = node.node_id;
120
121            // FlatData rules on reachability rather than on the type
122            // itself, so it is checked separately below.
123            if profile != Profile::FlatData {
124                let construct = ConstructKind::of(&node.kind);
125                if let Verdict::Rejected {
126                    rule,
127                    spec_reference,
128                    detail,
129                } = construct_verdict(profile, construct)
130                {
131                    out.push(ProfileViolation {
132                        node: node_id,
133                        profile,
134                        rule,
135                        spec_reference,
136                        detail: detail.to_string(),
137                    });
138                }
139            }
140
141            check_facets(self, node.facets_ref(), node_id, profile, &mut out);
142
143            for member in node.kind.members() {
144                let Ok(m) = self.member(*member) else {
145                    continue;
146                };
147                check_facets(self, &m.facets, m.node_id, profile, &mut out);
148            }
149
150            match profile {
151                Profile::XTypes13 => check_xtypes_bounds(self, id, node_id, &mut out),
152                Profile::Corba33 => check_corba_shapes(self, id, node_id, &mut out),
153                Profile::Ros2 => {
154                    let mut seen = alloc::collections::BTreeSet::new();
155                    check_ros_shapes(self, id, node_id, &mut seen, &mut out);
156                }
157                Profile::FlatData => check_flatdata(self, id, node_id, &mut out),
158                _ => {}
159            }
160        }
161
162        if matches!(profile, Profile::DdsXml) {
163            check_dds_xml(self, &mut out);
164        }
165
166        if out.is_empty() { Ok(()) } else { Err(out) }
167    }
168
169    /// Check the completeness rules: nothing left pending, nothing
170    /// broken, every forward declaration given a body.
171    ///
172    /// Deliberately separate from the commit gate: a partially
173    /// discovered model passes every structural invariant and fails
174    /// this. Spec §8.2.
175    ///
176    /// # Errors
177    /// Every incompleteness found.
178    pub fn validate_complete(&self) -> Result<(), Vec<ProfileViolation>> {
179        let mut out = Vec::new();
180        for (_, node) in self.types() {
181            for reference in node.kind.type_refs() {
182                if reference.is_pending() {
183                    out.push(ProfileViolation {
184                        node: node.node_id,
185                        profile: Profile::Idl42Core,
186                        rule: "no unresolved reference",
187                        spec_reference: "zerodds-ast-1.0 §8.2",
188                        detail: "a type reference is still pending".to_string(),
189                    });
190                } else if reference.is_broken() {
191                    out.push(ProfileViolation {
192                        node: node.node_id,
193                        profile: Profile::Idl42Core,
194                        rule: "no broken reference",
195                        spec_reference: "zerodds-ast-1.0 §8.2",
196                        detail: "a type reference could not be resolved".to_string(),
197                    });
198                }
199            }
200            if node.completeness == crate::Completeness::Forward {
201                out.push(ProfileViolation {
202                    node: node.node_id,
203                    profile: Profile::Idl42Core,
204                    rule: "forward declaration defined",
205                    spec_reference: "zerodds-ast-1.0 §8.2",
206                    detail: "declared but never defined".to_string(),
207                });
208            }
209        }
210        for (id, member) in self.members_all() {
211            let _ = id;
212            if member.type_.is_pending() || member.type_.is_broken() {
213                out.push(ProfileViolation {
214                    node: member.node_id,
215                    profile: Profile::Idl42Core,
216                    rule: "no unresolved reference",
217                    spec_reference: "zerodds-ast-1.0 §8.2",
218                    detail: "the member's type is not resolved".to_string(),
219                });
220            }
221        }
222        if out.is_empty() { Ok(()) } else { Err(out) }
223    }
224
225    pub(crate) fn members_all(
226        &self,
227    ) -> impl Iterator<Item = (zerodds_ast_core::Id<crate::MemberNode>, &crate::MemberNode)> {
228        self.members.iter()
229    }
230
231    pub(crate) fn topics_all(
232        &self,
233    ) -> impl Iterator<Item = (zerodds_ast_core::Id<crate::TopicNode>, &crate::TopicNode)> {
234        self.topics.iter()
235    }
236
237    pub(crate) fn qos_profiles_all(
238        &self,
239    ) -> impl Iterator<
240        Item = (
241            zerodds_ast_core::Id<crate::QosProfileNode>,
242            &crate::QosProfileNode,
243        ),
244    > {
245        self.qos_profiles.iter()
246    }
247}
248
249fn violation(
250    node: NodeId,
251    profile: Profile,
252    rule: &'static str,
253    spec_reference: &'static str,
254    detail: &str,
255) -> ProfileViolation {
256    ProfileViolation {
257        node,
258        profile,
259        rule,
260        spec_reference,
261        detail: detail.to_string(),
262    }
263}
264
265/// Rule on every facet property a node carries.
266fn check_facets(
267    ast: &Ast,
268    facets: &crate::Facets,
269    node_id: NodeId,
270    profile: Profile,
271    out: &mut Vec<ProfileViolation>,
272) {
273    let _ = ast;
274    for property in FacetProperty::ALL {
275        if !carries(facets, property) {
276            continue;
277        }
278        if let Verdict::Rejected {
279            rule,
280            spec_reference,
281            detail,
282        } = facet_verdict(profile, property)
283        {
284            out.push(ProfileViolation {
285                node: node_id,
286                profile,
287                rule,
288                spec_reference,
289                detail: detail.to_string(),
290            });
291        }
292    }
293}
294
295/// Whether a node actually carries a facet property.
296fn carries(facets: &crate::Facets, property: FacetProperty) -> bool {
297    let xtypes = facets.xtypes.as_ref();
298    match property {
299        // Both positions Table 21 gives `@key`: a structure member, and
300        // a union discriminator. A check that looked only at the member
301        // would let `union U switch (@key K)` through a profile that
302        // refuses keys altogether.
303        FacetProperty::Key => facets.is_key() || xtypes.is_some_and(|x| x.discriminator_is_key),
304        FacetProperty::Optional => facets.is_optional(),
305        FacetProperty::External => xtypes.is_some_and(|x| x.is_external),
306        FacetProperty::MustUnderstand => facets.is_must_understand(),
307        FacetProperty::NonSerialized => xtypes.is_some_and(|x| x.is_non_serialized),
308        // Any of the four positions counts. A profile that refuses
309        // `@try_construct` refuses it wherever it was written, and a
310        // check that looked only at the member position would let
311        // `sequence<@try_construct(TRIM) T, N>` through a CORBA profile
312        // that has no such concept.
313        FacetProperty::TryConstruct => xtypes.is_some_and(|x| {
314            x.try_construct.is_some()
315                || x.element_try_construct.is_some()
316                || x.key_try_construct.is_some()
317                || x.discriminator_try_construct.is_some()
318        }),
319        FacetProperty::NonFinalExtensibility => matches!(
320            facets.extensibility(),
321            Some(crate::Extensibility::Appendable | crate::Extensibility::Mutable)
322        ),
323        // The *origin* and not the presence. Lowering fills a member id
324        // in for every member, because XTypes requires one; the default
325        // rule is the previous member's id plus one and no source ever
326        // wrote it. Asking `member_id.is_some()` therefore rejected
327        // every aggregate ever lowered — `struct Sample { long a; }`
328        // came back with "member ids were introduced by XTypes", once
329        // per member, and CORBA 3.3 admitted 2 of the 376 corpus cases
330        // where the doc comment on [`FacetProperty::ExplicitMemberId`]
331        // says the property is `@id`, `@hashid`, `@autoid`.
332        FacetProperty::ExplicitMemberId => xtypes.is_some_and(|x| {
333            x.member_id.as_ref().is_some_and(|id| {
334                !matches!(
335                    id.origin,
336                    crate::facet::xtypes::MemberIdOrigin::PreviousPlusOne
337                )
338            })
339        }),
340        FacetProperty::DefaultValue => xtypes.is_some_and(|x| x.default_value.is_some()),
341        FacetProperty::RangeConstraint => xtypes.is_some_and(|x| x.range.is_some()),
342        FacetProperty::Unit => xtypes.is_some_and(|x| x.unit.is_some()),
343        FacetProperty::Verbatim => xtypes.is_some_and(|x| !x.verbatim.is_empty()),
344        FacetProperty::Ros2DefaultValue => facets
345            .ros2
346            .as_ref()
347            .is_some_and(|r| r.default_value.is_some()),
348        FacetProperty::VendorDirective => !facets.vendor.is_empty(),
349    }
350}
351
352/// Bit-bound rules, which are a range check rather than a yes/no on a
353/// construct. DDS-XTypes 1.3 §7.3.1.2.1.9 and §7.3.1.2.1.10.
354/// Three CORBA rules the construct table cannot express, because they
355/// are about a construct's *shape* rather than its kind.
356///
357/// `ConstructKind` has one `Structure` for a structure with a base and
358/// one without, and no category at all for an enumeration's bit bound
359/// or an enumerator's value. So the table admitted all three into
360/// CORBA 3.3 while `crates/wire-probe/src/corba_idl.rs` — the tool
361/// that actually rewrites a type into CORBA IDL — refused them by
362/// name. Two opinions about one specification, and the laxer one was
363/// the instrument used to decide whether a case belonged to a column
364/// at all.
365///
366/// The rules are the rewriter's, with its clauses:
367///
368/// - CORBA 3.3 part 1 §7.4.1.4.4.2.2 has no syntax for a structure
369///   with a base. Only a valuetype inherits.
370/// - part 2 §9.3.2.5 makes an enumeration an unsigned long whose value
371///   is the enumerator's ordinal position, so `@bit_bound` and
372///   `@value` are both XTypes and both change the wire.
373fn check_corba_shapes(
374    ast: &Ast,
375    id: zerodds_ast_core::Id<crate::TypeNode>,
376    node_id: NodeId,
377    out: &mut Vec<ProfileViolation>,
378) {
379    let Ok(node) = ast.type_(id) else {
380        return;
381    };
382    match &node.kind {
383        TypeKind::Structure { base: Some(_), .. } => out.push(violation(
384            node_id,
385            Profile::Corba33,
386            "a structure with no base",
387            "CORBA 3.3 part 1 §7.4.1.4.4.2.2",
388            "CORBA IDL has no syntax for a structure with a base",
389        )),
390        TypeKind::Enumeration { .. } => {
391            if ast.effective_bit_bound(id) != 32 {
392                out.push(violation(
393                    node_id,
394                    Profile::Corba33,
395                    "an enumeration in CORBA's holder",
396                    "CORBA 3.3 part 2 §9.3.2.5",
397                    "an enumeration with a @bit_bound, where CORBA's holder is \
398                     always a 32-bit unsigned long",
399                ));
400            }
401            for (position, enumerator) in ast.enumerators_of(id).iter().enumerate() {
402                let ordinal = i128::try_from(position).unwrap_or(i128::MAX);
403                if ast.effective_enumerator_value(*enumerator) != Some(ordinal) {
404                    out.push(violation(
405                        node_id,
406                        Profile::Corba33,
407                        "an enumerator at its ordinal",
408                        "CORBA 3.3 part 2 §9.3.2.5",
409                        "an enumerator with an explicit @value, where CORBA's is \
410                         its ordinal position",
411                    ));
412                    break;
413                }
414            }
415        }
416        _ => {}
417    }
418}
419
420/// Two ROS rules the construct table cannot express, for the same
421/// reason as [`check_corba_shapes`]: they are about a construct's
422/// shape and `ConstructKind` has one category for both shapes.
423///
424/// Both are `crates/wire-probe/src/ros_idl.rs`'s, in its words. A
425/// `wchar` inside a collection is deliberately **not** here: that one
426/// refuses because `rosidl_typesupport_fastrtps` generates C++ that
427/// does not compile, which is a defect in a generator and not an
428/// absence in the specification — the profile is right to admit it and
429/// the finding belongs to rosidl.
430/// How far a chain of typedefs is followed before it is taken for a
431/// cycle, matching the depth the resolver uses.
432const MAX_ALIAS_DEPTH: usize = 64;
433
434/// zerodds-lint: recursion-depth = distinct reachable type nodes (the
435/// `seen` set admits every `Id<TypeNode>` once, so the descent cannot
436/// re-enter a type and the AST's node count bounds it)
437fn check_ros_shapes(
438    ast: &Ast,
439    id: zerodds_ast_core::Id<crate::TypeNode>,
440    node_id: NodeId,
441    seen: &mut alloc::collections::BTreeSet<zerodds_ast_core::Id<crate::TypeNode>>,
442    out: &mut Vec<ProfileViolation>,
443) {
444    // A recursive type — `struct Node { @optional @external Node next;
445    // … }` — reaches itself through its members; without this the walk
446    // was the stack overflow `profile_matches_the_rewriter` died of,
447    // not a verdict of any kind.
448    if !seen.insert(id) {
449        return;
450    }
451    let Ok(node) = ast.type_(id) else {
452        return;
453    };
454    // A member's collection is often anonymous — `sequence<long>
455    // rows[2];` declares an array on the member and no type of its own
456    // — so the members are walked as well as the types. Without it the
457    // rule fired for a named collection and not for the shape a corpus
458    // case actually writes.
459    if let TypeKind::Structure { members, .. } = &node.kind {
460        for member in members {
461            let Ok(member_node) = ast.member(*member) else {
462                continue;
463            };
464            if let Some(inner) = member_node.type_.resolved() {
465                check_ros_shapes(ast, inner, node_id, seen, out);
466            }
467        }
468    }
469    match &node.kind {
470        TypeKind::Structure { base: Some(_), .. } => out.push(violation(
471            node_id,
472            Profile::Ros2,
473            "a structure with no base",
474            "ROS 2 interface definition rules",
475            "rosidl's IDL grammar has no syntax for a struct with a base",
476        )),
477        TypeKind::Sequence { element, .. } | TypeKind::Array { element, .. } => {
478            // Through the aliases: `typedef sequence<long> Ids; Ids
479            // rows[3];` is an array whose element is an alias, and
480            // stopping at the alias missed the shape the corpus
481            // actually writes.
482            let mut current = element.resolved();
483            let mut nested = false;
484            for _ in 0..MAX_ALIAS_DEPTH {
485                let Some(inner) = current else { break };
486                let Ok(node) = ast.type_(inner) else { break };
487                match &node.kind {
488                    TypeKind::Sequence { .. } | TypeKind::Array { .. } | TypeKind::Map { .. } => {
489                        nested = true;
490                        break;
491                    }
492                    TypeKind::Alias { aliased } => current = aliased.resolved(),
493                    _ => break,
494                }
495            }
496            if nested {
497                out.push(violation(
498                    node_id,
499                    Profile::Ros2,
500                    "a sequence of a non-collection",
501                    "ROS 2 interface definition rules",
502                    "a collection whose element is itself a collection, which rosidl \
503                     has no shape for",
504                ));
505            }
506        }
507        _ => {}
508    }
509}
510
511fn check_xtypes_bounds(
512    ast: &Ast,
513    id: zerodds_ast_core::Id<crate::TypeNode>,
514    node_id: NodeId,
515    out: &mut Vec<ProfileViolation>,
516) {
517    let Ok(node) = ast.type_(id) else {
518        return;
519    };
520    let bit_bound = node.facets.xtypes.as_ref().and_then(|x| x.bit_bound);
521
522    match &node.kind {
523        TypeKind::Enumeration { .. } => {
524            if let Some(bound) = bit_bound {
525                if bound == 0 || bound > 32 {
526                    out.push(violation(
527                        node_id,
528                        Profile::XTypes13,
529                        "enum bit bound in 1..=32",
530                        "DDS-XTypes 1.3 §7.3.1.2.1.9",
531                        "an enumeration's @bit_bound must be between 1 and 32",
532                    ));
533                }
534            }
535        }
536        TypeKind::Bitmask { bits } => {
537            let bound = bit_bound.unwrap_or(32);
538            if bound == 0 || bound > 64 {
539                out.push(violation(
540                    node_id,
541                    Profile::XTypes13,
542                    "bitmask bit bound in 1..=64",
543                    "DDS-XTypes 1.3 §7.3.1.2.1.10",
544                    "a bitmask's @bit_bound must be between 1 and 64",
545                ));
546            }
547            for bit in bits {
548                let Ok(b) = ast.bit_value(*bit) else {
549                    continue;
550                };
551                if let Some(position) = b.position {
552                    if position >= bound {
553                        out.push(violation(
554                            b.node_id,
555                            Profile::XTypes13,
556                            "bit position within bound",
557                            "DDS-XTypes 1.3 §7.3.1.2.1.10",
558                            "@position is at or beyond the bitmask's bit bound",
559                        ));
560                    }
561                }
562            }
563        }
564        _ => {}
565    }
566}
567
568/// FlatData rules on what a type can *reach*, not on the type itself, so
569/// it walks the graph instead of consulting the table directly.
570fn check_flatdata(
571    ast: &Ast,
572    id: zerodds_ast_core::Id<crate::TypeNode>,
573    node_id: NodeId,
574    out: &mut Vec<ProfileViolation>,
575) {
576    let Ok(node) = ast.type_(id) else {
577        return;
578    };
579    // Only a type the frontend marked plain is held to the rule; one
580    // that never claimed to be loanable is not in scope.
581    if !node
582        .facets
583        .flatdata
584        .as_ref()
585        .is_some_and(|f| f.is_plain || f.explicit_fixed_layout)
586    {
587        return;
588    }
589
590    for entry in ast.descendants(AnyId::Type(id, node_id)) {
591        let Some(type_id) = entry.as_type() else {
592            continue;
593        };
594        let Ok(reachable) = ast.type_(type_id) else {
595            continue;
596        };
597        let construct = ConstructKind::of(&reachable.kind);
598        if let Verdict::Rejected {
599            rule,
600            spec_reference,
601            detail,
602        } = construct_verdict(Profile::FlatData, construct)
603        {
604            out.push(ProfileViolation {
605                node: node_id,
606                profile: Profile::FlatData,
607                rule,
608                spec_reference,
609                detail: detail.to_string(),
610            });
611        }
612        check_facets(ast, &reachable.facets, node_id, Profile::FlatData, out);
613        for member in reachable.kind.members() {
614            if let Ok(m) = ast.member(*member) {
615                check_facets(ast, &m.facets, node_id, Profile::FlatData, out);
616            }
617        }
618    }
619}
620
621fn check_dds_xml(ast: &Ast, out: &mut Vec<ProfileViolation>) {
622    for (id, topic) in ast.topics_all() {
623        let _ = id;
624        if !topic.type_.is_resolved() {
625            out.push(violation(
626                topic.node_id,
627                Profile::DdsXml,
628                "topic type resolvable",
629                "DDS-XML 1.0 §7.3",
630                "a topic must name a type the model holds",
631            ));
632        }
633    }
634
635    // A QoS profile's inheritance chain must terminate.
636    for (id, profile) in ast.qos_profiles_all() {
637        let mut seen = alloc::collections::BTreeSet::new();
638        let _ = seen.insert(profile.node_id);
639        let mut current = profile
640            .base
641            .as_ref()
642            .and_then(zerodds_ast_core::Ref::resolved);
643        let mut cyclic = false;
644        while let Some(next) = current {
645            let Ok(node) = ast.qos_profile(next) else {
646                break;
647            };
648            if !seen.insert(node.node_id) {
649                cyclic = true;
650                break;
651            }
652            current = node.base.as_ref().and_then(zerodds_ast_core::Ref::resolved);
653        }
654        if cyclic {
655            let _ = id;
656            out.push(violation(
657                profile.node_id,
658                Profile::DdsXml,
659                "acyclic profile inheritance",
660                "DDS-XML 1.0 §7.5",
661                "the profile's base chain closes a cycle",
662            ));
663        }
664    }
665}