Skip to main content

zerodds_lang_rules/adapter/
node.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! The Node CST as the other end of a rule. Spec §3.2.
4//!
5//! Read [`crate::adapter::lua`] first: JavaScript's situation is Lua's —
6//! "there is no type node" — with two consequences that follow from
7//! JavaScript's own grammar and not from a simplification made here.
8//!
9//! ## Why this materialiser declares almost nothing
10//!
11//! [`crate::adapter::zig`] declares a struct type for every IDL
12//! aggregate because Zig is statically typed and a codec function needs
13//! a name to take. [`crate::adapter::lua`] still declares a *descriptor*
14//! table for every aggregate, because `lua-language-server`'s `---@class`
15//! reads one and because a Lua table has no order of its own, so a
16//! member list has to be written down somewhere to be read back in
17//! order. Neither reason holds here: this column has no LuaCATS-shaped
18//! consumer to serve, and a JavaScript object literal's own property
19//! order is insertion order (ECMA-262 §6.1.7.1) — the shared codec walk
20//! already writes an aggregate's members in the model's declaration
21//! order, so the order is carried by the generated code and needs no
22//! separate table to state it again.
23//!
24//! What is left to declare is exactly what needs a **name at load
25//! time**, because something outside the value it belongs to refers to
26//! it by that name:
27//!
28//! - an **enumeration**, because [`crate::emit::CodecTarget::enumerator_constant`]
29//!   writes `Colour.RED` and something has to bind `Colour`;
30//! - a **bitmask**'s bits, because a sample or a test names one by IDL
31//!   spelling and the value has to exist somewhere to be read; a
32//!   bitmask's own "type" is nothing here — a member holding one is a
33//!   plain `number` or `bigint`, exactly as [`crate::emit::node`]'s
34//!   module documentation says;
35//! - a **constant**, an IDL `const` declaration, because that is what it
36//!   is for.
37//!
38//! A struct, a union, a bitset, a valuetype, an exception, an interface,
39//! a native declaration and a `typedef` all declare **nothing**. Every
40//! one of them is a plain object literal wherever the codec walk or
41//! [`crate::default_node`] builds one, and "the runtime carries the
42//! shape" is not a slogan here — it is the reason there is no
43//! `node_member_type` in this file for [`crate::adapter::zig::zig_member_type`]
44//! to answer to.
45//!
46//! ## This column does not join the seesaw rule catalogue
47//!
48//! [`crate::rule`] and [`crate::apply()`] exist to compare what different
49//! backends declare for one construct, and every backend that has
50//! nothing to compare has nothing to gain from joining. This column
51//! declares nothing for six of the ten constructs the catalogue tracks
52//! — struct, union, bitset, valuetype, exception, interface — because a
53//! JavaScript object literal needs no declaration to exist. Registering
54//! a `Language::Node` and a `rule::node::catalogue()` whose rules mostly
55//! answer "nothing is created here" would be six rules whose only
56//! purpose is to say so, in a system built to find the interesting cases
57//! where two columns disagree about *something*. [`node_items`] is
58//! called directly from the generator, the same as [`crate::adapter::c`]
59//! is on the side that predates the catalogue.
60//!
61//! ## Where the IDL spelling of a name is kept
62//!
63//! Nothing is renamed for style: JavaScript has no export-by-case rule
64//! and this column changes no letter's case. [`node::escape_member`] is
65//! the identity always — [`zerodds_lang_cst::node`]'s module
66//! documentation says why a property name is never renamed — so the
67//! only escape in this file is [`node::escape_decl`], applied to a
68//! **declaration**'s flattened name: an enumeration's own binding, a
69//! bitmask bit's, a constant's. Every name it moves carries `IDL: `…`.`
70//! in the comment above it, for the same reason [`crate::adapter::zig`]
71//! keeps one: DDS-XTypes 1.3 §7.3.1.2.1.1 computes a member's ID under
72//! `@autoid(HASH)` from the first four octets of the MD5 of its **IDL**
73//! name, and the escape does not invert.
74
75use crate::adapter::cst::{MaterialiseError, no_spelling_collision};
76use crate::verbatim;
77use zerodds_ast::{Ast, TypeKind, TypeNode};
78use zerodds_ast_core::Id;
79use zerodds_lang_cst::common::{Doc, Ident, Literal};
80use zerodds_lang_cst::node::{self, Expr, Stat};
81
82/// The language this column names in a diagnostic.
83const LANGUAGE: &str = "JavaScript";
84
85/// The marker [`crate::emit::CodecTarget::map_entry_name`] answers with.
86///
87/// Not a name this column declares: a `map<K,V>` entry is a plain
88/// `{ key: …, value: … }` object literal wherever the codec walk builds
89/// one, exactly as [`crate::adapter::lua`]'s `MAP_ENTRY` marker is for
90/// Lua's tables and for the same reason.
91pub const MAP_ENTRY: &str = "_zd_map_entry";
92
93/// How far up a module chain the walk follows before it stops.
94const MAX_MODULE_DEPTH: usize = 64;
95
96/// Build the Node declarations a rule application produced.
97///
98/// Enumerations, bitmask bit constants and IDL constants only — the
99/// module documentation says why nothing else declares. The order is
100/// the model's own declaration order, exactly as
101/// [`crate::adapter::zig::zig_items`] keeps it: nothing here depends on
102/// where a name was written, because JavaScript resolves a top-level
103/// `const` wherever it sits in one file scanned top to bottom before it
104/// runs, same as every sibling.
105///
106/// # Errors
107/// [`MaterialiseError`] when a value the output has to state does not
108/// evaluate, or two declarations reach one JavaScript spelling — never a
109/// silently dropped declaration.
110pub fn node_items(ast: &Ast) -> Result<Vec<Stat>, MaterialiseError> {
111    let mut out = Vec::new();
112    let mut emitted: Vec<(String, String)> = Vec::new();
113
114    for (id, _) in ast.types() {
115        let start = out.len();
116        declare(ast, id, &mut out, &mut emitted)?;
117        verbatim::weave(
118            verbatim::declaration_slots(ast, id, verbatim::aliases::NODE),
119            "",
120            start,
121            &mut out,
122        );
123    }
124
125    for (_, node) in ast.constants() {
126        // `eval_const` and not `eval_integral`: `const string NAME` is a
127        // constant that evaluates perfectly well and is not a number.
128        let value = ast
129            .eval_const(node.expr)
130            .map_err(|_| MaterialiseError::Unevaluated {
131                construct: "constant's value".to_string(),
132                at: node.name.clone(),
133            })?;
134        let declared = node.type_.resolved();
135        let is_wide = declared.is_some_and(|target| is_wide_integer(ast, target));
136        let name = declared_name(ast, &node.name, node.module);
137        remember(&mut emitted, &name, &node.name)?;
138        out.push(Stat::Declare {
139            doc: idl_note(&name, &node.name),
140            kind: node::VarKind::Const,
141            name: Ident::new(&name),
142            value: Some(node_constant(&value, is_wide, &node.name)?),
143        });
144    }
145
146    Ok(verbatim::weave_file(ast, verbatim::aliases::NODE, out))
147}
148
149/// `@verbatim` in the Node column. A declaration is an object literal or
150/// a frozen table, which holds properties and not statements, so
151/// `BEGIN_DECLARATION` and `END_DECLARATION` land adjacent to it.
152impl verbatim::Carrier for Stat {
153    fn verbatim(text: String) -> Self {
154        Self::Verbatim(text)
155    }
156
157    fn take_into_body(&mut self, _: &str, _: &mut Vec<String>, _: &mut Vec<String>) -> bool {
158        false
159    }
160}
161
162/// The Node declarations, in a file with its own header.
163///
164/// A caller that only wants the declarations uses [`node_items`]; this
165/// is what a generator writes to disk.
166///
167/// # Errors
168/// As [`node_items`].
169pub fn node_file(ast: &Ast) -> Result<node::File, MaterialiseError> {
170    let body = node_items(ast)?;
171    Ok(node::File {
172        doc: Doc::lines(&[
173            "Generated by ZeroDDS from an IDL source. Do not edit.",
174            "",
175            "The IDL spelling of every renamed name is in the comment above it.",
176            "Nothing is renamed for style: a rename here is always an escape, of a",
177            "JavaScript reserved word used as a top-level declaration. A member's",
178            "ID under @autoid(HASH) is the MD5 of its IDL name (DDS-XTypes 1.3",
179            "7.3.1.2.1.1) and the escape does not invert, which is why the",
180            "comment is there.",
181            "",
182            "A struct, a union, a bitset, a valuetype, an exception, an",
183            "interface, a native declaration and a typedef declare nothing at",
184            "all here: every one of them is a plain object literal wherever the",
185            "codec below or its Table 9 defaulter builds one.",
186        ]),
187        body,
188    })
189}
190
191/// One declared type: an enumeration or a bitmask's bits. Every other
192/// kind declares nothing — the module documentation says why.
193fn declare(
194    ast: &Ast,
195    id: Id<TypeNode>,
196    out: &mut Vec<Stat>,
197    emitted: &mut Vec<(String, String)>,
198) -> Result<(), MaterialiseError> {
199    let Ok(type_node) = ast.type_(id) else {
200        return Ok(());
201    };
202
203    match &type_node.kind {
204        TypeKind::Enumeration { enumerators } => {
205            let name = node_type_name(ast, id);
206            remember(emitted, &name, &type_node.name)?;
207            let mut pairs = Vec::new();
208            for enumerator in enumerators {
209                if let Ok(enum_node) = ast.enumerator(*enumerator) {
210                    pairs.push((field_name(&enum_node.name), enum_node.name.clone()));
211                }
212            }
213            no_spelling_collision(as_pairs(&pairs), LANGUAGE, &name)?;
214            let mut fields = Vec::new();
215            for enumerator in enumerators {
216                let Ok(enum_node) = ast.enumerator(*enumerator) else {
217                    return Err(MaterialiseError::NoNode {
218                        kind: "enumerator",
219                        at: crate::adapter::cst::handle_of(*enumerator),
220                    });
221                };
222                let value = ast.effective_enumerator_value(*enumerator).ok_or_else(|| {
223                    MaterialiseError::Unevaluated {
224                        construct: "enumerator value".to_string(),
225                        at: enum_node.name.clone(),
226                    }
227                })?;
228                fields.push((Ident::new(&field_name(&enum_node.name)), Expr::int(value)));
229            }
230            out.push(Stat::Declare {
231                doc: with_idl_note(
232                    Doc::lines(&[
233                        "An enumeration, as a frozen object of its enumerator names.",
234                        "`Object.freeze` is not an encoding decision — nothing here",
235                        "reads a value off the wire this way — it only refuses an",
236                        "accidental write to a shared constant.",
237                    ]),
238                    &name,
239                    &type_node.name,
240                ),
241                kind: node::VarKind::Const,
242                name: Ident::new(&name),
243                value: Some(Expr::Call {
244                    callee: Box::new(Expr::member(Expr::name("Object"), "freeze")),
245                    args: vec![Expr::Object(fields)],
246                }),
247            });
248        }
249
250        TypeKind::Bitmask { bits } => {
251            let bound = ast.effective_bit_bound(id);
252            let wide = bound > 32;
253            let prefix = module_prefix(ast, id);
254            for bit in bits {
255                let Ok(bit_node) = ast.bit_value(*bit) else {
256                    return Err(MaterialiseError::NoNode {
257                        kind: "bit",
258                        at: crate::adapter::cst::handle_of(*bit),
259                    });
260                };
261                let position = ast.effective_bit_position(*bit).ok_or_else(|| {
262                    MaterialiseError::Unevaluated {
263                        construct: "bit position".to_string(),
264                        at: bit_node.name.clone(),
265                    }
266                })?;
267                let constant = node::escape_decl(&format!("{prefix}{}", bit_node.name));
268                remember(emitted, &constant, &bit_node.name)?;
269                let value = if wide {
270                    Expr::Binary {
271                        op: "<<",
272                        lhs: Box::new(Expr::Literal(Literal::Int("1n".to_string()))),
273                        rhs: Box::new(Expr::Literal(Literal::Int(format!("{position}n")))),
274                    }
275                } else {
276                    Expr::Binary {
277                        op: "<<",
278                        lhs: Box::new(Expr::int(1)),
279                        rhs: Box::new(Expr::int(i128::from(position))),
280                    }
281                };
282                out.push(Stat::Declare {
283                    doc: idl_note(&constant, &bit_node.name),
284                    kind: node::VarKind::Const,
285                    name: Ident::new(&constant),
286                    value: Some(value),
287                });
288            }
289        }
290
291        // Everything else is a plain object literal wherever it is
292        // built and declares nothing here.
293        TypeKind::Primitive(_)
294        | TypeKind::String { .. }
295        | TypeKind::Fixed { .. }
296        | TypeKind::Sequence { .. }
297        | TypeKind::Array { .. }
298        | TypeKind::Map { .. }
299        | TypeKind::Alias { .. }
300        | TypeKind::Bitset { .. }
301        | TypeKind::Structure { .. }
302        | TypeKind::Union { .. }
303        | TypeKind::Native
304        | TypeKind::ValueBox { .. }
305        | TypeKind::Valuetype { .. }
306        | TypeKind::ObjectRef { .. }
307        | TypeKind::AnnotationType { .. }
308        | TypeKind::Any
309        | TypeKind::ValueBase => {}
310    }
311    Ok(())
312}
313
314/// Whether a resolved type is the 64-bit primitive pair — the one
315/// boundary where a constant needs a `bigint` literal rather than a
316/// `number` one, exactly as [`crate::emit::node::Node::scalar`] draws it
317/// for a codec local.
318fn is_wide_integer(ast: &Ast, id: Id<TypeNode>) -> bool {
319    matches!(
320        ast.type_(id).map(|node| node.kind.clone()),
321        Ok(TypeKind::Primitive(
322            zerodds_ast::PrimitiveKind::Int64 | zerodds_ast::PrimitiveKind::UInt64
323        ))
324    )
325}
326
327/// A declared type's flattened JavaScript identifier.
328///
329/// The scoped name with the module separators joined by `_`, exactly as
330/// [`crate::adapter::zig::zig_type_name`] flattens: `::common::util::Colour`
331/// is `common_util_Colour`. No case is changed. This is also the tag
332/// `crate::emit::node::Node::type_name` falls back to for a construct
333/// that declares nothing here — a codec function's name is built from
334/// it whether or not the type it codes has a declaration of its own.
335#[must_use]
336pub fn node_type_name(ast: &Ast, id: Id<TypeNode>) -> String {
337    let Ok(any) = ast.any_of_type(id) else {
338        return "ZdUnnamed".to_string();
339    };
340    match ast.scoped_name_of(any) {
341        Some(scoped) => flatten(scoped.segments()),
342        None => "ZdUnnamed".to_string(),
343    }
344}
345
346/// The flattened module path a file-scope declaration carries: a
347/// bitmask's bit constants, which sit outside the type their mask
348/// belongs to and need the whole scope in their own name to keep two
349/// modules' `READY` apart.
350fn module_prefix(ast: &Ast, id: Id<TypeNode>) -> String {
351    let Ok(any) = ast.any_of_type(id) else {
352        return String::new();
353    };
354    let Some(scoped) = ast.scoped_name_of(any) else {
355        return String::new();
356    };
357    let segments = scoped.segments();
358    if segments.len() <= 1 {
359        return String::new();
360    }
361    let mut prefix = segments[..segments.len() - 1].join("_");
362    prefix.push('_');
363    prefix
364}
365
366/// The Node name a declaration outside the type arena takes — a
367/// constant, whose scope comes from the module it was declared in
368/// rather than from [`Ast::scoped_name_of`].
369fn declared_name(ast: &Ast, name: &str, module: Id<zerodds_ast::ModuleNode>) -> String {
370    let mut segments: Vec<String> = Vec::new();
371    let mut current = Some(module);
372    let mut depth = 0;
373    while let Some(id) = current {
374        if depth > MAX_MODULE_DEPTH {
375            break;
376        }
377        depth += 1;
378        let Ok(node) = ast.module(id) else { break };
379        if node.name.is_empty() {
380            break;
381        }
382        segments.push(node.name.clone());
383        current = node.parent;
384    }
385    segments.reverse();
386    segments.push(name.to_string());
387    node::escape_decl(&segments.join("_"))
388}
389
390/// Join scoped segments with `_` and escape the result — the identity
391/// unless the joined name lands on a JavaScript reserved word.
392fn flatten(segments: &[String]) -> String {
393    node::escape_decl(&segments.join("_"))
394}
395
396/// The JavaScript spelling of a member, an enumerator, a bitfield or a
397/// map-entry half. The identity, always: [`zerodds_lang_cst::node`]'s
398/// module documentation says why a property name never collides with
399/// anything JavaScript reserves.
400#[must_use]
401pub fn field_name(name: &str) -> String {
402    node::escape_member(name)
403}
404
405/// The declaration's own IDL name, where the Node spelling moved it.
406fn idl_note(spelled: &str, declared: &str) -> Doc {
407    if spelled == declared {
408        return Doc::none();
409    }
410    Doc::line(&format!("IDL: `{declared}`."))
411}
412
413/// The same, appended to a documentation comment the caller wrote.
414fn with_idl_note(mut doc: Doc, spelled: &str, declared: &str) -> Doc {
415    if spelled != declared {
416        doc.push(&format!("IDL: `{declared}`."));
417    }
418    doc
419}
420
421/// Record a declared name, refusing a second declaration that reaches
422/// the same JavaScript spelling.
423///
424/// One file scope holds every top-level `const`, so this is a
425/// whole-file check and not a per-scope one — which is the difference
426/// from [`no_spelling_collision`], used for the members of one
427/// enumeration.
428fn remember(
429    emitted: &mut Vec<(String, String)>,
430    spelled: &str,
431    declared: &str,
432) -> Result<(), MaterialiseError> {
433    if let Some((_, first)) = emitted.iter().find(|(other, _)| other == spelled) {
434        return Err(MaterialiseError::SpellingCollision {
435            first: first.clone(),
436            second: declared.to_string(),
437            spelled: spelled.to_string(),
438            language: LANGUAGE,
439            at: "the generated file".to_string(),
440        });
441    }
442    emitted.push((spelled.to_string(), declared.to_string()));
443    Ok(())
444}
445
446/// Borrow a list of owned name pairs for [`no_spelling_collision`].
447fn as_pairs(pairs: &[(String, String)]) -> Vec<(&str, &str)> {
448    pairs
449        .iter()
450        .map(|(spelled, declared)| (spelled.as_str(), declared.as_str()))
451        .collect()
452}
453
454/// The Node expression a top-level constant's value takes.
455///
456/// `is_wide` says the declared type is a 64-bit primitive, so the
457/// literal needs the trailing `n` [`zerodds_lang_cst::node::Expr::Literal`]'s
458/// documentation carries — a `number` cannot hold every `i64` or `u64`
459/// exactly, and a bare decimal here would already have lost precision
460/// before anything read it.
461///
462/// # Errors
463/// [`MaterialiseError::NoLanguageForm`] for a constant form this column
464/// has no literal for.
465fn node_constant(
466    value: &zerodds_ast::ConstValue,
467    is_wide: bool,
468    at: &str,
469) -> Result<Expr, MaterialiseError> {
470    use zerodds_ast::ConstValue;
471    let refuse = |construct: &str| MaterialiseError::NoLanguageForm {
472        construct: construct.to_string(),
473        language: LANGUAGE,
474        at: at.to_string(),
475    };
476    Ok(match value {
477        ConstValue::Boolean(flag) => Expr::bool(*flag),
478        ConstValue::Integer(number) => {
479            if is_wide {
480                Expr::Literal(Literal::Int(format!("{number}n")))
481            } else {
482                Expr::int(*number)
483            }
484        }
485        // The code point as a one-character string: an IDL `char` and
486        // an IDL `wchar` are both held that way here.
487        ConstValue::Char(character) => Expr::Literal(Literal::Char(*character)),
488        // Refused rather than folded, the same answer the `WString` arm
489        // below gives for the plural case: `Literal::Char` holds a Rust
490        // `char`, which a lone surrogate is not, and substituting NUL
491        // would put a `const wchar` in the generated file that no reader
492        // could tell from one the source declared as `'\0'`.
493        ConstValue::WChar(point) => {
494            Expr::Literal(Literal::Char(char::from_u32(*point).ok_or_else(|| {
495                refuse("a `wchar` constant that is not a Unicode scalar value")
496            })?))
497        }
498        ConstValue::Float { mantissa, exponent } => Expr::Literal(Literal::Float(
499            crate::adapter::constant::float_text(mantissa, exponent.as_deref()),
500        )),
501        ConstValue::Fixed { digits, scale } => {
502            Expr::string(&crate::adapter::constant::fixed_decimal(digits, *scale))
503        }
504        ConstValue::String(text) => Expr::string(text),
505        ConstValue::WString(points) => Expr::string(
506            &crate::adapter::constant::wstring_utf8(points)
507                .ok_or_else(|| refuse("a `wstring` constant holding a lone surrogate"))?,
508        ),
509        _ => return Err(refuse("a constant form this binding has no literal for")),
510    })
511}
512
513#[cfg(test)]
514mod tests {
515    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
516    use super::*;
517
518    #[test]
519    fn a_member_name_never_moves() {
520        for name in ["class", "new", "constructor", "x"] {
521            assert_eq!(field_name(name), name);
522        }
523    }
524
525    #[test]
526    fn a_map_entry_has_no_declared_name() {
527        assert_eq!(MAP_ENTRY, "_zd_map_entry");
528    }
529}