Skip to main content

zerodds_lang_render/
typescript.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Rendering the TypeScript CST. Spec §5.
4//!
5//! One style, the one `prettier` produces and every TypeScript project
6//! in this repository is written in: two spaces, the opening brace on
7//! the same line, a semicolon after every member.
8//!
9//! Two spaces and not the four the other three renderers use. That is
10//! not a preference: TypeScript nests further than Java does for the
11//! same IDL — a namespace per module, then an interface, then a member —
12//! and the community formatter that every consumer of this output runs
13//! would reindent a four-space file on first save, turning every
14//! generated file into a diff.
15//!
16//! Nothing is decided here. Whether a declaration is `export`ed, whether
17//! a property is `readonly`, whether a name is quoted: all of it is in
18//! the tree, and a renderer that chose any of it would put the choice
19//! where no rule diff can see it.
20
21use crate::writer::{Writer, comment_lines, escape_string, verbatim_lines};
22use alloc::format;
23use alloc::string::{String, ToString};
24use alloc::vec::Vec;
25use zerodds_lang_cst::common::{Doc, Ident, Literal, Path};
26use zerodds_lang_cst::typescript::{
27    Block, Class, ClassMember, Constructor, Decl, Enum, EnumMember, Expr, Field, File, Function,
28    Import, IndexSignature, Interface, Method, MethodSignature, Namespace, Param, Property,
29    PropertyName, Signature, Statement, SwitchCase, Type, TypeAlias, TypeParam, Variable,
30};
31
32/// Render a whole file.
33#[must_use]
34pub fn file(node: &File) -> String {
35    let mut w = writer();
36    doc(&mut w, &node.doc);
37    for import in &node.imports {
38        w.line(&import_text(import));
39    }
40    if !node.imports.is_empty() {
41        w.blank_line();
42    }
43    render_decls(&mut w, &node.decls);
44    w.finish()
45}
46
47/// Render one declaration, for a caller that has only one.
48#[must_use]
49pub fn decl(node: &Decl) -> String {
50    let mut w = writer();
51    render_decl(&mut w, node);
52    w.finish()
53}
54
55/// Render one type, for a caller that has only one.
56#[must_use]
57pub fn type_(node: &Type) -> String {
58    type_text(node)
59}
60
61/// Render one expression, for a caller that has only one.
62#[must_use]
63pub fn expr(node: &Expr) -> String {
64    expr_text(node)
65}
66
67/// Two spaces per level. See the module documentation.
68fn writer() -> Writer {
69    Writer::new("  ")
70}
71
72fn import_text(node: &Import) -> String {
73    let mut out = String::from("import ");
74    if node.type_only {
75        out.push_str("type ");
76    }
77    let mut clauses: Vec<String> = Vec::new();
78    if let Some(default) = &node.default {
79        clauses.push(default.as_str().to_string());
80    }
81    if let Some(namespace) = &node.namespace {
82        let mut text = String::from("* as ");
83        text.push_str(namespace.as_str());
84        clauses.push(text);
85    }
86    if !node.names.is_empty() {
87        let parts: Vec<String> = node
88            .names
89            .iter()
90            .map(|name| {
91                let mut text = String::new();
92                if name.type_only {
93                    text.push_str("type ");
94                }
95                text.push_str(name.name.as_str());
96                if let Some(alias) = &name.alias {
97                    text.push_str(" as ");
98                    text.push_str(alias.as_str());
99                }
100                text
101            })
102            .collect();
103        let mut text = String::from("{ ");
104        text.push_str(&parts.join(", "));
105        text.push_str(" }");
106        clauses.push(text);
107    }
108    out.push_str(&clauses.join(", "));
109    out.push_str(" from \"");
110    out.push_str(&escape_string(&node.module));
111    out.push_str("\";");
112    out
113}
114
115/// Declarations run together where they are one-liners and get a blank
116/// line where they have a body — the same rule the Java renderer applies
117/// to members, and for the same reason: a run of `export const` lines is
118/// a table, and a run of interfaces is not.
119///
120/// zerodds-lint: recursion-depth bounded by the tree's own nesting;
121/// `Decl::Namespace` is the only recursive arm and a materialiser builds
122/// it from a module chain it has already bounded.
123fn render_decls(w: &mut Writer, decls: &[Decl]) {
124    let mut previous_had_body = false;
125    for (index, node) in decls.iter().enumerate() {
126        let has_body = matches!(
127            node,
128            Decl::Interface(_)
129                | Decl::Class(_)
130                | Decl::Enum(_)
131                | Decl::Namespace(_)
132                | Decl::Function(_)
133        );
134        if index > 0 && (has_body || previous_had_body) {
135            w.blank_line();
136        }
137        render_decl(w, node);
138        previous_had_body = has_body;
139    }
140}
141
142fn render_decl(w: &mut Writer, node: &Decl) {
143    match node {
144        Decl::Interface(inner) => render_interface(w, inner),
145        Decl::Class(inner) => render_class(w, inner),
146        Decl::TypeAlias(inner) => render_type_alias(w, inner),
147        Decl::Enum(inner) => render_enum(w, inner),
148        Decl::Variable(inner) => render_variable(w, inner),
149        Decl::Function(inner) => render_function(w, inner),
150        Decl::Namespace(inner) => render_namespace(w, inner),
151        Decl::Statement(inner) => render_statement(w, inner),
152        Decl::Comment(inner) => doc(w, inner),
153        Decl::Verbatim(text) => verbatim_lines(w, text),
154    }
155}
156
157fn render_interface(w: &mut Writer, node: &Interface) {
158    doc(w, &node.doc);
159    let mut head = String::new();
160    if node.exported {
161        head.push_str("export ");
162    }
163    head.push_str("interface ");
164    head.push_str(node.name.as_str());
165    head.push_str(&generics_text(&node.generics));
166    if !node.extends.is_empty() {
167        head.push_str(" extends ");
168        let parts: Vec<String> = node.extends.iter().map(type_text).collect();
169        head.push_str(&parts.join(", "));
170    }
171    // An interface with no members is `{}` on the head line: a pair of
172    // braces on two lines is what a formatter collapses.
173    if node.members.is_empty() {
174        head.push_str(" {}");
175        w.line(&head);
176        return;
177    }
178    head.push_str(" {");
179    w.line(&head);
180    w.indent();
181    for member in &node.members {
182        render_signature(w, member);
183    }
184    w.dedent();
185    w.line("}");
186}
187
188fn render_class(w: &mut Writer, node: &Class) {
189    doc(w, &node.doc);
190    let mut head = String::new();
191    if node.exported {
192        head.push_str("export ");
193    }
194    if node.abstract_ {
195        head.push_str("abstract ");
196    }
197    head.push_str("class ");
198    head.push_str(node.name.as_str());
199    head.push_str(&generics_text(&node.generics));
200    if let Some(extends) = &node.extends {
201        head.push_str(" extends ");
202        head.push_str(&type_text(extends));
203    }
204    if !node.implements.is_empty() {
205        head.push_str(" implements ");
206        let parts: Vec<String> = node.implements.iter().map(type_text).collect();
207        head.push_str(&parts.join(", "));
208    }
209    if node.members.is_empty() {
210        head.push_str(" {}");
211        w.line(&head);
212        return;
213    }
214    head.push_str(" {");
215    w.line(&head);
216    w.indent();
217    render_class_members(w, &node.members);
218    w.dedent();
219    w.line("}");
220}
221
222fn render_class_members(w: &mut Writer, members: &[ClassMember]) {
223    let mut previous_had_body = false;
224    for (index, member) in members.iter().enumerate() {
225        let has_body = matches!(member, ClassMember::Method(_) | ClassMember::Constructor(_));
226        if index > 0 && (has_body || previous_had_body) {
227            w.blank_line();
228        }
229        match member {
230            ClassMember::Field(field) => render_field(w, field),
231            ClassMember::Method(method) => render_method(w, method),
232            ClassMember::Constructor(constructor) => render_constructor(w, constructor),
233            ClassMember::Comment(text) => doc(w, text),
234            ClassMember::Verbatim(text) => verbatim_lines(w, text),
235        }
236        previous_had_body = has_body;
237    }
238}
239
240fn render_field(w: &mut Writer, node: &Field) {
241    doc(w, &node.doc);
242    let mut line = String::new();
243    if let Some(accessibility) = node.accessibility {
244        line.push_str(accessibility.as_str());
245        line.push(' ');
246    }
247    if node.static_ {
248        line.push_str("static ");
249    }
250    if node.readonly {
251        line.push_str("readonly ");
252    }
253    line.push_str(&property_name_text(&node.name));
254    if node.optional {
255        line.push('?');
256    }
257    if node.definite {
258        line.push('!');
259    }
260    if let Some(type_) = &node.type_ {
261        line.push_str(": ");
262        line.push_str(&type_text(type_));
263    }
264    if let Some(value) = &node.value {
265        line.push_str(" = ");
266        line.push_str(&expr_text(value));
267    }
268    line.push(';');
269    w.line(&line);
270}
271
272fn render_method(w: &mut Writer, node: &Method) {
273    doc(w, &node.doc);
274    let mut head = String::new();
275    if let Some(accessibility) = node.accessibility {
276        head.push_str(accessibility.as_str());
277        head.push(' ');
278    }
279    if node.static_ {
280        head.push_str("static ");
281    }
282    if node.abstract_ {
283        head.push_str("abstract ");
284    }
285    head.push_str(&property_name_text(&node.name));
286    head.push_str(&generics_text(&node.generics));
287    head.push('(');
288    head.push_str(&params_text(&node.params));
289    head.push(')');
290    if let Some(returns) = &node.returns {
291        head.push_str(": ");
292        head.push_str(&type_text(returns));
293    }
294    match &node.body {
295        None => {
296            head.push(';');
297            w.line(&head);
298        }
299        Some(block) => {
300            head.push_str(" {");
301            w.line(&head);
302            w.indent();
303            render_block(w, block);
304            w.dedent();
305            w.line("}");
306        }
307    }
308}
309
310fn render_constructor(w: &mut Writer, node: &Constructor) {
311    doc(w, &node.doc);
312    let mut head = String::new();
313    if let Some(accessibility) = node.accessibility {
314        head.push_str(accessibility.as_str());
315        head.push(' ');
316    }
317    head.push_str("constructor(");
318    head.push_str(&params_text(&node.params));
319    head.push_str(") {");
320    w.line(&head);
321    w.indent();
322    render_block(w, &node.body);
323    w.dedent();
324    w.line("}");
325}
326
327fn render_type_alias(w: &mut Writer, node: &TypeAlias) {
328    doc(w, &node.doc);
329    let mut line = String::new();
330    if node.exported {
331        line.push_str("export ");
332    }
333    line.push_str("type ");
334    line.push_str(node.name.as_str());
335    line.push_str(&generics_text(&node.generics));
336    line.push_str(" = ");
337    line.push_str(&type_text(&node.type_));
338    line.push(';');
339    w.line(&line);
340}
341
342fn render_enum(w: &mut Writer, node: &Enum) {
343    doc(w, &node.doc);
344    let mut head = String::new();
345    if node.exported {
346        head.push_str("export ");
347    }
348    if node.const_ {
349        head.push_str("const ");
350    }
351    head.push_str("enum ");
352    head.push_str(node.name.as_str());
353    if node.members.is_empty() {
354        head.push_str(" {}");
355        w.line(&head);
356        return;
357    }
358    head.push_str(" {");
359    w.line(&head);
360    w.indent();
361    for member in &node.members {
362        render_enum_member(w, member);
363    }
364    w.dedent();
365    w.line("}");
366}
367
368/// Every member ends in a comma, the last one included. A trailing comma
369/// is what the formatter writes and what keeps a one-line diff to one
370/// line when a member is appended.
371fn render_enum_member(w: &mut Writer, node: &EnumMember) {
372    doc(w, &node.doc);
373    let mut line = property_name_text(&node.name);
374    if let Some(value) = &node.value {
375        line.push_str(" = ");
376        line.push_str(&expr_text(value));
377    }
378    line.push(',');
379    w.line(&line);
380}
381
382fn render_variable(w: &mut Writer, node: &Variable) {
383    doc(w, &node.doc);
384    let mut line = String::new();
385    if node.exported {
386        line.push_str("export ");
387    }
388    if node.declare {
389        line.push_str("declare ");
390    }
391    line.push_str(node.kind.as_str());
392    line.push(' ');
393    line.push_str(node.name.as_str());
394    if let Some(type_) = &node.type_ {
395        line.push_str(": ");
396        line.push_str(&type_text(type_));
397    }
398    if let Some(value) = &node.value {
399        line.push_str(" = ");
400        line.push_str(&expr_text(value));
401    }
402    line.push(';');
403    w.line(&line);
404}
405
406fn render_function(w: &mut Writer, node: &Function) {
407    doc(w, &node.doc);
408    let mut head = String::new();
409    if node.exported {
410        head.push_str("export ");
411    }
412    head.push_str("function ");
413    head.push_str(node.name.as_str());
414    head.push_str(&generics_text(&node.generics));
415    head.push('(');
416    head.push_str(&params_text(&node.params));
417    head.push(')');
418    if let Some(returns) = &node.returns {
419        head.push_str(": ");
420        head.push_str(&type_text(returns));
421    }
422    match &node.body {
423        None => {
424            head.push(';');
425            w.line(&head);
426        }
427        Some(block) => {
428            head.push_str(" {");
429            w.line(&head);
430            w.indent();
431            render_block(w, block);
432            w.dedent();
433            w.line("}");
434        }
435    }
436}
437
438/// zerodds-lint: recursion-depth bounded by the tree's own nesting; a
439/// materialiser builds the chain from a module chain it has bounded.
440fn render_namespace(w: &mut Writer, node: &Namespace) {
441    doc(w, &node.doc);
442    let mut head = String::new();
443    if node.exported {
444        head.push_str("export ");
445    }
446    if node.declare {
447        head.push_str("declare ");
448    }
449    head.push_str("namespace ");
450    head.push_str(node.name.as_str());
451    if node.decls.is_empty() {
452        head.push_str(" {}");
453        w.line(&head);
454        return;
455    }
456    head.push_str(" {");
457    w.line(&head);
458    w.indent();
459    render_decls(w, &node.decls);
460    w.dedent();
461    w.line("}");
462}
463
464fn render_signature(w: &mut Writer, node: &Signature) {
465    match node {
466        Signature::Comment(text) => doc(w, text),
467        Signature::Verbatim(text) => verbatim_lines(w, text),
468        Signature::Property(property) => {
469            doc(w, &property.doc);
470            let mut line = property_head(property);
471            line.push(';');
472            w.line(&line);
473        }
474        Signature::Method(method) => {
475            doc(w, &method.doc);
476            let mut line = method_signature_head(method);
477            line.push(';');
478            w.line(&line);
479        }
480        Signature::Index(index) => {
481            doc(w, &index.doc);
482            let mut line = index_signature_head(index);
483            line.push(';');
484            w.line(&line);
485        }
486    }
487}
488
489fn property_head(node: &Property) -> String {
490    let mut out = String::new();
491    if node.readonly {
492        out.push_str("readonly ");
493    }
494    out.push_str(&property_name_text(&node.name));
495    if node.optional {
496        out.push('?');
497    }
498    out.push_str(": ");
499    out.push_str(&type_text(&node.type_));
500    out
501}
502
503fn method_signature_head(node: &MethodSignature) -> String {
504    let mut out = property_name_text(&node.name);
505    if node.optional {
506        out.push('?');
507    }
508    out.push_str(&generics_text(&node.generics));
509    out.push('(');
510    out.push_str(&params_text(&node.params));
511    out.push_str("): ");
512    out.push_str(&type_text(&node.returns));
513    out
514}
515
516fn index_signature_head(node: &IndexSignature) -> String {
517    let mut out = String::new();
518    if node.readonly {
519        out.push_str("readonly ");
520    }
521    out.push('[');
522    out.push_str(node.key.as_str());
523    out.push_str(": ");
524    out.push_str(&type_text(&node.key_type));
525    out.push_str("]: ");
526    out.push_str(&type_text(&node.value_type));
527    out
528}
529
530/// One signature as it appears inside an inline object type, on one
531/// line.
532///
533/// A doc comment becomes a `/* … */` on the same line rather than being
534/// dropped: an inline object type has nowhere to put a line comment, and
535/// silently losing the text would be losing what the tree carried.
536fn inline_signature_text(node: &Signature) -> String {
537    let (doc, body) = match node {
538        Signature::Property(property) => (&property.doc, property_head(property)),
539        Signature::Method(method) => (&method.doc, method_signature_head(method)),
540        Signature::Index(index) => (&index.doc, index_signature_head(index)),
541        Signature::Comment(text) => (text, String::new()),
542        // Raw text has no one-line form; the lines are joined with a
543        // space, as the doc comment above them is.
544        Signature::Verbatim(text) => return text.lines().collect::<Vec<_>>().join(" "),
545    };
546    if doc.is_empty() {
547        return body;
548    }
549    let mut out = String::from("/* ");
550    out.push_str(&doc.as_lines().join(" "));
551    out.push_str(" */");
552    if !body.is_empty() {
553        out.push(' ');
554        out.push_str(&body);
555    }
556    out
557}
558
559/// zerodds-lint: recursion-depth = the generated body's block nesting.
560/// The codec walk emits a routine per type rather than inlining one
561/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
562/// graph. What does grow with the IDL is the dispatch chain: a union's
563/// cases, an enumeration's enumerators and a `@mutable` aggregate's
564/// members each become one `If` per arm, in the previous arm's `else`.
565/// The model bounds none of the three counts, and TypeScript nests the arms in
566/// its own output too, so they cannot be walked iteratively the way
567/// `c.rs` walks them. No number is claimed: this signature returns no
568/// error, so nothing here could report one.
569fn render_block(w: &mut Writer, block: &Block) {
570    for statement in &block.statements {
571        render_statement(w, statement);
572    }
573}
574
575/// zerodds-lint: recursion-depth = the generated body's block nesting.
576/// The codec walk emits a routine per type rather than inlining one
577/// (`lang-rules/src/emit/encode.rs`), so nothing here follows the type
578/// graph. What does grow with the IDL is the dispatch chain: a union's
579/// cases, an enumeration's enumerators and a `@mutable` aggregate's
580/// members each become one `If` per arm, in the previous arm's `else`.
581/// The model bounds none of the three counts, and TypeScript nests the arms in
582/// its own output too, so they cannot be walked iteratively the way
583/// `c.rs` walks them. No number is claimed: this signature returns no
584/// error, so nothing here could report one.
585fn render_statement(w: &mut Writer, node: &Statement) {
586    match node {
587        Statement::Declare {
588            kind,
589            name,
590            type_,
591            value,
592        } => {
593            let mut line = kind.as_str().to_string();
594            line.push(' ');
595            line.push_str(name.as_str());
596            if let Some(type_) = type_ {
597                line.push_str(": ");
598                line.push_str(&type_text(type_));
599            }
600            if let Some(value) = value {
601                line.push_str(" = ");
602                line.push_str(&expr_text(value));
603            }
604            line.push(';');
605            w.line(&line);
606        }
607        Statement::Expr(value) => {
608            let mut line = expr_text(value);
609            line.push(';');
610            w.line(&line);
611        }
612        Statement::Return(value) => {
613            let mut line = String::from("return");
614            if let Some(value) = value {
615                line.push(' ');
616                line.push_str(&expr_text(value));
617            }
618            line.push(';');
619            w.line(&line);
620        }
621        Statement::If {
622            condition,
623            then,
624            otherwise,
625        } => {
626            let mut head = String::from("if (");
627            head.push_str(&expr_text(condition));
628            head.push_str(") {");
629            w.line(&head);
630            w.indent();
631            render_block(w, then);
632            w.dedent();
633            match otherwise {
634                Some(block) => {
635                    w.line("} else {");
636                    w.indent();
637                    render_block(w, block);
638                    w.dedent();
639                    w.line("}");
640                }
641                None => w.line("}"),
642            }
643        }
644        Statement::Break => w.line("break;"),
645        Statement::For {
646            counter,
647            bound,
648            body,
649        } => {
650            let name = counter.as_str();
651            w.line(&format!(
652                "for (let {name} = 0; {name} < {}; {name} += 1) {{",
653                expr_text(bound)
654            ));
655            w.indent();
656            render_block(w, body);
657            w.dedent();
658            w.line("}");
659        }
660        Statement::While { condition, body } => {
661            w.line(&format!("while ({}) {{", expr_text(condition)));
662            w.indent();
663            render_block(w, body);
664            w.dedent();
665            w.line("}");
666        }
667        Statement::ForOf {
668            kind,
669            binding,
670            range,
671            body,
672        } => {
673            let mut head = String::from("for (");
674            head.push_str(kind.as_str());
675            head.push(' ');
676            head.push_str(binding.as_str());
677            head.push_str(" of ");
678            head.push_str(&expr_text(range));
679            head.push_str(") {");
680            w.line(&head);
681            w.indent();
682            render_block(w, body);
683            w.dedent();
684            w.line("}");
685        }
686        Statement::Switch { value, cases } => {
687            let mut head = String::from("switch (");
688            head.push_str(&expr_text(value));
689            head.push_str(") {");
690            w.line(&head);
691            for case in cases {
692                render_case(w, case);
693            }
694            w.line("}");
695        }
696        Statement::Throw(value) => {
697            let mut line = String::from("throw ");
698            line.push_str(&expr_text(value));
699            line.push(';');
700            w.line(&line);
701        }
702        Statement::Comment(text) => doc(w, text),
703    }
704}
705
706fn render_case(w: &mut Writer, case: &SwitchCase) {
707    if case.labels.is_empty() {
708        w.line("default:");
709    } else {
710        for label in &case.labels {
711            let mut line = String::from("case ");
712            line.push_str(&expr_text(label));
713            line.push(':');
714            w.line(&line);
715        }
716    }
717    w.indent();
718    render_block(w, &case.body);
719    if case.breaks {
720        w.line("break;");
721    }
722    w.dedent();
723}
724
725fn generics_text(params: &[TypeParam]) -> String {
726    if params.is_empty() {
727        return String::new();
728    }
729    let parts: Vec<String> = params
730        .iter()
731        .map(|param| {
732            let mut text = param.name.as_str().to_string();
733            if let Some(extends) = &param.extends {
734                text.push_str(" extends ");
735                text.push_str(&type_text(extends));
736            }
737            if let Some(default) = &param.default {
738                text.push_str(" = ");
739                text.push_str(&type_text(default));
740            }
741            text
742        })
743        .collect();
744    let mut out = String::from("<");
745    out.push_str(&parts.join(", "));
746    out.push('>');
747    out
748}
749
750/// zerodds-lint: recursion-depth 64 (one level per collection, alias or
751/// generic argument of the spelled type; `MAX_TYPE_DEPTH` in
752/// `lang-rules/src/adapter/typescript.rs` refuses a type that nests deeper)
753fn params_text(params: &[Param]) -> String {
754    let parts: Vec<String> = params
755        .iter()
756        .map(|param| {
757            let mut text = String::new();
758            if param.rest {
759                text.push_str("...");
760            }
761            text.push_str(param.name.as_str());
762            if param.optional {
763                text.push('?');
764            }
765            if let Some(type_) = &param.type_ {
766                text.push_str(": ");
767                text.push_str(&type_text(type_));
768            }
769            if let Some(default) = &param.default {
770                text.push_str(" = ");
771                text.push_str(&expr_text(default));
772            }
773            text
774        })
775        .collect();
776    parts.join(", ")
777}
778
779/// A property name, quoted or bare exactly as the tree says.
780fn property_name_text(name: &PropertyName) -> String {
781    match name {
782        PropertyName::Ident(ident) => ident.as_str().to_string(),
783        PropertyName::Quoted(text) => {
784            let mut out = String::from("\"");
785            out.push_str(&escape_string(text));
786            out.push('"');
787            out
788        }
789    }
790}
791
792/// A path, joined with `.`.
793///
794/// A rooted path renders no differently from a relative one: TypeScript
795/// has no root operator — there is no `::A::B` — so a materialiser that
796/// needs a name to be unambiguous has to make it unambiguous itself.
797fn path_text(path: &Path) -> String {
798    let parts: Vec<&str> = path.segments().iter().map(Ident::as_str).collect();
799    parts.join(".")
800}
801
802/// zerodds-lint: recursion-depth 64 (one level per collection, alias or
803/// generic argument of the spelled type; `MAX_TYPE_DEPTH` in
804/// `lang-rules/src/adapter/typescript.rs` refuses a type that nests deeper)
805fn type_text(node: &Type) -> String {
806    match node {
807        Type::Keyword(keyword) => keyword.as_str().to_string(),
808        Type::Named { path, args } => {
809            let mut out = path_text(path);
810            if !args.is_empty() {
811                out.push('<');
812                let parts: Vec<String> = args.iter().map(type_text).collect();
813                out.push_str(&parts.join(", "));
814                out.push('>');
815            }
816            out
817        }
818        Type::Array(element) => {
819            let mut out = type_text(element);
820            out.push_str("[]");
821            out
822        }
823        Type::ReadonlyArray(element) => {
824            let mut out = String::from("readonly ");
825            out.push_str(&type_text(element));
826            out.push_str("[]");
827            out
828        }
829        Type::Tuple(elements) => {
830            let parts: Vec<String> = elements.iter().map(type_text).collect();
831            let mut out = String::from("[");
832            out.push_str(&parts.join(", "));
833            out.push(']');
834            out
835        }
836        Type::Union(arms) => {
837            let parts: Vec<String> = arms.iter().map(type_text).collect();
838            parts.join(" | ")
839        }
840        Type::Intersection(arms) => {
841            let parts: Vec<String> = arms.iter().map(type_text).collect();
842            parts.join(" & ")
843        }
844        Type::Object(members) => {
845            if members.is_empty() {
846                return "{}".to_string();
847            }
848            let parts: Vec<String> = members.iter().map(inline_signature_text).collect();
849            let mut out = String::from("{ ");
850            out.push_str(&parts.join("; "));
851            out.push_str(" }");
852            out
853        }
854        Type::Literal(literal) => literal_text(literal),
855        Type::Function { params, returns } => {
856            let mut out = String::from("(");
857            out.push_str(&params_text(params));
858            out.push_str(") => ");
859            out.push_str(&type_text(returns));
860            out
861        }
862        Type::Parenthesised(inner) => {
863            let mut out = String::from("(");
864            out.push_str(&type_text(inner));
865            out.push(')');
866            out
867        }
868        Type::TypeOf(path) => {
869            let mut out = String::from("typeof ");
870            out.push_str(&path_text(path));
871            out
872        }
873        Type::KeyOf(inner) => {
874            let mut out = String::from("keyof ");
875            out.push_str(&type_text(inner));
876            out
877        }
878        Type::Predicate { param, type_ } => {
879            let mut out = param.as_str().to_string();
880            out.push_str(" is ");
881            out.push_str(&type_text(type_));
882            out
883        }
884        Type::Indexed { object, index } => {
885            let mut out = type_text(object);
886            out.push('[');
887            out.push_str(&type_text(index));
888            out.push(']');
889            out
890        }
891    }
892}
893
894/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
895/// calls a routine per type rather than nesting one, so the one operand
896/// tree that grows with the IDL is an array's default value: one composite
897/// literal per dimension, built in `lang-rules/src/default_typescript.rs`,
898/// and nothing bounds the dimension count. No number is claimed: this
899/// signature returns no error.
900#[allow(clippy::too_many_lines)]
901fn expr_text(node: &Expr) -> String {
902    match node {
903        Expr::Literal(literal) => literal_text(literal),
904        Expr::Path(path) => path_text(path),
905        Expr::Array(elements) => {
906            let parts: Vec<String> = elements.iter().map(expr_text).collect();
907            let mut out = String::from("[");
908            out.push_str(&parts.join(", "));
909            out.push(']');
910            out
911        }
912        Expr::Object(entries) => {
913            if entries.is_empty() {
914                return "{}".to_string();
915            }
916            let parts: Vec<String> = entries
917                .iter()
918                .map(|(name, value)| {
919                    let mut text = property_name_text(name);
920                    text.push_str(": ");
921                    text.push_str(&expr_text(value));
922                    text
923                })
924                .collect();
925            let mut out = String::from("{ ");
926            out.push_str(&parts.join(", "));
927            out.push_str(" }");
928            out
929        }
930        Expr::New { callee, args } => {
931            let mut out = String::from("new ");
932            out.push_str(&path_text(callee));
933            out.push('(');
934            let parts: Vec<String> = args.iter().map(expr_text).collect();
935            out.push_str(&parts.join(", "));
936            out.push(')');
937            out
938        }
939        Expr::Call { callee, args } => {
940            let mut out = expr_text(callee);
941            out.push('(');
942            let parts: Vec<String> = args.iter().map(expr_text).collect();
943            out.push_str(&parts.join(", "));
944            out.push(')');
945            out
946        }
947        Expr::Member { object, name } => {
948            let mut out = expr_text(object);
949            out.push('.');
950            out.push_str(name.as_str());
951            out
952        }
953        Expr::Index { object, index } => {
954            let mut out = expr_text(object);
955            out.push('[');
956            out.push_str(&expr_text(index));
957            out.push(']');
958            out
959        }
960        Expr::Paren(inner) => {
961            let mut out = String::from("(");
962            out.push_str(&expr_text(inner));
963            out.push(')');
964            out
965        }
966        Expr::Binary { op, lhs, rhs } => {
967            let mut out = expr_text(lhs);
968            out.push(' ');
969            out.push_str(op);
970            out.push(' ');
971            out.push_str(&expr_text(rhs));
972            out
973        }
974        Expr::Unary { op, value } => {
975            let mut out = (*op).to_string();
976            out.push_str(&expr_text(value));
977            out
978        }
979        Expr::As { value, type_ } => {
980            let mut out = expr_text(value);
981            out.push_str(" as ");
982            out.push_str(&type_text(type_));
983            out
984        }
985        Expr::AsConst(value) => {
986            let mut out = expr_text(value);
987            out.push_str(" as const");
988            out
989        }
990        Expr::Ternary {
991            condition,
992            then,
993            otherwise,
994        } => {
995            let mut out = expr_text(condition);
996            out.push_str(" ? ");
997            out.push_str(&expr_text(then));
998            out.push_str(" : ");
999            out.push_str(&expr_text(otherwise));
1000            out
1001        }
1002        Expr::Arrow { params, body } => {
1003            let parts: Vec<&str> = params.iter().map(Ident::as_str).collect();
1004            let mut out = String::from("(");
1005            out.push_str(&parts.join(", "));
1006            out.push_str(") => ");
1007            out.push_str(&expr_text(body));
1008            out
1009        }
1010        Expr::Parenthesised(inner) => {
1011            let mut out = String::from("(");
1012            out.push_str(&expr_text(inner));
1013            out.push(')');
1014            out
1015        }
1016        Expr::NonNull(inner) => {
1017            let mut out = expr_text(inner);
1018            out.push('!');
1019            out
1020        }
1021        Expr::This => "this".to_string(),
1022        Expr::Undefined => "undefined".to_string(),
1023    }
1024}
1025
1026/// A literal, in the one spelling TypeScript reads it back as.
1027///
1028/// [`Literal::Char`] becomes a **string** literal of one code unit, not
1029/// a number and not a single-quoted form: TypeScript has no character
1030/// type, so an IDL `char` is held as a one-unit `string` and its literal
1031/// has to be one too.
1032///
1033/// [`Literal::Bytes`] becomes a `Uint8Array`. That is a call expression
1034/// rather than a literal because JavaScript has no byte-string syntax;
1035/// what it is *not* is an ordinary array, which would be a `number[]`
1036/// and would not hold octets.
1037fn literal_text(literal: &Literal) -> String {
1038    match literal {
1039        // As written, which is where a `bigint` literal's `n` comes
1040        // from: `Literal::Int("2n")` is the tree's way of saying so.
1041        Literal::Int(text) | Literal::Float(text) => text.clone(),
1042        Literal::Bool(value) => {
1043            if *value {
1044                "true".to_string()
1045            } else {
1046                "false".to_string()
1047            }
1048        }
1049        Literal::Char(ch) => {
1050            let mut out = String::from("\"");
1051            out.push_str(&escape_string(&ch.to_string()));
1052            out.push('"');
1053            out
1054        }
1055        Literal::Str(text) => {
1056            let mut out = String::from("\"");
1057            out.push_str(&escape_string(text));
1058            out.push('"');
1059            out
1060        }
1061        Literal::Bytes(bytes) => {
1062            let mut out = String::from("new Uint8Array([");
1063            let parts: Vec<String> = bytes.iter().map(|b| alloc::format!("0x{b:02X}")).collect();
1064            out.push_str(&parts.join(", "));
1065            out.push_str("])");
1066            out
1067        }
1068        Literal::Null => "null".to_string(),
1069    }
1070}
1071
1072/// TSDoc, which is what the convention and the tooling expect.
1073fn doc(w: &mut Writer, doc: &Doc) {
1074    if doc.is_empty() {
1075        return;
1076    }
1077    w.line("/**");
1078    comment_lines(w, " * ", doc.as_lines());
1079    w.line(" */");
1080}