Skip to main content

bynk_syntax/
print.rs

1//! The expression/pattern/statement printer: renders a parsed AST fragment back to Bynk
2//! source text.
3//!
4//! Moved here from `bynk-fmt` (crate-hygiene A3, `design/tracks/crate-hygiene.md` §5) so the
5//! checker can quote an expression in a diagnostic without depending on the formatter. The
6//! formatter and the checker both call it from here; the text is unchanged byte for byte.
7
8use crate::ast::*;
9use crate::span::Span;
10
11thread_local! {
12    /// The source being formatted, so a string literal can be re-emitted as it
13    /// was written (#2619). Set by `render_units` (in `bynk-fmt`) for the duration of a render.
14    static WRITTEN_SOURCE: std::cell::RefCell<Option<std::rc::Rc<str>>> =
15        const { std::cell::RefCell::new(None) };
16}
17
18/// Scope guard installing `WRITTEN_SOURCE`.
19pub struct WrittenForms(Option<std::rc::Rc<str>>);
20
21impl WrittenForms {
22    pub fn enter(source: &str) -> Self {
23        let new = Some(std::rc::Rc::<str>::from(source));
24        Self(WRITTEN_SOURCE.with(|cell| cell.replace(new)))
25    }
26}
27
28impl Drop for WrittenForms {
29    fn drop(&mut self) {
30        let previous = self.0.take();
31        WRITTEN_SOURCE.with(|cell| *cell.borrow_mut() = previous);
32    }
33}
34
35/// #2619: a string literal's written form, when the source holds one that is
36/// worth preserving: a raw or multi-line literal, or one using a `\u{…}`
37/// escape. It is the source text at `e.span`, kept only if it decodes to
38/// `value`, so a synthesised or mis-spanned literal falls back to the canonical
39/// rendering. Formatting must not turn `r"\d+"` into `"\\d+"`.
40fn written_string_form(e: &Expr, value: Option<&str>) -> Option<String> {
41    written_literal(e.span, value)
42}
43
44/// [`written_string_form`] for a literal at `span` anywhere (an expression, a
45/// `Matches` argument, a message template).
46fn written_literal(span: Span, value: Option<&str>) -> Option<String> {
47    WRITTEN_SOURCE.with(|cell| {
48        let source = cell.borrow();
49        let text = source.as_deref()?.get(span.range())?;
50        let special = text.starts_with("r\"")
51            || text.starts_with("r#")
52            || text.starts_with("\"\"\"")
53            || text.contains("\\u{");
54        if !special {
55            return None;
56        }
57        // A multi-line literal is re-emitted at column zero, with the closing
58        // delimiter's indentation removed from every line: the value is
59        // unchanged (that indentation is stripped anyway), the formatter's
60        // re-indent pass then sets the current depth for all of it, and the
61        // result no longer depends on how the author happened to indent it.
62        let text = if text.starts_with("\"\"\"") || text.starts_with("r\"\"\"") {
63            dedent_multiline(text)
64        } else {
65            text.to_string()
66        };
67        match value {
68            Some(value) => {
69                let decoded = crate::lexer::decode_string_literal(&text, span).ok()?;
70                (decoded == value).then_some(text)
71            }
72            // An interpolated multi-line string is kept whole only when it is
73            // multi-line (its holes would otherwise be reformatted).
74            None => (text.starts_with("\"\"\"") || text.contains("\\u{")).then_some(text),
75        }
76    })
77}
78
79/// Strip the closing delimiter's indentation from every line of a multi-line
80/// literal's text, so it sits at column zero (see [`written_string_form`]).
81fn dedent_multiline(text: &str) -> String {
82    let last = text.rsplit('\n').next().unwrap_or("");
83    let indent = &last[..last.len() - last.trim_start_matches([' ', '\t']).len()];
84    let mut out = String::with_capacity(text.len());
85    for (i, line) in text.split('\n').enumerate() {
86        if i > 0 {
87            out.push('\n');
88        }
89        if i == 0 {
90            // The opening line keeps a language tag (`"""sql`, #2958) as
91            // written, minus any trailing spaces after it.
92            let (line, cr) = line.strip_suffix('\r').map_or((line, ""), |l| (l, "\r"));
93            out.push_str(line.trim_end_matches([' ', '\t']));
94            out.push_str(cr);
95        } else if let Some(rest) = line.strip_prefix(indent) {
96            out.push_str(rest);
97        } else if line.trim().is_empty() {
98            // a blank line shorter than the indentation is empty
99        } else {
100            out.push_str(line);
101        }
102    }
103    out
104}
105
106/// v0.182 (#664): render a call-site actor clause — `by User("bob")` or the
107/// unit-identity `by Visitor`.
108pub fn call_site_actor_src(p: &CallSiteActor) -> String {
109    match (&p.identity, &p.claims) {
110        // #2380 (L5): an `Oidc` principal may carry the token's claims too.
111        (Some(id), Some(claims)) => format!(
112            "by {}({}, {})",
113            p.actor.name,
114            expr_with_prec(id, 0),
115            expr_with_prec(claims, 0)
116        ),
117        (Some(id), None) => format!("by {}({})", p.actor.name, expr_with_prec(id, 0)),
118        (None, _) => format!("by {}", p.actor.name),
119    }
120}
121
122pub fn type_ref_to_string(t: &TypeRef) -> String {
123    match t {
124        TypeRef::Base(b, _) => b.name().to_string(),
125        TypeRef::Named(id) => id.name.clone(),
126        TypeRef::Result(a, b, _) => format!(
127            "Result[{}, {}]",
128            type_ref_to_string(a),
129            type_ref_to_string(b)
130        ),
131        TypeRef::Option(t, _) => format!("Option[{}]", type_ref_to_string(t)),
132        TypeRef::Effect(t, _) => format!("Effect[{}]", type_ref_to_string(t)),
133        TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", type_ref_to_string(t)),
134        TypeRef::QueueResult(_) => "QueueResult".to_string(),
135        TypeRef::Asset(_) => "Asset".to_string(),
136        TypeRef::RequestHeaders(_) => "RequestHeaders".to_string(),
137        TypeRef::RequestQuery(_) => "RequestQuery".to_string(),
138        TypeRef::TokenClaims(_) => "TokenClaims".to_string(),
139        TypeRef::BoundaryRejection(_) => "BoundaryRejection".to_string(),
140        TypeRef::List(t, _) => format!("List[{}]", type_ref_to_string(t)),
141        TypeRef::Set(t, _) => format!("Set[{}]", type_ref_to_string(t)),
142        TypeRef::Query(t, _) => format!("Query[{}]", type_ref_to_string(t)),
143        TypeRef::Stream(t, _) => format!("Stream[{}]", type_ref_to_string(t)),
144        TypeRef::Connection(t, _) => format!("Connection[{}]", type_ref_to_string(t)),
145        TypeRef::History(t, _) => format!("History[{}]", type_ref_to_string(t)),
146        TypeRef::Map(k, v, _) => {
147            format!("Map[{}, {}]", type_ref_to_string(k), type_ref_to_string(v))
148        }
149        TypeRef::ValidationError(_) => "ValidationError".to_string(),
150        TypeRef::JsonError(_) => "JsonError".to_string(),
151        TypeRef::Unit(_) => "()".to_string(),
152        // v0.157 (ADR 0183): a user generic-type application, as written.
153        TypeRef::App { name, args, .. } => format!(
154            "{}[{}]",
155            name.name,
156            args.iter()
157                .map(type_ref_to_string)
158                .collect::<Vec<_>>()
159                .join(", ")
160        ),
161        TypeRef::Fn(params, ret, _) => {
162            let lhs = match params.len() {
163                0 => "()".to_string(),
164                1 if !matches!(params[0], TypeRef::Fn(..)) => type_ref_to_string(&params[0]),
165                _ => format!(
166                    "({})",
167                    params
168                        .iter()
169                        .map(type_ref_to_string)
170                        .collect::<Vec<_>>()
171                        .join(", ")
172                ),
173            };
174            format!("{lhs} -> {}", type_ref_to_string(ret))
175        }
176    }
177}
178
179pub fn refinement_to_string(r: &Refinement) -> String {
180    let mut s = String::new();
181    for (i, p) in r.predicates.iter().enumerate() {
182        if i > 0 {
183            s.push_str(" && ");
184        }
185        s.push_str(&pred_to_string(p));
186    }
187    s
188}
189
190/// A literal as written if it has a special form, else canonically escaped.
191pub fn literal_text(span: Span, value: &str) -> String {
192    written_literal(span, Some(value)).unwrap_or_else(|| format!("\"{}\"", escape_string(value)))
193}
194
195/// The span of a `Matches(…)` predicate's string argument.
196pub fn matches_argument_span(p: &RefinementPred) -> Span {
197    WRITTEN_SOURCE.with(|cell| {
198        let source = cell.borrow();
199        let Some(text) = source.as_deref().and_then(|s| s.get(p.span.range())) else {
200            return p.span;
201        };
202        let (Some(open), Some(close)) = (text.find('('), text.rfind(')')) else {
203            return p.span;
204        };
205        let inner = &text[open + 1..close];
206        let lead = inner.len() - inner.trim_start().len();
207        let start = p.span.start + open + 1 + lead;
208        Span::new_in(p.span.file, start, start + inner.trim().len())
209    })
210}
211
212fn pred_to_string(p: &RefinementPred) -> String {
213    let arg = |n: i64| p.arg_text.clone().unwrap_or_else(|| n.to_string());
214    match &p.kind {
215        PredKind::Matches(re) => format!("Matches({})", literal_text(matches_argument_span(p), re)),
216        PredKind::InRange(a, b) => format!("InRange({}, {})", a.lexeme, b.lexeme),
217        PredKind::InRangeF(a, b) => format!("InRange({}, {})", a.lexeme, b.lexeme),
218        PredKind::InRangeD(a, b) => format!("InRange({}, {})", a.lexeme, b.lexeme),
219        PredKind::MinLength(n) => format!("MinLength({})", arg(*n)),
220        PredKind::MaxLength(n) => format!("MaxLength({})", arg(*n)),
221        PredKind::Length(n) => format!("Length({})", arg(*n)),
222        PredKind::NonNegative => "NonNegative".to_string(),
223        PredKind::Positive => "Positive".to_string(),
224        PredKind::NonEmpty => "NonEmpty".to_string(),
225        PredKind::IsoDate => "IsoDate".to_string(),
226    }
227}
228
229pub fn escape_string(s: &str) -> String {
230    let mut out = String::with_capacity(s.len());
231    for ch in s.chars() {
232        match ch {
233            '\\' => out.push_str("\\\\"),
234            '"' => out.push_str("\\\""),
235            '\n' => out.push_str("\\n"),
236            '\t' => out.push_str("\\t"),
237            // #2619: a CR or NUL cannot sit raw in a literal, nor can other control
238            // characters; they print as `\u{…}`. Anything else (a ZWJ, an
239            // RLM, a no-break space) is written as it was.
240            '\r' => out.push_str("\\r"),
241            '\0' => out.push_str("\\0"),
242            c if c.is_control() => {
243                out.push_str(&format!("\\u{{{:X}}}", c as u32));
244            }
245            c => out.push(c),
246        }
247    }
248    out
249}
250
251pub fn expr_to_string(e: &Expr) -> String {
252    expr_with_prec(e, 0)
253}
254
255// Operator precedences (smaller = binds looser):
256//   1: || 2: && 3: == != 4: < <= > >= 5: + - 6: * / 7: unary ! - 8: postfix . () ?
257pub fn binop_prec(op: BinOp) -> u8 {
258    match op {
259        // v0.80: `implies` is the lowest-precedence binary operator (below `||`).
260        BinOp::Implies => 0,
261        BinOp::Or => 1,
262        BinOp::And => 2,
263        BinOp::Eq | BinOp::NotEq => 3,
264        BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq => 4,
265        BinOp::Add | BinOp::Sub => 5,
266        BinOp::Mul | BinOp::Div => 6,
267    }
268}
269
270pub fn expr_with_prec(e: &Expr, parent_prec: u8) -> String {
271    match &e.kind {
272        // v0.142 (ADR 0166): the stored lexeme verbatim — formatting must not
273        // normalise away the author's `_` digit separators.
274        ExprKind::IntLit { lexeme, .. } => lexeme.clone(),
275        // v0.21: the stored lexeme verbatim — formatting must not normalise.
276        ExprKind::FloatLit { lexeme, .. } => lexeme.clone(),
277        ExprKind::DecimalLit { lexeme, .. } => lexeme.clone(),
278        // v0.86 (ADR 0112): a duration literal `<value>.<unit>`.
279        ExprKind::DurationLit { value, unit, .. } => format!("{value}.{}", unit.name()),
280        ExprKind::StrLit(s) => {
281            written_string_form(e, Some(s)).unwrap_or_else(|| format!("\"{}\"", escape_string(s)))
282        }
283        // v0.43: re-emit the interpolated string — chunks re-escaped, each
284        // hole as `\(expr)`. Re-escaping a chunk's literal `\` to `\\` keeps a
285        // source `\\(` (an escaped `\(`) round-tripping as text, not a hole.
286        ExprKind::InterpStr(parts) => {
287            if let Some(written) = written_string_form(e, None) {
288                return written;
289            }
290            let mut out = String::from("\"");
291            for part in parts {
292                match part {
293                    InterpPart::Chunk(text) => out.push_str(&escape_string(text)),
294                    InterpPart::Hole(hole) => {
295                        out.push_str(&format!("\\({})", expr_with_prec(hole, 0)));
296                    }
297                }
298            }
299            out.push('"');
300            out
301        }
302        ExprKind::BoolLit(b) => b.to_string(),
303        ExprKind::UnitLit => "()".to_string(),
304        ExprKind::Ident(id) => id.name.clone(),
305        ExprKind::ListLit(elems, ..) => format!(
306            "[{}]",
307            elems
308                .iter()
309                .map(expr_to_string)
310                .collect::<Vec<_>>()
311                .join(", ")
312        ),
313        ExprKind::Call {
314            name,
315            type_args,
316            args,
317            ..
318        } => {
319            let targs = if type_args.is_empty() {
320                String::new()
321            } else {
322                format!(
323                    "[{}]",
324                    type_args
325                        .iter()
326                        .map(type_ref_to_string)
327                        .collect::<Vec<_>>()
328                        .join(", ")
329                )
330            };
331            let parts: Vec<String> = args.iter().map(|a| expr_with_prec(a, 0)).collect();
332            format!("{}{}({})", name.name, targs, parts.join(", "))
333        }
334        ExprKind::BinOp(op, l, r) => {
335            let prec = binop_prec(*op);
336            let inner = format!(
337                "{} {} {}",
338                expr_with_prec(l, prec),
339                op.name(),
340                expr_with_prec(r, prec + 1)
341            );
342            if prec < parent_prec {
343                format!("({inner})")
344            } else {
345                inner
346            }
347        }
348        ExprKind::UnaryOp(op, inner) => {
349            // Unary binds tightly (prec 7).
350            let s = format!("{}{}", op.name(), expr_with_prec(inner, 7));
351            if parent_prec > 7 { format!("({s})") } else { s }
352        }
353        ExprKind::Paren(inner) => format!("({})", expr_with_prec(inner, 0)),
354        // v0.20a: a lambda prints as `(params) => body`.
355        ExprKind::Lambda(lambda) => {
356            let params: Vec<String> = lambda.params.iter().map(lambda_param_src).collect();
357            let body = match lambda_body_src(lambda) {
358                LambdaBodySrc::Block(b) => format_block_oneline(b),
359                LambdaBodySrc::Expr(e) => match &e.kind {
360                    ExprKind::Block(b) => format_block_oneline(b),
361                    _ => expr_with_prec(e, 0),
362                },
363            };
364            format!("({}) => {}", params.join(", "), body)
365        }
366        ExprKind::Block(b) => format_block_oneline(b),
367        ExprKind::If {
368            cond,
369            then_block,
370            else_block,
371        } => {
372            // v0.146 (ADR 0170): an `if` with no `else` carries a synthesised
373            // unit else-branch — omit it so the else-less form round-trips.
374            if else_block.is_synth_unit() {
375                format!(
376                    "if {} {}",
377                    expr_with_prec(cond, 0),
378                    format_block_oneline(then_block),
379                )
380            } else if let Some(chained) = else_if_tail(else_block) {
381                format!(
382                    "if {} {} else {}",
383                    expr_with_prec(cond, 0),
384                    format_block_oneline(then_block),
385                    expr_with_prec(chained, 0),
386                )
387            } else {
388                format!(
389                    "if {} {} else {}",
390                    expr_with_prec(cond, 0),
391                    format_block_oneline(then_block),
392                    format_block_oneline(else_block),
393                )
394            }
395        }
396        ExprKind::Ok(v) => format!("Ok({})", expr_with_prec(v, 0)),
397        ExprKind::Err(v) => format!("Err({})", expr_with_prec(v, 0)),
398        ExprKind::Some(v) => format!("Some({})", expr_with_prec(v, 0)),
399        ExprKind::None => "None".to_string(),
400        ExprKind::Question(v) => format!("{}?", expr_with_prec(v, 8)),
401        ExprKind::RecordConstruction {
402            type_name, fields, ..
403        } => {
404            let parts: Vec<String> = fields
405                .iter()
406                .map(|f| match &f.value {
407                    Some(v) => format!("{}: {}", f.name.name, expr_with_prec(v, 0)),
408                    None => f.name.name.clone(),
409                })
410                .collect();
411            if parts.is_empty() {
412                format!("{} {{}}", type_name.name)
413            } else {
414                format!("{} {{ {} }}", type_name.name, parts.join(", "))
415            }
416        }
417        ExprKind::FieldAccess { receiver, field } => {
418            format!("{}.{}", expr_with_prec(receiver, 8), field.name)
419        }
420        ExprKind::MethodCall {
421            receiver,
422            method,
423            type_args,
424            args,
425            ..
426        } => {
427            let targs = if type_args.is_empty() {
428                String::new()
429            } else {
430                format!(
431                    "[{}]",
432                    type_args
433                        .iter()
434                        .map(type_ref_to_string)
435                        .collect::<Vec<_>>()
436                        .join(", ")
437                )
438            };
439            let parts: Vec<String> = args.iter().map(|a| expr_with_prec(a, 0)).collect();
440            format!(
441                "{}.{}{targs}({})",
442                expr_with_prec(receiver, 8),
443                method.name,
444                parts.join(", ")
445            )
446        }
447        ExprKind::Match { discriminant, arms } => {
448            let mut out = String::new();
449            out.push_str("match ");
450            out.push_str(&expr_with_prec(discriminant, 0));
451            out.push_str(" {\n");
452            for arm in arms {
453                out.push('\t');
454                out.push_str(&pattern_to_string(&arm.pattern));
455                if let Some(guard) = &arm.guard {
456                    out.push_str(" if ");
457                    out.push_str(&expr_with_prec(guard, 0));
458                }
459                out.push_str(" => ");
460                match &arm.body {
461                    MatchBody::Expr(e) => out.push_str(&expr_with_prec(e, 0)),
462                    MatchBody::Block(b) => out.push_str(&format_block_oneline(b)),
463                }
464                out.push_str(",\n");
465            }
466            out.push('}');
467            out
468        }
469        ExprKind::Is { value, pattern } => {
470            format!(
471                "{} is {}",
472                expr_with_prec(value, 4),
473                is_pattern_src(pattern)
474            )
475        }
476        ExprKind::RecordSpread {
477            type_name,
478            base,
479            overrides,
480            ..
481        } => {
482            let mut parts = vec![format!("...{}", expr_with_prec(base, 0))];
483            for f in overrides {
484                if let Some(v) = &f.value {
485                    parts.push(format!("{}: {}", f.name.name, expr_with_prec(v, 0)));
486                } else {
487                    parts.push(f.name.name.clone());
488                }
489            }
490            let body = parts.join(", ");
491            match type_name {
492                Some(tn) => format!("{} {{ {} }}", tn.name, body),
493                None => format!("{{ {} }}", body),
494            }
495        }
496        ExprKind::EffectPure(v) => format!("Effect.pure({})", expr_with_prec(v, 0)),
497        ExprKind::Expect(v) => format!("expect {}", expr_with_prec(v, 0)),
498        ExprKind::Val { type_ref, args, .. } => {
499            let t = type_ref_to_string(type_ref);
500            if args.is_empty() {
501                format!("Val[{t}]")
502            } else {
503                let a = args
504                    .iter()
505                    .map(|x| expr_with_prec(x, 0))
506                    .collect::<Vec<_>>()
507                    .join(", ");
508                format!("Val[{t}]({a})")
509            }
510        }
511        ExprKind::Wire(inner) => format!("Wire({})", expr_with_prec(inner, 0)),
512        ExprKind::Trace { cap, op } => format!("trace({}.{})", cap.name, op.name),
513        // #1706: the fault claim — the call, then the contextual `faults`.
514        // #1812: the call-site principal sits between the call and `faults`,
515        // in the same position as on an effect-let.
516        ExprKind::Faults(f) => match &f.principal {
517            Some(p) => format!(
518                "{} {} faults",
519                expr_with_prec(&f.call, 0),
520                call_site_actor_src(p)
521            ),
522            None => format!("{} faults", expr_with_prec(&f.call, 0)),
523        },
524        ExprKind::Observation(o) => {
525            let subject = format!("{}.{}", o.cap.name, o.op.name);
526            match &o.matcher {
527                ObservationMatcher::NeverCalled => format!("{subject} never called"),
528                ObservationMatcher::Before { cap, op } => {
529                    format!("{subject} before {}.{}", cap.name, op.name)
530                }
531                ObservationMatcher::Called { count, with_pred } => {
532                    let mut s = format!("{subject} called");
533                    if let Some(c) = count {
534                        if matches!(c.kind, ExprKind::IntLit { value: 1, .. }) {
535                            s.push_str(" once");
536                        } else {
537                            s.push_str(&format!(" {} times", expr_with_prec(c, 0)));
538                        }
539                    }
540                    if let Some(p) = with_pred {
541                        s.push_str(&format!(" with {}", expr_with_prec(p, 0)));
542                    }
543                    s
544                }
545            }
546        }
547    }
548}
549
550/// #2891: the `if` an `else if` desugars to. The parser wraps it in a
551/// brace-less block with the same span as the `if` itself, which tells it apart
552/// from an explicit `else { if … }` (whose block spans its braces).
553pub fn else_if_tail(b: &Block) -> Option<&Expr> {
554    (b.statements.is_empty()
555        && b.tail_leading_comments.is_empty()
556        && !b.implicit_tail
557        && matches!(b.tail.kind, ExprKind::If { .. })
558        && b.span == b.tail.span)
559        .then_some(&*b.tail)
560}
561
562pub fn pattern_to_string(p: &Pattern) -> String {
563    match p {
564        Pattern::Wildcard(_) => "_".to_string(),
565        // ADR 0169: a bare name binding renders as its identifier.
566        Pattern::Binding(id) => id.name.clone(),
567        // v0.130: literal patterns render as their source literal.
568        Pattern::Literal { value, text, .. } => match value {
569            LiteralValue::Int(n) => text.clone().unwrap_or_else(|| n.to_string()),
570            LiteralValue::Str(s) => format!("\"{}\"", escape_string(s)),
571            LiteralValue::Bool(b) => b.to_string(),
572        },
573        // #2699: `lo..hi`, `lo..`, `..hi`, with no spaces.
574        Pattern::Range(r) => r.describe_source(),
575        // #472: `p where predicate` — the inner pattern, then the predicate
576        // list rendered the same way a `type X = Base where P` refinement is.
577        Pattern::Refined {
578            inner, predicate, ..
579        } => format!(
580            "{} where {}",
581            pattern_to_string(inner),
582            refinement_to_string(predicate)
583        ),
584        Pattern::Variant {
585            type_name,
586            variant,
587            bindings,
588            ..
589        } => {
590            let name_part = match type_name {
591                Some(t) => format!("{}.{}", t.name, variant.name),
592                None => variant.name.clone(),
593            };
594            if bindings.is_empty() {
595                name_part
596            } else {
597                // ADR 0169: each payload binding is a full sub-pattern.
598                let parts: Vec<String> = bindings
599                    .iter()
600                    .map(|b| match &b.kind {
601                        PatternBindingKind::Positional { pattern } => pattern_to_string(pattern),
602                        PatternBindingKind::Named { field, pattern } => {
603                            format!("{}: {}", field.name, pattern_to_string(pattern))
604                        }
605                    })
606                    .collect();
607                format!("{}({})", name_part, parts.join(", "))
608            }
609        }
610        // #2641: a record pattern renders like a record literal; the trailing
611        // `..` is part of the syntax, not an optional marker.
612        Pattern::Record {
613            type_name, fields, ..
614        } => {
615            let mut parts: Vec<String> = fields
616                .iter()
617                .map(|(name, sub)| match sub {
618                    Some(p) => format!("{}: {}", name.name, pattern_to_string(p)),
619                    None => name.name.clone(),
620                })
621                .collect();
622            parts.push("..".to_string());
623            format!("{} {{ {} }}", type_name.name, parts.join(", "))
624        }
625        // #2680: a list pattern renders like a list literal, with the rest
626        // marker last.
627        Pattern::List { elems, rest, .. } => {
628            let mut parts: Vec<String> = elems.iter().map(pattern_to_string).collect();
629            if let Some(r) = rest {
630                parts.push(match &r.binder {
631                    Some(b) => format!("..{}", b.name),
632                    None => "..".to_string(),
633                });
634            }
635            format!("[{}]", parts.join(", "))
636        }
637        // #474: an or-pattern renders as its alternatives joined by `|`.
638        Pattern::Or(alts, _) => alts
639            .iter()
640            .map(pattern_to_string)
641            .collect::<Vec<_>>()
642            .join(" | "),
643    }
644}
645
646/// #2641: a pattern after `is`. A record pattern there must be parenthesised —
647/// its `{` would otherwise read as the block that follows the condition — so a
648/// record, or an or-pattern with a record alternative, keeps the parentheses
649/// the parser dropped as transparent grouping.
650pub fn is_pattern_src(p: &Pattern) -> String {
651    let src = pattern_to_string(p);
652    let has_record = match p {
653        Pattern::Record { .. } => true,
654        Pattern::Or(alts, _) => alts.iter().any(|a| matches!(a, Pattern::Record { .. })),
655        _ => false,
656    };
657    if has_record { format!("({src})") } else { src }
658}
659
660/// #981: whether a block's `()` tail should be omitted rather than printed.
661///
662/// A `()` tail — whether the parser synthesised it ([`Block::implicit_tail`])
663/// or the user wrote it out explicitly — is exactly the block's default
664/// value, so dropping it is loss-free: the parser re-derives the same
665/// implicit unit tail either way (v0.7 / v0.146, ADR 0170).
666///
667/// Omitting it is not just an idempotency nicety, it is required for
668/// correctness whenever anything precedes the tail (a statement, a `case`'s
669/// `stub` clause): Bynk has no statement terminator, so a printed `()`
670/// immediately after a preceding line's last token re-attaches to it as a
671/// zero-arg call on re-parse (`x` / `()` → `x()`) rather than staying two
672/// separate constructs. #735 only special-cased the *implicit*-tail shape;
673/// #981 found the identical corruption for an *explicit* `()` tail (e.g. the
674/// last statement of a `match` arm's block), which is exactly as dangerous
675/// once anything comes before it. So this covers both, structurally, rather
676/// than special-casing another syntactic position.
677pub fn omit_unit_tail(b: &Block) -> bool {
678    matches!(b.tail.kind, ExprKind::UnitLit) && b.tail_leading_comments.is_empty()
679}
680
681fn format_block_oneline(b: &Block) -> String {
682    if b.statements.is_empty() {
683        // v0.146 (ADR 0170): an empty block with a synthesised `()` tail prints
684        // as `{}` — printing `{ () }` would not round-trip against the parser's
685        // implicit-tail synthesis.
686        if b.implicit_tail {
687            "{}".to_string()
688        } else {
689            format!("{{ {} }}", expr_with_prec(&b.tail, 0))
690        }
691    } else {
692        // Multi-line block — render with newlines and tab indentation.
693        let mut out = String::from("{\n");
694        block_body_lines(b, &mut out);
695        out.push('}');
696        out
697    }
698}
699
700/// The string form of [`Formatter::format_block_body`]: each statement and
701/// the tail on its own tab-indented line, a `let … else` printed back as
702/// written (#2010).
703fn block_body_lines(b: &Block, out: &mut String) {
704    for stmt in &b.statements {
705        if let Some((l, effectful, le)) = let_else_binding(stmt)
706            && let ExprKind::Match { arms, .. } = &b.tail.kind
707            && let Some(MatchBody::Block(rest)) = arms.first().map(|a| &a.body)
708        {
709            out.push('\t');
710            out.push_str(&let_else_to_string(l, effectful, le, arms));
711            out.push('\n');
712            block_body_lines(rest, out);
713            return;
714        }
715        out.push('\t');
716        out.push_str(&stmt_to_string(stmt));
717        out.push('\n');
718    }
719    if !omit_unit_tail(b) {
720        out.push('\t');
721        out.push_str(&expr_with_prec(&b.tail, 0));
722        out.push('\n');
723    }
724}
725
726/// #2712: a lambda's body as the author wrote it.
727pub enum LambdaBodySrc<'a> {
728    Expr(&'a Expr),
729    Block(&'a Block),
730}
731
732/// #2712: the body a lambda with pattern parameters was written with. The
733/// parser wraps it, one level per pattern parameter (first outermost), in
734/// `{ let __letElse = __lambdaArg; match __letElse { P => { <rest> } } }`;
735/// this peels those back off. A lambda with no pattern parameter is untouched.
736pub fn lambda_body_src(lambda: &LambdaExpr) -> LambdaBodySrc<'_> {
737    let patterns = lambda.params.iter().filter(|p| p.pattern.is_some()).count();
738    if patterns == 0 {
739        return LambdaBodySrc::Expr(&lambda.body);
740    }
741    let ExprKind::Block(block) = &lambda.body.kind else {
742        return LambdaBodySrc::Expr(&lambda.body);
743    };
744    let mut block: &Block = block;
745    for _ in 0..patterns {
746        let ExprKind::Match { arms, .. } = &block.tail.kind else {
747            return LambdaBodySrc::Expr(&lambda.body);
748        };
749        let Some(MatchBody::Block(rest)) = arms.first().map(|a| &a.body) else {
750            return LambdaBodySrc::Expr(&lambda.body);
751        };
752        block = rest;
753    }
754    if lambda.block_body {
755        LambdaBodySrc::Block(block)
756    } else {
757        LambdaBodySrc::Expr(&block.tail)
758    }
759}
760
761/// #2010: the hidden binding a `let … else` desugars into, if `s` is one —
762/// with whether it was `<-` and the sugar's own marker.
763pub fn let_else_binding(s: &Statement) -> Option<(&LetStmt, bool, &LetElse)> {
764    match s {
765        Statement::Let(l) => l.let_else.as_ref().map(|le| (l, false, le)),
766        Statement::EffectLet(l) => l.let_else.as_ref().map(|le| (l, true, le)),
767        _ => None,
768    }
769}
770
771/// #2010: a `let … else` pattern as source. After `let`, a bare name is a
772/// binding, not a pattern (`let Missing | Late = p` would re-parse as a
773/// binding named `Missing`), so a pattern whose text opens with a name not
774/// followed by `(`, `.` or `{` keeps the parentheses its author had to write.
775/// `None`, `true` and `false` are keywords, not names, so they need none.
776pub fn let_pattern_src(p: &Pattern) -> String {
777    let src = pattern_to_string(p);
778    let name_len = src
779        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
780        .unwrap_or(src.len());
781    let opens_with_name = src.starts_with(|c: char| c.is_ascii_alphabetic())
782        && !matches!(&src[..name_len], "None" | "true" | "false");
783    let then = src[name_len..].chars().next();
784    // #2654: `Name {` opens a record pattern, so it needs no parentheses.
785    let opens_record = src[name_len..].trim_start().starts_with('{');
786    if opens_with_name && !opens_record && !matches!(then, Some('(' | '.')) {
787        format!("({src})")
788    } else {
789        src
790    }
791}
792
793/// #2010: the single-line string form of a `let … else` (see
794/// [`Formatter::format_let_else`]).
795fn let_else_to_string(l: &LetStmt, effectful: bool, le: &LetElse, arms: &[MatchArm]) -> String {
796    let mut out = format!(
797        "let {} {} {}",
798        let_pattern_src(&arms[0].pattern),
799        if effectful { "<-" } else { "=" },
800        expr_with_prec(&l.value, 0)
801    );
802    if let Some(p) = &l.principal {
803        out.push_str(&format!(" {}", call_site_actor_src(p)));
804    }
805    match (le.form, arms.get(1).map(|a| &a.body)) {
806        (LetElseForm::Plain, _) => {}
807        (LetElseForm::Block, Some(MatchBody::Block(otherwise))) => {
808            out.push_str(&format!(" else {}", format_block_oneline(otherwise)));
809        }
810        _ => {
811            let rendered: Vec<String> = arms[1..]
812                .iter()
813                .map(|a| {
814                    let body = match &a.body {
815                        MatchBody::Expr(e) => expr_with_prec(e, 0),
816                        MatchBody::Block(b) => format_block_oneline(b),
817                    };
818                    let guard = a
819                        .guard
820                        .as_ref()
821                        .map(|g| format!(" if {}", expr_with_prec(g, 0)))
822                        .unwrap_or_default();
823                    format!("{}{guard} => {body}", pattern_to_string(&a.pattern))
824                })
825                .collect();
826            out.push_str(&format!(" else match {{ {} }}", rendered.join(", ")));
827        }
828    }
829    out
830}
831
832fn stmt_to_string(s: &Statement) -> String {
833    match s {
834        Statement::Let(l) => {
835            let mut out = format!("let {}", l.name.name);
836            if let Some(t) = &l.type_annot {
837                out.push_str(&format!(": {}", type_ref_to_string(t)));
838            }
839            out.push_str(&format!(" = {}", expr_with_prec(&l.value, 0)));
840            out
841        }
842        Statement::EffectLet(l) => {
843            let mut out = format!("let {}", l.name.name);
844            if let Some(t) = &l.type_annot {
845                out.push_str(&format!(": {}", type_ref_to_string(t)));
846            }
847            out.push_str(&format!(" <- {}", expr_with_prec(&l.value, 0)));
848            if let Some(p) = &l.principal {
849                out.push_str(&format!(" {}", call_site_actor_src(p)));
850            }
851            out
852        }
853        Statement::Expect(a) => format!(
854            "expect {}{}",
855            expr_with_prec(&a.value, 0),
856            a.snapshot.as_ref().map_or(String::new(), |c| format!(
857                " matches snapshot \"{}\"",
858                escape_string(&c.name)
859            ))
860        ),
861        Statement::Send(s) => format!("~> {}", expr_with_prec(&s.value, 0)),
862        Statement::Do(d) => format!("do {}", expr_with_prec(&d.value, 0)),
863        Statement::Assign(a) => format!("{} := {}", a.target.name, expr_with_prec(&a.value, 0)),
864        Statement::Advance(a) => {
865            format!("advance {} by {}", a.cap.name, expr_with_prec(&a.amount, 0))
866        }
867        Statement::Par(p) => {
868            let body: Vec<String> = p.statements.iter().map(stmt_to_string).collect();
869            format!("par {{ {} }}", body.join(" "))
870        }
871    }
872}
873
874/// #2712: a lambda parameter as source — the author's record pattern when it
875/// has one (its hidden binder never prints), else the name and any annotation.
876pub fn lambda_param_src(p: &LambdaParam) -> String {
877    let name = if p.is_wildcard() { "_" } else { &p.name.name };
878    match (&p.pattern, &p.type_ref) {
879        (Some(pattern), _) => pattern_to_string(pattern),
880        (None, Some(tr)) => format!("{name}: {}", type_ref_to_string(tr)),
881        (None, None) => name.to_string(),
882    }
883}