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