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    match &p.kind {
214        PredKind::Matches(re) => format!("Matches({})", literal_text(matches_argument_span(p), re)),
215        PredKind::InRange(a, b) => format!("InRange({}, {})", a.value, b.value),
216        PredKind::InRangeF(a, b) => format!("InRange({}, {})", a.lexeme, b.lexeme),
217        PredKind::InRangeD(a, b) => format!("InRange({}, {})", a.lexeme, b.lexeme),
218        PredKind::MinLength(n) => format!("MinLength({n})"),
219        PredKind::MaxLength(n) => format!("MaxLength({n})"),
220        PredKind::Length(n) => format!("Length({n})"),
221        PredKind::NonNegative => "NonNegative".to_string(),
222        PredKind::Positive => "Positive".to_string(),
223        PredKind::NonEmpty => "NonEmpty".to_string(),
224        PredKind::IsoDate => "IsoDate".to_string(),
225    }
226}
227
228pub fn escape_string(s: &str) -> String {
229    let mut out = String::with_capacity(s.len());
230    for ch in s.chars() {
231        match ch {
232            '\\' => out.push_str("\\\\"),
233            '"' => out.push_str("\\\""),
234            '\n' => out.push_str("\\n"),
235            '\t' => out.push_str("\\t"),
236            // #2619: a CR or NUL cannot sit raw in a literal, nor can other control
237            // characters; they print as `\u{…}`. Anything else (a ZWJ, an
238            // RLM, a no-break space) is written as it was.
239            '\r' => out.push_str("\\r"),
240            '\0' => out.push_str("\\0"),
241            c if c.is_control() => {
242                out.push_str(&format!("\\u{{{:X}}}", c as u32));
243            }
244            c => out.push(c),
245        }
246    }
247    out
248}
249
250pub fn expr_to_string(e: &Expr) -> String {
251    expr_with_prec(e, 0)
252}
253
254// Operator precedences (smaller = binds looser):
255//   1: || 2: && 3: == != 4: < <= > >= 5: + - 6: * / 7: unary ! - 8: postfix . () ?
256pub fn binop_prec(op: BinOp) -> u8 {
257    match op {
258        // v0.80: `implies` is the lowest-precedence binary operator (below `||`).
259        BinOp::Implies => 0,
260        BinOp::Or => 1,
261        BinOp::And => 2,
262        BinOp::Eq | BinOp::NotEq => 3,
263        BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq => 4,
264        BinOp::Add | BinOp::Sub => 5,
265        BinOp::Mul | BinOp::Div => 6,
266    }
267}
268
269pub fn expr_with_prec(e: &Expr, parent_prec: u8) -> String {
270    match &e.kind {
271        // v0.142 (ADR 0166): the stored lexeme verbatim — formatting must not
272        // normalise away the author's `_` digit separators.
273        ExprKind::IntLit { lexeme, .. } => lexeme.clone(),
274        // v0.21: the stored lexeme verbatim — formatting must not normalise.
275        ExprKind::FloatLit { lexeme, .. } => lexeme.clone(),
276        ExprKind::DecimalLit { lexeme, .. } => lexeme.clone(),
277        // v0.86 (ADR 0112): a duration literal `<value>.<unit>`.
278        ExprKind::DurationLit { value, unit, .. } => format!("{value}.{}", unit.name()),
279        ExprKind::StrLit(s) => {
280            written_string_form(e, Some(s)).unwrap_or_else(|| format!("\"{}\"", escape_string(s)))
281        }
282        // v0.43: re-emit the interpolated string — chunks re-escaped, each
283        // hole as `\(expr)`. Re-escaping a chunk's literal `\` to `\\` keeps a
284        // source `\\(` (an escaped `\(`) round-tripping as text, not a hole.
285        ExprKind::InterpStr(parts) => {
286            if let Some(written) = written_string_form(e, None) {
287                return written;
288            }
289            let mut out = String::from("\"");
290            for part in parts {
291                match part {
292                    InterpPart::Chunk(text) => out.push_str(&escape_string(text)),
293                    InterpPart::Hole(hole) => {
294                        out.push_str(&format!("\\({})", expr_with_prec(hole, 0)));
295                    }
296                }
297            }
298            out.push('"');
299            out
300        }
301        ExprKind::BoolLit(b) => b.to_string(),
302        ExprKind::UnitLit => "()".to_string(),
303        ExprKind::Ident(id) => id.name.clone(),
304        ExprKind::ListLit(elems) => format!(
305            "[{}]",
306            elems
307                .iter()
308                .map(expr_to_string)
309                .collect::<Vec<_>>()
310                .join(", ")
311        ),
312        ExprKind::Call {
313            name,
314            type_args,
315            args,
316        } => {
317            let targs = if type_args.is_empty() {
318                String::new()
319            } else {
320                format!(
321                    "[{}]",
322                    type_args
323                        .iter()
324                        .map(type_ref_to_string)
325                        .collect::<Vec<_>>()
326                        .join(", ")
327                )
328            };
329            let parts: Vec<String> = args.iter().map(|a| expr_with_prec(a, 0)).collect();
330            format!("{}{}({})", name.name, targs, parts.join(", "))
331        }
332        ExprKind::BinOp(op, l, r) => {
333            let prec = binop_prec(*op);
334            let inner = format!(
335                "{} {} {}",
336                expr_with_prec(l, prec),
337                op.name(),
338                expr_with_prec(r, prec + 1)
339            );
340            if prec < parent_prec {
341                format!("({inner})")
342            } else {
343                inner
344            }
345        }
346        ExprKind::UnaryOp(op, inner) => {
347            // Unary binds tightly (prec 7).
348            let s = format!("{}{}", op.name(), expr_with_prec(inner, 7));
349            if parent_prec > 7 { format!("({s})") } else { s }
350        }
351        ExprKind::Paren(inner) => format!("({})", expr_with_prec(inner, 0)),
352        // v0.20a: a lambda prints as `(params) => body`.
353        ExprKind::Lambda(lambda) => {
354            let params: Vec<String> = lambda.params.iter().map(lambda_param_src).collect();
355            let body = match lambda_body_src(lambda) {
356                LambdaBodySrc::Block(b) => format_block_oneline(b),
357                LambdaBodySrc::Expr(e) => match &e.kind {
358                    ExprKind::Block(b) => format_block_oneline(b),
359                    _ => expr_with_prec(e, 0),
360                },
361            };
362            format!("({}) => {}", params.join(", "), body)
363        }
364        ExprKind::Block(b) => format_block_oneline(b),
365        ExprKind::If {
366            cond,
367            then_block,
368            else_block,
369        } => {
370            // v0.146 (ADR 0170): an `if` with no `else` carries a synthesised
371            // unit else-branch — omit it so the else-less form round-trips.
372            if else_block.is_synth_unit() {
373                format!(
374                    "if {} {}",
375                    expr_with_prec(cond, 0),
376                    format_block_oneline(then_block),
377                )
378            } else if let Some(chained) = else_if_tail(else_block) {
379                format!(
380                    "if {} {} else {}",
381                    expr_with_prec(cond, 0),
382                    format_block_oneline(then_block),
383                    expr_with_prec(chained, 0),
384                )
385            } else {
386                format!(
387                    "if {} {} else {}",
388                    expr_with_prec(cond, 0),
389                    format_block_oneline(then_block),
390                    format_block_oneline(else_block),
391                )
392            }
393        }
394        ExprKind::Ok(v) => format!("Ok({})", expr_with_prec(v, 0)),
395        ExprKind::Err(v) => format!("Err({})", expr_with_prec(v, 0)),
396        ExprKind::Some(v) => format!("Some({})", expr_with_prec(v, 0)),
397        ExprKind::None => "None".to_string(),
398        ExprKind::Question(v) => format!("{}?", expr_with_prec(v, 8)),
399        ExprKind::RecordConstruction { type_name, fields } => {
400            let parts: Vec<String> = fields
401                .iter()
402                .map(|f| match &f.value {
403                    Some(v) => format!("{}: {}", f.name.name, expr_with_prec(v, 0)),
404                    None => f.name.name.clone(),
405                })
406                .collect();
407            if parts.is_empty() {
408                format!("{} {{}}", type_name.name)
409            } else {
410                format!("{} {{ {} }}", type_name.name, parts.join(", "))
411            }
412        }
413        ExprKind::FieldAccess { receiver, field } => {
414            format!("{}.{}", expr_with_prec(receiver, 8), field.name)
415        }
416        ExprKind::MethodCall {
417            receiver,
418            method,
419            type_args,
420            args,
421        } => {
422            let targs = if type_args.is_empty() {
423                String::new()
424            } else {
425                format!(
426                    "[{}]",
427                    type_args
428                        .iter()
429                        .map(type_ref_to_string)
430                        .collect::<Vec<_>>()
431                        .join(", ")
432                )
433            };
434            let parts: Vec<String> = args.iter().map(|a| expr_with_prec(a, 0)).collect();
435            format!(
436                "{}.{}{targs}({})",
437                expr_with_prec(receiver, 8),
438                method.name,
439                parts.join(", ")
440            )
441        }
442        ExprKind::Match { discriminant, arms } => {
443            let mut out = String::new();
444            out.push_str("match ");
445            out.push_str(&expr_with_prec(discriminant, 0));
446            out.push_str(" {\n");
447            for arm in arms {
448                out.push('\t');
449                out.push_str(&pattern_to_string(&arm.pattern));
450                if let Some(guard) = &arm.guard {
451                    out.push_str(" if ");
452                    out.push_str(&expr_with_prec(guard, 0));
453                }
454                out.push_str(" => ");
455                match &arm.body {
456                    MatchBody::Expr(e) => out.push_str(&expr_with_prec(e, 0)),
457                    MatchBody::Block(b) => out.push_str(&format_block_oneline(b)),
458                }
459                out.push_str(",\n");
460            }
461            out.push('}');
462            out
463        }
464        ExprKind::Is { value, pattern } => {
465            format!(
466                "{} is {}",
467                expr_with_prec(value, 4),
468                is_pattern_src(pattern)
469            )
470        }
471        ExprKind::RecordSpread {
472            type_name,
473            base,
474            overrides,
475        } => {
476            let mut parts = vec![format!("...{}", expr_with_prec(base, 0))];
477            for f in overrides {
478                if let Some(v) = &f.value {
479                    parts.push(format!("{}: {}", f.name.name, expr_with_prec(v, 0)));
480                } else {
481                    parts.push(f.name.name.clone());
482                }
483            }
484            let body = parts.join(", ");
485            match type_name {
486                Some(tn) => format!("{} {{ {} }}", tn.name, body),
487                None => format!("{{ {} }}", body),
488            }
489        }
490        ExprKind::EffectPure(v) => format!("Effect.pure({})", expr_with_prec(v, 0)),
491        ExprKind::Expect(v) => format!("expect {}", expr_with_prec(v, 0)),
492        ExprKind::Val { type_ref, args } => {
493            let t = type_ref_to_string(type_ref);
494            if args.is_empty() {
495                format!("Val[{t}]")
496            } else {
497                let a = args
498                    .iter()
499                    .map(|x| expr_with_prec(x, 0))
500                    .collect::<Vec<_>>()
501                    .join(", ");
502                format!("Val[{t}]({a})")
503            }
504        }
505        ExprKind::Wire(inner) => format!("Wire({})", expr_with_prec(inner, 0)),
506        ExprKind::Trace { cap, op } => format!("trace({}.{})", cap.name, op.name),
507        // #1706: the fault claim — the call, then the contextual `faults`.
508        // #1812: the call-site principal sits between the call and `faults`,
509        // in the same position as on an effect-let.
510        ExprKind::Faults(f) => match &f.principal {
511            Some(p) => format!(
512                "{} {} faults",
513                expr_with_prec(&f.call, 0),
514                call_site_actor_src(p)
515            ),
516            None => format!("{} faults", expr_with_prec(&f.call, 0)),
517        },
518        ExprKind::Observation(o) => {
519            let subject = format!("{}.{}", o.cap.name, o.op.name);
520            match &o.matcher {
521                ObservationMatcher::NeverCalled => format!("{subject} never called"),
522                ObservationMatcher::Before { cap, op } => {
523                    format!("{subject} before {}.{}", cap.name, op.name)
524                }
525                ObservationMatcher::Called { count, with_pred } => {
526                    let mut s = format!("{subject} called");
527                    if let Some(c) = count {
528                        if matches!(c.kind, ExprKind::IntLit { value: 1, .. }) {
529                            s.push_str(" once");
530                        } else {
531                            s.push_str(&format!(" {} times", expr_with_prec(c, 0)));
532                        }
533                    }
534                    if let Some(p) = with_pred {
535                        s.push_str(&format!(" with {}", expr_with_prec(p, 0)));
536                    }
537                    s
538                }
539            }
540        }
541    }
542}
543
544/// #2891: the `if` an `else if` desugars to. The parser wraps it in a
545/// brace-less block with the same span as the `if` itself, which tells it apart
546/// from an explicit `else { if … }` (whose block spans its braces).
547pub fn else_if_tail(b: &Block) -> Option<&Expr> {
548    (b.statements.is_empty()
549        && b.tail_leading_comments.is_empty()
550        && !b.implicit_tail
551        && matches!(b.tail.kind, ExprKind::If { .. })
552        && b.span == b.tail.span)
553        .then_some(&*b.tail)
554}
555
556pub fn pattern_to_string(p: &Pattern) -> String {
557    match p {
558        Pattern::Wildcard(_) => "_".to_string(),
559        // ADR 0169: a bare name binding renders as its identifier.
560        Pattern::Binding(id) => id.name.clone(),
561        // v0.130: literal patterns render as their source literal.
562        Pattern::Literal { value, text, .. } => match value {
563            LiteralValue::Int(n) => text.clone().unwrap_or_else(|| n.to_string()),
564            LiteralValue::Str(s) => format!("\"{}\"", escape_string(s)),
565            LiteralValue::Bool(b) => b.to_string(),
566        },
567        // #2699: `lo..hi`, `lo..`, `..hi`, with no spaces.
568        Pattern::Range(r) => r.describe_source(),
569        // #472: `p where predicate` — the inner pattern, then the predicate
570        // list rendered the same way a `type X = Base where P` refinement is.
571        Pattern::Refined {
572            inner, predicate, ..
573        } => format!(
574            "{} where {}",
575            pattern_to_string(inner),
576            refinement_to_string(predicate)
577        ),
578        Pattern::Variant {
579            type_name,
580            variant,
581            bindings,
582            ..
583        } => {
584            let name_part = match type_name {
585                Some(t) => format!("{}.{}", t.name, variant.name),
586                None => variant.name.clone(),
587            };
588            if bindings.is_empty() {
589                name_part
590            } else {
591                // ADR 0169: each payload binding is a full sub-pattern.
592                let parts: Vec<String> = bindings
593                    .iter()
594                    .map(|b| match &b.kind {
595                        PatternBindingKind::Positional { pattern } => pattern_to_string(pattern),
596                        PatternBindingKind::Named { field, pattern } => {
597                            format!("{}: {}", field.name, pattern_to_string(pattern))
598                        }
599                    })
600                    .collect();
601                format!("{}({})", name_part, parts.join(", "))
602            }
603        }
604        // #2641: a record pattern renders like a record literal; the trailing
605        // `..` is part of the syntax, not an optional marker.
606        Pattern::Record {
607            type_name, fields, ..
608        } => {
609            let mut parts: Vec<String> = fields
610                .iter()
611                .map(|(name, sub)| match sub {
612                    Some(p) => format!("{}: {}", name.name, pattern_to_string(p)),
613                    None => name.name.clone(),
614                })
615                .collect();
616            parts.push("..".to_string());
617            format!("{} {{ {} }}", type_name.name, parts.join(", "))
618        }
619        // #2680: a list pattern renders like a list literal, with the rest
620        // marker last.
621        Pattern::List { elems, rest, .. } => {
622            let mut parts: Vec<String> = elems.iter().map(pattern_to_string).collect();
623            if let Some(r) = rest {
624                parts.push(match &r.binder {
625                    Some(b) => format!("..{}", b.name),
626                    None => "..".to_string(),
627                });
628            }
629            format!("[{}]", parts.join(", "))
630        }
631        // #474: an or-pattern renders as its alternatives joined by `|`.
632        Pattern::Or(alts, _) => alts
633            .iter()
634            .map(pattern_to_string)
635            .collect::<Vec<_>>()
636            .join(" | "),
637    }
638}
639
640/// #2641: a pattern after `is`. A record pattern there must be parenthesised —
641/// its `{` would otherwise read as the block that follows the condition — so a
642/// record, or an or-pattern with a record alternative, keeps the parentheses
643/// the parser dropped as transparent grouping.
644pub fn is_pattern_src(p: &Pattern) -> String {
645    let src = pattern_to_string(p);
646    let has_record = match p {
647        Pattern::Record { .. } => true,
648        Pattern::Or(alts, _) => alts.iter().any(|a| matches!(a, Pattern::Record { .. })),
649        _ => false,
650    };
651    if has_record { format!("({src})") } else { src }
652}
653
654/// #981: whether a block's `()` tail should be omitted rather than printed.
655///
656/// A `()` tail — whether the parser synthesised it ([`Block::implicit_tail`])
657/// or the user wrote it out explicitly — is exactly the block's default
658/// value, so dropping it is loss-free: the parser re-derives the same
659/// implicit unit tail either way (v0.7 / v0.146, ADR 0170).
660///
661/// Omitting it is not just an idempotency nicety, it is required for
662/// correctness whenever anything precedes the tail (a statement, a `case`'s
663/// `stub` clause): Bynk has no statement terminator, so a printed `()`
664/// immediately after a preceding line's last token re-attaches to it as a
665/// zero-arg call on re-parse (`x` / `()` → `x()`) rather than staying two
666/// separate constructs. #735 only special-cased the *implicit*-tail shape;
667/// #981 found the identical corruption for an *explicit* `()` tail (e.g. the
668/// last statement of a `match` arm's block), which is exactly as dangerous
669/// once anything comes before it. So this covers both, structurally, rather
670/// than special-casing another syntactic position.
671pub fn omit_unit_tail(b: &Block) -> bool {
672    matches!(b.tail.kind, ExprKind::UnitLit) && b.tail_leading_comments.is_empty()
673}
674
675fn format_block_oneline(b: &Block) -> String {
676    if b.statements.is_empty() {
677        // v0.146 (ADR 0170): an empty block with a synthesised `()` tail prints
678        // as `{}` — printing `{ () }` would not round-trip against the parser's
679        // implicit-tail synthesis.
680        if b.implicit_tail {
681            "{}".to_string()
682        } else {
683            format!("{{ {} }}", expr_with_prec(&b.tail, 0))
684        }
685    } else {
686        // Multi-line block — render with newlines and tab indentation.
687        let mut out = String::from("{\n");
688        block_body_lines(b, &mut out);
689        out.push('}');
690        out
691    }
692}
693
694/// The string form of [`Formatter::format_block_body`]: each statement and
695/// the tail on its own tab-indented line, a `let … else` printed back as
696/// written (#2010).
697fn block_body_lines(b: &Block, out: &mut String) {
698    for stmt in &b.statements {
699        if let Some((l, effectful, le)) = let_else_binding(stmt)
700            && let ExprKind::Match { arms, .. } = &b.tail.kind
701            && let Some(MatchBody::Block(rest)) = arms.first().map(|a| &a.body)
702        {
703            out.push('\t');
704            out.push_str(&let_else_to_string(l, effectful, le, arms));
705            out.push('\n');
706            block_body_lines(rest, out);
707            return;
708        }
709        out.push('\t');
710        out.push_str(&stmt_to_string(stmt));
711        out.push('\n');
712    }
713    if !omit_unit_tail(b) {
714        out.push('\t');
715        out.push_str(&expr_with_prec(&b.tail, 0));
716        out.push('\n');
717    }
718}
719
720/// #2712: a lambda's body as the author wrote it.
721pub enum LambdaBodySrc<'a> {
722    Expr(&'a Expr),
723    Block(&'a Block),
724}
725
726/// #2712: the body a lambda with pattern parameters was written with. The
727/// parser wraps it, one level per pattern parameter (first outermost), in
728/// `{ let __letElse = __lambdaArg; match __letElse { P => { <rest> } } }`;
729/// this peels those back off. A lambda with no pattern parameter is untouched.
730pub fn lambda_body_src(lambda: &LambdaExpr) -> LambdaBodySrc<'_> {
731    let patterns = lambda.params.iter().filter(|p| p.pattern.is_some()).count();
732    if patterns == 0 {
733        return LambdaBodySrc::Expr(&lambda.body);
734    }
735    let ExprKind::Block(block) = &lambda.body.kind else {
736        return LambdaBodySrc::Expr(&lambda.body);
737    };
738    let mut block: &Block = block;
739    for _ in 0..patterns {
740        let ExprKind::Match { arms, .. } = &block.tail.kind else {
741            return LambdaBodySrc::Expr(&lambda.body);
742        };
743        let Some(MatchBody::Block(rest)) = arms.first().map(|a| &a.body) else {
744            return LambdaBodySrc::Expr(&lambda.body);
745        };
746        block = rest;
747    }
748    if lambda.block_body {
749        LambdaBodySrc::Block(block)
750    } else {
751        LambdaBodySrc::Expr(&block.tail)
752    }
753}
754
755/// #2010: the hidden binding a `let … else` desugars into, if `s` is one —
756/// with whether it was `<-` and the sugar's own marker.
757pub fn let_else_binding(s: &Statement) -> Option<(&LetStmt, bool, &LetElse)> {
758    match s {
759        Statement::Let(l) => l.let_else.as_ref().map(|le| (l, false, le)),
760        Statement::EffectLet(l) => l.let_else.as_ref().map(|le| (l, true, le)),
761        _ => None,
762    }
763}
764
765/// #2010: a `let … else` pattern as source. After `let`, a bare name is a
766/// binding, not a pattern (`let Missing | Late = p` would re-parse as a
767/// binding named `Missing`), so a pattern whose text opens with a name not
768/// followed by `(`, `.` or `{` keeps the parentheses its author had to write.
769/// `None`, `true` and `false` are keywords, not names, so they need none.
770pub fn let_pattern_src(p: &Pattern) -> String {
771    let src = pattern_to_string(p);
772    let name_len = src
773        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
774        .unwrap_or(src.len());
775    let opens_with_name = src.starts_with(|c: char| c.is_ascii_alphabetic())
776        && !matches!(&src[..name_len], "None" | "true" | "false");
777    let then = src[name_len..].chars().next();
778    // #2654: `Name {` opens a record pattern, so it needs no parentheses.
779    let opens_record = src[name_len..].trim_start().starts_with('{');
780    if opens_with_name && !opens_record && !matches!(then, Some('(' | '.')) {
781        format!("({src})")
782    } else {
783        src
784    }
785}
786
787/// #2010: the single-line string form of a `let … else` (see
788/// [`Formatter::format_let_else`]).
789fn let_else_to_string(l: &LetStmt, effectful: bool, le: &LetElse, arms: &[MatchArm]) -> String {
790    let mut out = format!(
791        "let {} {} {}",
792        let_pattern_src(&arms[0].pattern),
793        if effectful { "<-" } else { "=" },
794        expr_with_prec(&l.value, 0)
795    );
796    if let Some(p) = &l.principal {
797        out.push_str(&format!(" {}", call_site_actor_src(p)));
798    }
799    match (le.form, arms.get(1).map(|a| &a.body)) {
800        (LetElseForm::Plain, _) => {}
801        (LetElseForm::Block, Some(MatchBody::Block(otherwise))) => {
802            out.push_str(&format!(" else {}", format_block_oneline(otherwise)));
803        }
804        _ => {
805            let rendered: Vec<String> = arms[1..]
806                .iter()
807                .map(|a| {
808                    let body = match &a.body {
809                        MatchBody::Expr(e) => expr_with_prec(e, 0),
810                        MatchBody::Block(b) => format_block_oneline(b),
811                    };
812                    let guard = a
813                        .guard
814                        .as_ref()
815                        .map(|g| format!(" if {}", expr_with_prec(g, 0)))
816                        .unwrap_or_default();
817                    format!("{}{guard} => {body}", pattern_to_string(&a.pattern))
818                })
819                .collect();
820            out.push_str(&format!(" else match {{ {} }}", rendered.join(", ")));
821        }
822    }
823    out
824}
825
826fn stmt_to_string(s: &Statement) -> String {
827    match s {
828        Statement::Let(l) => {
829            let mut out = format!("let {}", l.name.name);
830            if let Some(t) = &l.type_annot {
831                out.push_str(&format!(": {}", type_ref_to_string(t)));
832            }
833            out.push_str(&format!(" = {}", expr_with_prec(&l.value, 0)));
834            out
835        }
836        Statement::EffectLet(l) => {
837            let mut out = format!("let {}", l.name.name);
838            if let Some(t) = &l.type_annot {
839                out.push_str(&format!(": {}", type_ref_to_string(t)));
840            }
841            out.push_str(&format!(" <- {}", expr_with_prec(&l.value, 0)));
842            if let Some(p) = &l.principal {
843                out.push_str(&format!(" {}", call_site_actor_src(p)));
844            }
845            out
846        }
847        Statement::Expect(a) => format!(
848            "expect {}{}",
849            expr_with_prec(&a.value, 0),
850            a.snapshot.as_ref().map_or(String::new(), |c| format!(
851                " matches snapshot \"{}\"",
852                escape_string(&c.name)
853            ))
854        ),
855        Statement::Send(s) => format!("~> {}", expr_with_prec(&s.value, 0)),
856        Statement::Do(d) => format!("do {}", expr_with_prec(&d.value, 0)),
857        Statement::Assign(a) => format!("{} := {}", a.target.name, expr_with_prec(&a.value, 0)),
858        Statement::Advance(a) => {
859            format!("advance {} by {}", a.cap.name, expr_with_prec(&a.amount, 0))
860        }
861        Statement::Par(p) => {
862            let body: Vec<String> = p.statements.iter().map(stmt_to_string).collect();
863            format!("par {{ {} }}", body.join(" "))
864        }
865    }
866}
867
868/// #2712: a lambda parameter as source — the author's record pattern when it
869/// has one (its hidden binder never prints), else the name and any annotation.
870pub fn lambda_param_src(p: &LambdaParam) -> String {
871    let name = if p.is_wildcard() { "_" } else { &p.name.name };
872    match (&p.pattern, &p.type_ref) {
873        (Some(pattern), _) => pattern_to_string(pattern),
874        (None, Some(tr)) => format!("{name}: {}", type_ref_to_string(tr)),
875        (None, None) => name.to_string(),
876    }
877}