Skip to main content

zerodds_lang_rules/adapter/
fsharp.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! The F# CST as the other end of a rule. Spec §3.2.
4//!
5//! The counterpart of [`crate::adapter::zig`] and [`crate::adapter::go`].
6//! Reading the three side by side is the point: all three flatten a
7//! module onto the name, and everything else they do differently is
8//! their language's — [`crate::adapter::zig`]'s own documentation and
9//! [`zerodds_lang_cst::fsharp`]'s carry why this column's aggregate is a
10//! mutable class and not Zig's `struct` or F#'s own record.
11//!
12//! Values are resolved from the model, never from the overlay — the same
13//! rule the sibling materialisers follow (spec §2.3).
14//!
15//! ## The memory shapes
16//!
17//! | IDL | F# |
18//! |---|---|
19//! | `boolean` | `bool` |
20//! | `octet`, `uint8`, `char` | `byte` |
21//! | `int8` | `sbyte` |
22//! | `short` | `int16` |
23//! | `unsigned short`, `wchar` | `uint16` |
24//! | `long` | `int32` |
25//! | `unsigned long` | `uint32` |
26//! | `long long` | `int64` |
27//! | `unsigned long long` | `uint64` |
28//! | `float` | `float32` |
29//! | `double` | `float` |
30//! | **`long double`** | **`string`, the decimal — .NET has no wider float** |
31//! | `string`, `wstring` | `string` |
32//! | `fixed<P,S>` | `string`, holding the decimal |
33//! | `sequence<T>` | `T []` |
34//! | `T[N][M]` | `T [] []`, outer dimension outermost |
35//! | `map<K,V>` | `ZdMapEntry_K_V []`, **not** `Dictionary` |
36//! | `@optional T m` | `T option`, `None` when absent |
37//! | `@external T m` | `T` — a class is a reference already |
38//! | `@optional @external T m` | `T option`, exactly as plain `@optional` |
39//! | `struct`, `valuetype`, `exception`, `bitset` | a mutable class |
40//! | `union` | a mutable class: `_d` and one field per branch |
41//! | `enum` | a CLI enum, every value written out |
42//! | `bitmask` | an unsigned type abbreviation and one constant per bit |
43//! | `typedef` | `type Distance = int32` |
44//! | `native` | `obj`, or the facet's mapping |
45//! | `interface` | a class of function-valued fields |
46//!
47//! ### `long double` holds text, but the wire is a real binary128
48//!
49//! .NET's floating-point set stops at `System.Double`; this column
50//! holds the decimal as `string`, the same storage
51//! [`crate::adapter::go`] and [`crate::adapter::python`] give, and not
52//! [`crate::adapter::zig`]'s `f128` — Zig's own documentation says why
53//! it is the one column with a native type wide enough. What goes on
54//! the wire is not a workaround, though: `runtime/fsharp/ZdWire.fs`'s
55//! `WriteF128`/`ReadF128` write and read the real sixteen-byte
56//! IEEE 754 binary128 pattern, the same algorithm
57//! `runtime/go/float128.go` carries, ported onto
58//! `System.Numerics.BigInteger`.
59//!
60//! ### A union is a class with a discriminator, not a discriminated union
61//!
62//! [`zerodds_lang_cst::fsharp`]'s module documentation carries the
63//! argument in full; the summary is the one [`crate::adapter::zig`]
64//! gives for its own tagged union: an IDL discriminator may take a value
65//! no `case` label selects (IDL 4.2 §7.4.1.4.4.2.3), in which state the
66//! union carries no branch value at all
67//! (DDS-XTypes 1.3 §7.4.1.1.5.2) and F#'s own sum type — whose case *is*
68//! its tag — cannot be constructed that way.
69//!
70//! ### `map<K,V>` is an array of entries and never a `Dictionary`
71//!
72//! A `map<K,V>` goes on the wire as a sequence of key/value pairs
73//! (DDS-XTypes 1.3 §7.4.1.1.4), ordered; `System.Collections.Generic.
74//! Dictionary` iterates in an implementation-defined order that follows
75//! the hash and the insertion history, not the order the pairs arrived
76//! in. The entry class is declared once and named, exactly as Zig's
77//! entry struct is, so a codec has a type to take.
78//!
79//! ### A bitmask is a type abbreviation and one constant per bit
80//!
81//! As in Zig: the bits carry `@position` (DDS-XTypes 1.3 §7.4.1.1.3.2),
82//! a property of the wire, and a language aggregate whose layout follows
83//! declaration order — an F# `[<Flags>]` enum included, whose values are
84//! still just an integer with named constants and buys nothing a bare
85//! type abbreviation does not already have — would still need the same
86//! constants to state a position outside declaration order.
87//!
88//! ### `@external` needs nothing at all
89//!
90//! DDS-XTypes 1.3 Table 21 says a member is *stored* outside the
91//! containing object; it changes no octet. An F# class is heap-allocated
92//! reference storage already — `Node()` is exactly as much an
93//! indirection as Zig's `*Node`, without a pointer type to write — so
94//! `@external` reaches [`fsharp_member_type`] and changes nothing it
95//! returns. [`crate::adapter::zig`]'s pointer and this column's absence
96//! of one are the same finding read two ways: an indirection is a tree
97//! concern only where the language's default aggregate is a value.
98//!
99//! ### Where the IDL spelling of a name is kept
100//!
101//! Nothing is renamed for style — F# has no export-by-case rule either.
102//! The only rename is the escape, and it reaches further than Zig's:
103//! [`fsharp::escape_field`] and [`fsharp::escape_decl`] check the same
104//! keyword list in both positions, because unlike Zig's field namespace
105//! F# refuses a keyword as a member name too — that module's
106//! documentation carries the measurement. Every name the escape moved
107//! carries `IDL: \`…\`.` in the documentation comment above it, for the
108//! same `@hashid`/`@autoid(HASH)` reason [`crate::adapter::zig`] states.
109//!
110//! ### Why a class needs a default at every field, and why that costs no
111//! ### generated defaulter function
112//!
113//! [`zerodds_lang_cst::fsharp`]'s module documentation carries the
114//! reasoning; [`crate::default_fsharp`] is where DDS-XTypes 1.3 Table 9
115//! is read into an expression for `ClassField::default` to hold.
116
117use crate::adapter::constant;
118use crate::adapter::cst::{MaterialiseError, no_spelling_collision};
119use crate::adapter::interface;
120use crate::apply::Applied;
121use crate::default_fsharp;
122use crate::verbatim;
123use seesaw_tgg::ident::GhostId;
124use zerodds_ast::{Ast, CharWidth, PrimitiveKind, TypeKind, TypeNode};
125use zerodds_ast_core::{Id, NodeId};
126use zerodds_lang_cst::common::{Doc, Ident, Literal};
127use zerodds_lang_cst::fsharp;
128
129/// The language this column names in a diagnostic.
130const LANGUAGE: &str = "F#";
131
132/// The prefix a name this column invented carries.
133const INVENTED_PREFIX: &str = "ZdMapEntry_";
134
135/// The F# field name of a union's discriminator.
136///
137/// `_d`, as in every column but Go's, for the same reason
138/// [`crate::adapter::zig`] keeps it: IDL 4.2 §7.4.1.4.4.1 gives the
139/// discriminator no member name of its own, and F#'s visibility has
140/// nothing to do with a leading underscore the way Go's export rule
141/// does.
142pub const DISCRIMINATOR: &str = "_d";
143
144/// How far up a module chain the walk follows before it stops.
145const MAX_MODULE_DEPTH: usize = 64;
146
147/// How deep a type's F# spelling may nest before the walk refuses
148/// it.
149///
150/// The same 64 every reference walk in the tree carries;
151/// `zerodds-wire-codec`'s `default.rs` and `zerodds-types`'
152/// `dynamic/type_.rs` both stop there. An aggregate is a leaf of these
153/// walks — it is named, not descended into — so only a cycle among the
154/// connectors (a `typedef`, a `sequence`, an `array`, a `map`) can come
155/// back round, and the counter is what stops that rather than the
156/// frontend happening to build inline types as a tree.
157const MAX_TYPE_DEPTH: usize = 64;
158
159/// The name every generated file's `module rec` takes.
160///
161/// One name and not one per IDL file's own name: the generator writes
162/// exactly one file per probe run, and — as `Program.fs`'s own
163/// `EntryPoint` does — the module a script is compiled as needs no
164/// bearing on the IDL source's name.
165pub const MODULE_NAME: &str = "ZdTypes";
166
167/// Build the F# declarations a rule application produced.
168///
169/// The order is the model's own declaration order. `module rec` gives
170/// every declaration mutual visibility regardless of where it is
171/// written (see [`zerodds_lang_cst::fsharp`]'s module documentation), so
172/// — as in Zig and Go — there is no dependency walk here either.
173///
174/// # Errors
175/// [`MaterialiseError`] when a value the output has to state does not
176/// evaluate, a type has no F# shape, or two declarations reach one F#
177/// spelling.
178pub fn fsharp_items(ast: &Ast, applied: &Applied) -> Result<Vec<fsharp::Decl>, MaterialiseError> {
179    let mut out = Vec::new();
180    let mut emitted: Vec<(String, String)> = Vec::new();
181
182    for (id, _) in ast.types() {
183        let start = out.len();
184        declare(ast, applied, id, &mut out, &mut emitted)?;
185        verbatim::weave(
186            verbatim::declaration_slots(ast, id, verbatim::aliases::FSHARP),
187            &fsharp_type_name(ast, id),
188            start,
189            &mut out,
190        );
191    }
192
193    for (_, node) in ast.constants() {
194        if ghost_created_for(applied, node.node_id, "FsConst").is_none() {
195            continue;
196        }
197        let value = ast
198            .eval_const(node.expr)
199            .map_err(|_| MaterialiseError::Unevaluated {
200                construct: "constant's value".to_string(),
201                at: node.name.clone(),
202            })?;
203        let declared = node.type_.resolved();
204        let type_ = match declared {
205            Some(target) => fsharp_value_type(ast, target, &node.name)?,
206            None => fsharp::Type::Primitive(fsharp::Primitive::Int32),
207        };
208        // `long double` and `string`/`wstring`/`fixed<P,S>` all
209        // materialise to the same `Type::Primitive(String)`, and only
210        // the first of the four takes a floating-point `ConstValue` —
211        // `fsharp_constant` needs to tell them apart to answer a
212        // `const long double` with the decimal text rather than
213        // refusing it as "a float into a text-shaped type".
214        let is_long_double = declared.is_some_and(|target| {
215            matches!(
216                ast.type_(target).map(|node| node.kind.clone()),
217                Ok(TypeKind::Primitive(PrimitiveKind::Float128))
218            )
219        });
220        let name = declared_name(ast, &node.name, node.module);
221        remember(&mut emitted, &name, &node.name)?;
222        out.push(fsharp::Decl::Let(fsharp::LetDecl {
223            doc: idl_note(&name, &node.name),
224            name: Ident::new(&name),
225            params: Vec::new(),
226            type_: Some(type_.clone()),
227            body: vec![fsharp::Stmt::Return(fsharp_constant(
228                &value,
229                &type_,
230                is_long_double,
231                &node.name,
232            )?)],
233        }));
234    }
235
236    for (_, node) in ast.exceptions() {
237        if ghost_created_for(applied, node.node_id, "FsError").is_none() {
238            continue;
239        }
240        let name = declared_name(ast, &node.name, node.module);
241        remember(&mut emitted, &name, &node.name)?;
242        let mut pairs = Vec::new();
243        for member in &node.members {
244            if let Ok(member_node) = ast.member(*member) {
245                pairs.push((field_name(&member_node.name), member_node.name.clone()));
246            }
247        }
248        no_spelling_collision(as_pairs(&pairs), LANGUAGE, &name)?;
249        let mut fields = Vec::new();
250        for member in &node.members {
251            let Ok(member_node) = ast.member(*member) else {
252                return Err(MaterialiseError::NoNode {
253                    kind: "member",
254                    at: crate::adapter::cst::handle_of(*member),
255                });
256            };
257            let Some(target) = member_node.type_.resolved() else {
258                return Err(MaterialiseError::Unevaluated {
259                    construct: "exception member type".to_string(),
260                    at: member_node.name.clone(),
261                });
262            };
263            fields.push(member_field(ast, member_node, target)?);
264        }
265        out.push(fsharp::Decl::Type(fsharp::TypeDecl {
266            body_text: zerodds_lang_cst::common::BodyText::none(),
267            doc: with_idl_note(
268                Doc::line("An exception, as the class its members make."),
269                &name,
270                &node.name,
271            ),
272            name: Ident::new(&name),
273            body: fsharp::TypeBody::Class(fields),
274        }));
275    }
276
277    for (id, node) in ast.interfaces() {
278        if ghost_created_for(applied, node.node_id, "FsInterface").is_none() {
279            continue;
280        }
281        let name = declared_name(ast, &node.name, node.module);
282        remember(&mut emitted, &name, &node.name)?;
283        let mut fields = Vec::new();
284        let mut signatures = Vec::new();
285        // The whole answer and not the declared one. `operations_of`
286        // stops at what the interface writes down; §7.4.3 says a client
287        // holding a `Derived` may call every operation of `Base` (and
288        // every attribute) too, and this column's class has no base to
289        // inherit them through — it is a flat class of function-valued
290        // fields. Walking the declared list left `interface Derived :
291        // Base` with a class carrying only its own operations, the
292        // base's gone and no diagnostic anywhere.
293        // `interface::all_operations_flattened` gives the flattened
294        // operations and, per §7.5.1.1.3, the get_/set_ pair each
295        // attribute implies — which `Ast::all_operations_of` alone
296        // never did.
297        let operations = interface::all_operations_flattened(ast, id)
298            .map_err(|error| interface::interface_error_to_materialise(error, &node.name))?;
299        for op in &operations {
300            // The field of an attribute accessor is `get_<attribute>` or
301            // `set_<attribute>`: the attribute's own name, not the
302            // `get_attribute_`/`set_attribute_` DDS-RPC 1.0 §7.5.1.1.3 gives
303            // the implied operation, and two names so that the pair does not
304            // collide.
305            let field_ident = match op.origin {
306                interface::Origin::AttributeGetter => {
307                    field_name(&format!("get_{}", op.attribute_name()))
308                }
309                interface::Origin::AttributeSetter => {
310                    field_name(&format!("set_{}", op.attribute_name()))
311                }
312                interface::Origin::Declared => field_name(&op.name),
313            };
314            signatures.push((field_ident.clone(), op.name.clone()));
315            let mut param_types = Vec::new();
316            for param in &op.params {
317                param_types.push(fsharp_member_type(ast, param.type_, &param.name)?);
318            }
319            let returns = match op.returns {
320                Some(target) => fsharp_member_type(ast, target, &op.name)?,
321                None => fsharp::Type::Primitive(fsharp::Primitive::Unit),
322            };
323            let fn_type = fsharp::Type::Fn {
324                params: param_types,
325                returns: Box::new(returns.clone()),
326            };
327            let mut doc = idl_note(&field_ident, &op.name);
328            if let Some(line) = op.raises_line() {
329                doc.push(&line);
330            }
331            fields.push(fsharp::ClassField {
332                doc,
333                name: Ident::new(&field_ident),
334                type_: fn_type,
335                default: interface_default(&returns),
336            });
337        }
338        no_spelling_collision(as_pairs(&signatures), LANGUAGE, &name)?;
339        out.push(fsharp::Decl::Type(fsharp::TypeDecl {
340            body_text: zerodds_lang_cst::common::BodyText::none(),
341            doc: with_idl_note(
342                Doc::lines(&[
343                    "An interface, as the class of function-valued fields its",
344                    "operations and attributes make — an attribute a get_/set_ pair of",
345                    "fields, `readonly` only the first. The transport is the RPC arc's",
346                    "business; the shape is the IDL's.",
347                    "`raises`/`getraises`/`setraises` are documented on the field they",
348                    "belong to.",
349                ]),
350                &name,
351                &node.name,
352            ),
353            name: Ident::new(&name),
354            body: fsharp::TypeBody::Class(fields),
355        }));
356    }
357
358    Ok(verbatim::weave_file(ast, verbatim::aliases::FSHARP, out))
359}
360
361/// `@verbatim` in the F# column: `BEGIN_DECLARATION` and
362/// `END_DECLARATION` open and close a class's body; an enumeration and an
363/// abbreviation take them adjacent.
364impl verbatim::Carrier for fsharp::Decl {
365    fn verbatim(text: String) -> Self {
366        Self::Verbatim(text)
367    }
368
369    fn take_into_body(
370        &mut self,
371        name: &str,
372        begin: &mut Vec<String>,
373        end: &mut Vec<String>,
374    ) -> bool {
375        let Self::Type(node) = self else {
376            return false;
377        };
378        if node.name.as_str() != name || !matches!(node.body, fsharp::TypeBody::Class(_)) {
379            return false;
380        }
381        node.body_text.begin.append(begin);
382        node.body_text.end.append(end);
383        true
384    }
385}
386
387/// The default an interface operation's function-valued field carries:
388/// a lambda that raises rather than a plausible answer, because there
389/// is no Table 9 row for an operation and a silently-wrong return value
390/// is the class of defect this project refuses to build in.
391fn interface_default(returns: &fsharp::Type) -> fsharp::Expr {
392    let _ = returns;
393    fsharp::Expr::call(
394        "fun _ -> failwith",
395        vec![fsharp::Expr::Literal(Literal::string(
396            "an interface field with no implementation bound to it",
397        ))],
398    )
399}
400
401/// The F# declarations, in a file that names its module.
402///
403/// A caller that only wants the declarations uses [`fsharp_items`]; this
404/// is what a generator writes to disk.
405///
406/// # Errors
407/// As [`fsharp_items`].
408pub fn fsharp_file(ast: &Ast, applied: &Applied) -> Result<fsharp::File, MaterialiseError> {
409    let decls = fsharp_items(ast, applied)?;
410    Ok(fsharp::File {
411        doc: Doc::lines(&[
412            "Generated by ZeroDDS from an IDL source. Do not edit.",
413            "",
414            "The IDL spelling of every renamed name is in the comment above it.",
415            "Nothing is renamed for style; a rename here is always an escape: an F#",
416            "keyword used as a member or a declaration. A member's ID under",
417            "@autoid(HASH) is the MD5 of its IDL name (DDS-XTypes 1.3 7.3.1.2.1.1)",
418            "and the escape does not invert, which is why the comment is there.",
419        ]),
420        module: Ident::new(MODULE_NAME),
421        decls,
422    })
423}
424
425/// One declared type.
426#[allow(clippy::too_many_lines)]
427fn declare(
428    ast: &Ast,
429    applied: &Applied,
430    id: Id<TypeNode>,
431    out: &mut Vec<fsharp::Decl>,
432    emitted: &mut Vec<(String, String)>,
433) -> Result<(), MaterialiseError> {
434    // `id` came from `ast.types()` in `fsharp_items`, so an arena that
435    // will not answer for it contradicts the iterator that produced it.
436    // `Ok(())` here meant the whole declaration left the file with no
437    // word said, and the two walks below — `NoNode` for an exception
438    // member, an operation, a parameter, an enumerator and a bit — are
439    // this file's own contract for the same handle.
440    let Ok(node) = ast.type_(id) else {
441        return Err(MaterialiseError::NoNode {
442            kind: "type",
443            at: crate::adapter::cst::handle_of(id),
444        });
445    };
446
447    match &node.kind {
448        TypeKind::Primitive(_)
449        | TypeKind::String { .. }
450        | TypeKind::Fixed { .. }
451        | TypeKind::Sequence { .. }
452        | TypeKind::Array { .. } => {}
453
454        TypeKind::Map { key, value, .. } => {
455            let (Some(key), Some(value)) = (key.resolved(), value.resolved()) else {
456                return Err(MaterialiseError::Unevaluated {
457                    construct: "map key or value".to_string(),
458                    at: fsharp_type_name(ast, id),
459                });
460            };
461            let name = map_entry_name_at(ast, id, 0)?;
462            if emitted.iter().any(|(spelled, _)| *spelled == name) {
463                return Ok(());
464            }
465            emitted.push((name.clone(), name.clone()));
466            let key_default =
467                default_fsharp::default_expr(ast, key).map_err(|error| no_form(&error, &name))?;
468            let value_default =
469                default_fsharp::default_expr(ast, value).map_err(|error| no_form(&error, &name))?;
470            out.push(fsharp::Decl::Type(fsharp::TypeDecl {
471                body_text: zerodds_lang_cst::common::BodyText::none(),
472                doc: Doc::lines(&[
473                    "One entry of a map. DDS-XTypes 1.3 7.4.1.1.4 defines a map as a",
474                    "sequence of these, and the rules write the two halves with no",
475                    "framing between them.",
476                ]),
477                name: Ident::new(&name),
478                body: fsharp::TypeBody::Class(vec![
479                    fsharp::ClassField {
480                        doc: Doc::none(),
481                        name: Ident::new("Key"),
482                        type_: fsharp_member_type(ast, key, &name)?,
483                        default: key_default,
484                    },
485                    fsharp::ClassField {
486                        doc: Doc::none(),
487                        name: Ident::new("Value"),
488                        type_: fsharp_member_type(ast, value, &name)?,
489                        default: value_default,
490                    },
491                ]),
492            }));
493        }
494
495        TypeKind::Alias { aliased } => {
496            if node.name.is_empty() {
497                return Ok(());
498            }
499            if ghost_created_for(applied, node.node_id, "FsAlias").is_none() {
500                return Ok(());
501            }
502            let Some(target) = aliased.resolved() else {
503                return Err(MaterialiseError::Unevaluated {
504                    construct: "typedef target".to_string(),
505                    at: node.name.clone(),
506                });
507            };
508            let name = fsharp_type_name(ast, id);
509            remember(emitted, &name, &node.name)?;
510            // `type Distance = int32` — a type abbreviation binds a name
511            // to a type and creates no new one, which IDL 4.2 §7.4.1.4.3
512            // says an alias does.
513            out.push(fsharp::Decl::Type(fsharp::TypeDecl {
514                body_text: zerodds_lang_cst::common::BodyText::none(),
515                doc: idl_note(&name, &node.name),
516                name: Ident::new(&name),
517                body: fsharp::TypeBody::Alias(fsharp_member_type(ast, target, &node.name)?),
518            }));
519        }
520
521        TypeKind::Enumeration { enumerators } => {
522            if ghost_created_for(applied, node.node_id, "FsEnum").is_none() {
523                return Ok(());
524            }
525            let name = fsharp_type_name(ast, id);
526            remember(emitted, &name, &node.name)?;
527            let mut pairs = Vec::new();
528            for enumerator in enumerators {
529                if let Ok(enum_node) = ast.enumerator(*enumerator) {
530                    pairs.push((field_name(&enum_node.name), enum_node.name.clone()));
531                }
532            }
533            no_spelling_collision(as_pairs(&pairs), LANGUAGE, &name)?;
534            let mut variants = Vec::new();
535            for enumerator in enumerators {
536                let Ok(enum_node) = ast.enumerator(*enumerator) else {
537                    return Err(MaterialiseError::NoNode {
538                        kind: "enumerator",
539                        at: crate::adapter::cst::handle_of(*enumerator),
540                    });
541                };
542                if ghost_created_for(applied, enum_node.node_id, "FsVariant").is_none() {
543                    continue;
544                }
545                let value = ast.effective_enumerator_value(*enumerator).ok_or_else(|| {
546                    MaterialiseError::Unevaluated {
547                        construct: "enumerator value".to_string(),
548                        at: enum_node.name.clone(),
549                    }
550                })?;
551                let spelled = field_name(&enum_node.name);
552                variants.push(fsharp::Variant {
553                    doc: idl_note(&spelled, &enum_node.name),
554                    name: Ident::new(&spelled),
555                    value: fsharp::Expr::int(value),
556                });
557            }
558            out.push(fsharp::Decl::Type(fsharp::TypeDecl {
559                body_text: zerodds_lang_cst::common::BodyText::none(),
560                doc: with_idl_note(
561                    Doc::lines(&[
562                        "An enumeration. The backing type is signed because DDS-XTypes",
563                        "1.3 7.4.3.5.3 makes it so; the width on the wire is the",
564                        "@bit_bound's and is applied by the runtime.",
565                    ]),
566                    &name,
567                    &node.name,
568                ),
569                name: Ident::new(&name),
570                body: fsharp::TypeBody::Enum {
571                    backing: fsharp::Type::Primitive(fsharp::Primitive::Int32),
572                    variants,
573                },
574            }));
575        }
576
577        TypeKind::Bitmask { bits } => {
578            if ghost_created_for(applied, node.node_id, "FsBitmask").is_none() {
579                return Ok(());
580            }
581            let name = fsharp_type_name(ast, id);
582            remember(emitted, &name, &node.name)?;
583            let bound = ast.effective_bit_bound(id);
584            out.push(fsharp::Decl::Type(fsharp::TypeDecl {
585                body_text: zerodds_lang_cst::common::BodyText::none(),
586                doc: with_idl_note(
587                    Doc::lines(&[
588                        "A bitmask, as the unsigned type abbreviation Table 33 of",
589                        "DDS-XTypes 1.3 7.4.1.1.3.2 gives for its bound. Not an F#",
590                        "[<Flags>] enum: a bit's place is its @position, which admits",
591                        "gaps and any order, and an enum's own layout would say nothing",
592                        "about it that these constants do not already say.",
593                    ]),
594                    &name,
595                    &node.name,
596                ),
597                name: Ident::new(&name),
598                body: fsharp::TypeBody::Alias(fsharp::Type::Primitive(fsharp_holder(bound))),
599            }));
600            let prefix = module_prefix(ast, id);
601            for bit in bits {
602                let Ok(bit_node) = ast.bit_value(*bit) else {
603                    return Err(MaterialiseError::NoNode {
604                        kind: "bit",
605                        at: crate::adapter::cst::handle_of(*bit),
606                    });
607                };
608                if ghost_created_for(applied, bit_node.node_id, "FsBitConst").is_none() {
609                    continue;
610                }
611                let position = ast.effective_bit_position(*bit).ok_or_else(|| {
612                    MaterialiseError::Unevaluated {
613                        construct: "bit position".to_string(),
614                        at: bit_node.name.clone(),
615                    }
616                })?;
617                let constant = fsharp::escape_decl(&format!("{prefix}{}", bit_node.name));
618                remember(emitted, &constant, &bit_node.name)?;
619                // `uint16(1 <<< 4)` and not a bare `1 <<< 4`: F# gives
620                // `1` no width until something asks, `<<<`'s result is
621                // then the same `int32` its left operand was, and the
622                // `let`'s own ascription to `Flags` does not coerce it
623                // — `typed_int`'s documentation carries the measurement.
624                // `Flags` is `uint16`'s own abbreviation, so wrapping in
625                // the holder's conversion function types the whole
626                // expression exactly the ascription needs.
627                let shifted = fsharp::Expr::Binary {
628                    op: "<<<",
629                    lhs: Box::new(fsharp::Expr::int(1)),
630                    rhs: Box::new(fsharp::Expr::int(i128::from(position))),
631                };
632                out.push(fsharp::Decl::Let(fsharp::LetDecl {
633                    doc: idl_note(&constant, &bit_node.name),
634                    name: Ident::new(&constant),
635                    params: Vec::new(),
636                    type_: Some(fsharp::Type::named(&name)),
637                    body: vec![fsharp::Stmt::Return(typed_int(
638                        fsharp_holder(bound),
639                        shifted,
640                    ))],
641                }));
642            }
643        }
644
645        TypeKind::Bitset { .. } => {
646            if ghost_created_for(applied, node.node_id, "FsBitset").is_none() {
647                return Ok(());
648            }
649            let name = fsharp_type_name(ast, id);
650            remember(emitted, &name, &node.name)?;
651            let mut pairs = Vec::new();
652            for field in &ast.all_bitfields_of(id) {
653                // Loud, as the bitmask's `bit_value` walk above already
654                // is: a skipped bitfield left the class one field short
655                // and the collision check one name short, and the
656                // generated file still compiled — a bitset that lost a
657                // declared field with nothing said.
658                let Ok(field_node) = ast.bitfield(*field) else {
659                    return Err(MaterialiseError::NoNode {
660                        kind: "bitfield",
661                        at: crate::adapter::cst::handle_of(*field),
662                    });
663                };
664                // An anonymous bitfield is padding and declares no
665                // field; the codec still advances by its width.
666                let Some(field_ident) = field_node.name.as_ref() else {
667                    continue;
668                };
669                pairs.push((field_name(field_ident), field_ident.clone()));
670            }
671            no_spelling_collision(as_pairs(&pairs), LANGUAGE, &name)?;
672            let mut fields = Vec::new();
673            let mut index = 0;
674            for field in &ast.all_bitfields_of(id) {
675                let Ok(field_node) = ast.bitfield(*field) else {
676                    return Err(MaterialiseError::NoNode {
677                        kind: "bitfield",
678                        at: crate::adapter::cst::handle_of(*field),
679                    });
680                };
681                let Some(field_ident) = field_node.name.as_ref() else {
682                    continue;
683                };
684                let _ = field_ident;
685                fields.push(fsharp::ClassField {
686                    doc: idl_note(
687                        &pairs[index].0,
688                        &field_node.name.clone().unwrap_or_default(),
689                    ),
690                    name: Ident::new(&pairs[index].0),
691                    type_: fsharp::Type::Primitive(fsharp_holder(field_node.bits)),
692                    default: typed_int(fsharp_holder(field_node.bits), fsharp::Expr::int(0)),
693                });
694                index += 1;
695            }
696            out.push(fsharp::Decl::Type(fsharp::TypeDecl {
697                body_text: zerodds_lang_cst::common::BodyText::none(),
698                doc: with_idl_note(
699                    Doc::lines(&[
700                        "A bitset. The fields are separate integers here and not packed",
701                        "into one holder, because the order on the wire is the",
702                        "declaration's and the packing into the anchor is the codec's.",
703                    ]),
704                    &name,
705                    &node.name,
706                ),
707                name: Ident::new(&name),
708                body: fsharp::TypeBody::Class(fields),
709            }));
710        }
711
712        TypeKind::Structure { .. } | TypeKind::Valuetype { .. } => {
713            let created = if matches!(node.kind, TypeKind::Valuetype { .. }) {
714                "FsValue"
715            } else {
716                "FsClass"
717            };
718            if ghost_created_for(applied, node.node_id, created).is_none() {
719                return Ok(());
720            }
721            let name = fsharp_type_name(ast, id);
722            remember(emitted, &name, &node.name)?;
723            // XTypes 1.3 §7.5.1.4.1.3: the base is the first member `parent`.
724            let members = ast.members_of(id).to_vec();
725            let base = super::c::struct_base(ast, id);
726            let mut pairs = Vec::new();
727            if base.is_some() {
728                pairs.push((field_name("parent"), "parent".to_string()));
729            }
730            for member in &members {
731                if let Ok(member_node) = ast.member(*member) {
732                    pairs.push((field_name(&member_node.name), member_node.name.clone()));
733                }
734            }
735            no_spelling_collision(as_pairs(&pairs), LANGUAGE, &name)?;
736            let mut fields = Vec::new();
737            if let Some(base) = base {
738                let mut doc = Doc::none();
739                doc.push("The base structure, the first member (XTypes 1.3 7.5.1.4.1.3).");
740                fields.push(fsharp::ClassField {
741                    doc,
742                    name: Ident::new(&field_name("parent")),
743                    type_: fsharp_member_type(ast, base, "parent")?,
744                    default: default_fsharp::default_expr(ast, base)
745                        .map_err(|error| no_form(&error, "parent"))?,
746                });
747            }
748            for member in &members {
749                // Loud, as the exception's member walk in `fsharp_items`
750                // already is: skipping one left the class a field short
751                // — a member the IDL declares, absent from the generated
752                // type and from every diagnostic — while the file still
753                // compiled, which is the defect class this tree refuses.
754                let Ok(member_node) = ast.member(*member) else {
755                    return Err(MaterialiseError::NoNode {
756                        kind: "member",
757                        at: crate::adapter::cst::handle_of(*member),
758                    });
759                };
760                let Some(target) = member_node.type_.resolved() else {
761                    return Err(MaterialiseError::Unevaluated {
762                        construct: "member type".to_string(),
763                        at: member_node.name.clone(),
764                    });
765                };
766                fields.push(member_field(ast, member_node, target)?);
767            }
768            out.push(fsharp::Decl::Type(fsharp::TypeDecl {
769                body_text: zerodds_lang_cst::common::BodyText::none(),
770                doc: idl_note(&name, &node.name),
771                name: Ident::new(&name),
772                body: fsharp::TypeBody::Class(fields),
773            }));
774        }
775
776        TypeKind::Union { discriminator, .. } => {
777            if ghost_created_for(applied, node.node_id, "FsUnion").is_none() {
778                return Ok(());
779            }
780            let name = fsharp_type_name(ast, id);
781            remember(emitted, &name, &node.name)?;
782            let Some(disc) = discriminator.resolved() else {
783                return Err(MaterialiseError::Unevaluated {
784                    construct: "union discriminator".to_string(),
785                    at: name,
786                });
787            };
788            let cases = ast.cases_of(id);
789            let mut pairs = vec![(DISCRIMINATOR.to_string(), "<discriminator>".to_string())];
790            for case in cases {
791                if let Ok(case_node) = ast.case(*case) {
792                    if let Ok(member) = ast.member(case_node.member) {
793                        pairs.push((field_name(&member.name), member.name.clone()));
794                    }
795                }
796            }
797            no_spelling_collision(as_pairs(&pairs), LANGUAGE, &name)?;
798            let disc_default = default_fsharp::discriminator_default(ast, disc)
799                .map_err(|error| no_form(&error, &name))?;
800            let mut fields = vec![fsharp::ClassField {
801                doc: Doc::line("Which branch carries the value."),
802                name: Ident::new(DISCRIMINATOR),
803                type_: fsharp_member_type(ast, disc, &name)?,
804                default: disc_default,
805            }];
806            for case in cases {
807                // Loud on both handles. A skipped case is a branch the
808                // union declares and the class does not carry: the
809                // discriminator still reaches that label, and the
810                // generated file has no field to read it into — a
811                // branch that vanishes between the IDL and the type.
812                // The `ghost_created_for` skip between them is a
813                // different thing: the rule did not fire for that case,
814                // which the overlay says on purpose.
815                let Ok(case_node) = ast.case(*case) else {
816                    return Err(MaterialiseError::NoNode {
817                        kind: "case",
818                        at: crate::adapter::cst::handle_of(*case),
819                    });
820                };
821                if ghost_created_for(applied, case_node.node_id, "FsCase").is_none() {
822                    continue;
823                }
824                let Ok(member) = ast.member(case_node.member) else {
825                    return Err(MaterialiseError::NoNode {
826                        kind: "member",
827                        at: crate::adapter::cst::handle_of(case_node.member),
828                    });
829                };
830                let Some(target) = member.type_.resolved() else {
831                    return Err(MaterialiseError::Unevaluated {
832                        construct: "union case type".to_string(),
833                        at: member.name.clone(),
834                    });
835                };
836                fields.push(member_field(ast, member, target)?);
837            }
838            out.push(fsharp::Decl::Type(fsharp::TypeDecl {
839                body_text: zerodds_lang_cst::common::BodyText::none(),
840                doc: with_idl_note(
841                    Doc::lines(&[
842                        "A union, as a class carrying the discriminator beside every",
843                        "branch. Not an F# discriminated union: a case's data *is* its",
844                        "tag, and an IDL discriminator may take a value no case label",
845                        "selects (IDL 4.2 7.4.1.4.4.2.3), in which state no branch is",
846                        "live and only the discriminator goes on the wire",
847                        "(DDS-XTypes 1.3 7.4.1.1.5.2). A discriminated union cannot be",
848                        "constructed that way; this class holds it exactly. Every",
849                        "branch field defaults to its own type's Table 9 value, whether",
850                        "or not the default discriminator selects it — the wire never",
851                        "reads an unselected branch, so any value there is as good as",
852                        "any other, and a real one costs nothing an undefined one saves.",
853                    ]),
854                    &name,
855                    &node.name,
856                ),
857                name: Ident::new(&name),
858                body: fsharp::TypeBody::Class(fields),
859            }));
860        }
861
862        TypeKind::Native => {
863            if ghost_created_for(applied, node.node_id, "FsNative").is_none() {
864                return Ok(());
865            }
866            let name = fsharp_type_name(ast, id);
867            remember(emitted, &name, &node.name)?;
868            let mapping = ast
869                .any_of_type(id)
870                .ok()
871                .and_then(|any| ast.facets(any))
872                .and_then(|facets| facets.corba.as_ref())
873                .and_then(|corba| corba.native_mapping.clone());
874            out.push(fsharp::Decl::Type(fsharp::TypeDecl {
875                body_text: zerodds_lang_cst::common::BodyText::none(),
876                doc: with_idl_note(
877                    Doc::line("A native type. `obj` unless a facet named its mapping."),
878                    &name,
879                    &node.name,
880                ),
881                name: Ident::new(&name),
882                body: fsharp::TypeBody::Alias(match mapping {
883                    Some(spelling) => fsharp::Type::named(&spelling),
884                    None => fsharp::Type::named("obj"),
885                }),
886            }));
887        }
888
889        TypeKind::ValueBox { boxed } => {
890            let Some(target) = boxed.resolved() else {
891                return Ok(());
892            };
893            let name = fsharp_type_name(ast, id);
894            if emitted.iter().any(|(spelled, _)| *spelled == name) {
895                return Ok(());
896            }
897            remember(emitted, &name, &node.name)?;
898            // `T option`: an F# class reference already carries the
899            // indirection a boxed value needs, so only the absence has
900            // to be added and `option` is exactly that.
901            out.push(fsharp::Decl::Type(fsharp::TypeDecl {
902                body_text: zerodds_lang_cst::common::BodyText::none(),
903                doc: with_idl_note(
904                    Doc::line("A boxed value: `None` when absent."),
905                    &name,
906                    &node.name,
907                ),
908                name: Ident::new(&name),
909                body: fsharp::TypeBody::Alias(fsharp::Type::option(fsharp_member_type(
910                    ast, target, &name,
911                )?)),
912            }));
913        }
914
915        TypeKind::ObjectRef { .. }
916        | TypeKind::AnnotationType { .. }
917        | TypeKind::Any
918        | TypeKind::ValueBase => {}
919    }
920    Ok(())
921}
922
923/// Turn a [`crate::encode::EncodeError`] arising from a Table 9 lookup
924/// into the materialiser's own error type, for the two positions —
925/// a map entry's fields, a union discriminator's default — that need
926/// one before any codec walk exists to report it through `EncodeError`
927/// itself.
928fn no_form(error: &crate::encode::EncodeError, at: &str) -> MaterialiseError {
929    MaterialiseError::NoLanguageForm {
930        construct: error.to_string(),
931        language: LANGUAGE,
932        at: at.to_string(),
933    }
934}
935
936/// The F# spelling of a value's type, refusing what F# cannot hold.
937///
938/// # Errors
939/// [`MaterialiseError::NoLanguageForm`] for `any`, `ValueBase` and an
940/// object reference.
941pub fn fsharp_member_type(
942    ast: &Ast,
943    target: Id<TypeNode>,
944    at: &str,
945) -> Result<fsharp::Type, MaterialiseError> {
946    let construct = match ast.type_(target).map(|node| &node.kind) {
947        Ok(TypeKind::Any) => Some("any"),
948        Ok(TypeKind::ValueBase) => Some("ValueBase"),
949        Ok(TypeKind::ObjectRef { .. }) => Some("Object"),
950        _ => None,
951    };
952    if let Some(construct) = construct {
953        return Err(MaterialiseError::NoLanguageForm {
954            construct: construct.to_string(),
955            language: LANGUAGE,
956            at: at.to_string(),
957        });
958    }
959    fsharp_value_type(ast, target, at)
960}
961
962/// The F# type a value of `id` is held in. The one answer, for every
963/// emitter: a class field, a codec's parameter and a default expression
964/// all have to name the same F# type or the file does not compile.
965///
966/// # Errors
967/// [`MaterialiseError::Unevaluated`] when the type, an array length or a
968/// collection's element does not resolve.
969pub fn fsharp_value_type(
970    ast: &Ast,
971    id: Id<TypeNode>,
972    at: &str,
973) -> Result<fsharp::Type, MaterialiseError> {
974    value_type_at(ast, id, at, 0)
975}
976
977/// [`fsharp_value_type`], carrying how many connector steps the walk has
978/// already taken.
979///
980/// # Errors
981/// [`MaterialiseError::Unevaluated`] when the spelling nests past
982/// `MAX_TYPE_DEPTH`.
983///
984/// zerodds-lint: recursion-depth 64 (sequence, array, map and
985/// anonymous-alias nesting; bounded by `MAX_TYPE_DEPTH`, which returns
986/// `MaterialiseError::Unevaluated`)
987fn value_type_at(
988    ast: &Ast,
989    id: Id<TypeNode>,
990    at: &str,
991    depth: usize,
992) -> Result<fsharp::Type, MaterialiseError> {
993    if depth > MAX_TYPE_DEPTH {
994        return Err(MaterialiseError::Unevaluated {
995            construct: format!("type, whose F# spelling nests past {MAX_TYPE_DEPTH}"),
996            at: fsharp_type_name(ast, id),
997        });
998    }
999    let Ok(node) = ast.type_(id) else {
1000        return Err(MaterialiseError::Unevaluated {
1001            construct: "type behind a member".to_string(),
1002            at: at.to_string(),
1003        });
1004    };
1005    Ok(match &node.kind {
1006        TypeKind::Primitive(kind) => fsharp_primitive_type(*kind),
1007        TypeKind::String { .. } | TypeKind::Fixed { .. } => fsharp::Type::text(),
1008        TypeKind::Sequence { element, .. } => {
1009            let Some(element) = element.resolved() else {
1010                return Err(MaterialiseError::Unevaluated {
1011                    construct: "sequence element".to_string(),
1012                    at: node.name.clone(),
1013                });
1014            };
1015            fsharp::Type::array(value_type_at(ast, element, at, depth + 1)?)
1016        }
1017        TypeKind::Array { element, dims } => {
1018            let Some(element) = element.resolved() else {
1019                return Err(MaterialiseError::Unevaluated {
1020                    construct: "array element".to_string(),
1021                    at: node.name.clone(),
1022                });
1023            };
1024            let mut type_ = value_type_at(ast, element, at, depth + 1)?;
1025            for dim in dims.iter().rev() {
1026                let _ = ast
1027                    .eval_integral(*dim)
1028                    .map_err(|_| MaterialiseError::Unevaluated {
1029                        construct: "array length".to_string(),
1030                        at: node.name.clone(),
1031                    })?;
1032                type_ = fsharp::Type::array(type_);
1033            }
1034            type_
1035        }
1036        TypeKind::Map { .. } => {
1037            fsharp::Type::array(fsharp::Type::named(&map_entry_name_at(ast, id, depth + 1)?))
1038        }
1039        TypeKind::Alias { aliased } if node.name.is_empty() => {
1040            let Some(target) = aliased.resolved() else {
1041                return Err(MaterialiseError::Unevaluated {
1042                    construct: "inline typedef target".to_string(),
1043                    at: at.to_string(),
1044                });
1045            };
1046            value_type_at(ast, target, at, depth + 1)?
1047        }
1048        _ => fsharp::Type::named(&fsharp_type_name(ast, id)),
1049    })
1050}
1051
1052/// The F# type an IDL primitive becomes.
1053///
1054/// Exhaustive, so a primitive added to the model without an F# spelling
1055/// is a compile error. `char` is one octet and `wchar` two, the wire
1056/// widths DDS-XTypes 1.3 §7.5.1.1.1 leaves the binding free to choose
1057/// for `Char16`; `long double` is `string`, the module documentation's
1058/// answer to .NET having nothing 113 bits wide.
1059#[must_use]
1060pub fn fsharp_primitive_type(kind: PrimitiveKind) -> fsharp::Type {
1061    use fsharp::Primitive as P;
1062    let primitive = match kind {
1063        PrimitiveKind::Boolean => P::Bool,
1064        PrimitiveKind::Octet | PrimitiveKind::UInt8 | PrimitiveKind::Char => P::Byte,
1065        PrimitiveKind::Int8 => P::SByte,
1066        PrimitiveKind::WChar | PrimitiveKind::UInt16 => P::UInt16,
1067        PrimitiveKind::Int16 => P::Int16,
1068        PrimitiveKind::Int32 => P::Int32,
1069        PrimitiveKind::UInt32 => P::UInt32,
1070        PrimitiveKind::Int64 => P::Int64,
1071        PrimitiveKind::UInt64 => P::UInt64,
1072        PrimitiveKind::Float32 => P::Float32,
1073        PrimitiveKind::Float64 => P::Double,
1074        PrimitiveKind::Float128 => return fsharp::Type::text(),
1075    };
1076    fsharp::Type::Primitive(primitive)
1077}
1078
1079/// The unsigned integer a bit width calls for: the smallest standard
1080/// width that holds it, exactly [`crate::adapter::zig::zig_holder`]'s
1081/// reasoning read onto F#'s type names.
1082#[must_use]
1083pub const fn fsharp_holder(bits: u16) -> fsharp::Primitive {
1084    match bits {
1085        0..=8 => fsharp::Primitive::Byte,
1086        9..=16 => fsharp::Primitive::UInt16,
1087        17..=32 => fsharp::Primitive::UInt32,
1088        _ => fsharp::Primitive::UInt64,
1089    }
1090}
1091
1092/// Wraps `value` in the conversion function a literal of `primitive`
1093/// needs to compile as one.
1094///
1095/// F# gives `int32` and `float` (`System.Double`) bare literal syntax
1096/// and every other numeric primitive none: measured (`fsi`, .NET SDK
1097/// 9.0.106), `let b : byte = 0` is `error FS0001` — `0` is `int` and
1098/// does not become `byte` under the ascription, a function argument
1099/// position refuses the same way, and neither does an operator
1100/// expression like `1 <<< 4` assigned to a narrower-typed `let`. The
1101/// conversion function is what every one of those positions accepts
1102/// unchanged: `byte 0`, `uint16 (1 <<< 4)`, `byte(1)` all compile
1103/// (parentheses or a space both work; this column always uses the
1104/// former, through [`fsharp::Expr::call`]).
1105///
1106/// ## Why a literal gets the suffix and not the conversion
1107///
1108/// The conversion function takes an *expression*, and an integer
1109/// literal that is not ascribed anything is `int` before the conversion
1110/// ever sees it. `uint32(3735928559)` and `uint64(16045690984833335998)`
1111/// are therefore both `error FS1147`, "this number is outside the
1112/// allowable range for a 32-bit signed integer" — measured (`dotnet
1113/// fsi`, .NET SDK 9.0.106) on `matrix/cases/array-1d-primitives`, whose
1114/// `unsigned long` sample carries 0xDEADBEEF. The literal suffixes of
1115/// the F# specification §4.3 give the constant its width in the lexer
1116/// instead, where no `int` range applies: `3735928559u`,
1117/// `16045690984833335998UL`, and the signed minima `-128y`, `-32768s`
1118/// and `-9223372036854775808L` that the conversion form cannot express
1119/// either.
1120#[must_use]
1121pub fn typed_int(primitive: fsharp::Primitive, value: fsharp::Expr) -> fsharp::Expr {
1122    use fsharp::Primitive as P;
1123    let (conversion, suffix) = match primitive {
1124        P::SByte => ("sbyte", "y"),
1125        P::Byte => ("byte", "uy"),
1126        P::Int16 => ("int16", "s"),
1127        P::UInt16 => ("uint16", "us"),
1128        // `int32` takes a bare literal; wrapping it would compile too,
1129        // but every other column's constant tables read as decimals
1130        // and not as calls, and this one is no different where it does
1131        // not have to be.
1132        P::Int32 => return value,
1133        P::UInt32 => ("uint32", "u"),
1134        P::Int64 => ("int64", "L"),
1135        P::UInt64 => ("uint64", "UL"),
1136        P::Bool | P::Float32 | P::Double | P::String | P::Unit => return value,
1137    };
1138    if let fsharp::Expr::Literal(Literal::Int(digits)) = &value {
1139        return fsharp::Expr::Literal(Literal::Int(format!("{digits}{suffix}")));
1140    }
1141    fsharp::Expr::call(conversion, vec![value])
1142}
1143
1144/// A declared type's F# identifier: the scoped name flattened onto the
1145/// module, exactly as [`crate::adapter::zig::zig_type_name`] flattens
1146/// its own — `::common::util::Location` is `common_util_Location`, and
1147/// no case is changed.
1148#[must_use]
1149pub fn fsharp_type_name(ast: &Ast, id: Id<TypeNode>) -> String {
1150    let Ok(any) = ast.any_of_type(id) else {
1151        return "ZdUnnamed".to_string();
1152    };
1153    match ast.scoped_name_of(any) {
1154        Some(scoped) => flatten(scoped.segments()),
1155        None => "ZdUnnamed".to_string(),
1156    }
1157}
1158
1159/// The entry class a `map<K,V>` needs a name for. Structural, exactly as
1160/// [`crate::adapter::zig::zig_map_entry_name`] is, and built from the
1161/// IDL kind rather than the F# spelling for the reason that function's
1162/// documentation gives.
1163///
1164/// # Errors
1165/// [`MaterialiseError::Unevaluated`] when a key's or a value's tag
1166/// nests past `MAX_TYPE_DEPTH`.
1167pub fn fsharp_map_entry_name(ast: &Ast, id: Id<TypeNode>) -> Result<String, MaterialiseError> {
1168    map_entry_name_at(ast, id, 0)
1169}
1170
1171/// [`fsharp_map_entry_name`], carrying the depth of the walk that reached it.
1172///
1173/// The depth is threaded rather than reset: `type_tag` reaches a
1174/// map's entry name through this function and this function reaches
1175/// `type_tag` again, so a counter that started over here would leave
1176/// the pair with no bound at all.
1177///
1178/// # Errors
1179/// [`MaterialiseError::Unevaluated`] when a key's or a value's tag
1180/// nests past `MAX_TYPE_DEPTH`.
1181///
1182/// zerodds-lint: recursion-depth 64 (key and value tags, via
1183/// `type_tag`; bounded by `MAX_TYPE_DEPTH`, which returns
1184/// `MaterialiseError::Unevaluated`)
1185fn map_entry_name_at(
1186    ast: &Ast,
1187    id: Id<TypeNode>,
1188    depth: usize,
1189) -> Result<String, MaterialiseError> {
1190    if depth > MAX_TYPE_DEPTH {
1191        return Err(MaterialiseError::Unevaluated {
1192            construct: format!("map entry name, which nests past {MAX_TYPE_DEPTH}"),
1193            at: fsharp_type_name(ast, id),
1194        });
1195    }
1196    let Ok(node) = ast.type_(id) else {
1197        return Ok(format!("{INVENTED_PREFIX}unknown"));
1198    };
1199    let TypeKind::Map { key, value, .. } = &node.kind else {
1200        return Ok(format!("{INVENTED_PREFIX}unknown"));
1201    };
1202    let key = match key.resolved() {
1203        Some(k) => type_tag(ast, k, depth + 1)?,
1204        None => "unknown".to_string(),
1205    };
1206    let value = match value.resolved() {
1207        Some(v) => type_tag(ast, v, depth + 1)?,
1208        None => "unknown".to_string(),
1209    };
1210    Ok(format!("{INVENTED_PREFIX}{key}_{value}"))
1211}
1212
1213/// # Errors
1214/// [`MaterialiseError::Unevaluated`] when the tag nests past
1215/// `MAX_TYPE_DEPTH`.
1216///
1217/// zerodds-lint: recursion-depth 64 (sequence, array and map
1218/// nesting; bounded by `MAX_TYPE_DEPTH`, which returns
1219/// `MaterialiseError::Unevaluated`)
1220fn type_tag(ast: &Ast, id: Id<TypeNode>, depth: usize) -> Result<String, MaterialiseError> {
1221    if depth > MAX_TYPE_DEPTH {
1222        return Err(MaterialiseError::Unevaluated {
1223            construct: format!("type tag, which nests past {MAX_TYPE_DEPTH}"),
1224            at: fsharp_type_name(ast, id),
1225        });
1226    }
1227    Ok(match ast.type_(id).map(|node| node.kind.clone()) {
1228        Ok(TypeKind::Primitive(kind)) => kind.as_str().replace(' ', "_"),
1229        Ok(TypeKind::String {
1230            width: CharWidth::Char,
1231            ..
1232        }) => "string".to_string(),
1233        Ok(TypeKind::String {
1234            width: CharWidth::WChar,
1235            ..
1236        }) => "wstring".to_string(),
1237        Ok(TypeKind::Sequence { element, .. }) => match element.resolved() {
1238            Some(element) => format!("seq_of_{}", type_tag(ast, element, depth + 1)?),
1239            None => "seq_of_unknown".to_string(),
1240        },
1241        Ok(TypeKind::Array { element, dims }) => match element.resolved() {
1242            Some(element) => {
1243                let mut name = format!("arr_of_{}", type_tag(ast, element, depth + 1)?);
1244                for dim in &dims {
1245                    match ast.eval_integral(*dim) {
1246                        Ok(length) => name.push_str(&format!("_{length}")),
1247                        Err(_) => name.push_str("_x"),
1248                    }
1249                }
1250                name
1251            }
1252            None => "arr_of_unknown".to_string(),
1253        },
1254        Ok(TypeKind::Map { .. }) => map_entry_name_at(ast, id, depth + 1)?,
1255        _ => fsharp_type_name(ast, id),
1256    })
1257}
1258
1259/// The flattened module path a file-scope constant carries.
1260fn module_prefix(ast: &Ast, id: Id<TypeNode>) -> String {
1261    let Ok(any) = ast.any_of_type(id) else {
1262        return String::new();
1263    };
1264    let Some(scoped) = ast.scoped_name_of(any) else {
1265        return String::new();
1266    };
1267    let segments = scoped.segments();
1268    if segments.len() <= 1 {
1269        return String::new();
1270    }
1271    let mut prefix = segments[..segments.len() - 1].join("_");
1272    prefix.push('_');
1273    prefix
1274}
1275
1276/// The F# name a declaration outside the type arena takes.
1277fn declared_name(ast: &Ast, name: &str, module: Id<zerodds_ast::ModuleNode>) -> String {
1278    let mut segments: Vec<String> = Vec::new();
1279    let mut current = Some(module);
1280    let mut depth = 0;
1281    while let Some(id) = current {
1282        if depth > MAX_MODULE_DEPTH {
1283            break;
1284        }
1285        depth += 1;
1286        let Ok(node) = ast.module(id) else { break };
1287        if node.name.is_empty() {
1288            break;
1289        }
1290        segments.push(node.name.clone());
1291        current = node.parent;
1292    }
1293    segments.reverse();
1294    segments.push(name.to_string());
1295    fsharp::escape_decl(&segments.join("_"))
1296}
1297
1298/// Join scoped segments with `_` and escape the result — the same
1299/// non-per-segment escape [`crate::adapter::zig`]'s `flatten` applies,
1300/// for the reason its documentation gives.
1301fn flatten(segments: &[String]) -> String {
1302    fsharp::escape_decl(&segments.join("_"))
1303}
1304
1305/// The F# spelling of a member, an enumerator, a bitfield or an
1306/// operation. Unlike Zig's [`crate::adapter::zig::field_name`], this
1307/// checks the same keyword list [`fsharp::escape_decl`] does — the
1308/// module documentation of [`zerodds_lang_cst::fsharp`] carries the
1309/// measurement that a field position refuses a keyword too.
1310#[must_use]
1311pub fn field_name(name: &str) -> String {
1312    fsharp::escape_field(name)
1313}
1314
1315/// Whether a member is annotated `@external`.
1316fn is_external(member: &zerodds_ast::MemberNode) -> bool {
1317    member
1318        .facets
1319        .xtypes
1320        .as_ref()
1321        .is_some_and(|xtypes| xtypes.is_external)
1322}
1323
1324/// The field one member declaration becomes.
1325///
1326/// `@external` reaches nothing here — the module documentation says
1327/// why — and `@optional` becomes `T option`, `None` its default: F#'s
1328/// optional has a `None` no value of `T` can equal, where a
1329/// zero-initialised `int32` that arrived as 0 and one that did not
1330/// arrive at all would be the same value.
1331fn member_field(
1332    ast: &Ast,
1333    member: &zerodds_ast::MemberNode,
1334    target: Id<TypeNode>,
1335) -> Result<fsharp::ClassField, MaterialiseError> {
1336    let spelled = field_name(&member.name);
1337    let mut doc = idl_note(&spelled, &member.name);
1338    let type_ = fsharp_member_type(ast, target, &member.name)?;
1339    if is_external(member) {
1340        doc.push("Stored by reference — @external, DDS-XTypes 1.3 Table 21. An F# class");
1341        doc.push("is a reference already, and the indirection is what makes a");
1342        doc.push("self-recursive type finite.");
1343    }
1344    let (type_, default) = if member.facets.is_optional() {
1345        doc.push("Optional. `None` when the member is absent — DDS-XTypes 1.3");
1346        doc.push("7.2.2.4.4.4.7. In a @mutable type an absent member is not written");
1347        doc.push("at all; outside one its absence is written.");
1348        (fsharp::Type::option(type_), fsharp::Expr::None_)
1349    } else {
1350        let default = default_fsharp::default_expr(ast, target)
1351            .map_err(|error| no_form(&error, &member.name))?;
1352        (type_, default)
1353    };
1354    Ok(fsharp::ClassField {
1355        doc,
1356        name: Ident::new(&spelled),
1357        type_,
1358        default,
1359    })
1360}
1361
1362/// The declaration's own IDL name, where the F# spelling moved it.
1363fn idl_note(spelled: &str, declared: &str) -> Doc {
1364    if spelled == declared {
1365        return Doc::none();
1366    }
1367    Doc::line(&format!("IDL: `{declared}`."))
1368}
1369
1370/// The same, appended to a documentation comment the caller wrote.
1371fn with_idl_note(mut doc: Doc, spelled: &str, declared: &str) -> Doc {
1372    if spelled != declared {
1373        doc.push(&format!("IDL: `{declared}`."));
1374    }
1375    doc
1376}
1377
1378/// Record a declared name, refusing a second declaration that reaches
1379/// the same F# spelling — one `module rec` holds every declaration, so
1380/// this is a whole-file check, exactly as
1381/// [`crate::adapter::zig`]'s own `remember` is.
1382fn remember(
1383    emitted: &mut Vec<(String, String)>,
1384    spelled: &str,
1385    declared: &str,
1386) -> Result<(), MaterialiseError> {
1387    if let Some((_, first)) = emitted.iter().find(|(other, _)| other == spelled) {
1388        return Err(MaterialiseError::SpellingCollision {
1389            first: first.clone(),
1390            second: declared.to_string(),
1391            spelled: spelled.to_string(),
1392            language: LANGUAGE,
1393            at: "the generated file".to_string(),
1394        });
1395    }
1396    emitted.push((spelled.to_string(), declared.to_string()));
1397    Ok(())
1398}
1399
1400fn as_pairs(pairs: &[(String, String)]) -> Vec<(&str, &str)> {
1401    pairs
1402        .iter()
1403        .map(|(spelled, declared)| (spelled.as_str(), declared.as_str()))
1404        .collect()
1405}
1406
1407/// Whether the rules created a node of `type_name` for `node`.
1408fn ghost_created_for(applied: &Applied, node: NodeId, type_name: &str) -> Option<GhostId> {
1409    let anchor = applied
1410        .origin
1411        .iter()
1412        .find(|(_, model)| **model == node)
1413        .map(|(ghost, _)| *ghost)?;
1414    let typ = applied.graph.types.lookup(type_name)?;
1415    applied
1416        .graph
1417        .nodes_of_type(typ)
1418        .map(|created| created.id)
1419        .find(|created| {
1420            applied.graph.connected(&anchor, created)
1421                || applied
1422                    .graph
1423                    .parts(&anchor)
1424                    .any(|part| applied.graph.connected(&part.other, created))
1425        })
1426}
1427
1428/// The F# expression a constant's value takes.
1429fn fsharp_constant(
1430    value: &zerodds_ast::ConstValue,
1431    type_: &fsharp::Type,
1432    is_long_double: bool,
1433    at: &str,
1434) -> Result<fsharp::Expr, MaterialiseError> {
1435    use zerodds_ast::ConstValue;
1436    let refuse = |construct: &str| MaterialiseError::NoLanguageForm {
1437        construct: construct.to_string(),
1438        language: LANGUAGE,
1439        at: at.to_string(),
1440    };
1441    let is_text = matches!(type_, fsharp::Type::Primitive(fsharp::Primitive::String));
1442    // `typed_int` only where `type_` is a bare primitive: a `const`
1443    // whose type is a bitmask's abbreviation or an enumeration reaches
1444    // this through a different `TypeKind` arm entirely, and wrapping a
1445    // `Named` type here would call a conversion function that does not
1446    // exist.
1447    let wrap = |value: fsharp::Expr| match type_ {
1448        fsharp::Type::Primitive(primitive) => typed_int(*primitive, value),
1449        _ => value,
1450    };
1451    Ok(match value {
1452        ConstValue::Boolean(flag) => fsharp::Expr::Literal(Literal::Bool(*flag)),
1453        ConstValue::Integer(number) => wrap(fsharp::Expr::int(*number)),
1454        ConstValue::Char(character) => wrap(fsharp::Expr::int(i128::from(u32::from(*character)))),
1455        ConstValue::WChar(point) => wrap(fsharp::Expr::int(i128::from(*point))),
1456        ConstValue::Float { mantissa, exponent } => {
1457            let decimal = constant::float_text(mantissa, exponent.as_deref());
1458            // `long double` and `string`/`wstring`/`fixed<P,S>` share
1459            // one F# type; a `long double` constant is legitimately a
1460            // floating-point value of a text-shaped type, and the
1461            // decimal text is exactly what its member holds — the
1462            // caller's `is_long_double` says which of the two this is.
1463            if is_text && !is_long_double {
1464                return Err(refuse("a floating-point constant of a text-shaped type"));
1465            }
1466            if is_long_double {
1467                return Ok(fsharp::Expr::Literal(Literal::Str(decimal)));
1468            }
1469            let literal = fsharp::Expr::Literal(Literal::Float(decimal));
1470            // `float32`'s bare decimal is `float` (F#'s default width);
1471            // `typed_int`'s documentation carries the measurement for
1472            // the integer case, and this is the same finding for the
1473            // one non-integer width it does not cover.
1474            if *type_ == fsharp::Type::Primitive(fsharp::Primitive::Float32) {
1475                fsharp::Expr::call("float32", vec![literal])
1476            } else {
1477                literal
1478            }
1479        }
1480        ConstValue::Fixed { digits, scale } => {
1481            fsharp::Expr::Literal(Literal::Str(constant::fixed_decimal(digits, *scale)))
1482        }
1483        ConstValue::String(text) => fsharp::Expr::Literal(Literal::Str(text.clone())),
1484        ConstValue::WString(points) => fsharp::Expr::Literal(Literal::Str(
1485            constant::wstring_utf8(points)
1486                .ok_or_else(|| refuse("a `wstring` constant holding a lone surrogate"))?,
1487        )),
1488        _ => return Err(refuse("a constant form this binding has no literal for")),
1489    })
1490}