Skip to main content

zerodds_rpc/
annotations.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! DDS-RPC IDL-Annotations — Spec §7.3.
5//!
6//! Lowering der RPC-spezifischen Annotations:
7//!
8//! * `@service(name="<svc>")` — markiert eine Interface-Definition als
9//!   RPC-Service. Wenn `name` fehlt, wird der Interface-Name verwendet.
10//! * `@oneway` — Methode ohne Reply-Erwartung. Aequivalent zum nativen
11//!   IDL-`oneway`-Keyword (das im AST bereits durch `OpDecl::oneway`
12//!   abgebildet ist).
13//! * `@in`, `@out`, `@inout` — explizite Parameter-Direction. Aequivalent
14//!   zu den nativen IDL-Direction-Keywords (`ParamAttribute::In/Out/InOut`).
15//!
16//! Architektur-Entscheidung: das `zerodds-idl`-Crate kennt bewusst nur die
17//! Standard-XTypes-Annotations (siehe Memo `project_codegen_templates_scope`
18//! — RPC-Specifics gehoeren nicht ins idl-Crate). Diese Bridge konsumiert
19//! die generischen `Annotation`-Werte aus dem AST und lowert sie zu
20//! `RpcAnnotation`-Variants.
21
22extern crate alloc;
23
24use alloc::string::{String, ToString};
25use alloc::vec::Vec;
26
27use zerodds_idl::ast::{Annotation, AnnotationParams, ConstExpr, LiteralKind, NamedParam};
28
29/// Typisierte Repraesentation der RPC-spezifischen Builtin-Annotations.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum RpcAnnotation {
32    /// `@service` oder `@service(name="...")`.
33    Service {
34        /// Optional `name="..."`-Attribut.
35        name: Option<String>,
36    },
37    /// `@oneway` (Annotation-Form; orthogonal zum AST-`oneway`-Keyword).
38    Oneway,
39    /// `@in` (Annotation-Form; orthogonal zu `ParamAttribute::In`).
40    In,
41    /// `@out`.
42    Out,
43    /// `@inout`.
44    InOut,
45    /// `@RPCInterfaceQos(profile="<lib::profile>")` (Spec §7.10.1).
46    /// Verweist auf ein QoS-Profile aus einer XML-QoS-Library, das
47    /// auf Service-Endpoints (Request-Writer/Reader, Reply-
48    /// Writer/Reader) angewendet wird.
49    InterfaceQos {
50        /// `lib::profile`-Verweis (Spec-Konvention).
51        profile_ref: String,
52    },
53    /// `@DDSRequestTopic("<topic>")` (Spec §7.4.2.2).
54    /// Override des Default-Request-Topic-Namens.
55    DdsRequestTopic(String),
56    /// `@DDSReplyTopic("<topic>")` (Spec §7.4.2.2).
57    DdsReplyTopic(String),
58    /// `@RPCRequestType` (Spec §7.3.1.4).
59    /// Markiert einen IDL-Struct als Pair-of-Types Request-Type.
60    RpcRequestType,
61    /// `@RPCReplyType` (Spec §7.3.1.4).
62    RpcReplyType,
63    /// `@RPCRequest` (Spec §7.3.1.2 Enhanced).
64    /// Markiert eine IDL-Operation-Inputs-Struktur.
65    RpcRequest,
66    /// `@RPCReply` (Spec §7.3.1.2 Enhanced).
67    RpcReply,
68}
69
70/// Ergebnis eines RPC-Annotation-Lowerings — getrennte Listen fuer
71/// erkannte und durchgereichte (unbekannte) Annotations.
72#[derive(Debug, Clone, PartialEq, Default)]
73pub struct LoweredRpc {
74    /// Erkannte RPC-Builtins.
75    pub builtins: Vec<RpcAnnotation>,
76    /// Nicht erkannt (vendor-spezifisch oder XTypes-Builtin).
77    pub custom: Vec<Annotation>,
78}
79
80impl LoweredRpc {
81    /// `true` wenn `@service` (mit oder ohne Argument) gesetzt ist.
82    #[must_use]
83    pub fn is_service(&self) -> bool {
84        self.builtins
85            .iter()
86            .any(|a| matches!(a, RpcAnnotation::Service { .. }))
87    }
88
89    /// Liefert den expliziten `@service(name="...")`-Wert, falls gesetzt.
90    #[must_use]
91    pub fn service_name(&self) -> Option<&str> {
92        self.builtins.iter().find_map(|a| match a {
93            RpcAnnotation::Service { name: Some(n) } => Some(n.as_str()),
94            _ => None,
95        })
96    }
97
98    /// `true` wenn `@oneway`-Annotation gesetzt ist.
99    #[must_use]
100    pub fn has_oneway(&self) -> bool {
101        self.builtins
102            .iter()
103            .any(|a| matches!(a, RpcAnnotation::Oneway))
104    }
105
106    /// Spec §7.10.1: liefert das `@RPCInterfaceQos(profile=...)`-Argument.
107    #[must_use]
108    pub fn interface_qos_profile(&self) -> Option<&str> {
109        self.builtins.iter().find_map(|a| match a {
110            RpcAnnotation::InterfaceQos { profile_ref } => Some(profile_ref.as_str()),
111            _ => None,
112        })
113    }
114
115    /// Spec §7.4.2.2: liefert den `@DDSRequestTopic`-Override.
116    #[must_use]
117    pub fn dds_request_topic(&self) -> Option<&str> {
118        self.builtins.iter().find_map(|a| match a {
119            RpcAnnotation::DdsRequestTopic(t) => Some(t.as_str()),
120            _ => None,
121        })
122    }
123
124    /// Spec §7.4.2.2: liefert den `@DDSReplyTopic`-Override.
125    #[must_use]
126    pub fn dds_reply_topic(&self) -> Option<&str> {
127        self.builtins.iter().find_map(|a| match a {
128            RpcAnnotation::DdsReplyTopic(t) => Some(t.as_str()),
129            _ => None,
130        })
131    }
132
133    /// Spec §7.3.1.4: `true` wenn `@RPCRequestType` gesetzt.
134    #[must_use]
135    pub fn is_rpc_request_type(&self) -> bool {
136        self.builtins
137            .iter()
138            .any(|a| matches!(a, RpcAnnotation::RpcRequestType))
139    }
140
141    /// Spec §7.3.1.4: `true` wenn `@RPCReplyType` gesetzt.
142    #[must_use]
143    pub fn is_rpc_reply_type(&self) -> bool {
144        self.builtins
145            .iter()
146            .any(|a| matches!(a, RpcAnnotation::RpcReplyType))
147    }
148
149    /// Spec §7.3.1.2: `true` wenn `@RPCRequest` gesetzt.
150    #[must_use]
151    pub fn is_rpc_request(&self) -> bool {
152        self.builtins
153            .iter()
154            .any(|a| matches!(a, RpcAnnotation::RpcRequest))
155    }
156
157    /// Spec §7.3.1.2: `true` wenn `@RPCReply` gesetzt.
158    #[must_use]
159    pub fn is_rpc_reply(&self) -> bool {
160        self.builtins
161            .iter()
162            .any(|a| matches!(a, RpcAnnotation::RpcReply))
163    }
164}
165
166fn name_tail(a: &Annotation) -> &str {
167    a.name
168        .parts
169        .last()
170        .map(|p| p.text.as_str())
171        .unwrap_or_default()
172}
173
174fn const_to_string(expr: &ConstExpr) -> Option<String> {
175    if let ConstExpr::Literal(l) = expr {
176        let s = l.raw.as_str();
177        if matches!(l.kind, LiteralKind::String) {
178            return Some(
179                s.strip_prefix('"')
180                    .and_then(|s| s.strip_suffix('"'))
181                    .unwrap_or(s)
182                    .to_string(),
183            );
184        }
185        return Some(s.to_string());
186    }
187    None
188}
189
190fn extract_named<'a>(named: &'a [NamedParam], key: &str) -> Option<&'a ConstExpr> {
191    named.iter().find(|p| p.name.text == key).map(|p| &p.value)
192}
193
194/// Lower eine einzelne Annotation auf ihre RPC-typisierte Form.
195/// Liefert `None` wenn sie kein RPC-Builtin ist (Caller stellt sie dann
196/// in `LoweredRpc::custom` ab).
197#[must_use]
198pub fn lower_single(ann: &Annotation) -> Option<RpcAnnotation> {
199    let name = name_tail(ann);
200    match name {
201        "service" => {
202            let svc_name = match &ann.params {
203                AnnotationParams::None | AnnotationParams::Empty => None,
204                AnnotationParams::Single(e) => const_to_string(e),
205                AnnotationParams::Named(named) => {
206                    extract_named(named, "name").and_then(const_to_string)
207                }
208            };
209            Some(RpcAnnotation::Service { name: svc_name })
210        }
211        "oneway" => Some(RpcAnnotation::Oneway),
212        "in" => Some(RpcAnnotation::In),
213        "out" => Some(RpcAnnotation::Out),
214        "inout" => Some(RpcAnnotation::InOut),
215        "RPCInterfaceQos" => {
216            let profile_ref = match &ann.params {
217                AnnotationParams::Single(e) => const_to_string(e),
218                AnnotationParams::Named(named) => {
219                    extract_named(named, "profile").and_then(const_to_string)
220                }
221                _ => None,
222            }?;
223            Some(RpcAnnotation::InterfaceQos { profile_ref })
224        }
225        "DDSRequestTopic" => {
226            let topic = match &ann.params {
227                AnnotationParams::Single(e) => const_to_string(e),
228                AnnotationParams::Named(named) => {
229                    extract_named(named, "name").and_then(const_to_string)
230                }
231                _ => None,
232            }?;
233            Some(RpcAnnotation::DdsRequestTopic(topic))
234        }
235        "DDSReplyTopic" => {
236            let topic = match &ann.params {
237                AnnotationParams::Single(e) => const_to_string(e),
238                AnnotationParams::Named(named) => {
239                    extract_named(named, "name").and_then(const_to_string)
240                }
241                _ => None,
242            }?;
243            Some(RpcAnnotation::DdsReplyTopic(topic))
244        }
245        "RPCRequestType" => Some(RpcAnnotation::RpcRequestType),
246        "RPCReplyType" => Some(RpcAnnotation::RpcReplyType),
247        "RPCRequest" => Some(RpcAnnotation::RpcRequest),
248        "RPCReply" => Some(RpcAnnotation::RpcReply),
249        _ => None,
250    }
251}
252
253/// Lower eine Annotation-Liste in das RPC-Builtin-Modell.
254#[must_use]
255pub fn lower_rpc_annotations(anns: &[Annotation]) -> LoweredRpc {
256    let mut out = LoweredRpc::default();
257    for a in anns {
258        match lower_single(a) {
259            Some(b) => out.builtins.push(b),
260            None => out.custom.push(a.clone()),
261        }
262    }
263    out
264}
265
266#[cfg(test)]
267#[allow(clippy::unwrap_used, clippy::expect_used)]
268mod tests {
269    use super::*;
270    use zerodds_idl::ast::{Identifier, Literal, ScopedName};
271    use zerodds_idl::errors::Span;
272
273    fn sp() -> Span {
274        Span::SYNTHETIC
275    }
276
277    fn ident(t: &str) -> Identifier {
278        Identifier::new(t, sp())
279    }
280
281    fn scoped(parts: &[&str]) -> ScopedName {
282        ScopedName {
283            absolute: false,
284            parts: parts.iter().map(|p| ident(p)).collect(),
285            span: sp(),
286        }
287    }
288
289    fn lit(kind: LiteralKind, raw: &str) -> ConstExpr {
290        ConstExpr::Literal(Literal {
291            kind,
292            raw: raw.to_string(),
293            span: sp(),
294        })
295    }
296
297    fn ann(name: &str, params: AnnotationParams) -> Annotation {
298        Annotation {
299            name: scoped(&[name]),
300            params,
301            span: sp(),
302        }
303    }
304
305    #[test]
306    fn service_no_args_lowers_without_name() {
307        let a = lower_single(&ann("service", AnnotationParams::None));
308        assert_eq!(a, Some(RpcAnnotation::Service { name: None }));
309    }
310
311    #[test]
312    fn service_named_arg_lowers_with_name() {
313        let a = lower_single(&ann(
314            "service",
315            AnnotationParams::Named(alloc::vec![NamedParam {
316                name: ident("name"),
317                value: lit(LiteralKind::String, "\"Calculator\""),
318                span: sp(),
319            }]),
320        ));
321        assert_eq!(
322            a,
323            Some(RpcAnnotation::Service {
324                name: Some("Calculator".into())
325            })
326        );
327    }
328
329    #[test]
330    fn service_single_string_arg_lowers_with_name() {
331        // Toleranter Pfad: @service("Foo") wird wie name="Foo" behandelt.
332        let a = lower_single(&ann(
333            "service",
334            AnnotationParams::Single(lit(LiteralKind::String, "\"Foo\"")),
335        ));
336        assert_eq!(
337            a,
338            Some(RpcAnnotation::Service {
339                name: Some("Foo".into())
340            })
341        );
342    }
343
344    #[test]
345    fn service_named_arg_with_unknown_key_yields_none_name() {
346        let a = lower_single(&ann(
347            "service",
348            AnnotationParams::Named(alloc::vec![NamedParam {
349                name: ident("ignored"),
350                value: lit(LiteralKind::String, "\"X\""),
351                span: sp(),
352            }]),
353        ));
354        assert_eq!(a, Some(RpcAnnotation::Service { name: None }));
355    }
356
357    #[test]
358    fn oneway_lowers() {
359        assert_eq!(
360            lower_single(&ann("oneway", AnnotationParams::None)),
361            Some(RpcAnnotation::Oneway)
362        );
363    }
364
365    #[test]
366    fn in_out_inout_lower() {
367        assert_eq!(
368            lower_single(&ann("in", AnnotationParams::None)),
369            Some(RpcAnnotation::In)
370        );
371        assert_eq!(
372            lower_single(&ann("out", AnnotationParams::None)),
373            Some(RpcAnnotation::Out)
374        );
375        assert_eq!(
376            lower_single(&ann("inout", AnnotationParams::None)),
377            Some(RpcAnnotation::InOut)
378        );
379    }
380
381    #[test]
382    fn unknown_annotation_returns_none() {
383        let a = lower_single(&ann("xtypes_builtin", AnnotationParams::None));
384        assert!(a.is_none());
385    }
386
387    #[test]
388    fn lower_rpc_annotations_splits_builtins_and_custom() {
389        let anns = alloc::vec![
390            ann("service", AnnotationParams::None),
391            ann("topic", AnnotationParams::None), // XTypes-builtin, nicht RPC
392            ann("oneway", AnnotationParams::None),
393        ];
394        let lowered = lower_rpc_annotations(&anns);
395        assert_eq!(lowered.builtins.len(), 2);
396        assert_eq!(lowered.custom.len(), 1);
397        assert!(lowered.is_service());
398        assert!(lowered.has_oneway());
399    }
400
401    #[test]
402    fn service_name_resolved_via_helper() {
403        let anns = alloc::vec![ann(
404            "service",
405            AnnotationParams::Named(alloc::vec![NamedParam {
406                name: ident("name"),
407                value: lit(LiteralKind::String, "\"Bar\""),
408                span: sp(),
409            }])
410        )];
411        let lowered = lower_rpc_annotations(&anns);
412        assert_eq!(lowered.service_name(), Some("Bar"));
413    }
414
415    #[test]
416    fn service_without_name_yields_none_helper() {
417        let anns = alloc::vec![ann("service", AnnotationParams::None)];
418        let lowered = lower_rpc_annotations(&anns);
419        assert!(lowered.is_service());
420        assert_eq!(lowered.service_name(), None);
421    }
422
423    // ---- §7.10.1 @RPCInterfaceQos -----------------------------------
424
425    #[test]
426    fn rpc_interface_qos_with_named_profile_lowers() {
427        let a = lower_single(&ann(
428            "RPCInterfaceQos",
429            AnnotationParams::Named(alloc::vec![NamedParam {
430                name: ident("profile"),
431                value: lit(LiteralKind::String, "\"Lib1::Reliable\""),
432                span: sp(),
433            }]),
434        ));
435        assert_eq!(
436            a,
437            Some(RpcAnnotation::InterfaceQos {
438                profile_ref: "Lib1::Reliable".into()
439            })
440        );
441    }
442
443    #[test]
444    fn rpc_interface_qos_single_string_arg_lowers() {
445        let a = lower_single(&ann(
446            "RPCInterfaceQos",
447            AnnotationParams::Single(lit(LiteralKind::String, "\"Lib::P\"")),
448        ));
449        assert_eq!(
450            a,
451            Some(RpcAnnotation::InterfaceQos {
452                profile_ref: "Lib::P".into()
453            })
454        );
455    }
456
457    #[test]
458    fn interface_qos_profile_resolved_via_helper() {
459        let anns = alloc::vec![ann(
460            "RPCInterfaceQos",
461            AnnotationParams::Single(lit(LiteralKind::String, "\"Lib::Foo\""))
462        )];
463        let lowered = lower_rpc_annotations(&anns);
464        assert_eq!(lowered.interface_qos_profile(), Some("Lib::Foo"));
465    }
466
467    // ---- §7.4.2.2 @DDSRequestTopic / @DDSReplyTopic ----------------
468
469    #[test]
470    fn dds_request_topic_lowers_and_resolves() {
471        let anns = alloc::vec![ann(
472            "DDSRequestTopic",
473            AnnotationParams::Single(lit(LiteralKind::String, "\"MyReqTopic\""))
474        )];
475        let lowered = lower_rpc_annotations(&anns);
476        assert_eq!(lowered.dds_request_topic(), Some("MyReqTopic"));
477    }
478
479    #[test]
480    fn dds_reply_topic_lowers_and_resolves() {
481        let anns = alloc::vec![ann(
482            "DDSReplyTopic",
483            AnnotationParams::Single(lit(LiteralKind::String, "\"MyRepTopic\""))
484        )];
485        let lowered = lower_rpc_annotations(&anns);
486        assert_eq!(lowered.dds_reply_topic(), Some("MyRepTopic"));
487    }
488
489    // ---- §7.3.1.4 Pair of Types: @RPCRequestType / @RPCReplyType ----
490
491    #[test]
492    fn rpc_request_type_lowers_and_resolves() {
493        let anns = alloc::vec![ann("RPCRequestType", AnnotationParams::None)];
494        let lowered = lower_rpc_annotations(&anns);
495        assert!(lowered.is_rpc_request_type());
496        assert!(!lowered.is_rpc_reply_type());
497    }
498
499    #[test]
500    fn rpc_reply_type_lowers_and_resolves() {
501        let anns = alloc::vec![ann("RPCReplyType", AnnotationParams::None)];
502        let lowered = lower_rpc_annotations(&anns);
503        assert!(lowered.is_rpc_reply_type());
504    }
505
506    // ---- §7.3.1.2 Enhanced @RPCRequest / @RPCReply -----------------
507
508    #[test]
509    fn rpc_request_and_reply_lower_and_resolve() {
510        let anns = alloc::vec![
511            ann("RPCRequest", AnnotationParams::None),
512            ann("RPCReply", AnnotationParams::None),
513        ];
514        let lowered = lower_rpc_annotations(&anns);
515        assert!(lowered.is_rpc_request());
516        assert!(lowered.is_rpc_reply());
517    }
518}