Skip to main content

zerodds_lang_rules/
default_lua.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! The value a Lua member takes when the stream does not carry it.
4//!
5//! Table 9 of DDS-XTypes 1.3, as Lua. [`crate::default_python`] is the
6//! same table as Python and carries the clause quotations; this is a
7//! further derivation and deliberately not a call into any sibling —
8//! the columns are compared against each other, and a shared helper
9//! would make them agree about a mistake.
10//!
11//! ## The Lua spellings, where they differ from every sibling
12//!
13//! **An aggregate is a table constructor, not a call.** A Lua table
14//! needs no class and no constructor: `{ x = 0, y = 0.0 }` *is* the
15//! value, member defaults nested inside. A nested aggregate member is
16//! still a call — `ZD.zd_def_Inner()` — so one type has one answer and
17//! two positions cannot drift.
18//!
19//! **An `@optional` member is a key left out.** §7.2.2.4.4.4.8's
20//! "Implementations shall not provide a default value" is the absence,
21//! and in Lua the absence of a value is the absence of the key; a
22//! constructor entry holding `nil` would not even create one.
23//!
24//! **A `char` and a `wchar` are the code point, as an integer.** The
25//! member holds an integer — `adapter/lua.rs`'s mapping — so Table 9's
26//! zero is `0`, not a one-character string.
27//!
28//! **A `long double` is the decimal text `"0.0"`.** A Lua float is a
29//! binary64; the member holds text and `runtime/lua` converts exactly.
30//! `"0.0"` and not `"0"`, because it is also what
31//! `float128.bits_to_decimal` answers for the zero pattern, and one
32//! value should have one spelling on both directions.
33//!
34//! **An enumeration's default is read out of the descriptor.**
35//! `M.a.b.Colour.values.RED` — the materialiser's own table, through
36//! [`CodecTarget::enumerator_constant`], so the file that declared the
37//! value is the file this reads it from.
38//!
39//! **A fixed array is that many entries, spelled out.** A Lua table
40//! constructor evaluates each entry, so `{ ZD.zd_def_T(), ZD.zd_def_T() }`
41//! is two distinct tables — there is no `[x] * n` aliasing trap to
42//! avoid, and the spelled-out form is simply the only form.
43//!
44//! [`CodecTarget::enumerator_constant`]: crate::emit::CodecTarget::enumerator_constant
45
46use crate::default_common::{discriminator_default_value, resolve_alias, selected_case};
47use crate::emit::CodecTarget;
48use crate::emit::lua::{CODECS, Lua};
49use crate::encode::EncodeError;
50use crate::fixed_literal::zero_decimal;
51use zerodds_ast::{
52    Ast, ConstExpr, Literal as AstLiteral, MemberNode, PrimitiveKind, TypeKind, TypeNode,
53};
54use zerodds_ast_core::Id;
55use zerodds_lang_cst::common::{Doc, Literal};
56use zerodds_lang_cst::lua::{self, Entry, Expr, Place, Stat};
57
58/// How deep a chain of typedefs is followed before the model is taken
59/// to be malformed.
60const MAX_ALIAS_DEPTH: usize = 64;
61
62/// The defaulter function name for a type — keyed like
63/// [`crate::emit::decode::decoder_name`], by the IDL kind for a
64/// primitive: ten of the fifteen are one Lua `integer` and a name built
65/// from the holder would give them one defaulter.
66#[must_use]
67pub fn defaulter_name(ast: &Ast, id: Id<TypeNode>) -> String {
68    match ast.type_(id).map(|node| node.kind.clone()) {
69        Ok(TypeKind::Primitive(kind)) => {
70            format!("zd_def_p_{}", kind.as_str().replace(' ', "_"))
71        }
72        _ => format!("zd_def_{}", Lua::type_name(ast, id)),
73    }
74}
75
76/// The defaulter that goes with a decoder, by name: the two schemes
77/// differ in one word because both are built from the same key.
78#[must_use]
79pub fn defaulter_from_decoder(decoder: &str) -> String {
80    match decoder.strip_prefix("zd_dec_") {
81        Some(rest) => format!("zd_def_{rest}"),
82        None => format!("zd_def_{decoder}"),
83    }
84}
85
86/// `place = ZD.zd_def_<T>()`.
87#[must_use]
88pub fn default_into(ast: &Ast, id: Id<TypeNode>, place: Expr) -> Stat {
89    Stat::Assign {
90        doc: Doc::none(),
91        target: place_of(place),
92        value: codec_call(&defaulter_name(ast, id), Vec::new()),
93    }
94}
95
96/// One member's default: its `@default`, or its type's. §7.2.2.4.4.4.9
97/// puts the explicit one first.
98///
99/// # Errors
100/// [`EncodeError::NoWriter`] where a `@default` names a value this
101/// column cannot spell as a Lua expression — refused rather than
102/// replaced by Table 9's zero, which is a different sample.
103pub fn member_default(
104    ast: &Ast,
105    node: &MemberNode,
106    type_: Id<TypeNode>,
107    place: Expr,
108) -> Result<Vec<Stat>, EncodeError> {
109    Ok(vec![Stat::Assign {
110        doc: Doc::none(),
111        target: place_of(place),
112        value: member_value(ast, node, type_)?,
113    }])
114}
115
116/// The defaulters `decls` call, and exactly those, read off the emitted
117/// calls rather than derived a second time from the model — the same
118/// closure discipline `default_python` and `default_c` state at length.
119///
120/// # Errors
121/// [`EncodeError`] for a construct with no Lua default.
122pub fn lua_defaulters_called_by(ast: &Ast, decls: &[Stat]) -> Result<Vec<Stat>, EncodeError> {
123    let index: Vec<(String, Id<TypeNode>)> = ast
124        .types()
125        .map(|(id, _)| (defaulter_name(ast, id), id))
126        .collect();
127    let mut called: Vec<String> = Vec::new();
128    for decl in decls {
129        calls_in_stat(decl, &mut called);
130    }
131    let mut wanted: Vec<Id<TypeNode>> = Vec::new();
132    let mut have: Vec<String> = Vec::new();
133    for _ in 0..=index.len() {
134        let mut grew = false;
135        for name in &called {
136            if have.contains(name) {
137                continue;
138            }
139            have.push(name.clone());
140            if let Some(id) = index.iter().find(|(n, _)| n == name).map(|(_, id)| *id) {
141                wanted.push(id);
142                grew = true;
143            }
144        }
145        let produced = lua_defaulters_over(ast, &wanted)?;
146        if !grew {
147            return Ok(produced);
148        }
149        for decl in &produced {
150            calls_in_stat(decl, &mut called);
151        }
152    }
153    lua_defaulters_over(ast, &wanted)
154}
155
156/// Build the defaulter for each of `types`, once per name.
157///
158/// # Errors
159/// [`EncodeError`] for a construct with no Lua default.
160pub fn lua_defaulters_over(ast: &Ast, types: &[Id<TypeNode>]) -> Result<Vec<Stat>, EncodeError> {
161    let mut out = Vec::new();
162    let mut emitted: Vec<String> = Vec::new();
163    for id in types.iter().copied() {
164        let name = defaulter_name(ast, id);
165        if emitted.contains(&name) {
166            continue;
167        }
168        let Some(value) = value_of(ast, id, &name)? else {
169            continue;
170        };
171        emitted.push(name.clone());
172        out.push(Stat::Function(lua::Function {
173            doc: Doc::line(
174                "Table 9 of DDS-XTypes 1.3: what this type is where the stream did not carry it.",
175            ),
176            target: Place::name(CODECS).field(&name),
177            params: Vec::new(),
178            body: vec![Stat::Return(Some(value))],
179        }));
180    }
181    Ok(out)
182}
183
184/// The value one defaulter answers with, or `None` where the type has
185/// no Lua data form at all.
186#[allow(clippy::too_many_lines)]
187fn value_of(ast: &Ast, id: Id<TypeNode>, own_name: &str) -> Result<Option<Expr>, EncodeError> {
188    // Resolved before the table: a typedef and its target share a
189    // defaulter name, and an arm answering with a call would name this
190    // very function.
191    let id = resolve_alias(ast, id);
192    let refuse = |kind: &'static str| EncodeError::NoWriter {
193        member: own_name.to_string(),
194        kind,
195    };
196    // A type the model does not hold. It used to answer `Ok(None)`,
197    // which the caller reads as "nothing to emit for this one" and
198    // skips — so a type whose handle no longer resolves quietly loses
199    // its defaulter, and the first sign of it is a member left at
200    // whatever the language zero-initialises to. Every other arm of
201    // this function either answers a value or refuses by name; this
202    // one now does too.
203    let Ok(node) = ast.type_(id) else {
204        return Err(refuse("a type the model does not hold"));
205    };
206    let value = match &node.kind {
207        TypeKind::Primitive(kind) => primitive_default(*kind),
208
209        // The empty string, for both widths.
210        TypeKind::String { .. } => Expr::string(""),
211
212        // The one spelling of zero the canonical form has for a scale.
213        TypeKind::Fixed { scale, .. } => Expr::string(&zero_decimal(*scale)),
214
215        TypeKind::Alias { .. } => {
216            return Err(refuse("typedef with an unresolved target"));
217        }
218
219        // "The first literal in the enumeration", or `@default_literal`'s
220        // (§7.3.1.2.1.10), read out of the descriptor by name so a moved
221        // `@value` moves this with it.
222        TypeKind::Enumeration { .. } => {
223            let Some(enumerator) = crate::emit::default_enumerator_name(ast, id) else {
224                return Err(refuse(
225                    "enumeration with no enumerator to take as its default",
226                ));
227            };
228            Lua::enumerator_constant(ast, id, &enumerator)
229        }
230
231        // No bit set.
232        TypeKind::Bitmask { .. } => Expr::int(0),
233
234        // One field per named bitfield, each at zero; the packing into
235        // the anchor is the codec's.
236        TypeKind::Bitset { .. } => {
237            let mut entries = Vec::new();
238            for field in &ast.all_bitfields_of(id) {
239                let Ok(field_node) = ast.bitfield(*field) else {
240                    continue;
241                };
242                // An anonymous bitfield is padding and holds no value.
243                let Some(field_name) = field_node.name.as_ref() else {
244                    continue;
245                };
246                entries.push(Entry {
247                    doc: Doc::none(),
248                    key: lua::table_key(field_name),
249                    value: Expr::int(0),
250                });
251            }
252            Expr::Table(lua::Table::of(entries))
253        }
254
255        // "A zero-length sequence", and the same for a map's entries:
256        // an empty table. Never `nil` — that is what an `@optional`
257        // member's absence means, and a table entry holding `nil` does
258        // not exist at all.
259        TypeKind::Sequence { .. } | TypeKind::Map { .. } => Expr::Table(lua::Table::default()),
260
261        // "An array of the same dimensions … whose elements take the
262        // default value": one call per element, one table per
263        // dimension. A Lua constructor evaluates each entry, so every
264        // element is a distinct value.
265        TypeKind::Array { element, dims } => {
266            let Some(element) = element.resolved() else {
267                return Err(refuse("array with an unresolved element"));
268            };
269            let mut lengths = Vec::new();
270            for dim in dims {
271                let length = ast
272                    .eval_integral(*dim)
273                    .map_err(|_| refuse("array whose length does not evaluate"))?;
274                lengths.push(
275                    usize::try_from(length)
276                        .map_err(|_| refuse("array of a length this column cannot spell"))?,
277                );
278            }
279            let mut out = codec_call(&defaulter_name(ast, element), Vec::new());
280            for length in lengths.into_iter().rev() {
281                out = Expr::Table(lua::Table::of(
282                    vec![out; length]
283                        .into_iter()
284                        .map(Entry::positional)
285                        .collect(),
286                ));
287            }
288            out
289        }
290
291        // "A structure without any of the optional members and with
292        // other members set to their default values."
293        TypeKind::Structure { .. } | TypeKind::Valuetype { .. } => {
294            let mut entries = Vec::new();
295            // The base is the first member `parent` (XTypes 1.3 §7.5.1.4.1.3).
296            if let Some(base) = crate::adapter::c::struct_base(ast, id) {
297                entries.push(Entry {
298                    doc: Doc::none(),
299                    key: lua::table_key("parent"),
300                    value: codec_call(&defaulter_name(ast, base), Vec::new()),
301                });
302            }
303            for member_id in ast.members_of(id) {
304                let Ok(member_node) = ast.member(*member_id) else {
305                    continue;
306                };
307                // Left out: the absence of the key is the absence.
308                if member_node.facets.is_optional() {
309                    continue;
310                }
311                let Some(member_type) = member_node.type_.resolved() else {
312                    return Err(EncodeError::NoWriter {
313                        member: member_node.name.clone(),
314                        kind: "member with an unresolved type",
315                    });
316                };
317                entries.push(Entry {
318                    doc: Doc::none(),
319                    key: lua::table_key(&member_node.name),
320                    value: member_value(ast, member_node, member_type)?,
321                });
322            }
323            Expr::Table(lua::Table::of(entries))
324        }
325
326        // "A union with the discriminator set to the default value for
327        // the discriminator type. If this selects a branch, then the
328        // selected member is also set" — and where it selects none,
329        // `_d` alone is the whole value, which a Lua table holds as it
330        // stands.
331        TypeKind::Union { discriminator, .. } => {
332            let Some(discriminator) = discriminator.resolved() else {
333                return Err(refuse("union with an unresolved discriminator"));
334            };
335            let selector = discriminator_default_value(ast, discriminator);
336            let mut entries = vec![Entry::named(
337                "_d",
338                discriminant_literal(ast, discriminator, selector.unwrap_or(0)),
339            )];
340            if let Some((member_type, field)) = selected_by_default(ast, id, selector)? {
341                entries.push(Entry {
342                    doc: Doc::none(),
343                    key: lua::table_key(&field),
344                    value: codec_call(&defaulter_name(ast, member_type), Vec::new()),
345                });
346            }
347            Expr::Table(lua::Table::of(entries))
348        }
349
350        TypeKind::Native | TypeKind::ObjectRef { .. } | TypeKind::AnnotationType { .. } => {
351            return Ok(None);
352        }
353        TypeKind::ValueBox { .. } => {
354            return Err(refuse(
355                "boxed value, whose presence flag has no settled wire form",
356            ));
357        }
358        TypeKind::Any => {
359            return Err(refuse(
360                "`any`, which DDS-XTypes has no encoding for; only CORBA CDR writes one, \
361                 as a TypeCode ahead of the value",
362            ));
363        }
364        TypeKind::ValueBase => {
365            return Err(refuse(
366                "`ValueBase`, which DDS-XTypes has no encoding for; only CORBA CDR writes \
367                 one, as a chunked value with its repository id",
368            ));
369        }
370    };
371    Ok(Some(value))
372}
373
374/// One member's default as a value: its `@default`, or a call to its
375/// type's defaulter.
376fn member_value(ast: &Ast, node: &MemberNode, type_: Id<TypeNode>) -> Result<Expr, EncodeError> {
377    match node.facets.xtypes.as_ref().and_then(|x| x.default_value) {
378        Some(expr) => explicit(ast, expr, type_, &node.name),
379        None => Ok(codec_call(&defaulter_name(ast, type_), Vec::new())),
380    }
381}
382
383/// Table 9's primitive rows, as the Lua holder each takes.
384fn primitive_default(kind: PrimitiveKind) -> Expr {
385    match kind {
386        PrimitiveKind::Boolean => Expr::bool(false),
387        // The code point, as the integer the member holds.
388        PrimitiveKind::Char | PrimitiveKind::WChar => Expr::int(0),
389        // `0.0` and not `0`: `math.type` answers `float` for the one
390        // and `integer` for the other, and the member is a float.
391        PrimitiveKind::Float32 | PrimitiveKind::Float64 => {
392            Expr::Literal(Literal::Float(lua::float_text("0")))
393        }
394        // The decimal text, spelled as `bits_to_decimal` spells the
395        // zero pattern, so both directions answer one string.
396        PrimitiveKind::Float128 => Expr::string("0.0"),
397        PrimitiveKind::Octet
398        | PrimitiveKind::Int8
399        | PrimitiveKind::UInt8
400        | PrimitiveKind::Int16
401        | PrimitiveKind::UInt16
402        | PrimitiveKind::Int32
403        | PrimitiveKind::UInt32
404        | PrimitiveKind::Int64
405        | PrimitiveKind::UInt64 => Expr::int(0),
406    }
407}
408
409/// The case a defaulted discriminator selects, if any.
410fn selected_by_default(
411    ast: &Ast,
412    id: Id<TypeNode>,
413    selector: Option<i128>,
414) -> Result<Option<(Id<TypeNode>, String)>, EncodeError> {
415    let Some(selector) = selector else {
416        return Ok(None);
417    };
418    Ok(selected_case(ast, id, selector)?
419        .map(|(member, member_type)| (member_type, member.name.clone())))
420}
421
422/// A discriminator value in the Lua form its type gives it: an
423/// enumeration takes the constant read out of the descriptor, a
424/// boolean the literal, and everything else — a `char` included, whose
425/// holder is the code point — the number.
426fn discriminant_literal(ast: &Ast, discriminator: Id<TypeNode>, value: i128) -> Expr {
427    let resolved = resolve_alias(ast, discriminator);
428    if matches!(
429        ast.type_(resolved).map(|node| node.kind.clone()),
430        Ok(TypeKind::Enumeration { .. })
431    ) {
432        if let Some(enumerator) = crate::emit::default_enumerator_name(ast, resolved) {
433            return Lua::enumerator_constant(ast, resolved, &enumerator);
434        }
435    }
436    if matches!(
437        resolved_primitive(ast, resolved),
438        Some(PrimitiveKind::Boolean)
439    ) {
440        return Expr::bool(value != 0);
441    }
442    Expr::int(value)
443}
444
445/// The primitive `id` is once typedefs are seen through.
446///
447/// zerodds-lint: recursion-depth 64 (alias chains; bounded by `MAX_ALIAS_DEPTH`)
448fn resolved_primitive(ast: &Ast, id: Id<TypeNode>) -> Option<PrimitiveKind> {
449    let mut current = id;
450    for _ in 0..MAX_ALIAS_DEPTH {
451        match ast.type_(current).map(|node| node.kind.clone()) {
452            Ok(TypeKind::Primitive(kind)) => return Some(kind),
453            Ok(TypeKind::Alias { aliased }) => current = aliased.resolved()?,
454            _ => return None,
455        }
456    }
457    None
458}
459
460/// An explicit `@default` as a Lua expression for `type_`.
461#[allow(clippy::too_many_lines)]
462fn explicit(
463    ast: &Ast,
464    expr: Id<zerodds_ast::ConstExprNode>,
465    type_: Id<TypeNode>,
466    member_name: &str,
467) -> Result<Expr, EncodeError> {
468    let refuse = |kind: &'static str| EncodeError::NoWriter {
469        member: member_name.to_string(),
470        kind,
471    };
472    let node = ast
473        .expr(expr)
474        .map_err(|_| refuse("a `@default` this frontend cannot follow"))?;
475    let resolved = resolve_alias(ast, type_);
476    let kind = ast.type_(resolved).map(|node| node.kind.clone());
477    let is_float128 = matches!(kind, Ok(TypeKind::Primitive(PrimitiveKind::Float128)));
478    match &node.expr {
479        ConstExpr::Literal(AstLiteral::Boolean(flag)) => Ok(Expr::bool(*flag)),
480        // The code point as the integer the member holds — a lone
481        // surrogate included, because §7.4.1.1.1 writes a `wchar` as
482        // one UTF-16 code unit and an integer holds any of them.
483        ConstExpr::Literal(AstLiteral::Char(ch)) => Ok(Expr::int(i128::from(u32::from(*ch)))),
484        ConstExpr::Literal(AstLiteral::WChar(code)) => Ok(Expr::int(i128::from(*code))),
485        ConstExpr::Literal(AstLiteral::String(value)) => Ok(Expr::string(value)),
486        ConstExpr::Literal(AstLiteral::WString(codes)) => {
487            let mut out = String::new();
488            for code in codes {
489                let Some(ch) = char::from_u32(*code) else {
490                    return Err(refuse("a `@default` wide string that is not code points"));
491                };
492                out.push(ch);
493            }
494            Ok(Expr::string(&out))
495        }
496        ConstExpr::Literal(AstLiteral::Float { mantissa, exponent }) => {
497            let exponent = exponent
498                .as_ref()
499                .map_or(String::new(), |text| format!("e{text}"));
500            let written = format!("{mantissa}{exponent}");
501            if is_float128 {
502                // The member holds the decimal text and `runtime/lua`
503                // parses it exactly; a Lua float literal would round to
504                // binary64 before the value reached the member.
505                return Ok(Expr::string(&written));
506            }
507            Ok(Expr::Literal(Literal::Float(lua::float_text(&written))))
508        }
509        ConstExpr::Literal(AstLiteral::Fixed { digits, scale }) => Ok(Expr::string(
510            &crate::adapter::constant::fixed_decimal(digits, *scale),
511        )),
512        ConstExpr::Literal(AstLiteral::Enumerator(reference)) => {
513            let target = reference
514                .resolved()
515                .ok_or_else(|| refuse("a `@default` enumerator that is unresolved"))?;
516            let enumerator = ast
517                .enumerator(target)
518                .map_err(|_| refuse("a `@default` enumerator this frontend cannot follow"))?;
519            Ok(Lua::enumerator_constant(ast, resolved, &enumerator.name))
520        }
521        _ => {
522            let value = ast
523                .eval_integral(expr)
524                .map_err(|_| refuse("a `@default` this frontend cannot evaluate"))?;
525            Ok(match kind {
526                Ok(TypeKind::Primitive(PrimitiveKind::Boolean)) => Expr::bool(value != 0),
527                // The code point as the integer the member holds; one
528                // outside a UTF-16 code unit is refused rather than
529                // folded, because the wire has no form for it.
530                Ok(TypeKind::Primitive(PrimitiveKind::Char | PrimitiveKind::WChar)) => {
531                    let point = u32::try_from(value)
532                        .ok()
533                        .filter(|code| *code <= u32::from(u16::MAX))
534                        .ok_or_else(|| {
535                            refuse("a `@default` character outside the range of a UTF-16 code unit")
536                        })?;
537                    Expr::int(i128::from(point))
538                }
539                // The decimal text of the integer, exactly.
540                Ok(TypeKind::Primitive(PrimitiveKind::Float128)) => {
541                    Expr::string(&format!("{value}"))
542                }
543                // Every integer width, through the wide spelling: a Lua
544                // decimal above 2^63 − 1 would denote a float.
545                _ => Lua::wide_int(value),
546            })
547        }
548    }
549}
550
551// -- reading the calls back out ----------------------------------------
552
553/// The `ZD.zd_def_*` functions one statement calls, appended to `out`.
554///
555/// zerodds-lint: recursion-depth 64 (statement nesting the walk builds).
556/// The tree walked here is the one this generator emitted and owns, so
557/// it holds no cycle; the depth is that body's height, and a defaulter
558/// body is a handful of statements deep.
559fn calls_in_stat(statement: &Stat, out: &mut Vec<String>) {
560    match statement {
561        Stat::Local { value, .. } => {
562            if let Some(value) = value {
563                calls_in_expr(value, out);
564            }
565        }
566        Stat::Assign { target, value, .. } => {
567            calls_in_place(target, out);
568            calls_in_expr(value, out);
569        }
570        Stat::Function(function) => {
571            for inner in &function.body {
572                calls_in_stat(inner, out);
573            }
574        }
575        Stat::Return(value) => {
576            if let Some(value) = value {
577                calls_in_expr(value, out);
578            }
579        }
580        Stat::Perform(value) => calls_in_expr(value, out),
581        Stat::If {
582            condition,
583            then,
584            otherwise,
585        } => {
586            calls_in_expr(condition, out);
587            for inner in then {
588                calls_in_stat(inner, out);
589            }
590            if let Some(rest) = otherwise {
591                for inner in rest {
592                    calls_in_stat(inner, out);
593                }
594            }
595        }
596        Stat::While { condition, body } => {
597            calls_in_expr(condition, out);
598            for inner in body {
599                calls_in_stat(inner, out);
600            }
601        }
602        Stat::NumericFor { from, to, body, .. } => {
603            calls_in_expr(from, out);
604            calls_in_expr(to, out);
605            for inner in body {
606                calls_in_stat(inner, out);
607            }
608        }
609        Stat::Break | Stat::Comment(_) | Stat::Annotation(_) | Stat::Verbatim(_) => {}
610    }
611}
612
613/// zerodds-lint: recursion-depth 64 (expression nesting the walk builds).
614/// The tree walked here is the one this generator emitted and owns, so
615/// it holds no cycle; the depth is that expression's height, which
616/// follows the member nesting the type walk already bounded.
617fn calls_in_expr(expr: &Expr, out: &mut Vec<String>) {
618    match expr {
619        Expr::Call { func, args } => {
620            // A defaulter call is `ZD.zd_def_*()` and nothing else this
621            // generator emits looks like one.
622            if let Place::Field { base, name } = func.as_ref() {
623                if matches!(base.as_ref(), Place::Name(root) if root.as_str() == CODECS)
624                    && name.as_str().starts_with("zd_def_")
625                    && !out.iter().any(|seen| seen == name.as_str())
626                {
627                    out.push(name.as_str().to_string());
628                }
629            }
630            calls_in_place(func, out);
631            for arg in args {
632                calls_in_expr(arg, out);
633            }
634        }
635        Expr::Table(table) => {
636            for entry in &table.entries {
637                if let lua::Key::Expr(key) = &entry.key {
638                    calls_in_expr(key, out);
639                }
640                calls_in_expr(&entry.value, out);
641            }
642        }
643        Expr::Place(place) => calls_in_place(place, out),
644        Expr::Binary { lhs, rhs, .. } => {
645            calls_in_expr(lhs, out);
646            calls_in_expr(rhs, out);
647        }
648        Expr::Unary { value, .. } | Expr::Paren(value) => calls_in_expr(value, out),
649        Expr::Nil | Expr::Literal(_) => {}
650    }
651}
652
653/// zerodds-lint: recursion-depth 64 (place-expression nesting the walk
654/// builds). The tree walked here is the one this generator emitted and
655/// owns, so it holds no cycle; the depth is that expression's height.
656fn calls_in_place(place: &Place, out: &mut Vec<String>) {
657    match place {
658        Place::Name(_) => {}
659        Place::Field { base, .. } => calls_in_place(base, out),
660        Place::Index { base, key } => {
661            calls_in_place(base, out);
662            calls_in_expr(key, out);
663        }
664        Place::Paren(inner) => calls_in_expr(inner, out),
665    }
666}
667
668// -- the small helpers --------------------------------------------------
669
670fn codec_call(callee: &str, args: Vec<Expr>) -> Expr {
671    Expr::Call {
672        func: Box::new(Place::name(CODECS).field(callee)),
673        args,
674    }
675}
676
677fn place_of(value: Expr) -> Place {
678    match value {
679        Expr::Place(place) => place,
680        Expr::Paren(inner) => match *inner {
681            Expr::Place(place) => place,
682            other => Place::Paren(Box::new(other)),
683        },
684        other => Place::Paren(Box::new(other)),
685    }
686}