Skip to main content

zerodds_lang_render/
fsharp.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Rendering the F# CST. Spec §5.
4//!
5//! One style: four spaces, F#'s offside rule doing the indentation's own
6//! job, `///` for a documentation comment and `//` for a plain one. F#
7//! has no canonical formatter shipped with the compiler the way `zig
8//! fmt` is one, so unlike [`crate::zig`] there is no external tool this
9//! module's layout is checked against; the generated file's own
10//! indentation is the only contract, and it is four spaces throughout
11//! because F#'s indentation is significant and a generated file that
12//! mixed widths would not parse.
13//!
14//! Nothing is decided here. An F# type reads left to right and carries
15//! its own optionality and dimensionality, so — as in
16//! [`crate::zig`] and unlike [`crate::c`] — there
17//! is no declarator to split around a name: a property is a name, a
18//! colon, and a type.
19//!
20//! ## Two things F#'s grammar puts here and nowhere else in this arc
21//!
22//! 1. **`module rec`.** [`file()`] always writes the `rec`; the module
23//!    documentation of [`zerodds_lang_cst::fsharp`] says why one word
24//!    replaces a forward-declaration pass.
25//! 2. **The loop-break exception.** [`Stmt::Until`] opens a `try` around the
26//!    `while` it renders and closes it with `with :? ZdWire.LoopBreak ->
27//!    ()`; [`Stmt::Leave`] is the
28//!    `raise` inside it. Neither statement carries a name for the other
29//!    to agree on — the exception type is the coordination, and it is
30//!    the runtime's, not this renderer's invention.
31//!    [`Stmt::Guarded`] and
32//!    [`Stmt::GiveBack`] are
33//!    the same pair one `try` further out, for a `return` the shared
34//!    walk writes inside a loop body: a filter matches on the
35//!    exception's type, so `ZdWire.GiveBack` passes straight through
36//!    `with :? ZdWire.LoopBreak -> ()`.
37
38use crate::writer::{Writer, comment_lines, verbatim_lines};
39use alloc::string::{String, ToString};
40use alloc::vec::Vec;
41use zerodds_lang_cst::common::{Doc, Ident, Literal};
42use zerodds_lang_cst::fsharp::{
43    ClassField, Decl, Expr, File, FnParam, LetDecl, Primitive, Stmt, Type, TypeBody, TypeDecl,
44    Variant,
45};
46
47/// Render a whole file.
48#[must_use]
49pub fn file(file: &File) -> String {
50    let mut w = Writer::spaces4();
51    if !file.doc.is_empty() {
52        comment_lines(&mut w, "// ", file.doc.as_lines());
53    }
54    let mut head = String::from("module rec ");
55    head.push_str(file.module.as_str());
56    w.line(&head);
57    for decl in &file.decls {
58        w.blank_line();
59        render_decl(&mut w, decl);
60    }
61    w.finish()
62}
63
64/// Render one declaration, for a caller that has only one.
65#[must_use]
66pub fn decl(node: &Decl) -> String {
67    let mut w = Writer::spaces4();
68    render_decl(&mut w, node);
69    w.finish()
70}
71
72/// Render one type, for a caller that has only one.
73#[must_use]
74pub fn type_(node: &Type) -> String {
75    type_text(node)
76}
77
78/// Render one expression, for a caller — a generator's harness — that
79/// builds a [`Expr`] tree and needs its text to splice into a larger,
80/// hand-assembled program fragment.
81#[must_use]
82pub fn expr(node: &Expr) -> String {
83    expr_text(node)
84}
85
86/// The identifier a name renders as, for a caller assembling one.
87#[must_use]
88pub fn ident(name: &Ident) -> String {
89    name.as_str().to_string()
90}
91
92fn render_decl(w: &mut Writer, node: &Decl) {
93    match node {
94        Decl::Type(decl) => render_type_decl(w, decl),
95        Decl::Let(decl) => render_let(w, decl),
96        Decl::Comment(lines) => comment_lines(w, "// ", lines.as_lines()),
97        Decl::Verbatim(text) => verbatim_lines(w, text),
98    }
99}
100
101/// `type Name = <alias>` / `type Name() = …` / `type Name = | A = 0 …`.
102fn render_type_decl(w: &mut Writer, node: &TypeDecl) {
103    doc(w, &node.doc);
104    match &node.body {
105        TypeBody::Alias(target) => {
106            let mut line = String::from("type ");
107            line.push_str(node.name.as_str());
108            line.push_str(" = ");
109            line.push_str(&type_text(target));
110            w.line(&line);
111        }
112        TypeBody::Class(fields) => {
113            let mut head = String::from("type ");
114            head.push_str(node.name.as_str());
115            head.push_str("() =");
116            w.line(&head);
117            w.indent();
118            let text = &node.body_text;
119            let has_text = !text.begin.is_empty() || !text.end.is_empty();
120            if fields.is_empty() && !has_text {
121                // A class with nothing to hold — an empty `struct`, an
122                // empty exception. `class end` is F#'s empty class body;
123                // written so the declaration has something under it.
124                w.line("class end");
125            } else if fields.is_empty() {
126                // `@verbatim` text and no field: the explicit `class … end`
127                // form, which stays a class body whether the text declares
128                // members or is nothing but comments.
129                w.line("class");
130                w.indent();
131                for line in text.begin.iter().chain(&text.end) {
132                    verbatim_lines(w, line);
133                }
134                w.dedent();
135                w.line("end");
136            } else {
137                for line in &text.begin {
138                    verbatim_lines(w, line);
139                }
140                for field in fields {
141                    render_class_field(w, field);
142                }
143                for line in &text.end {
144                    verbatim_lines(w, line);
145                }
146            }
147            w.dedent();
148        }
149        TypeBody::Enum { backing, variants } => {
150            let mut head = String::from("type ");
151            head.push_str(node.name.as_str());
152            head.push_str(" =");
153            w.line(&head);
154            w.indent();
155            for variant in variants {
156                render_variant(w, backing, variant);
157            }
158            w.dedent();
159        }
160    }
161}
162
163fn render_class_field(w: &mut Writer, node: &ClassField) {
164    doc(w, &node.doc);
165    let mut line = String::from("member val ");
166    line.push_str(node.name.as_str());
167    line.push_str(" : ");
168    line.push_str(&type_text(&node.type_));
169    line.push_str(" = ");
170    line.push_str(&expr_text(&node.default));
171    line.push_str(" with get, set");
172    w.line(&line);
173}
174
175/// `| Name = value` — the backing type is written once, on the
176/// declaration line, and not repeated per case.
177fn render_variant(w: &mut Writer, backing: &Type, node: &Variant) {
178    let _ = backing;
179    doc(w, &node.doc);
180    let mut line = String::from("| ");
181    line.push_str(node.name.as_str());
182    line.push_str(" = ");
183    line.push_str(&expr_text(&node.value));
184    w.line(&line);
185}
186
187/// `let name (p: T) : R = …` or `let name : T = value` for a constant.
188fn render_let(w: &mut Writer, node: &LetDecl) {
189    doc(w, &node.doc);
190    let mut head = String::from("let ");
191    head.push_str(node.name.as_str());
192    if node.params.is_empty() {
193        if let Some(type_) = &node.type_ {
194            head.push_str(" : ");
195            head.push_str(&type_text(type_));
196        }
197    } else {
198        for param in &node.params {
199            head.push_str(" (");
200            head.push_str(param_text(param).as_str());
201            head.push(')');
202        }
203        if let Some(type_) = &node.type_ {
204            head.push_str(" : ");
205            head.push_str(&type_text(type_));
206        }
207    }
208    head.push_str(" =");
209    // A one-statement body that is a bare trailing expression fits the
210    // declaration line; every other shape opens a block underneath.
211    if let [Stmt::Return(value)] = node.body.as_slice() {
212        head.push(' ');
213        head.push_str(&expr_text(value));
214        w.line(&head);
215        return;
216    }
217    w.line(&head);
218    w.indent();
219    for statement in &node.body {
220        render_stmt(w, statement);
221    }
222    w.dedent();
223}
224
225fn param_text(param: &FnParam) -> String {
226    let mut out = String::from(param.name.as_str());
227    out.push_str(": ");
228    out.push_str(&type_text(&param.type_));
229    out
230}
231
232/// zerodds-lint: recursion-depth 64 (one level per nested block of a
233/// generated body. The codec walk emits a routine per type rather than
234/// inlining one — `lang-rules/src/emit/encode.rs` — so a body nests by
235/// its own framing and by one loop per array dimension, never by the
236/// type graph, whose descent stops at 64 anyway; the else-if chain, one
237/// arm per union case, is walked iteratively and costs no depth.)
238fn render_stmt(w: &mut Writer, node: &Stmt) {
239    match node {
240        Stmt::Let {
241            name,
242            type_,
243            value,
244            mutable,
245        } => {
246            let mut line = String::from(if *mutable { "let mutable " } else { "let " });
247            line.push_str(name.as_str());
248            if let Some(type_) = type_ {
249                line.push_str(" : ");
250                line.push_str(&type_text(type_));
251            }
252            line.push_str(" = ");
253            line.push_str(&expr_text(value));
254            w.line(&line);
255        }
256        Stmt::Assign { place, value } => {
257            let mut line = expr_text(place);
258            line.push_str(" <- ");
259            line.push_str(&expr_text(value));
260            w.line(&line);
261        }
262        Stmt::Expr(value) => w.line(&expr_text(value)),
263        Stmt::If { .. } => render_if(w, node, "if "),
264        Stmt::CountUp {
265            counter,
266            type_,
267            bound,
268            body,
269        } => {
270            // `for i = 0 to bound - 1 do` — F#'s counted loop needs no
271            // declared type on the counter; it is inferred from `zero_of`.
272            let mut start = String::from("for ");
273            start.push_str(counter.as_str());
274            start.push_str(" = ");
275            start.push_str(&zero_of(type_));
276            start.push_str(" to (");
277            start.push_str(&expr_text(bound));
278            start.push_str(") - ");
279            start.push_str(&one_of(type_));
280            start.push_str(" do");
281            w.line(&start);
282            w.indent();
283            for statement in body {
284                render_stmt(w, statement);
285            }
286            w.dedent();
287        }
288        Stmt::Until { condition, body } => {
289            w.line("try");
290            w.indent();
291            let mut head = String::from("while ");
292            head.push_str(&expr_text(condition));
293            head.push_str(" do");
294            w.line(&head);
295            w.indent();
296            for statement in body {
297                render_stmt(w, statement);
298            }
299            w.dedent();
300            w.dedent();
301            w.line("with");
302            w.indent();
303            w.line(":? ZdWire.LoopBreak -> ()");
304            w.dedent();
305        }
306        Stmt::Leave => w.line("raise (ZdWire.LoopBreak())"),
307        Stmt::GiveBack => w.line("raise (ZdWire.GiveBack())"),
308        Stmt::Guarded { body } => {
309            w.line("try");
310            w.indent();
311            for statement in body {
312                render_stmt(w, statement);
313            }
314            w.dedent();
315            w.line("with");
316            w.indent();
317            w.line(":? ZdWire.GiveBack -> ()");
318            w.dedent();
319        }
320        Stmt::Comment(lines) => comment_lines(w, "// ", lines.as_lines()),
321        Stmt::Return(value) => w.line(&expr_text(value)),
322    }
323}
324
325/// The counted loop's zero, in the counter's own type — F#'s `for … =
326/// 0 to …` needs the bound written in the same numeric type as the
327/// start, and this walk's counters are `uint32`.
328fn zero_of(type_: &Type) -> String {
329    if *type_ == Type::Primitive(Primitive::UInt32) {
330        "0u".to_string()
331    } else {
332        "0".to_string()
333    }
334}
335
336fn one_of(type_: &Type) -> String {
337    if *type_ == Type::Primitive(Primitive::UInt32) {
338        "1u".to_string()
339    } else {
340        "1".to_string()
341    }
342}
343
344/// An `if`, with an `else`-block of exactly one `if` collapsed into
345/// `elif` — the shape a long dispatch chain stays readable in.
346///
347/// The next arm is taken in a loop and not by a call to this function
348/// again. The chain's length is the union's case count, the
349/// enumeration's enumerator count or the `@mutable` aggregate's member
350/// count — `lang-rules/src/emit/encode.rs` builds one `If` per arm, each
351/// in the previous one's `else` — and nothing in the model bounds any of
352/// the three.
353///
354/// zerodds-lint: recursion-depth 64 (block nesting only; the arm chain
355/// is walked iteratively, and what remains is one level per nested block
356/// of a generated body — the codec walk emits a routine per type rather
357/// than inlining, so a body nests by its framing and by one loop per
358/// array dimension, never by the type graph)
359fn render_if(w: &mut Writer, node: &Stmt, keyword: &str) {
360    let mut node = node;
361    let mut keyword = keyword;
362    loop {
363        let Stmt::If {
364            condition,
365            then,
366            otherwise,
367        } = node
368        else {
369            return;
370        };
371        let mut head = String::from(keyword);
372        head.push_str(&expr_text(condition));
373        head.push_str(" then");
374        w.line(&head);
375        w.indent();
376        if then.is_empty() {
377            w.line("()");
378        }
379        for statement in then {
380            render_stmt(w, statement);
381        }
382        w.dedent();
383        match otherwise {
384            None => return,
385            Some(chain) if chain.len() == 1 && matches!(chain[0], Stmt::If { .. }) => {
386                node = &chain[0];
387                keyword = "elif ";
388            }
389            Some(block) => {
390                w.line("else");
391                w.indent();
392                if block.is_empty() {
393                    w.line("()");
394                }
395                for statement in block {
396                    render_stmt(w, statement);
397                }
398                w.dedent();
399                return;
400            }
401        }
402    }
403}
404
405/// zerodds-lint: recursion-depth 64 (one level per collection, alias or
406/// generic argument of the spelled type; `MAX_TYPE_DEPTH` in
407/// `lang-rules/src/emit/fsharp.rs` refuses a type that nests deeper)
408fn type_text(node: &Type) -> String {
409    match node {
410        Type::Primitive(primitive) => primitive.as_str().to_string(),
411        Type::Named(name) => name.as_str().to_string(),
412        Type::Array(element) => {
413            let mut out = type_text(element);
414            out.push_str(" []");
415            out
416        }
417        Type::Option(inner) => {
418            let mut out = type_text(inner);
419            out.push_str(" option");
420            out
421        }
422        Type::Fn { params, returns } => {
423            if params.is_empty() {
424                let mut out = String::from("unit -> ");
425                out.push_str(&type_text(returns));
426                return out;
427            }
428            let parts: Vec<String> = params.iter().map(type_text).collect();
429            let mut out = parts.join(" -> ");
430            out.push_str(" -> ");
431            out.push_str(&type_text(returns));
432            out
433        }
434    }
435}
436
437/// zerodds-lint: recursion-depth = the built expression's nesting. A walk
438/// calls a routine per type rather than nesting one, so the one operand
439/// tree that grows with the IDL is an array's default value: one composite
440/// literal per dimension, built in `lang-rules/src/default_fsharp.rs`, and
441/// nothing bounds the dimension count. No number is claimed: this
442/// signature returns no error.
443fn expr_text(node: &Expr) -> String {
444    match node {
445        Expr::Literal(literal) => literal_text(literal),
446        Expr::Name(name) => name.as_str().to_string(),
447        Expr::Binary { op, lhs, rhs } => {
448            let mut out = expr_text(lhs);
449            out.push(' ');
450            out.push_str(op);
451            out.push(' ');
452            out.push_str(&expr_text(rhs));
453            out
454        }
455        Expr::Paren(inner) => {
456            let mut out = String::from("(");
457            out.push_str(&expr_text(inner));
458            out.push(')');
459            out
460        }
461        Expr::Call { func, args } => {
462            let mut out = expr_text(func);
463            out.push('(');
464            let parts: Vec<String> = args.iter().map(expr_text).collect();
465            out.push_str(&parts.join(", "));
466            out.push(')');
467            out
468        }
469        Expr::CurriedCall { func, args } => {
470            let mut out = expr_text(func);
471            if args.is_empty() {
472                // `f ()` — the unit argument, applied. A bare `f` here
473                // would name the function value and never invoke it:
474                // `let x = f` binds `x` to a `unit -> T`, not to the
475                // `T` calling it would answer.
476                out.push_str(" ()");
477                return out;
478            }
479            for arg in args {
480                out.push_str(" (");
481                out.push_str(&expr_text(arg));
482                out.push(')');
483            }
484            out
485        }
486        Expr::Dot { value, name } => {
487            let mut out = expr_text(value);
488            out.push('.');
489            out.push_str(name.as_str());
490            out
491        }
492        Expr::Index { value, index } => {
493            let mut out = expr_text(value);
494            out.push_str(".[");
495            out.push_str(&expr_text(index));
496            out.push(']');
497            out
498        }
499        Expr::Unary { op, value } => {
500            let mut out = String::from(*op);
501            out.push_str(&expr_text(value));
502            out
503        }
504        Expr::Some_(inner) => {
505            let mut out = String::from("Some (");
506            out.push_str(&expr_text(inner));
507            out.push(')');
508            out
509        }
510        Expr::None_ => "None".to_string(),
511        Expr::New { type_, args } => {
512            let mut out = type_.as_str().to_string();
513            out.push('(');
514            let parts: Vec<String> = args.iter().map(expr_text).collect();
515            out.push_str(&parts.join(", "));
516            out.push(')');
517            out
518        }
519        Expr::ObjectInit { type_, fields } => {
520            let mut out = type_.as_str().to_string();
521            out.push('(');
522            let parts: Vec<String> = fields
523                .iter()
524                .map(|(name, value)| {
525                    let mut part = name.as_str().to_string();
526                    part.push_str(" = ");
527                    part.push_str(&expr_text(value));
528                    part
529                })
530                .collect();
531            out.push_str(&parts.join(", "));
532            out.push(')');
533            out
534        }
535        Expr::ArrayLit(elements) => {
536            if elements.is_empty() {
537                return "[||]".to_string();
538            }
539            let mut out = String::from("[| ");
540            let parts: Vec<String> = elements.iter().map(expr_text).collect();
541            out.push_str(&parts.join("; "));
542            out.push_str(" |]");
543            out
544        }
545        Expr::ArrayZeroCreate { element, count } => {
546            let mut out = String::from("Array.zeroCreate<");
547            out.push_str(&type_text(element));
548            out.push_str("> (");
549            out.push_str(&expr_text(count));
550            out.push(')');
551            out
552        }
553    }
554}
555
556fn literal_text(literal: &Literal) -> String {
557    match literal {
558        Literal::Int(text) | Literal::Float(text) => text.clone(),
559        Literal::Bool(value) => {
560            if *value {
561                "true".to_string()
562            } else {
563                "false".to_string()
564            }
565        }
566        Literal::Char(ch) => {
567            // .NET's `char` is a UTF-16 code unit; this column never
568            // declares one, so a character literal only ever appears as
569            // an `int` code point.
570            alloc::format!("{}", *ch as u32)
571        }
572        Literal::Str(text) => {
573            let mut out = String::from("\"");
574            out.push_str(&string_text(text));
575            out.push('"');
576            out
577        }
578        Literal::Bytes(bytes) => {
579            let mut out = String::from("[| ");
580            let parts: Vec<String> = bytes.iter().map(|b| alloc::format!("{b}uy")).collect();
581            out.push_str(&parts.join("; "));
582            out.push_str(" |]");
583            out
584        }
585        Literal::Null => "None".to_string(),
586    }
587}
588
589/// Escape a string for an F# string literal.
590///
591/// F#'s escapes agree with the shared [`crate::writer::escape_string`]
592/// on every one it writes (`\"`, `\\`, `\n`, `\r`, `\t`, `\0`), so this
593/// module has no escaping of its own the way [`crate::zig`]'s does.
594#[must_use]
595pub fn string_text(text: &str) -> String {
596    crate::writer::escape_string(text)
597}
598
599fn doc(w: &mut Writer, doc: &Doc) {
600    if doc.is_empty() {
601        return;
602    }
603    comment_lines(w, "/// ", doc.as_lines());
604}
605
606#[cfg(test)]
607mod tests {
608    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
609    use super::*;
610    use alloc::boxed::Box;
611    use zerodds_lang_cst::fsharp::Primitive;
612
613    fn class_decl(fields: Vec<ClassField>) -> Decl {
614        Decl::Type(TypeDecl {
615            body_text: zerodds_lang_cst::common::BodyText::none(),
616            doc: Doc::none(),
617            name: Ident::new("Pose"),
618            body: TypeBody::Class(fields),
619        })
620    }
621
622    fn field(name: &str, type_: Type, default: Expr) -> ClassField {
623        ClassField {
624            doc: Doc::none(),
625            name: Ident::new(name),
626            type_,
627            default,
628        }
629    }
630
631    #[test]
632    fn a_class_declaration_indents_by_four_and_writes_member_val() {
633        let text = decl(&class_decl(alloc::vec![
634            field("X", Type::Primitive(Primitive::Int32), Expr::int(0)),
635            field(
636                "Y",
637                Type::Primitive(Primitive::Double),
638                Expr::Literal(Literal::Float("0.0".to_string())),
639            ),
640        ]));
641        assert_eq!(
642            text,
643            "type Pose() =\n    member val X : int32 = 0 with get, set\n    \
644             member val Y : float = 0.0 with get, set\n"
645        );
646    }
647
648    #[test]
649    fn an_empty_class_writes_class_end() {
650        assert_eq!(
651            decl(&class_decl(Vec::new())),
652            "type Pose() =\n    class end\n"
653        );
654    }
655
656    #[test]
657    fn a_type_reads_left_to_right_with_no_declarator_to_split() {
658        assert_eq!(
659            type_(&Type::array(Type::array(Type::Primitive(Primitive::Int32)))),
660            "int32 [] []"
661        );
662        assert_eq!(type_(&Type::text()), "string");
663        assert_eq!(type_(&Type::array(Type::named("Entry"))), "Entry []");
664        assert_eq!(
665            type_(&Type::option(Type::Primitive(Primitive::Byte))),
666            "byte option"
667        );
668    }
669
670    #[test]
671    fn an_enum_writes_its_backing_type_once_and_every_value() {
672        let text = decl(&Decl::Type(TypeDecl {
673            body_text: zerodds_lang_cst::common::BodyText::none(),
674            doc: Doc::none(),
675            name: Ident::new("Mode"),
676            body: TypeBody::Enum {
677                backing: Type::Primitive(Primitive::Int32),
678                variants: alloc::vec![
679                    Variant {
680                        doc: Doc::none(),
681                        name: Ident::new("IDLE"),
682                        value: Expr::int(0),
683                    },
684                    Variant {
685                        doc: Doc::none(),
686                        name: Ident::new("ACTIVE"),
687                        value: Expr::int(4),
688                    },
689                ],
690            },
691        }));
692        assert_eq!(text, "type Mode =\n    | IDLE = 0\n    | ACTIVE = 4\n");
693    }
694
695    #[test]
696    fn a_constant_writes_its_ascription_before_its_value() {
697        let text = decl(&Decl::Let(LetDecl {
698            doc: Doc::none(),
699            name: Ident::new("LIMIT"),
700            params: Vec::new(),
701            type_: Some(Type::Primitive(Primitive::Byte)),
702            body: alloc::vec![Stmt::Return(Expr::int(8))],
703        }));
704        assert_eq!(text, "let LIMIT : byte = 8\n");
705    }
706
707    #[test]
708    fn a_function_writes_its_parameters_curried() {
709        let text = decl(&Decl::Let(LetDecl {
710            doc: Doc::none(),
711            name: Ident::new("zd_enc_Pose"),
712            params: alloc::vec![
713                FnParam {
714                    name: Ident::new("w"),
715                    type_: Type::named("ZdWire.Writer"),
716                },
717                FnParam {
718                    name: Ident::new("v"),
719                    type_: Type::named("Pose"),
720                },
721            ],
722            type_: Some(Type::Primitive(Primitive::Unit)),
723            body: alloc::vec![Stmt::Return(Expr::Literal(Literal::Bool(true)))],
724        }));
725        assert_eq!(
726            text,
727            "let zd_enc_Pose (w: ZdWire.Writer) (v: Pose) : unit = true\n"
728        );
729    }
730
731    #[test]
732    fn the_module_line_always_carries_rec() {
733        let text = file(&File {
734            doc: Doc::none(),
735            module: Ident::new("ZdTypes"),
736            decls: Vec::new(),
737        });
738        assert!(text.starts_with("module rec ZdTypes\n"), "{text}");
739    }
740
741    #[test]
742    fn a_loop_break_renders_as_a_try_with_and_a_raise() {
743        let text = decl(&Decl::Let(LetDecl {
744            doc: Doc::none(),
745            name: Ident::new("f"),
746            params: Vec::new(),
747            type_: Some(Type::Primitive(Primitive::Unit)),
748            body: alloc::vec![
749                Stmt::Until {
750                    condition: Expr::Literal(Literal::Bool(true)),
751                    body: alloc::vec![Stmt::Leave],
752                },
753                Stmt::Return(Expr::Literal(Literal::Bool(true))),
754            ],
755        }));
756        assert!(text.contains("try\n"), "{text}");
757        assert!(text.contains("while true do\n"), "{text}");
758        assert!(text.contains("raise (ZdWire.LoopBreak())\n"), "{text}");
759        assert!(text.contains(":? ZdWire.LoopBreak -> ()\n"), "{text}");
760    }
761
762    #[test]
763    fn a_counted_loop_writes_fs_for_syntax() {
764        let text = decl(&Decl::Let(LetDecl {
765            doc: Doc::none(),
766            name: Ident::new("f"),
767            params: Vec::new(),
768            type_: Some(Type::Primitive(Primitive::Unit)),
769            body: alloc::vec![
770                Stmt::CountUp {
771                    counter: Ident::new("i"),
772                    type_: Box::new(Type::Primitive(Primitive::UInt32)),
773                    bound: Box::new(Expr::Name(Ident::new("n"))),
774                    body: alloc::vec![Stmt::Expr(Expr::Name(Ident::new("i")))],
775                },
776                Stmt::Return(Expr::Literal(Literal::Bool(true))),
777            ],
778        }));
779        assert!(text.contains("for i = 0u to (n) - 1u do\n"), "{text}");
780    }
781
782    #[test]
783    fn a_doc_comment_sits_above_the_field_it_belongs_to() {
784        let mut documented = field("type_", Type::Primitive(Primitive::Int32), Expr::int(0));
785        documented.doc = Doc::line("IDL: `type`.");
786        let text = decl(&class_decl(alloc::vec![documented]));
787        assert_eq!(
788            text,
789            "type Pose() =\n    /// IDL: `type`.\n    member val type_ : int32 = 0 with get, set\n"
790        );
791    }
792}