Skip to main content

zerodds_lang_rules/adapter/
python.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! The Python CST as the other end of a rule. Spec §3.2.
4//!
5//! The counterpart of [`crate::adapter::java`], [`crate::adapter::cpp`]
6//! and [`crate::adapter::c`]. Reading two of them side by side is the
7//! point: where they differ, the difference is the language's, and it is
8//! written down here rather than spread through a renderer.
9//!
10//! Values are resolved from the model, never from the overlay — the same
11//! rule the sibling materialisers follow and for the same reason
12//! (spec §2.3).
13//!
14//! # Python is the hardest of the five, and this is why
15//!
16//! The other four target languages state a width, a length and a
17//! signedness in the declaration itself. Python states none of them. A
18//! `long` is 32 bits and a Python `int` is arbitrary precision; an
19//! IDL `octet` and an IDL `unsigned long long` are the same Python type;
20//! `long a[3]` and `sequence<long> a` are both "a thing you can index".
21//! A backend that wrote `x: int` and stopped would produce a file that
22//! type-checks and a value the wire cannot hold, with nothing between
23//! the two to notice.
24//!
25//! So this column is arranged around one question: **for each IDL fact
26//! Python cannot enforce, where is it written down?** There are exactly
27//! two answers used here and no third.
28//!
29//! 1. **The Python type**, where Python can enforce it. Mutability
30//!    (`list` against `tuple`), arity (`tuple[T, T, T]`), absence
31//!    (`typing.Optional[T]`), and which class a member holds. A static
32//!    checker rejects a program that gets these wrong.
33//! 2. **`typing.Annotated` metadata**, where it cannot. The width, the
34//!    signedness, the bound, the digit count, the character width. A
35//!    checker passes these through untouched; they are read back at run
36//!    time with `typing.get_type_hints(cls, include_extras=True)`, which
37//!    is what the codec pass will do.
38//!
39//! Nothing is left implicit. `x: int` never appears in generated output.
40//!
41//! ## The integer mapping
42//!
43//! | IDL | Python | metadata |
44//! |---|---|---|
45//! | `boolean` | `bool` | — |
46//! | `char` | `str` | `_IdlText(bits=8, bound=1)` |
47//! | `wchar` | `str` | `_IdlText(bits=16, bound=1)` |
48//! | `int8` | `int` | `_IdlInt(bits=8, signed=True)` |
49//! | `octet`, `uint8` | `int` | `_IdlInt(bits=8, signed=False)` |
50//! | `short` | `int` | `_IdlInt(bits=16, signed=True)` |
51//! | `unsigned short` | `int` | `_IdlInt(bits=16, signed=False)` |
52//! | `long` | `int` | `_IdlInt(bits=32, signed=True)` |
53//! | `unsigned long` | `int` | `_IdlInt(bits=32, signed=False)` |
54//! | `long long` | `int` | `_IdlInt(bits=64, signed=True)` |
55//! | `unsigned long long` | `int` | `_IdlInt(bits=64, signed=False)` |
56//! | `float` | `float` | `_IdlFloat(bits=32)` |
57//! | `double` | `float` | `_IdlFloat(bits=64)` |
58//! | `long double` | **`str`** | `_IdlFloat(bits=128)` |
59//!
60//! Each of those is written once as a module-level alias — `_Int32`,
61//! `_UInt64` — so a field reads `x: _Int32` and the width is one hop
62//! away rather than inline on every line.
63//!
64//! An alias exists exactly where the type's whole description is fixed.
65//! A `string<7>` and a `string<9>` are not the same type, so the string
66//! family is written inline instead — including the unbounded one, so
67//! that `string` and `string<7>` have one spelling between them rather
68//! than a name for the common case and a form for the rest.
69//!
70//! **Where this departs from every other column.** Java has to decide
71//! which of its signed types is wide enough to *hold* an unsigned IDL
72//! value, and `unsigned long long` ends up a `long` carrying a bit
73//! pattern rather than a number. Python has no such problem and so
74//! makes no such trade: every one of the ten integers is a Python `int`
75//! holding the number, and only the metadata differs. That is the one
76//! place where Python's lack of widths is an advantage, and it is worth
77//! saying because everywhere else it is the cost.
78//!
79//! ### `long double` is a `str` and never a `float`
80//!
81//! A Python `float` is a C `double` — 53 bits of significand. An IDL
82//! `long double` is a binary128 with 113. Writing one into a `float`
83//! loses sixty bits at the assignment, before any codec runs, with
84//! nothing to say it happened. That is the silent-substitution class
85//! this project is arranged against, and it is the same defect the
86//! three-column gate found in the C column when `long double` reached
87//! `double` there.
88//!
89//! So the member holds the **decimal text**, exactly as `fixed<P,S>`
90//! does and for the same reason: it is the one Python type that holds
91//! the value the source wrote without rounding it. The Java column
92//! reaches for `org.zerodds.wire.Float128` instead, which this column
93//! cannot: a generated Python module here imports nothing but
94//! `dataclasses`, `enum` and `typing`, so a runtime class would make
95//! every generated file depend on a package that is not part of this
96//! layer.
97//!
98//! ## The shapes
99//!
100//! | IDL | Python |
101//! |---|---|
102//! | `string<N>`, `wstring<N>` | `str`, with the width and bound in the metadata |
103//! | `fixed<P,S>` | `str`, holding the decimal |
104//! | `sequence<T>` | `list[T]` |
105//! | `sequence<T,N>` | `list[T]`, with the bound in the metadata |
106//! | `T[N]` | `tuple[T, …]` — **N** arguments, not an ellipsis |
107//! | `T[N][M]` | `tuple[tuple[T, …M], …N]`, outer dimension outermost |
108//! | `map<K,V>` | `list[tuple[K, V]]` |
109//! | `union` | a dataclass with `_d` and one optional field per case |
110//! | `enum` | `enum.IntEnum`; a member of it is an `int` |
111//! | `bitmask` | `enum.IntFlag`; a member of it is an `int` |
112//! | `bitset` | a dataclass of one field per bitfield |
113//! | `@optional T m` | `typing.Optional[T]`, defaulting to `None` |
114//! | `typedef` | nothing; a comment, and the target's type at each use |
115//! | `module` | a class the declarations nest in |
116//!
117//! ### `sequence<T>` is a `list` and `T[N]` is a `tuple`
118//!
119//! Not a style choice. An IDL array has a length that is part of its
120//! type and a sequence does not, and those are the two Python built-ins
121//! that say exactly that: a `list` can be appended to and a `tuple`
122//! cannot. The array's length goes further — `tuple[int, int, int]`
123//! names three elements, and a static checker rejects a two-element
124//! literal — which makes the length the **one** IDL fact in this whole
125//! column that Python's own type system enforces. Writing
126//! `tuple[int, ...]` and putting the length in the metadata would give
127//! that up for nothing.
128//!
129//! The cost is stated rather than hidden: `long a[1000]` produces an
130//! annotation with a thousand arguments in it. Nothing truncates it and
131//! there is no threshold at which the spelling changes, because a
132//! threshold would mean the same IDL construct had two Python forms
133//! depending on a number nobody chose.
134//!
135//! ### `map<K,V>` is a `list` of pairs and not a `dict`
136//!
137//! Two independent reasons, either of which decides it.
138//!
139//! A `dict` requires hashable keys. A generated struct is a
140//! `@dataclasses.dataclass` with `eq=True`, which sets `__hash__` to
141//! `None` — so `dict[Point, int]` is a type that cannot hold anything,
142//! and `map<Point, long>` is ordinary IDL.
143//!
144//! And the entry order is a policy axis. `MapEntryOrder` exists to
145//! measure which order a peer puts the entries in, and a container that
146//! decided an order of its own would make the axis measure the container.
147//! A `list` of pairs keeps the sample's order where the sample put it.
148//! The Java column reaches the same answer through an array of a
149//! generated entry class; Python needs no generated class because
150//! `tuple[K, V]` already is one.
151//!
152//! ### `@optional` is `typing.Optional[T]` and `None` is the absence
153//!
154//! One field, not a field beside a flag. XTypes 1.3 §7.4.1.2.3 makes
155//! **absence** the value, not a zero, and `None` is Python's. A
156//! `bool _has_m` beside an `m` would be two things that can disagree,
157//! which is what the C column has to carry because C has no `None`.
158//!
159//! A non-optional member is emitted with no default at all, so
160//! constructing an aggregate without it is an error rather than a
161//! silently zero-filled sample.
162//!
163//! ### An enumeration is an `enum.IntEnum` and a member of it is an `int`
164//!
165//! Both halves matter.
166//!
167//! The **declaration** is a real `enum.IntEnum` because a Python enum
168//! member *is* an `int` — `Colour.RED == 0` is true and
169//! `Colour.RED + 1` is an `int`. Java cannot do this, because a Java
170//! `enum` constant's identity is its ordinal and `@value(-1)` is not an
171//! ordinal, which is why the Java column emits a class of `int`
172//! constants and loses the type.
173//!
174//! The **member** is nonetheless an `int` and not the enumeration:
175//! `Colour(7)` raises `ValueError`, and an `@appendable` writer that
176//! added an enumerator sends exactly that. A field typed `Colour` could
177//! only refuse the sample. So the member is
178//! `typing.Annotated[int, _IdlEnum(declared=Colour, bits=32)]` — the
179//! runtime type is what the wire can deliver, and which enumeration it
180//! is stays readable, in the metadata, where a codec can get at it.
181//!
182//! A bitmask is the same arrangement with `enum.IntFlag`.
183//!
184//! ### A bitset states the bit width and not a holder
185//!
186//! `bitfield<3> low` is three bits. C and Java have to round that up to
187//! the narrowest integer that holds it, because they have no three-bit
188//! type; Python has no fixed-width type at all, so nothing is rounded
189//! and the metadata states three. The declared holder of
190//! `bitfield<1, boolean>` names a range and not a wire form, and the
191//! width is what the packing uses — the same reading the Java column
192//! gives.
193//!
194//! ### A `typedef` declares nothing
195//!
196//! Python has type aliases, and this column does not use one, for a
197//! reason that is Python's own: an assignment is evaluated **eagerly**.
198//! `Position = a.Point` at module level runs when the module is
199//! imported, so it has to come after `class a`, and a typedef in one
200//! IDL module naming a type in another has no position in the nesting
201//! at which that is true. The alias would be a `NameError` at import,
202//! for exactly the cross-module cases and no others. So a typedef is
203//! transparent — the use takes the target's type — and a comment
204//! carries the name and what it resolved to, which is what the Java
205//! column does and for a different reason.
206//!
207//! ### Modules are nested classes
208//!
209//! Python's own unit of scope is the module, and one IDL file becomes
210//! one Python module, so the file's top level is already the global IDL
211//! scope; nothing needs an outer class the way the Java column does. A
212//! nested IDL module becomes a class, which is the only Python
213//! construct that nests, carries a name and can be referred to through
214//! a dotted path.
215//!
216//! Every reference to a generated type is written from the module root
217//! down — `a.b.Point`. Unlike Java there is no capture hazard in doing
218//! so: `typing.get_type_hints` resolves an annotation against the
219//! module globals, where `a` is bound, and a *member* named `a` is not
220//! in those globals. The escape below covers the case that does bite.
221//!
222//! ### Identifiers
223//!
224//! Through [`zerodds_lang_cst::python::escape_name`], which appends an
225//! underscore — PEP 8's own rule for this case. It moves three kinds of
226//! name: a keyword, a soft keyword, and a name the generated module
227//! itself binds (`int`, `list`, `typing`). The third is the one that is
228//! invisible without it: an `@optional` member named `int` becomes a
229//! class attribute set to `None`, and `typing.get_type_hints` resolves
230//! against the class namespace before the module's, so every `int` in
231//! that class would then resolve to `None`.
232//!
233//! **The IDL name is not lost.** A renamed field carries it in
234//! `dataclasses.field(metadata={"idl": …})`, which is machine-readable;
235//! every other renamed declaration carries it in a comment on the line
236//! above. And two IDL names that would escape onto one Python name in
237//! one scope are refused rather than letting the second take the first's
238//! declaration.
239//!
240//! ## What is refused
241//!
242//! `any`, `ValueBase`, an object reference and an annotation type have
243//! no Python data form here and are named as
244//! [`MaterialiseError::NoLanguageForm`] rather than left out of the
245//! class. Python would happily take `object` for all four, which is
246//! exactly why it is not written: a member typed `object` is a field no
247//! codec can write, from a generator that reported success.
248
249use crate::adapter::c::ordered_types;
250use crate::adapter::constant;
251use crate::adapter::cst::{MaterialiseError, no_spelling_collision};
252use crate::apply::Applied;
253use crate::verbatim;
254use seesaw_tgg::ident::GhostId;
255use zerodds_ast::{Ast, Bound, CharWidth, ModuleNode, PrimitiveKind, TypeKind, TypeNode};
256use zerodds_ast_core::{Id, NodeId};
257use zerodds_lang_cst::common::{Doc, Ident, Literal, Path};
258use zerodds_lang_cst::python::{
259    Annotated, Arg, Assign, Class, Decorator, Expr, Import, Keyword, Module, Statement, Type,
260    escape_name,
261};
262
263/// How deep a module chain is followed before the model is taken to be
264/// malformed. IDL nests nowhere near this.
265const MAX_MODULE_DEPTH: usize = 64;
266
267/// How deep a type's Python spelling is followed.
268///
269/// A `sequence<sequence<…>>` nests once per level; a chain longer than
270/// this is a cycle the resolver did not catch, and recursing into it
271/// would end the process rather than report anything.
272const MAX_TYPE_DEPTH: usize = 64;
273
274/// The names the generated preamble binds.
275///
276/// Every one starts with an underscore, and an IDL identifier cannot:
277/// IDL 4.2 §7.2.3 makes the first character an ASCII letter, and the
278/// escaped form `_keyword` of §7.2.3.2 denotes the identifier *without*
279/// the underscore. So this list should never fire. It is checked anyway,
280/// because "should never" about a name collision is the assumption that
281/// makes one declaration silently take another's.
282const PRELUDE_NAMES: &[&str] = &[
283    "_IdlInt",
284    "_IdlFloat",
285    "_IdlText",
286    "_IdlFixed",
287    "_IdlSequence",
288    "_IdlEnum",
289    "_Int8",
290    "_UInt8",
291    "_Int16",
292    "_UInt16",
293    "_Int32",
294    "_UInt32",
295    "_Int64",
296    "_UInt64",
297    "_Float32",
298    "_Float64",
299    "_Float128",
300    "_Char",
301    "_WChar",
302];
303
304/// Build the Python module a rule application produced.
305///
306/// The whole file and not a list of declarations: which imports the
307/// preamble needs and which markers the annotations are written in terms
308/// of are part of what this column decides, and a caller assembling them
309/// separately would be a second place where they are decided.
310///
311/// # Errors
312/// [`MaterialiseError`] when a value the output has to state does not
313/// evaluate, a type has no Python shape, or two declarations would take
314/// one name in one scope — never a silently dropped declaration.
315pub fn python_module(ast: &Ast, applied: &Applied) -> Result<Module, MaterialiseError> {
316    let mut items: Vec<Pending> = Vec::new();
317
318    // The same walk the C and Java columns use. Python needs no
319    // declaration before use — `from __future__ import annotations`
320    // makes every annotation a string, evaluated only when something
321    // asks — so the order is not load-bearing for the interpreter. It is
322    // load-bearing for the output, which must not move between two runs
323    // over one model.
324    for id in ordered_types(ast) {
325        let start = items.len();
326        declare(ast, applied, id, &mut items)?;
327        let Ok(node) = ast.type_(id) else {
328            continue;
329        };
330        let module = node.module;
331        let name = escape_name(&node.name);
332        verbatim::weave_with(
333            verbatim::declaration_slots(ast, id, verbatim::aliases::PYTHON),
334            start,
335            &mut items,
336            |text| Pending {
337                module,
338                statement: Statement::Verbatim(text),
339            },
340            |declared, begin, end| {
341                for pending in declared {
342                    if verbatim::Carrier::take_into_body(&mut pending.statement, &name, begin, end)
343                    {
344                        return;
345                    }
346                }
347            },
348        );
349    }
350
351    // Constants and exceptions are not types, so they are walked
352    // separately — the model keeps them in arenas of their own, and the
353    // sibling materialisers do the same.
354    for (id, node) in ast.constants() {
355        if ghost_created_for(applied, node.node_id, "PythonConst").is_none() {
356            continue;
357        }
358        let _ = id;
359        // `eval_const` and not `eval_integral`: `const string MODULE_ID`
360        // evaluates perfectly well and is not a number.
361        let value = ast
362            .eval_const(node.expr)
363            .map_err(|_| MaterialiseError::Unevaluated {
364                construct: "constant's value".to_string(),
365                at: node.name.clone(),
366            })?;
367        let (type_, kind) = match node.type_.resolved() {
368            Some(target) => (
369                python_member_type(ast, target, &node.name)?,
370                scalar_kind(ast, target),
371            ),
372            None => (alias(INT32), Some(PrimitiveKind::Int32)),
373        };
374        let value = constant_expr(kind, &value, &node.name)?;
375        items.push(Pending {
376            module: node.module,
377            statement: Statement::Annotated(Annotated {
378                doc: renamed_note(&node.name),
379                name: Ident::new(&escape_name(&node.name)),
380                // `typing.Final`: a constant that something reassigned
381                // would be a constant only in the IDL.
382                type_: Type::Subscript {
383                    base: Path::new(&["typing", "Final"]),
384                    args: vec![type_],
385                },
386                value: Some(value),
387            }),
388        });
389    }
390
391    for (id, node) in ast.exceptions() {
392        if ghost_created_for(applied, node.node_id, "PythonError").is_none() {
393            continue;
394        }
395        let _ = id;
396        let mut body = Vec::new();
397        for member in &node.members {
398            let Ok(member_node) = ast.member(*member) else {
399                return Err(MaterialiseError::Unevaluated {
400                    construct: "exception member the model listed".to_string(),
401                    at: node.name.clone(),
402                });
403            };
404            let Some(target) = member_node.type_.resolved() else {
405                return Err(MaterialiseError::Unevaluated {
406                    construct: "exception member type".to_string(),
407                    at: member_node.name.clone(),
408                });
409            };
410            body.push(field(
411                &member_node.name,
412                python_member_type(ast, target, &member_node.name)?,
413                member_node.facets.is_optional(),
414            ));
415        }
416        items.push(Pending {
417            module: node.module,
418            statement: dataclass(
419                &node.name,
420                body,
421                Doc::lines(&[
422                    "An exception, as the structure its members make.",
423                    "",
424                    "A dataclass and not a subclass of Exception: raising is a",
425                    "run-time decision the RPC arc makes, and a BaseException",
426                    "subclass carries __traceback__ and args, which are two more",
427                    "attributes than the wire form has.",
428                ]),
429            ),
430        });
431    }
432
433    let mut root = Scope::default();
434    for item in items {
435        insert(ast, &mut root, item)?;
436    }
437    let body = scope_body(root, 0)?;
438    check_scope(&body, 0)?;
439
440    let mut statements = prelude();
441    statements.extend(body);
442    let statements = verbatim::weave_file(ast, verbatim::aliases::PYTHON, statements);
443
444    Ok(Module {
445        doc: Doc::lines(&[
446            "The Python shape of one IDL file.",
447            "",
448            "Generated by zerodds-lang-rules. Do not edit.",
449            "",
450            "Every IDL fact Python cannot enforce in a type is written into",
451            "typing.Annotated metadata: the width of an integer, the bound of a",
452            "string or a sequence, the digit count of a fixed. Read it back with",
453            "typing.get_type_hints(cls, include_extras=True).",
454            "",
455            "Requires Python 3.10 or newer, for dataclasses.dataclass(kw_only=True).",
456        ]),
457        // Every annotation becomes a string, so nothing here has to be
458        // declared before it is named and a nested module class may be
459        // referred to from anywhere in the file.
460        future: vec![Ident::new("annotations")],
461        imports: vec![
462            Import::Module {
463                path: Path::single("dataclasses"),
464                alias: None,
465            },
466            Import::Module {
467                path: Path::single("enum"),
468                alias: None,
469            },
470            Import::Module {
471                path: Path::single("typing"),
472                alias: None,
473            },
474        ],
475        body: statements,
476    })
477}
478
479/// One declaration, kept with the module that declared it.
480struct Pending {
481    /// The IDL module it was declared in.
482    module: Id<ModuleNode>,
483    /// What it materialised as.
484    statement: Statement,
485}
486
487/// `@verbatim` in the Python column: `BEGIN_DECLARATION` and
488/// `END_DECLARATION` open and close a class body — the dataclass of a
489/// structure or a union, the `IntEnum` of an enumeration; an alias takes
490/// them adjacent.
491impl verbatim::Carrier for Statement {
492    fn verbatim(text: String) -> Self {
493        Self::Verbatim(text)
494    }
495
496    fn take_into_body(
497        &mut self,
498        name: &str,
499        begin: &mut Vec<String>,
500        end: &mut Vec<String>,
501    ) -> bool {
502        let Self::Class(class) = self else {
503            return false;
504        };
505        if class.name.as_str() != name {
506            return false;
507        }
508        // A `pass` stays: the text may be nothing but comments, and a
509        // class whose suite holds no statement does not parse.
510        let mut body: Vec<Statement> = begin.drain(..).map(Statement::Verbatim).collect();
511        body.append(&mut class.body);
512        body.extend(end.drain(..).map(Statement::Verbatim));
513        class.body = body;
514        true
515    }
516}
517
518/// One nesting level of the output: a module's declarations and the
519/// modules declared inside it.
520///
521/// A tree and not a list of chains. Grouping by module id alone would
522/// emit `module common { struct Robot; }` and
523/// `module common { module util { … } }` as two classes called `common`
524/// in one scope, which is the `tests/interop-corpus/module-issue41-shape`
525/// arrangement.
526#[derive(Default)]
527struct Scope {
528    /// What this module declares directly, in declaration order.
529    body: Vec<Statement>,
530    /// The modules declared inside it, in first-seen order.
531    children: Vec<(Id<ModuleNode>, String, Scope)>,
532}
533
534/// Put one declaration in the scope its module names.
535///
536/// # Errors
537/// [`MaterialiseError::NoModule`] when the model does not answer for a
538/// module in the chain, or the chain runs past [`MAX_MODULE_DEPTH`].
539fn insert(ast: &Ast, root: &mut Scope, item: Pending) -> Result<(), MaterialiseError> {
540    let chain = module_chain(ast, item.module)?;
541    let mut scope = root;
542    for (id, name) in chain {
543        let at = match scope.children.iter().position(|(seen, _, _)| *seen == id) {
544            Some(at) => at,
545            None => {
546                scope.children.push((id, name, Scope::default()));
547                scope.children.len() - 1
548            }
549        };
550        scope = &mut scope
551            .children
552            .get_mut(at)
553            .ok_or_else(|| MaterialiseError::NoModule {
554                at: module_handle(item.module),
555            })?
556            .2;
557    }
558    scope.body.push(item.statement);
559    Ok(())
560}
561
562/// A scope's statements, with each module inside it as a nested class.
563///
564/// # Errors
565/// [`MaterialiseError::NoModule`] when the nesting runs past
566/// [`MAX_MODULE_DEPTH`], which `insert` has already bounded.
567///
568/// zerodds-lint: recursion-depth 64 (module nesting; bounded by `MAX_MODULE_DEPTH`)
569fn scope_body(scope: Scope, depth: usize) -> Result<Vec<Statement>, MaterialiseError> {
570    if depth > MAX_MODULE_DEPTH {
571        return Err(MaterialiseError::NoModule {
572            at: format!("the nesting runs past {MAX_MODULE_DEPTH}"),
573        });
574    }
575    let mut out = scope.body;
576    for (_, name, child) in scope.children {
577        let body = scope_body(child, depth + 1)?;
578        out.push(Statement::Class(Class {
579            doc: Doc::line("An IDL module, as the class its declarations nest in."),
580            decorators: Vec::new(),
581            name: Ident::new(&name),
582            bases: Vec::new(),
583            // A class body has to hold something. An IDL module that
584            // declared nothing this column emits would otherwise render
585            // as a header with no block under it, which is a
586            // SyntaxError.
587            body: if body.is_empty() {
588                vec![Statement::Pass]
589            } else {
590                body
591            },
592        }));
593    }
594    Ok(out)
595}
596
597/// The modules a declaration nests in, outermost first, with the class
598/// name each takes.
599///
600/// The root module is the anonymous global scope and is the Python
601/// module itself, so it is not a class.
602///
603/// # Errors
604/// [`MaterialiseError::NoModule`] when the model does not answer for a
605/// module, or the parent chain runs past [`MAX_MODULE_DEPTH`]. Returning
606/// the declaration unnested instead would put it in another scope and say
607/// nothing, which is the same class of substitution as writing the wrong
608/// octet.
609fn module_chain(
610    ast: &Ast,
611    module: Id<ModuleNode>,
612) -> Result<Vec<(Id<ModuleNode>, String)>, MaterialiseError> {
613    let mut out = Vec::new();
614    let mut current = Some(module);
615    let mut depth = 0;
616    while let Some(id) = current {
617        if depth > MAX_MODULE_DEPTH {
618            return Err(MaterialiseError::NoModule {
619                at: format!(
620                    "{} — the parent chain runs past {MAX_MODULE_DEPTH}",
621                    module_handle(module)
622                ),
623            });
624        }
625        let node = ast.module(id).map_err(|_| MaterialiseError::NoModule {
626            at: module_handle(id),
627        })?;
628        // The root has no name and no parent, and produces no class.
629        if node.parent.is_none() {
630            break;
631        }
632        out.push((id, escape_name(&node.name)));
633        current = node.parent;
634        depth += 1;
635    }
636    out.reverse();
637    Ok(out)
638}
639
640/// The module handle, named without the node the model would not answer
641/// for. Slot and generation are what the arena was asked for.
642fn module_handle(module: Id<ModuleNode>) -> String {
643    format!("#{}@{}", module.slot(), module.generation())
644}
645
646/// Refuse two declarations that would take one name in one scope.
647///
648/// Python binds a name; it does not declare one. `class P: ...` twice in
649/// one body is not an error, it is a rebinding, and the second silently
650/// wins. Two IDL names cannot collide before escaping — IDL forbids it —
651/// but `class` and `class_` both escape onto `class_`, and so do a
652/// member named `int` and a member named `int_`.
653///
654/// zerodds-lint: recursion-depth 64 (module nesting; bounded by `MAX_MODULE_DEPTH`)
655fn check_scope(body: &[Statement], depth: usize) -> Result<(), MaterialiseError> {
656    if depth > MAX_MODULE_DEPTH {
657        return Err(MaterialiseError::NoModule {
658            at: format!("the nesting runs past {MAX_MODULE_DEPTH}"),
659        });
660    }
661    let mut seen: Vec<&str> = Vec::new();
662    for statement in body {
663        let name = match statement {
664            Statement::Class(class) => class.name.as_str(),
665            Statement::Annotated(binding) => binding.name.as_str(),
666            Statement::Assign(binding) => binding.name.as_str(),
667            Statement::Function(function) => function.name.as_str(),
668            // The forms the codec pass added to the tree. None of them
669            // binds a name a later declaration could replace — a store
670            // writes into a place that already exists, and the rest are
671            // control flow — and none of them reaches a *materialised*
672            // module at all: this walk sees only what
673            // [`python_module`] built. Named rather than swept up with
674            // a wildcard so that a form which does start binding names
675            // fails here instead of being scoped by omission.
676            Statement::Set { .. }
677            | Statement::Expr(_)
678            | Statement::If { .. }
679            | Statement::For { .. }
680            | Statement::While { .. }
681            | Statement::Break
682            | Statement::Return(_)
683            | Statement::Comment(_)
684            | Statement::Pass
685            | Statement::Verbatim(_) => continue,
686        };
687        if seen.contains(&name) {
688            return Err(MaterialiseError::NoLanguageForm {
689                construct: format!(
690                    "a second declaration binding the name `{name}` in one scope, \
691                     which would silently replace the first"
692                ),
693                language: "Python",
694                at: name.to_string(),
695            });
696        }
697        if PRELUDE_NAMES.contains(&name) {
698            return Err(MaterialiseError::NoLanguageForm {
699                construct: format!(
700                    "a declaration named `{name}`, which is one of the names the \
701                     generated preamble binds"
702                ),
703                language: "Python",
704                at: name.to_string(),
705            });
706        }
707        seen.push(name);
708        if let Statement::Class(class) = statement {
709            check_scope(&class.body, depth + 1)?;
710        }
711    }
712    Ok(())
713}
714
715/// One type's Python declarations, appended where they belong.
716#[allow(clippy::too_many_lines)]
717fn declare(
718    ast: &Ast,
719    applied: &Applied,
720    id: Id<TypeNode>,
721    items: &mut Vec<Pending>,
722) -> Result<(), MaterialiseError> {
723    let Ok(node) = ast.type_(id) else {
724        return Ok(());
725    };
726    match &node.kind {
727        // A primitive, a string, a `fixed`, a sequence and a map are all
728        // spellings Python already has — `list[tuple[K, V]]` needs no
729        // entry class, which is the one place this column declares less
730        // than the Java one. None of them declares anything.
731        TypeKind::Primitive(_)
732        | TypeKind::String { .. }
733        | TypeKind::Fixed { .. }
734        | TypeKind::Sequence { .. }
735        | TypeKind::Map { .. } => {}
736
737        // Neither does an array — but every dimension is evaluated
738        // anyway, and here it has to be: the length is written into the
739        // annotation as that many tuple arguments, so a dimension that
740        // does not evaluate has no spelling at all. The C and Java
741        // columns fail on the same input, and the columns' failure sets
742        // stay comparable.
743        TypeKind::Array { dims, .. } => {
744            for dim in dims {
745                ast.eval_integral(*dim)
746                    .map_err(|_| MaterialiseError::Unevaluated {
747                        construct: "array length".to_string(),
748                        at: named(&node.name, node.node_id),
749                    })?;
750            }
751        }
752
753        // A typedef has no Python declaration; see the module
754        // documentation for why an alias would be a `NameError` at
755        // import. The comment is what keeps it from being a silent
756        // omission: the name, and what it resolved to.
757        TypeKind::Alias { aliased } => {
758            if node.name.is_empty() {
759                return Ok(());
760            }
761            if ghost_created_for(applied, node.node_id, "PythonAlias").is_none() {
762                return Ok(());
763            }
764            let Some(target) = aliased.resolved() else {
765                return Err(MaterialiseError::Unevaluated {
766                    construct: "typedef target".to_string(),
767                    at: node.name.clone(),
768                });
769            };
770            let spelling =
771                zerodds_lang_render::python::type_(&python_member_type(ast, target, &node.name)?);
772            items.push(Pending {
773                module: node.module,
774                statement: Statement::Comment(Doc::line(&format!(
775                    "typedef {spelling} {}; -- not a Python alias, which is evaluated \
776                     eagerly and could not be ordered against the classes it names, so \
777                     every use takes the spelling above.",
778                    node.name
779                ))),
780            });
781        }
782
783        TypeKind::Enumeration { enumerators } => {
784            if ghost_created_for(applied, node.node_id, "PythonEnum").is_none() {
785                return Ok(());
786            }
787            let mut body = Vec::new();
788            for enumerator in enumerators {
789                let Ok(enum_node) = ast.enumerator(*enumerator) else {
790                    return Err(MaterialiseError::Unevaluated {
791                        construct: "enumerator the model listed".to_string(),
792                        at: node.name.clone(),
793                    });
794                };
795                if ghost_created_for(applied, enum_node.node_id, "PythonEnumerator").is_none() {
796                    continue;
797                }
798                let value = ast.effective_enumerator_value(*enumerator).ok_or_else(|| {
799                    MaterialiseError::Unevaluated {
800                        construct: "enumerator value".to_string(),
801                        at: enum_node.name.clone(),
802                    }
803                })?;
804                // Refused outside the 32 bits XTypes 1.3 §7.4.3.5.3
805                // gives an enumerated literal, and written as the number
806                // the source wrote otherwise. The Java column has to
807                // fold a value above `int`'s range onto the pattern it
808                // occupies; a Python `int` holds it, so nothing folds.
809                let value = in_range(
810                    value,
811                    i128::from(i32::MIN),
812                    i128::from(u32::MAX),
813                    "32-bit enumerated literal",
814                    &enum_node.name,
815                )?;
816                body.push(Statement::Assign(Assign {
817                    doc: renamed_note(&enum_node.name),
818                    name: Ident::new(&escape_name(&enum_node.name)),
819                    value: Expr::int(value),
820                }));
821            }
822            items.push(Pending {
823                module: node.module,
824                statement: enum_class(
825                    &node.name,
826                    "IntEnum",
827                    body,
828                    Doc::lines(&[
829                        "An enumeration. An enum.IntEnum member is an int, so the names",
830                        "and the values are one declaration rather than two.",
831                        "",
832                        "A member of this type is nonetheless annotated int and not this",
833                        "class: an @appendable writer may send a value the reader's IDL",
834                        "does not list, and Colour(7) raises ValueError.",
835                    ]),
836                ),
837            });
838        }
839
840        TypeKind::Bitmask { bits } => {
841            if ghost_created_for(applied, node.node_id, "PythonBitmask").is_none() {
842                return Ok(());
843            }
844            let mut body = Vec::new();
845            for bit in bits {
846                let Ok(bit_node) = ast.bit_value(*bit) else {
847                    return Err(MaterialiseError::Unevaluated {
848                        construct: "bitmask bit the model listed".to_string(),
849                        at: node.name.clone(),
850                    });
851                };
852                if ghost_created_for(applied, bit_node.node_id, "PythonBitConst").is_none() {
853                    continue;
854                }
855                let position = ast.effective_bit_position(*bit).ok_or_else(|| {
856                    MaterialiseError::Unevaluated {
857                        construct: "bit position".to_string(),
858                        at: bit_node.name.clone(),
859                    }
860                })?;
861                // `1 << 40` is an `int` shift in Python and it is 2^40;
862                // the Java column needs `1L` here because `1 << 40` is
863                // an int shift there and evaluates to 256.
864                body.push(Statement::Assign(Assign {
865                    doc: renamed_note(&bit_node.name),
866                    name: Ident::new(&escape_name(&bit_node.name)),
867                    value: Expr::Binary {
868                        op: "<<",
869                        lhs: Box::new(Expr::int(1)),
870                        rhs: Box::new(Expr::int(i128::from(position))),
871                    },
872                }));
873            }
874            items.push(Pending {
875                module: node.module,
876                statement: enum_class(
877                    &node.name,
878                    "IntFlag",
879                    body,
880                    Doc::lines(&[
881                        "A bitmask. An enum.IntFlag member is an int and combines with |,",
882                        "which is what a bitmask is for.",
883                        "",
884                        "A member of this type is annotated int: a writer may set a bit",
885                        "this declaration does not name.",
886                    ]),
887                ),
888            });
889        }
890
891        TypeKind::Bitset { .. } => {
892            if ghost_created_for(applied, node.node_id, "PythonBitset").is_none() {
893                return Ok(());
894            }
895            let mut body = Vec::new();
896            // The base's bitfields are fields of the derived class too:
897            // an inherited bitset is one flat run.
898            for bitfield in &ast.all_bitfields_of(id) {
899                let Ok(field_node) = ast.bitfield(*bitfield) else {
900                    return Err(MaterialiseError::Unevaluated {
901                        construct: "bitset field the model listed".to_string(),
902                        at: node.name.clone(),
903                    });
904                };
905                // An anonymous field is padding: it moves the next field
906                // along and carries no value of its own.
907                let Some(field_name) = field_node.name.as_ref() else {
908                    continue;
909                };
910                body.push(field(field_name, bit_width(field_node.bits), false));
911            }
912            items.push(Pending {
913                module: node.module,
914                statement: dataclass(
915                    &node.name,
916                    body,
917                    Doc::lines(&[
918                        "A bitset. Each field states its own bit width in the metadata;",
919                        "Python has no fixed-width integer, so nothing is rounded up to",
920                        "a holder the way the C and Java columns have to.",
921                        "",
922                        "The packing into the anchor is the codec's.",
923                    ]),
924                ),
925            });
926        }
927
928        TypeKind::Structure { .. } | TypeKind::Valuetype { .. } => {
929            let created = if matches!(node.kind, TypeKind::Valuetype { .. }) {
930                "PythonValue"
931            } else {
932                "PythonStruct"
933            };
934            if ghost_created_for(applied, node.node_id, created).is_none() {
935                return Ok(());
936            }
937            // XTypes 1.3 §7.5.1.4.1.3: the base is the first member `parent`, an
938            // instance of the base's dataclass, and not Python inheritance: a dataclass
939            // that inherited would also inherit the base's decorator arguments, which is
940            // a second place the shape is decided. The wire still carries the base's
941            // members first (§7.4.1.1.5.1).
942            //
943            // Every member is emitted, with no per-member rule lookup: a
944            // member whose lookup missed would vanish from the class and
945            // the file would still import. The C and Java columns take
946            // the same view.
947            let mut body = Vec::new();
948            let mut spelled = Vec::new();
949            if let Some(base) = super::c::struct_base(ast, id) {
950                spelled.push((escape_name("parent"), "parent".to_string()));
951                body.push(field(
952                    "parent",
953                    python_member_type(ast, base, "parent")?,
954                    false,
955                ));
956            }
957            for member in ast.members_of(id).iter().copied() {
958                let Ok(member_node) = ast.member(member) else {
959                    return Err(MaterialiseError::Unevaluated {
960                        construct: "member the model listed".to_string(),
961                        at: node.name.clone(),
962                    });
963                };
964                let Some(target) = member_node.type_.resolved() else {
965                    return Err(MaterialiseError::Unevaluated {
966                        construct: "member type".to_string(),
967                        at: member_node.name.clone(),
968                    });
969                };
970                spelled.push((escape_name(&member_node.name), member_node.name.clone()));
971                body.push(field(
972                    &member_node.name,
973                    python_member_type(ast, target, &member_node.name)?,
974                    member_node.facets.is_optional(),
975                ));
976            }
977            // `check_scope` catches this too, at the end, over the
978            // rendered statements — but by then the IDL names are gone
979            // and it can only name the spelling the two members share.
980            // Here both are still in hand.
981            no_spelling_collision(
982                spelled
983                    .iter()
984                    .map(|(field, declared)| (field.as_str(), declared.as_str())),
985                "Python",
986                &node.name,
987            )?;
988            items.push(Pending {
989                module: node.module,
990                statement: dataclass(&node.name, body, Doc::none()),
991            });
992        }
993
994        TypeKind::Union { discriminator, .. } => {
995            if ghost_created_for(applied, node.node_id, "PythonUnion").is_none() {
996                return Ok(());
997            }
998            let Some(disc) = discriminator.resolved() else {
999                return Err(MaterialiseError::Unevaluated {
1000                    construct: "union discriminator".to_string(),
1001                    at: node.name.clone(),
1002                });
1003            };
1004            // A field per case and not one per type: two cases may carry
1005            // the same type, and Python has no untagged union to overlay
1006            // them with. Which field is live is what `_d` says.
1007            let mut body = vec![Statement::Annotated(Annotated {
1008                doc: Doc::line("The discriminator. Which case field is live is what this says."),
1009                name: Ident::new("_d"),
1010                type_: python_member_type(ast, disc, &node.name)?,
1011                value: None,
1012            })];
1013            for case in ast.cases_of(id) {
1014                let Ok(case_node) = ast.case(*case) else {
1015                    return Err(MaterialiseError::Unevaluated {
1016                        construct: "union case the model listed".to_string(),
1017                        at: node.name.clone(),
1018                    });
1019                };
1020                if ghost_created_for(applied, case_node.node_id, "PythonCase").is_none() {
1021                    continue;
1022                }
1023                let Ok(member) = ast.member(case_node.member) else {
1024                    return Err(MaterialiseError::Unevaluated {
1025                        construct: "member behind a union case the model listed".to_string(),
1026                        at: node.name.clone(),
1027                    });
1028                };
1029                let Some(target) = member.type_.resolved() else {
1030                    return Err(MaterialiseError::Unevaluated {
1031                        construct: "union case type".to_string(),
1032                        at: member.name.clone(),
1033                    });
1034                };
1035                // Optional and defaulting to None, because at most one
1036                // case is live: a required field per case would make
1037                // constructing the union mean supplying every branch.
1038                body.push(field(
1039                    &member.name,
1040                    python_member_type(ast, target, &member.name)?,
1041                    true,
1042                ));
1043            }
1044            items.push(Pending {
1045                module: node.module,
1046                statement: dataclass(
1047                    &node.name,
1048                    body,
1049                    Doc::lines(&[
1050                        "A union: the discriminator, and one field per case.",
1051                        "",
1052                        "Every case field is Optional and defaults to None, because at",
1053                        "most one of them is live. Which one is what _d says; Python has",
1054                        "no way to state that in a type.",
1055                    ]),
1056                ),
1057            });
1058        }
1059
1060        // A native type is opaque and has no Python declaration — a
1061        // comment records the name and what a use of it takes.
1062        TypeKind::Native => {
1063            if ghost_created_for(applied, node.node_id, "PythonNative").is_none() {
1064                return Ok(());
1065            }
1066            items.push(Pending {
1067                module: node.module,
1068                statement: Statement::Comment(Doc::line(&format!(
1069                    "native {}; -- every use takes {}.{}",
1070                    node.name,
1071                    zerodds_lang_render::python::type_(&native_type()),
1072                    native_note(ast, id),
1073                ))),
1074            });
1075        }
1076
1077        // A value box is a reference in Python already: the type, or
1078        // None. Nothing to declare.
1079        TypeKind::ValueBox { .. } => {}
1080
1081        // An object reference belongs to the RPC arc and an annotation
1082        // type has no runtime form at all — `zerodds_ast_layout` refuses
1083        // to plan either — so a Python declaration would be a shape
1084        // nothing could serialise. `any` and `ValueBase` are in the
1085        // model because Building Block Any is one of IDL 4.2's fifteen,
1086        // and what they hold is decided at run time. All four are named
1087        // here rather than passed over, and `python_member_type` refuses
1088        // them by name where a member would carry one.
1089        TypeKind::ObjectRef { .. }
1090        | TypeKind::AnnotationType { .. }
1091        | TypeKind::Any
1092        | TypeKind::ValueBase => {}
1093    }
1094    Ok(())
1095}
1096
1097/// The Python annotation of a value in a position that has to hold one,
1098/// refusing the constructs Python cannot hold.
1099///
1100/// `any` is the first of those. What it holds is decided at run time —
1101/// there is no Python type for it and no wire form to generate a codec
1102/// against. Emitting `a: object` into the class and reporting success
1103/// produces a field no encoder can write, from a generator that said
1104/// nothing was wrong. Python makes this refusal harder than the other
1105/// four columns do, because `object` would type-check.
1106///
1107/// # Errors
1108/// [`MaterialiseError::NoLanguageForm`] for `any`, `ValueBase`, an object
1109/// reference and an annotation type;
1110/// [`MaterialiseError::Unevaluated`] for a type or a bound the model will
1111/// not answer for.
1112pub fn python_member_type(
1113    ast: &Ast,
1114    target: Id<TypeNode>,
1115    at: &str,
1116) -> Result<Type, MaterialiseError> {
1117    type_at(ast, target, at, 0)
1118}
1119
1120/// [`python_member_type`], carrying how deep the spelling has nested.
1121///
1122/// zerodds-lint: recursion-depth 64 (collection and typedef nesting;
1123/// bounded by `MAX_TYPE_DEPTH`, which returns
1124/// `MaterialiseError::Unevaluated` naming the spelling that nested)
1125#[allow(clippy::too_many_lines)]
1126fn type_at(ast: &Ast, id: Id<TypeNode>, at: &str, depth: usize) -> Result<Type, MaterialiseError> {
1127    if depth > MAX_TYPE_DEPTH {
1128        return Err(MaterialiseError::Unevaluated {
1129            construct: format!("type, whose Python spelling nests past {MAX_TYPE_DEPTH}"),
1130            at: at.to_string(),
1131        });
1132    }
1133    let node = ast.type_(id).map_err(|_| MaterialiseError::Unevaluated {
1134        construct: "type".to_string(),
1135        at: at.to_string(),
1136    })?;
1137    let refused = match &node.kind {
1138        TypeKind::Any => Some("any"),
1139        TypeKind::ValueBase => Some("ValueBase"),
1140        // An object reference is a handle into the RPC arc and
1141        // `coverage.rs` says so; there is no Python data type for it.
1142        TypeKind::ObjectRef { .. } => Some("Object"),
1143        // An annotation type has no runtime form, so nothing declares
1144        // one and a reference to it would name a class that is not
1145        // there.
1146        TypeKind::AnnotationType { .. } => Some("annotation type"),
1147        _ => None,
1148    };
1149    if let Some(construct) = refused {
1150        return Err(MaterialiseError::NoLanguageForm {
1151            construct: construct.to_string(),
1152            language: "Python",
1153            at: at.to_string(),
1154        });
1155    }
1156    Ok(match &node.kind {
1157        TypeKind::Primitive(kind) => alias(primitive_alias(*kind)),
1158        TypeKind::String { width, bound } => annotated(
1159            Type::name("str"),
1160            call(
1161                "_IdlText",
1162                vec![
1163                    kw("bits", Expr::int(char_bits(*width))),
1164                    kw("bound", Expr::int(bound_of(ast, *bound, at)?)),
1165                ],
1166            ),
1167        ),
1168        TypeKind::Fixed { digits, scale } => annotated(
1169            Type::name("str"),
1170            call(
1171                "_IdlFixed",
1172                vec![
1173                    kw("digits", Expr::int(i128::from(*digits))),
1174                    kw("scale", Expr::int(i128::from(*scale))),
1175                ],
1176            ),
1177        ),
1178        TypeKind::Sequence { element, bound } => {
1179            let Some(element) = element.resolved() else {
1180                return Err(MaterialiseError::Unevaluated {
1181                    construct: "sequence element".to_string(),
1182                    at: at.to_string(),
1183                });
1184            };
1185            let list = Type::subscript(
1186                Path::single("list"),
1187                vec![type_at(ast, element, at, depth + 1)?],
1188            );
1189            match bound_of(ast, *bound, at)? {
1190                0 => list,
1191                length => annotated(
1192                    list,
1193                    call("_IdlSequence", vec![kw("bound", Expr::int(length))]),
1194                ),
1195            }
1196        }
1197        TypeKind::Array { element, dims } => {
1198            let Some(element) = element.resolved() else {
1199                return Err(MaterialiseError::Unevaluated {
1200                    construct: "array element".to_string(),
1201                    at: at.to_string(),
1202                });
1203            };
1204            let mut out = type_at(ast, element, at, depth + 1)?;
1205            // Innermost dimension first, so the outer one ends up
1206            // outermost: `long a[2][3]` is two rows of three.
1207            for dim in dims.iter().rev() {
1208                let length =
1209                    ast.eval_integral(*dim)
1210                        .map_err(|_| MaterialiseError::Unevaluated {
1211                            construct: "array length".to_string(),
1212                            at: at.to_string(),
1213                        })?;
1214                let length =
1215                    usize::try_from(length).map_err(|_| MaterialiseError::NoLanguageForm {
1216                        construct: format!("an array of {length} elements"),
1217                        language: "Python",
1218                        at: at.to_string(),
1219                    })?;
1220                // One tuple argument per element, so a static checker
1221                // rejects a literal of the wrong length. `tuple[T, ...]`
1222                // would say only that it is a tuple.
1223                out = Type::subscript(Path::single("tuple"), vec![out; length]);
1224            }
1225            out
1226        }
1227        TypeKind::Map { key, value, bound } => {
1228            let (Some(key), Some(value)) = (key.resolved(), value.resolved()) else {
1229                return Err(MaterialiseError::Unevaluated {
1230                    construct: "map key or value".to_string(),
1231                    at: at.to_string(),
1232                });
1233            };
1234            let entry = Type::subscript(
1235                Path::single("tuple"),
1236                vec![
1237                    type_at(ast, key, at, depth + 1)?,
1238                    type_at(ast, value, at, depth + 1)?,
1239                ],
1240            );
1241            let list = Type::subscript(Path::single("list"), vec![entry]);
1242            match bound_of(ast, *bound, at)? {
1243                0 => list,
1244                length => annotated(
1245                    list,
1246                    call("_IdlSequence", vec![kw("bound", Expr::int(length))]),
1247                ),
1248            }
1249        }
1250        // A typedef is transparent: the use takes the target's type.
1251        TypeKind::Alias { aliased } => {
1252            let Some(target) = aliased.resolved() else {
1253                return Err(MaterialiseError::Unevaluated {
1254                    construct: "typedef target".to_string(),
1255                    at: at.to_string(),
1256                });
1257            };
1258            type_at(ast, target, at, depth + 1)?
1259        }
1260        // An `int`, with the enumeration named in the metadata. See the
1261        // module documentation: `Colour(7)` raises, and an @appendable
1262        // writer sends exactly that.
1263        TypeKind::Enumeration { .. } | TypeKind::Bitmask { .. } => annotated(
1264            Type::name("int"),
1265            call(
1266                "_IdlEnum",
1267                vec![
1268                    kw("declared", Expr::Path(qualified(ast, id)?)),
1269                    kw("bits", Expr::int(i128::from(ast.effective_bit_bound(id)))),
1270                ],
1271            ),
1272        ),
1273        TypeKind::Structure { .. }
1274        | TypeKind::Union { .. }
1275        | TypeKind::Bitset { .. }
1276        | TypeKind::Valuetype { .. } => Type::Name(qualified(ast, id)?),
1277        TypeKind::Native => native_type(),
1278        // A boxed value is a reference, absent when None — which is what
1279        // `typing.Optional` says.
1280        TypeKind::ValueBox { boxed: inner } => {
1281            let Some(target) = inner.resolved() else {
1282                return Err(MaterialiseError::Unevaluated {
1283                    construct: "value box target".to_string(),
1284                    at: at.to_string(),
1285                });
1286            };
1287            optional(type_at(ast, target, at, depth + 1)?)
1288        }
1289        TypeKind::Any
1290        | TypeKind::ValueBase
1291        | TypeKind::ObjectRef { .. }
1292        | TypeKind::AnnotationType { .. } => {
1293            // Refused above; the arm keeps the match total, so a kind
1294            // added to the model without a Python answer is a compile
1295            // error and not a field that silently takes another's type.
1296            return Err(MaterialiseError::NoLanguageForm {
1297                construct: node.kind.as_str().to_string(),
1298                language: "Python",
1299                at: at.to_string(),
1300            });
1301        }
1302    })
1303}
1304
1305/// The alias an IDL primitive takes.
1306///
1307/// Exhaustive, so a primitive added to the model without a Python
1308/// spelling is a compile error and not a member that silently takes
1309/// another's.
1310#[must_use]
1311pub const fn primitive_alias(kind: PrimitiveKind) -> &'static str {
1312    match kind {
1313        PrimitiveKind::Boolean => "bool",
1314        PrimitiveKind::Char => CHAR,
1315        PrimitiveKind::WChar => WCHAR,
1316        PrimitiveKind::Int8 => "_Int8",
1317        PrimitiveKind::Octet | PrimitiveKind::UInt8 => "_UInt8",
1318        PrimitiveKind::Int16 => "_Int16",
1319        PrimitiveKind::UInt16 => "_UInt16",
1320        PrimitiveKind::Int32 => INT32,
1321        PrimitiveKind::UInt32 => "_UInt32",
1322        PrimitiveKind::Int64 => "_Int64",
1323        PrimitiveKind::UInt64 => "_UInt64",
1324        PrimitiveKind::Float32 => "_Float32",
1325        PrimitiveKind::Float64 => "_Float64",
1326        // A `str` holding the decimal, and never a Python `float`: see
1327        // the module documentation.
1328        PrimitiveKind::Float128 => "_Float128",
1329    }
1330}
1331
1332/// The alias `long` takes, which is also what an unresolved constant
1333/// falls back to.
1334const INT32: &str = "_Int32";
1335/// The alias an IDL `char` takes.
1336const CHAR: &str = "_Char";
1337/// The alias an IDL `wchar` takes.
1338const WCHAR: &str = "_WChar";
1339
1340/// How wide one character of `width` is on the wire.
1341const fn char_bits(width: CharWidth) -> i128 {
1342    match width {
1343        CharWidth::Char => 8,
1344        CharWidth::WChar => 16,
1345    }
1346}
1347
1348/// A bound as a number, with `0` meaning unbounded.
1349///
1350/// # Errors
1351/// [`MaterialiseError::Unevaluated`] when the bounding expression does
1352/// not evaluate. Zero is what unbounded is written as, so a bound that
1353/// failed to evaluate must not fall back to it: the two would be
1354/// indistinguishable in the output.
1355fn bound_of(ast: &Ast, bound: Bound, at: &str) -> Result<i128, MaterialiseError> {
1356    match bound.expr() {
1357        None => Ok(0),
1358        Some(expr) => ast
1359            .eval_integral(expr)
1360            .map_err(|_| MaterialiseError::Unevaluated {
1361                construct: "bound".to_string(),
1362                at: at.to_string(),
1363            }),
1364    }
1365}
1366
1367/// What a `native` declaration maps to.
1368///
1369/// `object`, always. A `#pragma` or an annotation may give a mapping
1370/// spelling, and it is written into the comment rather than into the
1371/// annotation: a Python annotation naming a class this file does not
1372/// import would not resolve, and a generated module imports only
1373/// `dataclasses`, `enum` and `typing`.
1374fn native_type() -> Type {
1375    Type::name("object")
1376}
1377
1378/// The mapping a `native` declaration named, as a clause of its comment.
1379fn native_note(ast: &Ast, id: Id<TypeNode>) -> String {
1380    let mapping = ast
1381        .any_of_type(id)
1382        .ok()
1383        .and_then(|any| ast.facets(any))
1384        .and_then(|facets| facets.corba.as_ref())
1385        .and_then(|corba| corba.native_mapping.clone());
1386    match mapping {
1387        Some(spelling) => format!(
1388            " The source named the mapping `{spelling}`, which this module does not import."
1389        ),
1390        None => String::new(),
1391    }
1392}
1393
1394/// A generated type's path, written from the module root down.
1395///
1396/// `a.b.Point` and not `Point`: a nested module is a class, and a class
1397/// body is not a scope an annotation is resolved against.
1398///
1399/// # Errors
1400/// [`MaterialiseError::NoModule`] when the model will not answer for a
1401/// module the type nests in.
1402fn qualified(ast: &Ast, id: Id<TypeNode>) -> Result<Path, MaterialiseError> {
1403    let node = ast.type_(id).map_err(|_| MaterialiseError::Unevaluated {
1404        construct: "type".to_string(),
1405        at: "the module root".to_string(),
1406    })?;
1407    let mut segments = Vec::new();
1408    for (_, name) in module_chain(ast, node.module)? {
1409        segments.push(name);
1410    }
1411    segments.push(escape_name(&node.name));
1412    Ok(Path::new(
1413        &segments.iter().map(String::as_str).collect::<Vec<&str>>(),
1414    ))
1415}
1416
1417/// The path a generated declaration of `id` is reached by.
1418///
1419/// `qualified` under a public name, because the codec backend needs it
1420/// and is in a different module. A codec sits at the file root, where a
1421/// class nested in `class a: class b:` is `a.b.Point` and `Point` names
1422/// nothing — the same reason the annotations this file writes are
1423/// qualified.
1424///
1425/// # Errors
1426/// As `qualified`.
1427pub fn declared_path(ast: &Ast, id: Id<TypeNode>) -> Result<Path, MaterialiseError> {
1428    qualified(ast, id)
1429}
1430
1431/// The bound a `string`, a `sequence` or a `map` carries, as the suffix
1432/// that keeps two of them apart.
1433///
1434/// The tag names the routine a hoisted collection's codec is emitted
1435/// under, and one body is emitted per name. With the bound left out,
1436/// `sequence<long>` and `sequence<long, 4>` are one name and whichever
1437/// was declared first decides: measured on
1438/// `@final struct S { sequence<long> b; sequence<long, 4> a; };`, the
1439/// generated Python held no `zd_over` check at all, so `a` accepted a
1440/// stream of any length; with the members the other way round both got
1441/// the bounded body, so `b` refused — and truncated — a length the IDL
1442/// puts no limit on. `string<4>` beside `string` and `wstring<4>`
1443/// beside `wstring` collapse the same way, onto the trimming call.
1444/// Either way the reader answered with a value the IDL does not
1445/// describe and said nothing, which is why the bound is part of the
1446/// name. The same suffix the Zig column carries.
1447///
1448/// An `array` needs nothing here: its `dims` are already in the tag,
1449/// under the `Array` arm below.
1450fn bound_tag(ast: &Ast, bound: &zerodds_ast::Bound) -> String {
1451    match bound.expr() {
1452        None => String::new(),
1453        // `_b` and not a bare `_n`: `seq_long_4` is what the `Array`
1454        // arm answers for an array of four `long`, and the two are
1455        // different wire forms.
1456        Some(expr) => match ast.eval_integral(expr) {
1457            Ok(limit) => format!("_b{limit}"),
1458            // As the array's `_x`: a bound that does not evaluate is
1459            // still not the unbounded form, and the materialiser
1460            // reports the expression that would not evaluate.
1461            Err(_) => "_bx".to_string(),
1462        },
1463    }
1464}
1465
1466/// The name a codec function takes for the type it codes.
1467///
1468/// The **IDL kind** and not the Python spelling, and in this column that
1469/// distinction does more work than in any other: `octet`, `unsigned
1470/// short`, `long` and `unsigned long long` are all `int` in Python, and
1471/// they are one, two, four and eight octets on the wire. A tag built
1472/// from the Python type would give every one of them the same codec, and
1473/// the first type reached would decide the width for the rest with
1474/// nothing to say it had.
1475///
1476/// An alias is followed through: two IDL names for one shape are one
1477/// wire form, and two codecs for it would be two chances to differ.
1478///
1479/// # Errors
1480/// [`MaterialiseError`] when a module will not answer, or when the name
1481/// nests past `MAX_TYPE_DEPTH`.
1482///
1483/// zerodds-lint: recursion-depth 64 (collection nesting; bounded by `MAX_TYPE_DEPTH`)
1484pub fn type_tag(ast: &Ast, id: Id<TypeNode>, depth: usize) -> Result<String, MaterialiseError> {
1485    if depth > MAX_TYPE_DEPTH {
1486        return Err(MaterialiseError::Unevaluated {
1487            construct: format!("type, whose codec name nests past {MAX_TYPE_DEPTH}"),
1488            at: "the module root".to_string(),
1489        });
1490    }
1491    let Ok(node) = ast.type_(id) else {
1492        return Ok("unknown".to_string());
1493    };
1494    Ok(match &node.kind {
1495        TypeKind::Primitive(kind) => kind.as_str().replace(' ', "_"),
1496        TypeKind::String {
1497            width: CharWidth::Char,
1498            bound,
1499        } => format!("string{}", bound_tag(ast, bound)),
1500        TypeKind::String {
1501            width: CharWidth::WChar,
1502            bound,
1503        } => format!("wstring{}", bound_tag(ast, bound)),
1504        TypeKind::Fixed { digits, scale } => format!("fixed_{digits}_{scale}"),
1505        TypeKind::Sequence { element, bound } => match element.resolved() {
1506            Some(element) => format!(
1507                "seq{}_{}",
1508                bound_tag(ast, bound),
1509                type_tag(ast, element, depth + 1)?
1510            ),
1511            None => format!("seq_unknown{}", bound_tag(ast, bound)),
1512        },
1513        TypeKind::Array { element, dims } => {
1514            let mut name = match element.resolved() {
1515                Some(element) => format!("arr_{}", type_tag(ast, element, depth + 1)?),
1516                None => "arr_unknown".to_string(),
1517            };
1518            for dim in dims {
1519                match ast.eval_integral(*dim) {
1520                    Ok(length) => name.push_str(&format!("_{length}")),
1521                    // No number rather than a zero: the name has to
1522                    // differ from a `[0]` array's, and `declare` reports
1523                    // the dimension that would not evaluate by name.
1524                    Err(_) => name.push_str("_x"),
1525                }
1526            }
1527            name
1528        }
1529        TypeKind::Map { key, value, bound } => {
1530            let half = |half: Option<Id<TypeNode>>| match half {
1531                Some(target) => type_tag(ast, target, depth + 1),
1532                None => Ok("unknown".to_string()),
1533            };
1534            format!(
1535                "map{}_{}_{}",
1536                bound_tag(ast, bound),
1537                half(key.resolved())?,
1538                half(value.resolved())?
1539            )
1540        }
1541        TypeKind::Alias { aliased } => match aliased.resolved() {
1542            Some(target) => type_tag(ast, target, depth + 1)?,
1543            None => "unknown".to_string(),
1544        },
1545        TypeKind::ValueBox { boxed } => match boxed.resolved() {
1546            Some(target) => format!("box_{}", type_tag(ast, target, depth + 1)?),
1547            None => "box_unknown".to_string(),
1548        },
1549        TypeKind::Structure { .. }
1550        | TypeKind::Union { .. }
1551        | TypeKind::Enumeration { .. }
1552        | TypeKind::Bitmask { .. }
1553        | TypeKind::Bitset { .. }
1554        | TypeKind::Valuetype { .. }
1555        | TypeKind::Native => {
1556            let mut segments = Vec::new();
1557            for (_, name) in module_chain(ast, node.module)? {
1558                segments.push(name);
1559            }
1560            segments.push(escape_name(&node.name));
1561            segments.join("_")
1562        }
1563        TypeKind::Any => "any".to_string(),
1564        TypeKind::ValueBase => "valuebase".to_string(),
1565        TypeKind::ObjectRef { .. } => "objectref".to_string(),
1566        TypeKind::AnnotationType { .. } => "annotation".to_string(),
1567    })
1568}
1569
1570// -- building the tree ----------------------------------------------------
1571
1572/// `name = typing.Annotated[…]`, referred to by its alias.
1573fn alias(name: &'static str) -> Type {
1574    Type::name(name)
1575}
1576
1577/// `typing.Annotated[base, marker]`.
1578fn annotated(base: Type, marker: Expr) -> Type {
1579    Type::Annotated {
1580        base: Box::new(base),
1581        markers: vec![marker],
1582    }
1583}
1584
1585/// `typing.Optional[inner]`.
1586fn optional(inner: Type) -> Type {
1587    Type::Optional(Box::new(inner))
1588}
1589
1590/// `name(args)`.
1591fn call(name: &str, args: Vec<Arg>) -> Expr {
1592    Expr::Call {
1593        func: Path::single(name),
1594        args,
1595    }
1596}
1597
1598/// `name=value`, as a call argument.
1599fn kw(name: &str, value: Expr) -> Arg {
1600    Arg::Keyword(Keyword {
1601        name: Ident::new(name),
1602        value,
1603    })
1604}
1605
1606/// The metadata of an integer of `bits` bits, unsigned.
1607///
1608/// What a bitset's bitfield takes. The declared holder of
1609/// `bitfield<1, boolean>` names a range and not a wire form, and the
1610/// width is what the packing uses.
1611fn bit_width(bits: u16) -> Type {
1612    annotated(
1613        Type::name("int"),
1614        call(
1615            "_IdlInt",
1616            vec![
1617                kw("bits", Expr::int(i128::from(bits))),
1618                kw("signed", Expr::Literal(Literal::Bool(false))),
1619            ],
1620        ),
1621    )
1622}
1623
1624/// One dataclass field.
1625///
1626/// An `@optional` member defaults to `None` and a required one has no
1627/// default at all, so constructing an aggregate without a required
1628/// member is an error rather than a silently zero-filled sample.
1629///
1630/// A member whose name was escaped carries the IDL name in
1631/// `dataclasses.field(metadata=…)`, which is machine-readable — a
1632/// comment would not be.
1633fn field(name: &str, type_: Type, is_optional: bool) -> Statement {
1634    let escaped = escape_name(name);
1635    let renamed = escaped != name;
1636    let type_ = if is_optional { optional(type_) } else { type_ };
1637    let value = match (is_optional, renamed) {
1638        (false, false) => None,
1639        (true, false) => Some(Expr::Literal(Literal::Null)),
1640        (_, true) => {
1641            let mut args = Vec::new();
1642            if is_optional {
1643                args.push(kw("default", Expr::Literal(Literal::Null)));
1644            }
1645            args.push(kw(
1646                "metadata",
1647                Expr::Dict(vec![(
1648                    Expr::Literal(Literal::string("idl")),
1649                    Expr::Literal(Literal::string(name)),
1650                )]),
1651            ));
1652            Some(Expr::Call {
1653                func: Path::new(&["dataclasses", "field"]),
1654                args,
1655            })
1656        }
1657    };
1658    Statement::Annotated(Annotated {
1659        doc: Doc::none(),
1660        name: Ident::new(&escaped),
1661        type_,
1662        value,
1663    })
1664}
1665
1666/// `@dataclasses.dataclass(kw_only=True) class <name>: …`
1667///
1668/// `kw_only=True` because the field order is the IDL's and the defaults
1669/// are not: an `@optional` member defaults to `None` and the member
1670/// after it does not, and a positional dataclass refuses a field with no
1671/// default after one with a default. Reordering the fields to satisfy
1672/// that would make the class's field order differ from the IDL's, which
1673/// is the order the wire uses.
1674///
1675/// Mutable and comparing by value: the defaults. A frozen dataclass
1676/// could not be filled in as a sample is decoded, and `eq=True` is what
1677/// makes two decodes of one sample compare equal, which is what a
1678/// round-trip test asserts.
1679fn dataclass(name: &str, body: Vec<Statement>, doc: Doc) -> Statement {
1680    let escaped = escape_name(name);
1681    let mut doc = doc;
1682    if escaped != name && doc.is_empty() {
1683        doc = Doc::line(&format!("IDL name: {name}"));
1684    } else if escaped != name {
1685        doc.push("");
1686        doc.push(&format!("IDL name: {name}"));
1687    }
1688    Statement::Class(Class {
1689        doc,
1690        decorators: vec![Decorator {
1691            path: Path::new(&["dataclasses", "dataclass"]),
1692            args: vec![kw("kw_only", Expr::Literal(Literal::Bool(true)))],
1693        }],
1694        name: Ident::new(&escaped),
1695        bases: Vec::new(),
1696        body: if body.is_empty() {
1697            vec![Statement::Pass]
1698        } else {
1699            body
1700        },
1701    })
1702}
1703
1704/// `class <name>(enum.<base>): …`
1705fn enum_class(name: &str, base: &str, body: Vec<Statement>, doc: Doc) -> Statement {
1706    let escaped = escape_name(name);
1707    let mut doc = doc;
1708    if escaped != name {
1709        doc.push("");
1710        doc.push(&format!("IDL name: {name}"));
1711    }
1712    Statement::Class(Class {
1713        doc,
1714        decorators: Vec::new(),
1715        name: Ident::new(&escaped),
1716        bases: vec![Type::Name(Path::new(&["enum", base]))],
1717        // An IDL enumeration with no enumerators is legal in the model
1718        // and a class with an empty body is not legal Python.
1719        body: if body.is_empty() {
1720            vec![Statement::Pass]
1721        } else {
1722            body
1723        },
1724    })
1725}
1726
1727/// The comment a renamed declaration carries, and nothing when it kept
1728/// its name.
1729fn renamed_note(name: &str) -> Doc {
1730    if escape_name(name) == name {
1731        return Doc::none();
1732    }
1733    Doc::line(&format!("IDL name: {name}"))
1734}
1735
1736// -- the preamble ---------------------------------------------------------
1737
1738/// The marker classes and the width aliases every generated module
1739/// carries.
1740///
1741/// The same in every file, whether or not a given alias is used. Two
1742/// reasons, and the second is the one that decides it:
1743///
1744/// - the preamble is the vocabulary the rest of the file is written in,
1745///   so a reader learns it once and a diff between two generated modules
1746///   is only the declarations;
1747/// - it is the contract with the codec pass. A module that dropped
1748///   `_IdlFixed` because no member happened to be a `fixed` would make
1749///   `getattr(module, "_IdlFixed")` succeed or fail depending on the
1750///   IDL, which is a dependency on the input that nothing declares.
1751///
1752/// The markers are per-module classes, so `_IdlInt` in two generated
1753/// files are two classes. That is deliberate: a shared one would make
1754/// every generated module import a runtime package, and this layer emits
1755/// types and nothing else.
1756fn prelude() -> Vec<Statement> {
1757    let mut out = vec![
1758        marker(
1759            "_IdlInt",
1760            &[("bits", "int"), ("signed", "bool")],
1761            Doc::lines(&[
1762                "The IDL integer an int annotation stands for.",
1763                "",
1764                "A Python int is arbitrary precision and an IDL long is 32 bits.",
1765                "Nothing in Python enforces the difference, so it is stated here",
1766                "instead: read it back with typing.get_type_hints(cls,",
1767                "include_extras=True).",
1768            ]),
1769        ),
1770        marker(
1771            "_IdlFloat",
1772            &[("bits", "int")],
1773            Doc::lines(&[
1774                "The IDL floating-point type a float or str annotation stands for.",
1775                "",
1776                "bits=128 goes with a str and not a float: a Python float is a",
1777                "binary64 and would drop sixty bits of the significand at the",
1778                "assignment.",
1779            ]),
1780        ),
1781        marker(
1782            "_IdlText",
1783            &[("bits", "int"), ("bound", "int")],
1784            Doc::lines(&[
1785                "An IDL string, wstring, char or wchar, as a Python str.",
1786                "",
1787                "bits is the width of one character on the wire, 8 or 16. bound is",
1788                "the declared maximum length, or 0 for an unbounded one.",
1789            ]),
1790        ),
1791        marker(
1792            "_IdlFixed",
1793            &[("digits", "int"), ("scale", "int")],
1794            Doc::lines(&[
1795                "An IDL fixed<digits, scale>, as the decimal text.",
1796                "",
1797                "Not a decimal.Decimal: Decimal('0.00') keeps one digit and a",
1798                "scale, so the digit count the FixedDigitCount axis measures would",
1799                "be the container's answer rather than the source's.",
1800            ]),
1801        ),
1802        marker(
1803            "_IdlSequence",
1804            &[("bound", "int")],
1805            Doc::line("The declared maximum length of a bounded sequence or map."),
1806        ),
1807        marker(
1808            "_IdlEnum",
1809            &[("declared", "type[enum.Enum]"), ("bits", "int")],
1810            Doc::lines(&[
1811                "The enumeration or bitmask an int annotation stands for.",
1812                "",
1813                "The member is an int because a writer may send a value this",
1814                "declaration does not list, which enum.IntEnum would refuse.",
1815            ]),
1816        ),
1817    ];
1818    for (name, bits, signed) in [
1819        ("_Int8", 8, true),
1820        ("_UInt8", 8, false),
1821        ("_Int16", 16, true),
1822        ("_UInt16", 16, false),
1823        ("_Int32", 32, true),
1824        ("_UInt32", 32, false),
1825        ("_Int64", 64, true),
1826        ("_UInt64", 64, false),
1827    ] {
1828        out.push(Statement::Assign(Assign {
1829            doc: Doc::none(),
1830            name: Ident::new(name),
1831            value: type_value(annotated(
1832                Type::name("int"),
1833                call(
1834                    "_IdlInt",
1835                    vec![
1836                        kw("bits", Expr::int(bits)),
1837                        kw("signed", Expr::Literal(Literal::Bool(signed))),
1838                    ],
1839                ),
1840            )),
1841        }));
1842    }
1843    for (name, base, bits) in [
1844        ("_Float32", "float", 32),
1845        ("_Float64", "float", 64),
1846        // A binary128 in a Python float would lose sixty bits of the
1847        // significand at the assignment; the decimal text loses nothing.
1848        ("_Float128", "str", 128),
1849    ] {
1850        out.push(Statement::Assign(Assign {
1851            doc: Doc::none(),
1852            name: Ident::new(name),
1853            value: type_value(annotated(
1854                Type::name(base),
1855                call("_IdlFloat", vec![kw("bits", Expr::int(bits))]),
1856            )),
1857        }));
1858    }
1859    for (name, bits) in [(CHAR, 8), (WCHAR, 16)] {
1860        out.push(Statement::Assign(Assign {
1861            doc: Doc::none(),
1862            name: Ident::new(name),
1863            value: type_value(annotated(
1864                Type::name("str"),
1865                call(
1866                    "_IdlText",
1867                    vec![kw("bits", Expr::int(bits)), kw("bound", Expr::int(1))],
1868                ),
1869            )),
1870        }));
1871    }
1872    out
1873}
1874
1875/// One frozen marker dataclass.
1876fn marker(name: &str, fields: &[(&str, &str)], doc: Doc) -> Statement {
1877    Statement::Class(Class {
1878        doc,
1879        decorators: vec![Decorator {
1880            path: Path::new(&["dataclasses", "dataclass"]),
1881            args: vec![kw("frozen", Expr::Literal(Literal::Bool(true)))],
1882        }],
1883        name: Ident::new(name),
1884        bases: Vec::new(),
1885        body: fields
1886            .iter()
1887            .map(|(field_name, type_name)| {
1888                Statement::Annotated(Annotated {
1889                    doc: Doc::none(),
1890                    name: Ident::new(field_name),
1891                    type_: Type::Name(Path::new(&type_name.split('.').collect::<Vec<&str>>())),
1892                    value: None,
1893                })
1894            })
1895            .collect(),
1896    })
1897}
1898
1899/// A type written where a value belongs, which is what a type alias is.
1900fn type_value(type_: Type) -> Expr {
1901    Expr::Annotation(Box::new(type_))
1902}
1903
1904// -- constants ------------------------------------------------------------
1905
1906/// The IDL primitive a type resolves to, when it resolves to one.
1907///
1908/// What a constant's range check needs. A `const long` has to fit 32
1909/// bits whatever Python's `int` could hold, because the wire is 32 bits
1910/// wide.
1911fn scalar_kind(ast: &Ast, id: Id<TypeNode>) -> Option<PrimitiveKind> {
1912    let mut current = id;
1913    for _ in 0..MAX_TYPE_DEPTH {
1914        let node = ast.type_(current).ok()?;
1915        match &node.kind {
1916            TypeKind::Primitive(kind) => return Some(*kind),
1917            TypeKind::Alias { aliased } => current = aliased.resolved()?,
1918            _ => return None,
1919        }
1920    }
1921    None
1922}
1923
1924/// The Python expression a constant's value takes.
1925///
1926/// The declared type already carries the string-shaped forms: a
1927/// `string`, a `wstring`, a `fixed` and a `long double` are all a Python
1928/// `str` here. What this has to do is pick the right literal for each,
1929/// and refuse a value the IDL type does not hold.
1930///
1931/// **A range check even though Python needs none.** A Python `int` holds
1932/// 2^70 perfectly well, and `const octet X = 300;` would compile and
1933/// then be written as one byte. So the range is checked against the
1934/// **IDL** type and not against Python's, which is what keeps this
1935/// column's failure set comparable with the other four's.
1936///
1937/// # Errors
1938/// [`MaterialiseError`] naming the constant and what will not hold it.
1939fn constant_expr(
1940    kind: Option<PrimitiveKind>,
1941    value: &zerodds_ast::ConstValue,
1942    at: &str,
1943) -> Result<Expr, MaterialiseError> {
1944    use zerodds_ast::ConstValue;
1945    let refuse = |construct: String| MaterialiseError::NoLanguageForm {
1946        construct,
1947        language: "Python",
1948        at: at.to_string(),
1949    };
1950    match value {
1951        ConstValue::Integer(number) => {
1952            let (low, high, name) = integer_range(kind);
1953            Ok(Expr::int(in_range(*number, low, high, name, at)?))
1954        }
1955        ConstValue::Boolean(flag) => Ok(Expr::Literal(Literal::Bool(*flag))),
1956        // Python has no character type, so both are a string of one.
1957        // Unlike Java there is no code point a Python `str` cannot hold,
1958        // so a `wchar` above the Basic Multilingual Plane is written
1959        // rather than refused.
1960        ConstValue::Char(character) => Ok(Expr::Literal(Literal::Char(*character))),
1961        ConstValue::WChar(point) => match constant::wchar_char(*point) {
1962            Some(character) => Ok(Expr::Literal(Literal::Char(character))),
1963            None => Err(refuse(format!(
1964                "the code point U+{point:04X}, which is not a scalar value"
1965            ))),
1966        },
1967        ConstValue::Float { mantissa, exponent } => {
1968            let text = constant::float_text(mantissa, exponent.as_deref());
1969            // A `long double` constant is the decimal text, because the
1970            // member that holds one is a `str`. Parsing it into a Python
1971            // `float` here would round it at the point of generation.
1972            if matches!(kind, Some(PrimitiveKind::Float128)) {
1973                return Ok(Expr::Literal(Literal::Str(text)));
1974            }
1975            Ok(Expr::Literal(Literal::Float(text)))
1976        }
1977        ConstValue::Fixed { digits, scale } => Ok(Expr::Literal(Literal::Str(
1978            constant::fixed_decimal(digits, *scale),
1979        ))),
1980        ConstValue::String(text) => Ok(Expr::Literal(Literal::Str(text.clone()))),
1981        ConstValue::WString(points) => match constant::wstring_utf8(points) {
1982            Some(text) => Ok(Expr::Literal(Literal::Str(text))),
1983            None => Err(refuse(String::from(
1984                "a `wstring` constant holding a lone surrogate",
1985            ))),
1986        },
1987        // `ConstValue` is `#[non_exhaustive]`, so a form added to the
1988        // model reaches here. Refused by name rather than given a
1989        // plausible default: a constant emitted as the wrong literal is
1990        // a value that disagrees with the IDL and imports cleanly.
1991        _ => Err(refuse(String::from(
1992            "a constant form this binding has no literal for",
1993        ))),
1994    }
1995}
1996
1997/// The range an integer constant of `kind` has to fit, and the name to
1998/// report when it does not.
1999///
2000/// A type the model would not resolve falls back to the widest IDL
2001/// integer rather than to Python's unbounded one: something has to bound
2002/// it, and 64 bits is the widest thing the wire carries.
2003const fn integer_range(kind: Option<PrimitiveKind>) -> (i128, i128, &'static str) {
2004    match kind {
2005        Some(PrimitiveKind::Int8) => (-128, 127, "int8"),
2006        Some(PrimitiveKind::Octet | PrimitiveKind::UInt8) => (0, 255, "octet"),
2007        Some(PrimitiveKind::Int16) => (i16::MIN as i128, i16::MAX as i128, "short"),
2008        Some(PrimitiveKind::UInt16) => (0, u16::MAX as i128, "unsigned short"),
2009        Some(PrimitiveKind::Int32) => (i32::MIN as i128, i32::MAX as i128, "long"),
2010        Some(PrimitiveKind::UInt32) => (0, u32::MAX as i128, "unsigned long"),
2011        Some(PrimitiveKind::UInt64) => (0, u64::MAX as i128, "unsigned long long"),
2012        Some(PrimitiveKind::Char) => (0, 255, "char"),
2013        Some(PrimitiveKind::WChar) => (0, u16::MAX as i128, "wchar"),
2014        Some(PrimitiveKind::Boolean) => (0, 1, "boolean"),
2015        _ => (i64::MIN as i128, i64::MAX as i128, "long long"),
2016    }
2017}
2018
2019/// `value`, or the failure that says it does not fit.
2020///
2021/// # Errors
2022/// [`MaterialiseError::NoLanguageForm`], naming the value and the IDL
2023/// type that will not hold it. Truncating instead would put a number
2024/// nobody wrote into the output — and Python would not even notice,
2025/// which is why this check is here and not left to the language.
2026fn in_range(
2027    value: i128,
2028    low: i128,
2029    high: i128,
2030    type_: &str,
2031    at: &str,
2032) -> Result<i128, MaterialiseError> {
2033    if value < low || value > high {
2034        return Err(MaterialiseError::NoLanguageForm {
2035            construct: format!("the value {value}, which an IDL `{type_}` does not hold"),
2036            language: "Python",
2037            at: at.to_string(),
2038        });
2039    }
2040    Ok(value)
2041}
2042
2043/// A model node's name, or its identity when the node is anonymous.
2044///
2045/// An array type declared inline has no name of its own, and an error
2046/// that said only "the array length does not evaluate" would leave the
2047/// reader to find which array.
2048fn named(name: &str, node: NodeId) -> String {
2049    if name.is_empty() {
2050        format!("{node:?}")
2051    } else {
2052        name.to_string()
2053    }
2054}
2055
2056/// Whether the rules created a node of `type_name` for `node`.
2057///
2058/// The same two-hop walk the sibling materialisers do: a correspondence
2059/// is an ordinary node with a link to the anchor and a link to what was
2060/// created.
2061fn ghost_created_for(applied: &Applied, node: NodeId, type_name: &str) -> Option<GhostId> {
2062    let anchor = applied
2063        .origin
2064        .iter()
2065        .find(|(_, model)| **model == node)
2066        .map(|(ghost, _)| *ghost)?;
2067    let typ = applied.graph.types.lookup(type_name)?;
2068    applied
2069        .graph
2070        .nodes_of_type(typ)
2071        .map(|created| created.id)
2072        .find(|created| {
2073            applied.graph.connected(&anchor, created)
2074                || applied
2075                    .graph
2076                    .parts(&anchor)
2077                    .any(|part| applied.graph.connected(&part.other, created))
2078        })
2079}
2080
2081#[cfg(test)]
2082mod tests {
2083    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
2084    use super::*;
2085    use crate::rule;
2086    use crate::{ModelGraph, apply};
2087    use std::path::{Path as FsPath, PathBuf};
2088
2089    /// IDL in, one Python module out.
2090    fn render(idl: &str, path: &str) -> Result<String, String> {
2091        let parsed = zerodds_idl_parse::parse(idl);
2092        if parsed.has_errors() {
2093            return Err(format!("parse: {:?}", parsed.diagnostics()));
2094        }
2095        let lowered = zerodds_idl_lower::lower(&parsed.syntax(), path);
2096        if !lowered.diagnostics.is_empty() {
2097            return Err(format!("lower: {:?}", lowered.diagnostics));
2098        }
2099        let mut ast = lowered.ast;
2100        ast.resolve_all_by_name()
2101            .map_err(|error| format!("resolve: {error}"))?;
2102        let applied = apply(&ModelGraph::of(&ast), &rule::python::catalogue())
2103            .map_err(|error| format!("apply: {error}"))?;
2104        let module =
2105            python_module(&ast, &applied).map_err(|error| format!("materialise: {error}"))?;
2106        Ok(zerodds_lang_render::python::module(&module))
2107    }
2108
2109    /// The same, for a source with no file behind it.
2110    fn declarations(idl: &str) -> String {
2111        match render(idl, "test.idl") {
2112            Ok(text) => text,
2113            Err(why) => panic!("{why}"),
2114        }
2115    }
2116
2117    /// Every corpus case, by name.
2118    fn corpus() -> Vec<(String, PathBuf)> {
2119        let root = FsPath::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/interop-corpus");
2120        let mut out = Vec::new();
2121        for entry in std::fs::read_dir(&root).expect("the corpus directory") {
2122            let entry = entry.expect("a corpus entry");
2123            let idl = entry.path().join("type.idl");
2124            if idl.is_file() {
2125                out.push((entry.file_name().to_string_lossy().into_owned(), idl));
2126            }
2127        }
2128        out.sort();
2129        out
2130    }
2131
2132    /// The whole corpus, through every layer, with nothing skipped.
2133    ///
2134    /// A rendered file is not a compiled one. `ZD_PYTHON_OUT` names a
2135    /// directory to write the modules into, and `python3 -m py_compile`
2136    /// plus `mypy --strict` over that directory is the second half of
2137    /// the check — a module that does not compile or does not
2138    /// type-check is a materialiser defect, not a corpus defect.
2139    #[test]
2140    fn every_corpus_case_renders_or_names_its_failure() {
2141        let cases = corpus();
2142        assert!(
2143            cases.len() > 300,
2144            "the corpus is not where it was: {} cases",
2145            cases.len()
2146        );
2147        let out = std::env::var("ZD_PYTHON_OUT").ok();
2148        let mut failures = Vec::new();
2149        for (name, path) in cases {
2150            let source = std::fs::read_to_string(&path).expect("a corpus source");
2151            match render(&source, &path.display().to_string()) {
2152                Ok(text) => {
2153                    if let Some(dir) = &out {
2154                        let case = FsPath::new(dir).join(&name);
2155                        std::fs::create_dir_all(&case).expect("the output directory");
2156                        std::fs::write(case.join("zd_types.py"), text).expect("the output file");
2157                    }
2158                }
2159                Err(why) => failures.push(format!("{name}: {why}")),
2160            }
2161        }
2162        let failures = crate::adapter::without_the_front_end_refusals(failures);
2163        assert!(failures.is_empty(), "{failures:#?}");
2164    }
2165
2166    #[test]
2167    fn every_idl_primitive_reaches_an_annotation_that_states_its_width() {
2168        // The decision this column turns on: a Python `int` is
2169        // arbitrary precision, so the width has to be somewhere, and
2170        // `x: int` is never written.
2171        let generated = declarations(
2172            "@final struct P {
2173               boolean a; octet b; char c; wchar d;
2174               int8 e; uint8 f; short g; unsigned short h;
2175               long i; unsigned long j; long long k; unsigned long long l;
2176               float m; double n; long double o;
2177             };",
2178        );
2179        for expected in [
2180            "a: bool",
2181            "b: _UInt8",
2182            "c: _Char",
2183            "d: _WChar",
2184            "e: _Int8",
2185            "f: _UInt8",
2186            "g: _Int16",
2187            "h: _UInt16",
2188            "i: _Int32",
2189            "j: _UInt32",
2190            "k: _Int64",
2191            "l: _UInt64",
2192            "m: _Float32",
2193            "n: _Float64",
2194            "o: _Float128",
2195        ] {
2196            assert!(generated.contains(expected), "{expected}\n{generated}");
2197        }
2198        // Scoped to the aggregate: the marker dataclasses in the
2199        // preamble are the one place a bare `int` belongs.
2200        let body = generated
2201            .split_once("class P:")
2202            .map(|(_, rest)| rest.to_string())
2203            .expect("the aggregate");
2204        assert!(
2205            !body.contains(": int\n"),
2206            "a bare int reached a field\n{body}"
2207        );
2208        assert!(
2209            !body.contains(": float\n"),
2210            "a bare float reached a field\n{body}"
2211        );
2212        assert!(
2213            !body.contains(": str\n"),
2214            "a bare str reached a field\n{body}"
2215        );
2216    }
2217
2218    #[test]
2219    fn every_width_alias_states_its_bits_and_its_signedness() {
2220        let generated = declarations("@final struct P { long x; };");
2221        assert!(
2222            generated.contains("_Int32 = typing.Annotated[int, _IdlInt(bits=32, signed=True)]"),
2223            "{generated}"
2224        );
2225        assert!(
2226            generated.contains("_UInt64 = typing.Annotated[int, _IdlInt(bits=64, signed=False)]"),
2227            "{generated}"
2228        );
2229    }
2230
2231    #[test]
2232    fn a_long_double_is_the_decimal_text_and_never_a_python_float() {
2233        // A Python float is a binary64. Writing a binary128 into one
2234        // drops sixty bits of the significand at the assignment, before
2235        // any codec runs.
2236        let generated = declarations("@final struct P { long double x; };");
2237        assert!(
2238            generated.contains("_Float128 = typing.Annotated[str, _IdlFloat(bits=128)]"),
2239            "{generated}"
2240        );
2241        assert!(generated.contains("x: _Float128"), "{generated}");
2242    }
2243
2244    #[test]
2245    fn a_sequence_is_a_list_and_an_array_is_a_tuple_of_its_length() {
2246        let generated = declarations(
2247            "@final struct P { sequence<long> s; long a[3]; long g[2][3]; sequence<string> t; };",
2248        );
2249        assert!(generated.contains("s: list[_Int32]"), "{generated}");
2250        assert!(
2251            generated.contains("a: tuple[_Int32, _Int32, _Int32]"),
2252            "{generated}"
2253        );
2254        // Outer dimension outermost: two rows of three.
2255        assert!(
2256            generated
2257                .contains("g: tuple[tuple[_Int32, _Int32, _Int32], tuple[_Int32, _Int32, _Int32]]"),
2258            "{generated}"
2259        );
2260        assert!(
2261            generated.contains("t: list[typing.Annotated[str,"),
2262            "{generated}"
2263        );
2264    }
2265
2266    #[test]
2267    fn a_bound_reaches_the_annotation_and_an_unbounded_one_leaves_it_alone() {
2268        let generated = declarations(
2269            "@final struct P { sequence<long, 5> s; sequence<long> u; string<7> t; };",
2270        );
2271        assert!(
2272            generated.contains("s: typing.Annotated[list[_Int32], _IdlSequence(bound=5)]"),
2273            "{generated}"
2274        );
2275        assert!(generated.contains("u: list[_Int32]"), "{generated}");
2276        assert!(
2277            generated.contains("t: typing.Annotated[str, _IdlText(bits=8, bound=7)]"),
2278            "{generated}"
2279        );
2280    }
2281
2282    #[test]
2283    fn a_map_is_a_list_of_pairs_and_never_a_dict() {
2284        // A generated struct is an `eq=True` dataclass, so its
2285        // `__hash__` is None and `dict[Point, int]` is a type that
2286        // cannot hold anything. And the entry order is what the
2287        // MapEntryOrder axis measures.
2288        let generated = declarations("@final struct P { map<long, string> m; };");
2289        assert!(generated.contains("m: list[tuple[_Int32,"), "{generated}");
2290        assert!(!generated.contains("dict["), "{generated}");
2291    }
2292
2293    #[test]
2294    fn an_optional_member_is_optional_and_defaults_to_none() {
2295        // XTypes 1.3 7.4.1.2.3 makes absence the value, not a zero.
2296        let generated =
2297            declarations("@mutable struct P { @optional long a; @optional string b; long c; };");
2298        assert!(
2299            generated.contains("a: typing.Optional[_Int32] = None"),
2300            "{generated}"
2301        );
2302        assert!(
2303            generated.contains("b: typing.Optional[typing."),
2304            "{generated}"
2305        );
2306        // A required member has no default at all, so constructing the
2307        // aggregate without it is an error rather than a silently
2308        // zero-filled sample.
2309        assert!(generated.contains("c: _Int32\n"), "{generated}");
2310    }
2311
2312    #[test]
2313    fn a_dataclass_is_keyword_only_because_the_defaults_are_not_in_idl_order() {
2314        // An @optional member defaults to None and the member after it
2315        // does not; a positional dataclass refuses that, and reordering
2316        // the fields would make the class disagree with the wire.
2317        let generated = declarations("@mutable struct P { @optional long a; long b; };");
2318        assert!(
2319            generated.contains("@dataclasses.dataclass(kw_only=True)"),
2320            "{generated}"
2321        );
2322    }
2323
2324    #[test]
2325    fn an_enumeration_is_an_intenum_and_a_member_of_it_is_an_int() {
2326        let generated = declarations(
2327            "enum Colour { RED, GREEN, BLUE };
2328             @final struct P { Colour c; };",
2329        );
2330        assert!(
2331            generated.contains("class Colour(enum.IntEnum):"),
2332            "{generated}"
2333        );
2334        assert!(generated.contains("RED = 0"), "{generated}");
2335        assert!(generated.contains("BLUE = 2"), "{generated}");
2336        // An @appendable writer may send a value this IDL does not
2337        // list, and `Colour(7)` raises ValueError.
2338        assert!(
2339            generated.contains("c: typing.Annotated[int, _IdlEnum(declared=Colour, bits=32)]"),
2340            "{generated}"
2341        );
2342    }
2343
2344    #[test]
2345    fn an_explicit_enumerator_value_is_written_as_the_source_wrote_it() {
2346        // The Java column has to fold 70000 onto the pattern an `int`
2347        // occupies; a Python `int` holds it, so nothing folds.
2348        let generated = declarations("enum E { @value(-1) LOW, @value(70000) HIGH };");
2349        assert!(generated.contains("LOW = -1"), "{generated}");
2350        assert!(generated.contains("HIGH = 70000"), "{generated}");
2351    }
2352
2353    #[test]
2354    fn a_bitmask_is_an_intflag_and_a_wide_shift_needs_no_suffix() {
2355        let generated =
2356            declarations("@bit_bound(64) bitmask Big { @position(0) A, @position(40) B };");
2357        assert!(
2358            generated.contains("class Big(enum.IntFlag):"),
2359            "{generated}"
2360        );
2361        // `1 << 40` is 2^40 in Python. The Java column needs `1L` here,
2362        // because there `1 << 40` is an int shift and evaluates to 256.
2363        assert!(generated.contains("B = (1 << 40)"), "{generated}");
2364    }
2365
2366    #[test]
2367    fn a_bitset_states_the_declared_bit_width_and_rounds_nothing_up() {
2368        let generated = declarations(
2369            "bitset Packed { bitfield<3> low; bitfield<5> high; };
2370             @final struct P { Packed value; };",
2371        );
2372        assert!(
2373            generated.contains("low: typing.Annotated[int, _IdlInt(bits=3, signed=False)]"),
2374            "{generated}"
2375        );
2376        assert!(
2377            generated.contains("high: typing.Annotated[int, _IdlInt(bits=5, signed=False)]"),
2378            "{generated}"
2379        );
2380        assert!(generated.contains("value: Packed"), "{generated}");
2381    }
2382
2383    #[test]
2384    fn a_fixed_is_a_str_and_never_a_decimal() {
2385        // `Decimal("0.00").as_tuple().digits` is `(0,)` — one digit —
2386        // so a Decimal would answer the FixedDigitCount axis itself.
2387        let generated = declarations(
2388            "typedef fixed<9,3> Money;
2389             @final struct P { Money amount; };",
2390        );
2391        assert!(
2392            generated.contains("amount: typing.Annotated[str, _IdlFixed(digits=9, scale=3)]"),
2393            "{generated}"
2394        );
2395        // The preamble says in prose why a Decimal is not used; what
2396        // must not appear is an import of it or a member typed as one.
2397        assert!(!generated.contains("import decimal"), "{generated}");
2398        assert!(!generated.contains("amount: decimal"), "{generated}");
2399    }
2400
2401    #[test]
2402    fn a_union_is_a_dataclass_with_a_discriminator_and_one_optional_field_per_case() {
2403        let generated = declarations(
2404            "union Choice switch (long) {
2405               case 1: long whole;
2406               case 2: long other;
2407               default: double fraction;
2408             };
2409             @final struct P { Choice c; };",
2410        );
2411        assert!(generated.contains("_d: _Int32"), "{generated}");
2412        assert!(
2413            generated.contains("whole: typing.Optional[_Int32] = None"),
2414            "{generated}"
2415        );
2416        assert!(
2417            generated.contains("other: typing.Optional[_Int32] = None"),
2418            "{generated}"
2419        );
2420        assert!(
2421            generated.contains("fraction: typing.Optional[_Float64] = None"),
2422            "{generated}"
2423        );
2424        assert!(generated.contains("c: Choice"), "{generated}");
2425    }
2426
2427    #[test]
2428    fn a_typedef_declares_nothing_and_says_why() {
2429        // An eagerly evaluated Python alias could not be ordered
2430        // against the classes it names.
2431        let generated = declarations(
2432            "typedef sequence<long> Row;
2433             @final struct P { Row r; };",
2434        );
2435        assert!(generated.contains("r: list[_Int32]"), "{generated}");
2436        assert!(
2437            generated.contains("typedef list[_Int32] Row;"),
2438            "{generated}"
2439        );
2440    }
2441
2442    #[test]
2443    fn a_module_is_a_nested_class_and_a_reference_is_written_from_the_root() {
2444        let generated = declarations(
2445            "module a { @final struct P { long x; }; };
2446             module b { @final struct P { double y; }; };
2447             @final struct Q { a::P one; b::P two; };",
2448        );
2449        assert!(generated.contains("class a:"), "{generated}");
2450        assert!(generated.contains("class b:"), "{generated}");
2451        assert!(generated.contains("one: a.P"), "{generated}");
2452        assert!(generated.contains("two: b.P"), "{generated}");
2453    }
2454
2455    #[test]
2456    fn a_module_reopened_beside_a_nested_one_is_one_class() {
2457        // `module common { struct Robot; module util { … } }` — the
2458        // arrangement that produced two classes of one name when the
2459        // grouping was by module id rather than by path. Python binds a
2460        // name rather than declaring one, so the second would have
2461        // silently replaced the first.
2462        let generated = declarations(
2463            "module common {
2464               module util { @final struct Location { double x; }; };
2465               @final struct Robot { ::common::util::Location where_; };
2466             };",
2467        );
2468        assert_eq!(generated.matches("class common:").count(), 1, "{generated}");
2469        assert!(
2470            generated.contains("where_: common.util.Location"),
2471            "{generated}"
2472        );
2473    }
2474
2475    #[test]
2476    fn an_identifier_that_is_a_python_keyword_is_escaped_and_the_idl_name_is_kept() {
2477        let generated = declarations(
2478            "module class {
2479               enum type { match, lambda, virtual };
2480               @final struct P { long global; };
2481             };",
2482        );
2483        assert!(generated.contains("class class_:"), "{generated}");
2484        assert!(
2485            generated.contains("class type_(enum.IntEnum):"),
2486            "{generated}"
2487        );
2488        assert!(generated.contains("match_ = 0"), "{generated}");
2489        assert!(generated.contains("lambda_ = 1"), "{generated}");
2490        // Not a Python keyword, so it keeps its spelling.
2491        assert!(generated.contains("virtual = 2"), "{generated}");
2492        // The IDL name survives the escape, machine-readably for a
2493        // field and in a comment for everything else.
2494        assert!(
2495            generated
2496                .contains("global_: _Int32 = dataclasses.field(metadata={\"idl\": \"global\"})"),
2497            "{generated}"
2498        );
2499        assert!(generated.contains("IDL name: match"), "{generated}");
2500    }
2501
2502    #[test]
2503    fn a_member_named_after_a_builtin_the_annotations_use_is_escaped_too() {
2504        // The case the parser does not catch. `int: typing.Optional[…] =
2505        // None` sets a class attribute called `int`, and
2506        // `typing.get_type_hints` resolves the class namespace before
2507        // the module's — so every `int` in that class would resolve to
2508        // `None`.
2509        let generated = declarations("@mutable struct P { @optional long int; long list; };");
2510        assert!(generated.contains("int_:"), "{generated}");
2511        assert!(generated.contains("list_:"), "{generated}");
2512    }
2513
2514    #[test]
2515    fn two_names_that_would_escape_onto_one_are_refused() {
2516        // Python binds a name; it does not declare one. The second
2517        // `class_` would silently replace the first.
2518        let failure = render("@final struct P { long class; long class_; };", "test.idl")
2519            .expect_err("two members escaping onto one name are refused");
2520        assert!(failure.contains("class_"), "{failure}");
2521    }
2522
2523    #[test]
2524    fn a_construct_with_no_python_data_form_is_named_and_not_left_out() {
2525        // `object` would type-check for all of these, which is exactly
2526        // why it is not written: a member typed `object` is a field no
2527        // codec can write, from a generator that reported success.
2528        for (idl, construct) in [
2529            ("@final struct P { any a; long tail; };", "any"),
2530            ("@final struct P { Object o; long tail; };", "Object"),
2531        ] {
2532            let failure = render(idl, "test.idl").expect_err(construct);
2533            assert!(failure.contains(construct), "{failure}");
2534            assert!(failure.contains("Python"), "{failure}");
2535        }
2536    }
2537
2538    #[test]
2539    fn a_constant_outside_its_idl_range_is_refused_although_python_would_hold_it() {
2540        // A Python `int` holds 300 perfectly well and the wire holds one
2541        // byte. Nothing in the language would notice.
2542        let failure = render("const octet X = 300;", "test.idl")
2543            .expect_err("an octet constant of 300 is refused");
2544        assert!(failure.contains("300"), "{failure}");
2545        assert!(failure.contains("octet"), "{failure}");
2546    }
2547
2548    #[test]
2549    fn a_constant_is_final_and_carries_its_idl_type() {
2550        let generated = declarations("const long MAX = 5; const string NAME = \"z\";");
2551        assert!(
2552            generated.contains("MAX: typing.Final[_Int32] = 5"),
2553            "{generated}"
2554        );
2555        assert!(
2556            generated.contains("NAME: typing.Final[typing.Annotated[str,"),
2557            "{generated}"
2558        );
2559    }
2560
2561    #[test]
2562    fn an_unevaluated_array_length_is_named_and_not_substituted() {
2563        // The C and Java columns fail on the same input; the columns'
2564        // failure sets stay comparable.
2565        let failure = render("@final struct P { long a[UNKNOWN]; };", "test.idl")
2566            .expect_err("an array whose length does not evaluate is refused");
2567        assert!(!failure.is_empty(), "{failure}");
2568    }
2569
2570    #[test]
2571    fn an_interface_declares_nothing_and_the_rest_of_the_file_still_renders() {
2572        // The one rule in the catalogue whose node nothing reads. An
2573        // interface has no data form in a types-only column, so the
2574        // check is that it is passed over rather than that it breaks
2575        // the file — coverage.rs is where the cell says so.
2576        let generated = declarations(
2577            "interface Sensor { long read(); };
2578             @final struct P { long x; };",
2579        );
2580        assert!(generated.contains("class P:"), "{generated}");
2581        assert!(!generated.contains("Sensor"), "{generated}");
2582    }
2583
2584    #[test]
2585    fn an_empty_aggregate_still_has_a_body() {
2586        // A class header with no block under it is a SyntaxError, not
2587        // untidy output.
2588        let generated = declarations("@final struct P { };");
2589        assert!(generated.contains("class P:"), "{generated}");
2590        assert!(generated.contains("pass"), "{generated}");
2591    }
2592}