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}