Skip to main content

zerodds_idl_python/
emitter.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! IDL → Python codegen — emit logic.
5//!
6//! Generates, for an IDL specification, a Python module that uses the
7//! `@idl_struct(typename=...)`/`@dataclass` convention of the
8//! `zerodds-py` crate. Generated classes are usable directly with
9//! `zerodds.IdlTopic(MyClass)`.
10
11use std::collections::BTreeSet;
12use std::fmt::Write as _;
13
14use zerodds_idl::ast::types::{
15    BinaryOp, BitmaskDecl, BitsetDecl, CaseLabel, ConstDecl, ConstExpr, ConstType, ConstrTypeDecl,
16    Declarator, Definition, EnumDef, ExceptDecl, FloatingType, Identifier, IntegerType, Literal,
17    LiteralKind, Member, ModuleDef, PrimitiveType, ScopedName, SequenceType, Specification,
18    StringType, StructDcl, StructDef, SwitchTypeSpec, TypeDecl, TypeSpec, TypedefDecl, UnaryOp,
19    UnionDcl, UnionDef,
20};
21use zerodds_idl::semantics::annotations::{
22    BuiltinAnnotation, ExtensibilityKind, lower_annotations,
23};
24
25use crate::error::{IdlPythonError, Result};
26
27/// Codegen options for Python modules.
28#[derive(Debug, Clone, Default)]
29pub struct PythonGenOptions {
30    /// Optional header comment that lands at the top of the generated
31    /// file. Newlines are emitted as separate `#` comment lines.
32    pub header_comment: Option<String>,
33}
34
35thread_local! {
36    /// Enum literal simple-name → discriminant value, matching `emit_enum`
37    /// (sequential `0..N-1`). Lets `union switch(EnumT)` resolve its
38    /// `case ENUM_LITERAL:` labels to the same integers the generated IntEnum
39    /// uses. Populated by [`register_enum_values`] at the start of each run.
40    static ENUM_VALUES: std::cell::RefCell<std::collections::BTreeMap<String, i64>> =
41        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
42
43    /// Integer `const` values by simple name, so a later `const` (or array
44    /// bound) that references an earlier one folds to its value (Bug M / #56).
45    static CONST_VALUES: std::cell::RefCell<std::collections::BTreeMap<String, i64>> =
46        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
47
48    /// Every named type declaration, stored as its fully-qualified IDL scope
49    /// path (e.g. `["combo", "Reading"]`). A reference site resolves a
50    /// (possibly partially-qualified) `ScopedName` against the enclosing scope
51    /// by walking outward and matching one of these paths, then flattens it the
52    /// SAME way the definition site does (`join("_")`). Without this a member
53    /// `Reading` inside `module combo` would emit the bare name `Reading`,
54    /// but the class is defined flattened as `combo_Reading` (Bug Q-cluster).
55    static TYPE_PATHS: std::cell::RefCell<Vec<Vec<String>>> =
56        const { std::cell::RefCell::new(Vec::new()) };
57}
58
59/// Records the fully-qualified path of every named type declaration before
60/// emission, so reference-rendering can resolve names against the enclosing
61/// scope and produce the same flattened name as the definition site.
62/// zerodds-lint: recursion-depth 64 (codegen AST walk; bounded by IDL nesting).
63fn register_type_paths(defs: &[Definition], scope: &mut Vec<String>) {
64    for def in defs {
65        let name: Option<&Identifier> = match def {
66            Definition::Module(m) => {
67                scope.push(m.name.text.clone());
68                register_type_paths(&m.definitions, scope);
69                scope.pop();
70                None
71            }
72            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Struct(StructDcl::Def(s)))) => {
73                Some(&s.name)
74            }
75            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Enum(e))) => Some(&e.name),
76            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Bitmask(b))) => Some(&b.name),
77            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Bitset(b))) => Some(&b.name),
78            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Union(UnionDcl::Def(u)))) => {
79                Some(&u.name)
80            }
81            Definition::Type(TypeDecl::Typedef(td)) => {
82                for d in &td.declarators {
83                    let mut path = scope.clone();
84                    path.push(d.name().text.clone());
85                    TYPE_PATHS.with(|t| t.borrow_mut().push(path));
86                }
87                None
88            }
89            Definition::Except(e) => Some(&e.name),
90            Definition::Const(c) => Some(&c.name),
91            _ => None,
92        };
93        if let Some(id) = name {
94            let mut path = scope.clone();
95            path.push(id.text.clone());
96            TYPE_PATHS.with(|t| t.borrow_mut().push(path));
97        }
98    }
99}
100
101/// Resolves a referenced `ScopedName` against the enclosing `scope`, returning
102/// the flattened Python name (`join("_")`) of the matching declaration. IDL
103/// name lookup walks outward from the innermost scope (§7.5.2); we mirror that:
104/// for each prefix of `scope` (longest first), check whether
105/// `prefix + name.parts` matches a registered type path. The matched path is
106/// flattened the same way `python_class_name` flattens definitions, so a
107/// reference and its definition always agree.
108fn resolve_scoped_name(name: &ScopedName, scope: &[String]) -> String {
109    let parts: Vec<String> = name.parts.iter().map(|p| p.text.clone()).collect();
110    let known: Vec<Vec<String>> = TYPE_PATHS.with(|t| t.borrow().clone());
111    // Walk outward: try the full enclosing scope first, then shorter prefixes,
112    // finally the global scope (empty prefix).
113    for cut in (0..=scope.len()).rev() {
114        let mut candidate = scope[..cut].to_vec();
115        candidate.extend(parts.iter().cloned());
116        if known.contains(&candidate) {
117            return candidate.join("_");
118        }
119    }
120    // No registered match (e.g. a built-in/forward-only name): fall back to the
121    // literal flattening of whatever parts were written.
122    parts.join("_")
123}
124
125/// `true` if the member carries an `@optional` annotation (Bug R5 / #64).
126fn member_is_optional(m: &Member) -> bool {
127    let Ok(lowered) = lower_annotations(&m.annotations) else {
128        return false;
129    };
130    lowered
131        .builtins
132        .iter()
133        .any(|b| matches!(b, BuiltinAnnotation::Optional))
134}
135
136/// The explicit `@id(N)` of a member, if any. A `@mutable` struct frames each
137/// member with an EMHEADER carrying this member id (XTypes 1.3 §7.4.3.4.2); the
138/// Python runtime needs the exact ids so its EMHEADERs match the cross-vendor
139/// reference. Absent ids fall back to the SEQUENTIAL default (1-based) computed
140/// by the caller.
141fn member_explicit_id(m: &Member) -> Option<u32> {
142    let lowered = lower_annotations(&m.annotations).ok()?;
143    lowered.builtins.iter().find_map(|b| match b {
144        BuiltinAnnotation::Id(n) => Some(*n),
145        _ => None,
146    })
147}
148
149/// The effective `@bit_bound` of a bitmask — the wire holder width in bits.
150/// XTypes 1.3 §7.3.1.2.1.1: the DEFAULT bit_bound for a bitmask is **32** (→ a
151/// uint32 holder), so a 3-flag bitmask without an explicit `@bit_bound` is still
152/// 4 bytes on the wire (matching CycloneDDS/RTI/FastDDS and the rust backend's
153/// `bitmask_bit_bound`). The runtime sizes the holder from this value, NOT from
154/// the flag count.
155fn bitmask_bit_bound(b: &BitmaskDecl) -> u32 {
156    lower_annotations(&b.annotations)
157        .ok()
158        .and_then(|l| {
159            l.builtins.iter().find_map(|a| match a {
160                BuiltinAnnotation::BitBound(n) => Some(u32::from(*n)),
161                _ => None,
162            })
163        })
164        .unwrap_or(32)
165}
166
167/// The effective `@bit_bound` of an enum — the signed wire holder width in bits
168/// (XTypes 1.3 §7.4.5.1 / §7.3.1.2.1.2): DEFAULT 32. The runtime narrows the
169/// holder to int8 (≤8) / int16 (≤16) / int32 from this value; Cyclone honours it.
170fn enum_bit_bound(e: &EnumDef) -> u32 {
171    lower_annotations(&e.annotations)
172        .ok()
173        .and_then(|l| {
174            l.builtins.iter().find_map(|a| match a {
175                BuiltinAnnotation::BitBound(n) => Some(u32::from(*n)),
176                _ => None,
177            })
178        })
179        .filter(|&v| (1..=32).contains(&v))
180        .unwrap_or(32)
181}
182
183/// Computes the per-field member-id list for a `@mutable` struct in declaration
184/// order: an explicit `@id(N)` wins; otherwise the XTypes default autoid
185/// SEQUENTIAL assigns `prev_id + 1` (starting at 0, so the first un-annotated
186/// member is id 0 — §7.2.2.4.4). One id is produced per *declarator* (a single
187/// IDL `long a, b;` declares two members). Mirrors the Rust backend's
188/// `vec![10, 20, 30]` id list.
189fn mutable_member_ids(s: &StructDef) -> Vec<u32> {
190    let mut ids = Vec::new();
191    let mut next: u32 = 0;
192    for m in &s.members {
193        let explicit = member_explicit_id(m);
194        for _ in &m.declarators {
195            let id = explicit.unwrap_or(next);
196            ids.push(id);
197            next = id.saturating_add(1);
198        }
199    }
200    ids
201}
202
203/// Records every enum literal's value before emission, so a union case label
204/// referencing an enumerator resolves to the discriminant `emit_enum` assigns.
205/// zerodds-lint: recursion-depth 64 (codegen AST walk; bounded by IDL nesting).
206fn register_enum_values(defs: &[Definition]) {
207    for def in defs {
208        match def {
209            Definition::Module(m) => register_enum_values(&m.definitions),
210            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Enum(e))) => {
211                for (idx, en) in e.enumerators.iter().enumerate() {
212                    ENUM_VALUES.with(|m| {
213                        m.borrow_mut().insert(en.name.text.clone(), idx as i64);
214                    });
215                }
216            }
217            Definition::Const(c) => {
218                // Register integer consts so later consts/array bounds that
219                // reference them fold (declaration order is respected: only
220                // already-seen consts are visible, matching IDL §7.4.1.4.4).
221                if let Some(v) = eval_const_int(&c.value) {
222                    CONST_VALUES.with(|m| {
223                        m.borrow_mut().insert(c.name.text.clone(), v);
224                    });
225                }
226            }
227            _ => {}
228        }
229    }
230}
231
232/// Codegen entry point — IDL spec → complete Python source.
233///
234/// # Errors
235///
236/// `IdlPythonError::Unsupported` for IDL constructs that the Python
237/// mapping does not (yet) support: `valuetype`, `interface`,
238/// `fixed`, `map`, `any`.
239pub fn generate_python_module(spec: &Specification, opts: &PythonGenOptions) -> Result<String> {
240    ENUM_VALUES.with(|m| m.borrow_mut().clear());
241    CONST_VALUES.with(|m| m.borrow_mut().clear());
242    register_enum_values(&spec.definitions);
243    TYPE_PATHS.with(|t| t.borrow_mut().clear());
244    {
245        let mut path_scope: Vec<String> = Vec::new();
246        register_type_paths(&spec.definitions, &mut path_scope);
247    }
248    let mut imports = ImportSet::default();
249    collect_imports(&spec.definitions, &mut imports)?;
250
251    let mut out = String::new();
252    out.push_str("# SPDX-License-Identifier: Apache-2.0\n");
253    if let Some(c) = &opts.header_comment {
254        for line in c.lines() {
255            out.push_str("# ");
256            out.push_str(line);
257            out.push('\n');
258        }
259    }
260    out.push_str("# Auto-generated by `zerodds-idl-python`. Do not edit by hand.\n");
261    out.push('\n');
262
263    imports.emit(&mut out);
264
265    let mut scope: Vec<String> = Vec::new();
266    emit_definitions(&mut out, &spec.definitions, &mut scope)?;
267    Ok(out)
268}
269
270// ---------------------------------------------------------------------------
271// Import-Sammlung
272// ---------------------------------------------------------------------------
273
274#[derive(Default)]
275struct ImportSet {
276    dataclass: bool,
277    int_enum: bool,
278    int_flag: bool,
279    typing_list: bool,
280    typing_dict: bool,
281    typing_type_alias: bool,
282    zerodds_brands: BTreeSet<&'static str>,
283    zerodds_idl_struct: bool,
284    zerodds_idl_union: bool,
285}
286
287impl ImportSet {
288    fn emit(&self, out: &mut String) {
289        if self.dataclass {
290            out.push_str("from dataclasses import dataclass\n");
291        }
292        if self.int_enum || self.int_flag {
293            out.push_str("from enum import ");
294            let mut parts: Vec<&str> = Vec::new();
295            if self.int_enum {
296                parts.push("IntEnum");
297            }
298            if self.int_flag {
299                parts.push("IntFlag");
300            }
301            out.push_str(&parts.join(", "));
302            out.push('\n');
303        }
304        if self.typing_list || self.typing_dict || self.typing_type_alias {
305            out.push_str("from typing import ");
306            let mut parts: Vec<&str> = Vec::new();
307            if self.typing_dict {
308                parts.push("Dict");
309            }
310            if self.typing_list {
311                parts.push("List");
312            }
313            if self.typing_type_alias {
314                parts.push("TypeAlias");
315            }
316            out.push_str(&parts.join(", "));
317            out.push('\n');
318        }
319        if self.zerodds_idl_struct || self.zerodds_idl_union || !self.zerodds_brands.is_empty() {
320            out.push_str("from zerodds.idl import ");
321            let mut parts: Vec<String> = Vec::new();
322            if self.zerodds_idl_struct {
323                parts.push("idl_struct".into());
324            }
325            if self.zerodds_idl_union {
326                parts.push("idl_union".into());
327            }
328            for brand in &self.zerodds_brands {
329                parts.push((*brand).to_string());
330            }
331            out.push_str(&parts.join(", "));
332            out.push('\n');
333        }
334        out.push('\n');
335    }
336}
337
338/// zerodds-lint: recursion-depth 32
339fn collect_imports(defs: &[Definition], imports: &mut ImportSet) -> Result<()> {
340    for d in defs {
341        match d {
342            Definition::Module(m) => collect_imports(&m.definitions, imports)?,
343            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Struct(StructDcl::Def(s)))) => {
344                imports.dataclass = true;
345                imports.zerodds_idl_struct = true;
346                for m in &s.members {
347                    collect_type_imports(&m.type_spec, imports)?;
348                    collect_member_brand_imports(m, imports);
349                }
350            }
351            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Enum(_))) => {
352                imports.int_enum = true;
353            }
354            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Bitmask(_))) => {
355                imports.int_flag = true;
356            }
357            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Bitset(_))) => {
358                // Bitset: emit as a `TypeAlias = Bitset[total_bits]` (the runtime
359                // brand that selects the spec-correct unsigned holder width) +
360                // the `*_Bits` SHIFT/MASK helper. Needs the `Bitset` brand and
361                // `TypeAlias` for the alias statement.
362                imports.zerodds_brands.insert("Bitset");
363                imports.typing_type_alias = true;
364            }
365            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Union(UnionDcl::Def(u)))) => {
366                imports.zerodds_idl_union = true;
367                imports.int_enum = true;
368                collect_switch_type_imports(&u.switch_type, imports);
369                for case in &u.cases {
370                    collect_type_imports(&case.element.type_spec, imports)?;
371                }
372            }
373            Definition::Type(TypeDecl::Typedef(td)) => {
374                imports.typing_type_alias = true;
375                collect_type_imports(&td.type_spec, imports)?;
376                if td
377                    .declarators
378                    .iter()
379                    .any(|d| matches!(d, Declarator::Array(_)))
380                {
381                    imports.zerodds_brands.insert("Array");
382                }
383            }
384            Definition::Except(e) => {
385                imports.dataclass = true;
386                imports.zerodds_idl_struct = true;
387                for m in &e.members {
388                    collect_type_imports(&m.type_spec, imports)?;
389                    collect_member_brand_imports(m, imports);
390                }
391            }
392            _ => {
393                // Forward decls + const + interface + valuetype + RTI
394                // vendor constructs are reported as Unsupported or
395                // ignored in emit_definitions. For the import
396                // pass we can silently skip them here.
397            }
398        }
399    }
400    Ok(())
401}
402
403/// Adds the `Array`/`Optional` brand imports a member needs based on its
404/// declarators (fixed arrays) and `@optional` annotation. The type-spec import
405/// pass alone misses these because they are carried on the declarator/member,
406/// not the type-spec.
407fn collect_member_brand_imports(m: &Member, imports: &mut ImportSet) {
408    if m.declarators
409        .iter()
410        .any(|d| matches!(d, Declarator::Array(_)))
411    {
412        imports.zerodds_brands.insert("Array");
413    }
414    if member_is_optional(m) {
415        imports.zerodds_brands.insert("Optional");
416    }
417}
418
419/// zerodds-lint: recursion-depth 32
420fn collect_type_imports(ts: &TypeSpec, imports: &mut ImportSet) -> Result<()> {
421    match ts {
422        TypeSpec::Primitive(p) => {
423            imports.zerodds_brands.insert(brand_for_primitive(*p));
424        }
425        TypeSpec::String(s) => {
426            if s.bound.as_ref().and_then(eval_const_int).is_some() {
427                imports.zerodds_brands.insert(if s.wide {
428                    "BoundedWString"
429                } else {
430                    "BoundedString"
431                });
432            } else {
433                imports.zerodds_brands.insert(brand_for_string(s));
434            }
435        }
436        TypeSpec::Sequence(seq) => {
437            // A bounded sequence emits the `Sequence[T, N]` brand (not
438            // `List[T]`), so it needs the `Sequence` brand imported.
439            if seq.bound.as_ref().and_then(eval_const_int).is_some() {
440                imports.zerodds_brands.insert("Sequence");
441            } else {
442                imports.typing_list = true;
443            }
444            collect_type_imports(&seq.elem, imports)?;
445        }
446        TypeSpec::Scoped(_) => {
447            // Local reference to another type in the same module —
448            // no import needed.
449        }
450        TypeSpec::Map(m) => {
451            // A bounded map emits the `Map[K, V, N]` brand (not `Dict[K, V]`).
452            if m.bound.as_ref().and_then(eval_const_int).is_some() {
453                imports.zerodds_brands.insert("Map");
454            } else {
455                imports.typing_dict = true;
456            }
457            collect_type_imports(&m.key, imports)?;
458            collect_type_imports(&m.value, imports)?;
459        }
460        TypeSpec::Fixed(_) => {
461            // fixed<P,S> -> the `Fixed[P,S]` runtime brand (CORBA-BCD codec).
462            imports.zerodds_brands.insert("Fixed");
463        }
464        TypeSpec::Any => {
465            return Err(IdlPythonError::Unsupported(format!(
466                "type spec not yet supported in python codegen: {ts:?}"
467            )));
468        }
469    }
470    Ok(())
471}
472
473fn collect_switch_type_imports(switch: &SwitchTypeSpec, imports: &mut ImportSet) {
474    match switch {
475        SwitchTypeSpec::Integer(i) => {
476            imports.zerodds_brands.insert(brand_for_integer(*i));
477        }
478        SwitchTypeSpec::Boolean => {
479            // bool is a Python builtin, no brand import needed — the
480            // `_kind_from_annotation` fallback maps it to Bool.
481        }
482        SwitchTypeSpec::Octet => {
483            imports.zerodds_brands.insert("Octet");
484        }
485        SwitchTypeSpec::Char => {
486            imports.zerodds_brands.insert("Char");
487        }
488        SwitchTypeSpec::Scoped(_) => {
489            // Probably an IntEnum — referenced via the defined type,
490            // no extra brand import.
491        }
492    }
493}
494
495// ---------------------------------------------------------------------------
496// Definitions-Emit
497// ---------------------------------------------------------------------------
498
499/// zerodds-lint: recursion-depth 32
500fn emit_definitions(out: &mut String, defs: &[Definition], scope: &mut Vec<String>) -> Result<()> {
501    for d in defs {
502        match d {
503            Definition::Module(m) => emit_module(out, m, scope)?,
504            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Struct(StructDcl::Def(s)))) => {
505                emit_struct(out, s, scope)?;
506            }
507            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Struct(StructDcl::Forward(_)))) => {
508                // Forward declarations need no code in Python.
509            }
510            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Enum(e))) => {
511                emit_enum(out, e, scope)?;
512            }
513            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Bitmask(b))) => {
514                emit_bitmask(out, b, scope)?;
515            }
516            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Bitset(b))) => {
517                emit_bitset(out, b, scope)?;
518            }
519            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Union(UnionDcl::Def(u)))) => {
520                emit_union(out, u, scope)?;
521            }
522            Definition::Type(TypeDecl::Constr(ConstrTypeDecl::Union(UnionDcl::Forward(_)))) => {
523                // Forward declarations need no code in Python.
524            }
525            Definition::Type(TypeDecl::Typedef(td)) => {
526                emit_typedef(out, td, scope)?;
527            }
528            Definition::Except(e) => {
529                emit_exception(out, e, scope)?;
530            }
531            Definition::Const(c) => {
532                emit_const(out, c, scope)?;
533            }
534            _ => {
535                return Err(IdlPythonError::Unsupported(format!(
536                    "definition variant not yet supported: {d:?}"
537                )));
538            }
539        }
540    }
541    Ok(())
542}
543
544/// zerodds-lint: recursion-depth 32
545fn emit_module(out: &mut String, m: &ModuleDef, scope: &mut Vec<String>) -> Result<()> {
546    scope.push(m.name.text.clone());
547    emit_definitions(out, &m.definitions, scope)?;
548    scope.pop();
549    Ok(())
550}
551
552/// Maps the struct's `@final`/`@appendable`/`@mutable`/`@extensibility(...)`
553/// annotation to the `extensibility=` kwarg the Python runtime understands. The
554/// runtime default (when the kwarg is omitted) is `final`; we only emit the
555/// kwarg for the non-default cases so the compact `@final` layout stays
556/// untouched (XTypes 1.3 §7.4.3.5.3 — DHEADER framing is driven by this).
557fn struct_extensibility_kwarg(s: &StructDef) -> Option<&'static str> {
558    let lowered = lower_annotations(&s.annotations).ok()?;
559    match lowered.extensibility() {
560        Some(ExtensibilityKind::Final) => None, // explicit @final → compact, no kwarg
561        Some(ExtensibilityKind::Appendable) => Some("appendable"),
562        Some(ExtensibilityKind::Mutable) => Some("mutable"),
563        None => Some("appendable"), // SX2: unannotated default §7.3.3.1
564    }
565}
566
567/// Same mapping as [`struct_extensibility_kwarg`] but for a union — drives the
568/// union's DHEADER (appendable/mutable) in the Python runtime's `_IdlUnion`.
569fn union_extensibility_kwarg(u: &UnionDef) -> Option<&'static str> {
570    let lowered = lower_annotations(&u.annotations).ok()?;
571    match lowered.extensibility() {
572        Some(ExtensibilityKind::Final) => None,
573        Some(ExtensibilityKind::Appendable) => Some("appendable"),
574        Some(ExtensibilityKind::Mutable) => Some("mutable"),
575        None => Some("appendable"), // SX2 default §7.3.3.1
576    }
577}
578
579fn emit_struct(out: &mut String, s: &StructDef, scope: &[String]) -> Result<()> {
580    let typename = qualified_typename(&s.name, scope);
581    let class_name = python_class_name(&s.name, scope);
582
583    match struct_extensibility_kwarg(s) {
584        Some("mutable") => {
585            // A `@mutable` struct needs the per-member id list so the runtime's
586            // PL_CDR2 EMHEADERs (XTypes 1.3 §7.4.3.4.2) carry the right ids.
587            let ids = mutable_member_ids(s);
588            let id_list = ids
589                .iter()
590                .map(u32::to_string)
591                .collect::<Vec<_>>()
592                .join(", ");
593            writeln!(
594                out,
595                "@idl_struct(typename=\"{typename}\", extensibility=\"mutable\", \
596                 member_ids=[{id_list}])"
597            )
598            .ok();
599        }
600        Some(ext) => {
601            writeln!(
602                out,
603                "@idl_struct(typename=\"{typename}\", extensibility=\"{ext}\")"
604            )
605            .ok();
606        }
607        None => {
608            writeln!(out, "@idl_struct(typename=\"{typename}\")").ok();
609        }
610    }
611    writeln!(out, "@dataclass").ok();
612    if let Some(base) = &s.base {
613        let base_class = resolve_scoped_name(base, scope);
614        writeln!(out, "class {class_name}({base_class}):").ok();
615    } else {
616        writeln!(out, "class {class_name}:").ok();
617    }
618
619    if s.members.is_empty() && s.base.is_none() {
620        writeln!(out, "    pass").ok();
621    } else if s.members.is_empty() {
622        // With a base but no new fields: `pass` so the dataclass body
623        // is syntactically valid.
624        writeln!(out, "    pass").ok();
625    } else {
626        for m in &s.members {
627            emit_members_of(out, m, scope)?;
628        }
629    }
630    out.push('\n');
631    Ok(())
632}
633
634fn emit_exception(out: &mut String, e: &ExceptDecl, scope: &[String]) -> Result<()> {
635    let typename = qualified_typename(&e.name, scope);
636    let class_name = python_class_name(&e.name, scope);
637
638    writeln!(out, "@idl_struct(typename=\"{typename}\")").ok();
639    writeln!(out, "@dataclass").ok();
640    writeln!(out, "class {class_name}(Exception):").ok();
641
642    if e.members.is_empty() {
643        writeln!(out, "    pass").ok();
644    } else {
645        for m in &e.members {
646            emit_members_of(out, m, scope)?;
647        }
648    }
649    out.push('\n');
650    Ok(())
651}
652
653fn emit_members_of(out: &mut String, m: &Member, scope: &[String]) -> Result<()> {
654    let py_type = python_type_for(&m.type_spec, scope)?;
655    let optional = member_is_optional(m);
656    for d in &m.declarators {
657        let field_name = escape_python_keyword(d.name().text.as_str());
658        let mut t = py_type.clone();
659        if let Declarator::Array(arr) = d {
660            // Fixed-size array: wrap each dimension in the runtime `Array[T, N]`
661            // brand (NOT `List[T]`, which the runtime reads as an unbounded
662            // sequence with a length prefix). Innermost dimension first so a
663            // multi-dim `grid[4][4]` nests as `Array[Array[Int32, 4], 4]`
664            // (Bug Q-cluster fixed-array member).
665            for size in arr.sizes.iter().rev() {
666                let n = eval_const_int(size).ok_or_else(|| {
667                    IdlPythonError::Unsupported(format!(
668                        "array bound is not a literal integer: {size:?}"
669                    ))
670                })?;
671                t = format!("Array[{t}, {n}]");
672            }
673        }
674        // `@optional T` → `Optional[T]` so the runtime emits the u8 presence
675        // flag instead of treating the member as required (Bug R5 / #64).
676        if optional {
677            t = format!("Optional[{t}]");
678        }
679        writeln!(out, "    {field_name}: {t}").ok();
680    }
681    Ok(())
682}
683
684fn emit_enum(out: &mut String, e: &EnumDef, scope: &[String]) -> Result<()> {
685    let class_name = python_class_name(&e.name, scope);
686    writeln!(out, "class {class_name}(IntEnum):").ok();
687    if e.enumerators.is_empty() {
688        writeln!(out, "    pass").ok();
689    } else {
690        for (idx, en) in e.enumerators.iter().enumerate() {
691            writeln!(out, "    {} = {idx}", en.name.text).ok();
692        }
693    }
694    // T2: a non-default `@bit_bound` narrows the enum's signed wire holder; the
695    // runtime's `_IdlEnum` reads `_idl_bit_bound`. Emitted only when narrow so
696    // default (int32) enums keep byte-identical output (assigned after the class
697    // body — inside an `IntEnum` body it would become a spurious member).
698    let bound = enum_bit_bound(e);
699    if bound != 32 {
700        writeln!(out, "{class_name}._idl_bit_bound = {bound}").ok();
701    }
702    out.push('\n');
703    Ok(())
704}
705
706fn emit_bitmask(out: &mut String, b: &BitmaskDecl, scope: &[String]) -> Result<()> {
707    let class_name = python_class_name(&b.name, scope);
708    writeln!(out, "class {class_name}(IntFlag):").ok();
709    if b.values.is_empty() {
710        // An empty bitmask still carries its (default 32) holder width so the
711        // runtime serializes the spec-correct number of bytes.
712        writeln!(out, "    pass").ok();
713    } else {
714        for (idx, v) in b.values.iter().enumerate() {
715            writeln!(out, "    {} = 1 << {idx}", v.name.text).ok();
716        }
717    }
718    // Record the effective @bit_bound (default 32 → uint32 holder) so the
719    // runtime sizes the bitmask holder by bit_bound, NOT by flag count
720    // (XTypes 1.3 §7.3.1.2.1.1; cross-vendor reference). Set as a class
721    // attribute AFTER the class body — assigning it inside an `IntFlag` body
722    // would make the enum metaclass treat `_idl_bit_bound` as a (spurious) flag
723    // member; a post-hoc assignment stays a plain class attribute.
724    writeln!(
725        out,
726        "{class_name}._idl_bit_bound = {}",
727        bitmask_bit_bound(b)
728    )
729    .ok();
730    out.push('\n');
731    Ok(())
732}
733
734fn emit_bitset(out: &mut String, b: &BitsetDecl, scope: &[String]) -> Result<()> {
735    // OMG bitset: a container integer with packed sub-fields. We
736    // emit an Int64 alias plus a helper class with
737    // SHIFT/MASK constants per named bitfield. The `zerodds-py`
738    // runtime sees the alias as an Int64 brand and encodes accordingly;
739    // the application reads/writes fields via the helper constants:
740    //
741    //   value = SensorFlags_Bits.encode(sensor_id=42, quality=2)
742    //   sensor_id = (value >> SensorFlags_Bits.SENSOR_ID_SHIFT)
743    //               & SensorFlags_Bits.SENSOR_ID_MASK
744    let class_name = python_class_name(&b.name, scope);
745    // The holder is the smallest unsigned integer fitting the SUM of bitfield
746    // widths (XTypes 1.3 §7.4.13; cdr-core `bitset_storage_type`). Emit the
747    // alias as `Bitset[total_bits]` so the runtime serializes exactly that
748    // width — a plain `Int64` alias would put 8 bytes on the wire instead of
749    // the spec-correct holder (e.g. u8 for an 8-bit bitset).
750    let total_bits: i64 = b
751        .bitfields
752        .iter()
753        .map(|bf| eval_const_int(&bf.spec.width).unwrap_or(0))
754        .sum();
755    writeln!(out, "{class_name}: TypeAlias = Bitset[{total_bits}]").ok();
756    writeln!(out).ok();
757    writeln!(out, "class {class_name}_Bits:").ok();
758    if b.bitfields.is_empty() {
759        writeln!(out, "    pass").ok();
760    } else {
761        let mut shift: u64 = 0;
762        for bf in &b.bitfields {
763            let width = eval_const_int(&bf.spec.width).ok_or_else(|| {
764                IdlPythonError::Unsupported(format!(
765                    "bitset width is not a literal integer: {:?}",
766                    bf.spec.width
767                ))
768            })?;
769            if width <= 0 {
770                return Err(IdlPythonError::Unsupported(format!(
771                    "bitset width must be > 0, got {width}"
772                )));
773            }
774            let width = width as u64;
775            let mask = if width >= 64 {
776                u64::MAX
777            } else {
778                (1u64 << width) - 1
779            };
780            if let Some(name) = &bf.name {
781                let up = name.text.to_uppercase();
782                writeln!(out, "    {up}_SHIFT = {shift}").ok();
783                writeln!(out, "    {up}_WIDTH = {width}").ok();
784                writeln!(out, "    {up}_MASK  = 0x{mask:x}").ok();
785            }
786            shift += width;
787        }
788    }
789    out.push('\n');
790    Ok(())
791}
792
793fn emit_union(out: &mut String, u: &UnionDef, scope: &[String]) -> Result<()> {
794    let typename = qualified_typename(&u.name, scope);
795    let class_name = python_class_name(&u.name, scope);
796    let discriminator_repr = switch_type_python_repr(&u.switch_type, scope);
797
798    // Collect cases: one entry per label in dict[int, tuple[str, Any]].
799    // For a `default:` label we collect the case separately for the
800    // `default=` parameter of the idl_union constructor.
801    let mut case_entries: Vec<(i64, String, String)> = Vec::new();
802    let mut default_entry: Option<(String, String)> = None;
803
804    for case in &u.cases {
805        let field_name = escape_python_keyword(case.element.declarator.name().text.as_str());
806        let py_type = python_type_for(&case.element.type_spec, scope)?;
807        for label in &case.labels {
808            match label {
809                CaseLabel::Value(expr) => {
810                    let v = eval_const_int(expr).ok_or_else(|| {
811                        IdlPythonError::Unsupported(format!(
812                            "union case label is not a literal integer (scoped/enum-ref \
813                             references will be supported in a follow-up): {expr:?}"
814                        ))
815                    })?;
816                    case_entries.push((v, field_name.clone(), py_type.clone()));
817                }
818                CaseLabel::Default => {
819                    default_entry = Some((field_name.clone(), py_type.clone()));
820                }
821            }
822        }
823    }
824
825    writeln!(out, "{class_name} = idl_union(").ok();
826    writeln!(out, "    typename=\"{typename}\",").ok();
827    writeln!(out, "    discriminator={discriminator_repr},").ok();
828    writeln!(out, "    cases={{").ok();
829    for (label_value, field, py_type) in &case_entries {
830        writeln!(out, "        {label_value}: (\"{field}\", {py_type}),").ok();
831    }
832    writeln!(out, "    }},").ok();
833    if let Some((field, py_type)) = &default_entry {
834        writeln!(out, "    default=(\"{field}\", {py_type}),").ok();
835    } else {
836        writeln!(out, "    default=None,").ok();
837    }
838    if let Some(ext) = union_extensibility_kwarg(u) {
839        writeln!(out, "    extensibility=\"{ext}\",").ok();
840    }
841    writeln!(out, ")").ok();
842    out.push('\n');
843    Ok(())
844}
845
846fn emit_typedef(out: &mut String, td: &TypedefDecl, scope: &[String]) -> Result<()> {
847    let py_type = python_type_for(&td.type_spec, scope)?;
848    for d in &td.declarators {
849        let alias_name = python_class_name(d.name(), scope);
850        match d {
851            Declarator::Simple(_) => {
852                writeln!(out, "{alias_name}: TypeAlias = {py_type}").ok();
853            }
854            Declarator::Array(arr) => {
855                // A typedef'd fixed array must use the same `Array[T, N]` brand
856                // as struct members, so the runtime omits the length prefix.
857                let mut t = py_type.clone();
858                for size in arr.sizes.iter().rev() {
859                    let n = eval_const_int(size).ok_or_else(|| {
860                        IdlPythonError::Unsupported(format!(
861                            "array bound is not a literal integer: {size:?}"
862                        ))
863                    })?;
864                    t = format!("Array[{t}, {n}]");
865                }
866                writeln!(out, "{alias_name}: TypeAlias = {t}").ok();
867            }
868        }
869    }
870    out.push('\n');
871    Ok(())
872}
873
874/// Emits an IDL `const` as a module-level Python constant (Bug M / #56).
875///
876/// `const long MAX = 10;` → `MAX = 10`. Under a module the name is flattened
877/// the same way types are (`module conf { const long N = 4; }` → `conf_N = 4`)
878/// so references stay consistent with the rest of the generated module.
879fn emit_const(out: &mut String, c: &ConstDecl, scope: &[String]) -> Result<()> {
880    let name = python_class_name(&c.name, scope);
881    let value = render_const_value(&c.value, &c.type_, scope)?;
882    writeln!(out, "{name} = {value}").ok();
883    out.push('\n');
884    Ok(())
885}
886
887/// Renders a `const_expr` as a Python literal/expression. Integer arithmetic is
888/// folded; floats/bools/strings/chars map to their Python literal forms; a
889/// scoped reference resolves to an enum value (folded to an int) if known, else
890/// to the flattened constant name.
891/// zerodds-lint: recursion-depth 32
892fn render_const_value(expr: &ConstExpr, ty: &ConstType, scope: &[String]) -> Result<String> {
893    // Boolean/string/char/float literals do not survive the integer folder, so
894    // handle the literal forms the folder cannot represent first.
895    if let ConstExpr::Literal(lit) = expr {
896        match lit.kind {
897            LiteralKind::Boolean => {
898                let v = lit.raw.trim().eq_ignore_ascii_case("true");
899                return Ok(if v { "True".into() } else { "False".into() });
900            }
901            LiteralKind::Floating | LiteralKind::Fixed => {
902                return Ok(lit
903                    .raw
904                    .trim()
905                    .trim_end_matches(['f', 'F', 'd', 'D'])
906                    .to_string());
907            }
908            LiteralKind::String | LiteralKind::WideString => {
909                // raw already contains the surrounding quotes; emit verbatim.
910                return Ok(lit.raw.clone());
911            }
912            LiteralKind::Char | LiteralKind::WideChar => {
913                return Ok(lit.raw.clone());
914            }
915            LiteralKind::Integer => {}
916        }
917    }
918    // Integer / enum-folded / arithmetic expressions.
919    if let Some(n) = eval_const_int(expr) {
920        return Ok(n.to_string());
921    }
922    // Scoped reference that did not fold (non-enum const reference): emit the
923    // flattened name so it points at another emitted module constant.
924    if let ConstExpr::Scoped(s) = expr {
925        return Ok(resolve_scoped_name(s, scope));
926    }
927    Err(IdlPythonError::Unsupported(format!(
928        "const value not representable in python codegen (type {ty:?}): {expr:?}"
929    )))
930}
931
932// ---------------------------------------------------------------------------
933// Type-Mapping
934// ---------------------------------------------------------------------------
935
936/// zerodds-lint: recursion-depth 32
937fn python_type_for(ts: &TypeSpec, scope: &[String]) -> Result<String> {
938    match ts {
939        TypeSpec::Primitive(p) => Ok(brand_for_primitive(*p).to_string()),
940        TypeSpec::String(s) => {
941            // `string<N>` / `wstring<N>` carry a bound that MUST be enforced on
942            // the wire (over-bound writes corrupt downstream readers). Emit the
943            // runtime `BoundedString[N]` / `BoundedWString[N]` brand so the
944            // length is checked; an unbounded string stays the plain brand.
945            if let Some(n) = s.bound.as_ref().and_then(eval_const_int) {
946                let brand = if s.wide {
947                    "BoundedWString"
948                } else {
949                    "BoundedString"
950                };
951                Ok(format!("{brand}[{n}]"))
952            } else {
953                Ok(brand_for_string(s).to_string())
954            }
955        }
956        TypeSpec::Sequence(seq) => {
957            let inner = python_type_for_seq_elem(seq, scope)?;
958            // `sequence<T, N>` → `Sequence[T, N]` so the element count is
959            // enforced; unbounded → `List[T]` (runtime treats it as unbounded).
960            if let Some(n) = seq.bound.as_ref().and_then(eval_const_int) {
961                Ok(format!("Sequence[{inner}, {n}]"))
962            } else {
963                Ok(format!("List[{inner}]"))
964            }
965        }
966        TypeSpec::Scoped(name) => Ok(resolve_scoped_name(name, scope)),
967        TypeSpec::Map(m) => {
968            let k = python_type_for(&m.key, scope)?;
969            let v = python_type_for(&m.value, scope)?;
970            // `map<K, V, N>` → `Map[K, V, N]` (bounded entry count); unbounded
971            // → `Dict[K, V]`.
972            if let Some(n) = m.bound.as_ref().and_then(eval_const_int) {
973                Ok(format!("Map[{k}, {v}, {n}]"))
974            } else {
975                Ok(format!("Dict[{k}, {v}]"))
976            }
977        }
978        TypeSpec::Fixed(f) => {
979            // fixed<P,S> -> the `Fixed[P,S]` brand; the Python value is the
980            // decimal as a `str` (CORBA-BCD on the wire).
981            let p = eval_const_int(&f.digits).unwrap_or(0);
982            let s = eval_const_int(&f.scale).unwrap_or(0);
983            Ok(format!("Fixed[{p}, {s}]"))
984        }
985        TypeSpec::Any => Err(IdlPythonError::Unsupported(format!(
986            "type spec not yet supported in python codegen: {ts:?}"
987        ))),
988    }
989}
990
991/// zerodds-lint: recursion-depth 32
992fn python_type_for_seq_elem(seq: &SequenceType, scope: &[String]) -> Result<String> {
993    python_type_for(&seq.elem, scope)
994}
995
996fn brand_for_primitive(p: PrimitiveType) -> &'static str {
997    match p {
998        // The runtime exposes the `Bool` brand; emitting the lowercase builtin
999        // `bool` would make the codegen try `from zerodds.idl import bool`,
1000        // which does not exist. `Bool` round-trips identically (both are the
1001        // 1-byte XCDR2 boolean, §7.4.1.4.1).
1002        PrimitiveType::Boolean => "Bool",
1003        PrimitiveType::Octet => "Octet",
1004        PrimitiveType::Char => "Char",
1005        PrimitiveType::WideChar => "WChar",
1006        PrimitiveType::Integer(i) => brand_for_integer(i),
1007        PrimitiveType::Floating(f) => match f {
1008            FloatingType::Float => "Float32",
1009            FloatingType::Double => "Float64",
1010            FloatingType::LongDouble => "LongDouble",
1011        },
1012    }
1013}
1014
1015fn brand_for_integer(i: IntegerType) -> &'static str {
1016    match i {
1017        IntegerType::Short | IntegerType::Int16 => "Int16",
1018        IntegerType::UShort | IntegerType::UInt16 => "UInt16",
1019        IntegerType::Long | IntegerType::Int32 => "Int32",
1020        IntegerType::ULong | IntegerType::UInt32 => "UInt32",
1021        IntegerType::LongLong | IntegerType::Int64 => "Int64",
1022        IntegerType::ULongLong | IntegerType::UInt64 => "UInt64",
1023        IntegerType::Int8 => "Int8",
1024        IntegerType::UInt8 => "UInt8",
1025    }
1026}
1027
1028fn brand_for_string(s: &StringType) -> &'static str {
1029    if s.wide { "WString" } else { "String" }
1030}
1031
1032fn switch_type_python_repr(switch: &SwitchTypeSpec, scope: &[String]) -> String {
1033    match switch {
1034        SwitchTypeSpec::Integer(i) => brand_for_integer(*i).to_string(),
1035        SwitchTypeSpec::Boolean => "bool".to_string(),
1036        SwitchTypeSpec::Octet => "Octet".to_string(),
1037        SwitchTypeSpec::Char => "Char".to_string(),
1038        SwitchTypeSpec::Scoped(s) => resolve_scoped_name(s, scope),
1039    }
1040}
1041
1042fn qualified_typename(name: &Identifier, scope: &[String]) -> String {
1043    if scope.is_empty() {
1044        name.text.clone()
1045    } else {
1046        format!("{}::{}", scope.join("::"), name.text)
1047    }
1048}
1049
1050fn python_class_name(name: &Identifier, scope: &[String]) -> String {
1051    if scope.is_empty() {
1052        name.text.clone()
1053    } else {
1054        let mut parts = scope.to_vec();
1055        parts.push(name.text.clone());
1056        parts.join("_")
1057    }
1058}
1059
1060fn escape_python_keyword(name: &str) -> String {
1061    const PYTHON_KEYWORDS: &[&str] = &[
1062        "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
1063        "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global",
1064        "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return",
1065        "try", "while", "with", "yield", "match", "case",
1066    ];
1067    if PYTHON_KEYWORDS.contains(&name) {
1068        format!("{name}_")
1069    } else {
1070        name.to_string()
1071    }
1072}
1073
1074// ---------------------------------------------------------------------------
1075// Const-expression evaluation (limited: integer literals + unary +/- only)
1076// ---------------------------------------------------------------------------
1077
1078/// zerodds-lint: recursion-depth 32
1079fn eval_const_int(expr: &ConstExpr) -> Option<i64> {
1080    match expr {
1081        ConstExpr::Literal(Literal {
1082            kind: LiteralKind::Integer,
1083            raw,
1084            ..
1085        }) => parse_integer_literal(raw),
1086        ConstExpr::Unary { op, operand, .. } => {
1087            let inner = eval_const_int(operand)?;
1088            match op {
1089                UnaryOp::Plus => Some(inner),
1090                UnaryOp::Minus => Some(-inner),
1091                UnaryOp::BitNot => Some(!inner),
1092            }
1093        }
1094        // An enum literal as a union case label (`case K_A:`) resolves to the
1095        // discriminant value `emit_enum` assigns (Bug I).
1096        ConstExpr::Scoped(s) => {
1097            let name = s.parts.last()?.text.clone();
1098            ENUM_VALUES
1099                .with(|m| m.borrow().get(&name).copied())
1100                .or_else(|| CONST_VALUES.with(|m| m.borrow().get(&name).copied()))
1101        }
1102        // Fold integer arithmetic so `const long N = (A << 2) | 1;` works.
1103        ConstExpr::Binary { op, lhs, rhs, .. } => {
1104            let l = eval_const_int(lhs)?;
1105            let r = eval_const_int(rhs)?;
1106            match op {
1107                BinaryOp::Or => Some(l | r),
1108                BinaryOp::Xor => Some(l ^ r),
1109                BinaryOp::And => Some(l & r),
1110                BinaryOp::Shl => Some(l.checked_shl(u32::try_from(r).ok()?)?),
1111                BinaryOp::Shr => Some(l.checked_shr(u32::try_from(r).ok()?)?),
1112                BinaryOp::Add => l.checked_add(r),
1113                BinaryOp::Sub => l.checked_sub(r),
1114                BinaryOp::Mul => l.checked_mul(r),
1115                BinaryOp::Div => l.checked_div(r),
1116                BinaryOp::Mod => l.checked_rem(r),
1117            }
1118        }
1119        _ => None,
1120    }
1121}
1122
1123fn parse_integer_literal(raw: &str) -> Option<i64> {
1124    let s = raw.trim();
1125    // OMG IDL allows hex (0x...), octal (0...) and decimal literals.
1126    // Suffixes like `L`/`u` we ignore generously — the parser
1127    // has already validated that this is an integer literal.
1128    let s = s.trim_end_matches(['L', 'l', 'u', 'U']);
1129    if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
1130        i64::from_str_radix(hex, 16).ok()
1131    } else if let Some(rest) = s.strip_prefix("-0x").or_else(|| s.strip_prefix("-0X")) {
1132        i64::from_str_radix(rest, 16).ok().map(|n| -n)
1133    } else if s.starts_with('0') && s.len() > 1 && !s.contains(|c: char| !c.is_ascii_digit()) {
1134        // Octal — intentionally strict, because OMG IDL accepts octal.
1135        i64::from_str_radix(&s[1..], 8).ok()
1136    } else {
1137        s.parse::<i64>().ok()
1138    }
1139}