Skip to main content

whipplescript_parser/
format.rs

1//! Source formatting: rendering the AST back to canonical WhippleScript text.
2//!
3//! Moved verbatim out of `lib.rs`; `use super::*` keeps the IR types and
4//! helpers it already resolved against in scope.
5
6use super::*;
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct FormatOutput {
9    pub formatted: Option<String>,
10    pub diagnostics: Vec<Diagnostic>,
11}
12
13/// Formats the syntax tree without lowering or analyzing rule bodies.
14pub fn format_program(source: &str) -> FormatOutput {
15    let parsed = parse_program(source);
16    if !parsed.diagnostics.is_empty() {
17        return FormatOutput {
18            formatted: None,
19            diagnostics: parsed.diagnostics,
20        };
21    }
22
23    FormatOutput {
24        formatted: Some(format_syntax(parsed.program)),
25        diagnostics: Vec::new(),
26    }
27}
28
29/// Format `source` while preserving comments where they can be placed safely:
30/// top-level **leading** comments (a `# …` or `// …` line above a declaration, or
31/// a file-header block) and **trailing** comments on a single-line top-level
32/// declaration (`workflow Demo  # …`, attached to that element's line); comments
33/// inside raw-body declarations (`rule`/`apply`/`coerce`/`table`/`flow`, carried by
34/// the body substring); and comments inside `class`/`agent`/`enum` bodies, including a
35/// data-carrying `enum` variant's nested field block — both own-line (interleaved
36/// by source position) and trailing comments on a field/variant line (appended to
37/// it), and `signal`/`queue`/`file store` bodies the same way — even though those
38/// bodies rebuild from the AST. Returns `None` when the program does not parse, or
39/// when a comment has nowhere to attach — e.g. one trailing a declaration's
40/// opening-brace line, with no field on that line. The caller refuses such files
41/// rather than dropping comments.
42pub fn format_program_preserving_comments(source: &str) -> Option<String> {
43    let parsed = parse_program(source);
44    if !parsed.diagnostics.is_empty() {
45        return None;
46    }
47    let mut comments = lex_comments(source);
48    if comments.is_empty() {
49        return Some(format_syntax(parsed.program));
50    }
51    // Both the top-level interleave and the per-body interleave below assume
52    // ascending source order.
53    comments.sort_by_key(|comment| comment.span.start);
54    let program = parsed.program;
55
56    // Each top-level element as (source span, formatted chunk), in source order.
57    let mut elements: Vec<(SourceSpan, String)> = Vec::new();
58    if let Some(workflow) = program.workflow {
59        let mut chunk = String::new();
60        format_tags(&program.workflow_tags, &mut chunk);
61        format_description(program.workflow_description.as_ref(), &mut chunk);
62        push_line(&mut chunk, format!("workflow {}", workflow.name));
63        elements.push((workflow.span, chunk));
64    }
65    for pattern in program.patterns {
66        let span = pattern.span;
67        let mut chunk = String::new();
68        format_pattern(pattern, &mut chunk);
69        elements.push((span, chunk));
70    }
71    for item in program.items {
72        let span = item.span();
73        let mut chunk = String::new();
74        // Field-list bodies (`class`/`agent`/`enum`) rebuild from the AST, which
75        // drops comments. Interleave their own-line body comments here; a body
76        // comment that cannot be placed safely refuses the whole file (the
77        // raw-body formatters — rule/coerce/table — already carry their comments).
78        let placed = match &item {
79            Item::Class(class_decl) => Some(try_format_class_with_comments(
80                class_decl, source, &comments, &mut chunk,
81            )),
82            Item::Agent(agent) => Some(try_format_agent_with_comments(
83                agent, source, &comments, &mut chunk,
84            )),
85            Item::Enum(enum_decl) => Some(try_format_enum_with_comments(
86                enum_decl, source, &comments, &mut chunk,
87            )),
88            Item::Event(event) => Some(try_format_event_with_comments(
89                event, source, &comments, &mut chunk,
90            )),
91            Item::Tracker(queue) => Some(try_format_tracker_with_comments(
92                queue, source, &comments, &mut chunk,
93            )),
94            Item::FileStore(file_store) => Some(try_format_filestore_with_comments(
95                file_store, source, &comments, &mut chunk,
96            )),
97            _ => None,
98        };
99        match placed {
100            Some(true) => {}
101            Some(false) => return None,
102            None => format_item(item, &mut chunk),
103        }
104        elements.push((span, chunk));
105    }
106    for workflow in program.workflows {
107        let span = workflow.span;
108        let mut chunk = String::new();
109        format_workflow(workflow, &mut chunk);
110        elements.push((span, chunk));
111    }
112    elements.sort_by_key(|(span, _)| span.start);
113
114    // Classify top-level comments. A comment INSIDE an element's span is preserved
115    // by that element's body formatter — a raw `body.text` substring
116    // (rule/coerce/table) or the per-body interleave above (class/agent/enum) — so
117    // emitting it here too would duplicate it; skip it. Otherwise an own-line
118    // comment is `leading` (interleaved between elements by position), and a
119    // trailing comment (code before it) attaches to the element whose last source
120    // line it shares — typically a single-line declaration (`workflow Demo  # x`).
121    // A trailing comment with no such element has nowhere to attach, so the file is
122    // refused rather than dropping it.
123    let mut leading: Vec<&Comment> = Vec::new();
124    let mut element_trailing: Vec<Option<&Comment>> = vec![None; elements.len()];
125    for comment in &comments {
126        let in_body = elements
127            .iter()
128            .any(|(span, _)| span.start < comment.span.start && comment.span.start < span.end);
129        if in_body {
130            continue;
131        }
132        let line_start = source[..comment.span.start]
133            .rfind('\n')
134            .map(|newline| newline + 1)
135            .unwrap_or(0);
136        if source[line_start..comment.span.start].trim().is_empty() {
137            leading.push(comment);
138            continue;
139        }
140        let comment_line = line_index(source, comment.span.start);
141        let mut placed = false;
142        for (index, (span, _)) in elements.iter().enumerate() {
143            if line_index(source, span.end.saturating_sub(1)) == comment_line {
144                if element_trailing[index].is_some() {
145                    return None;
146                }
147                element_trailing[index] = Some(comment);
148                placed = true;
149                break;
150            }
151        }
152        if !placed {
153            return None;
154        }
155    }
156
157    let mut out = String::new();
158    let mut next_comment = 0;
159    let element_count = elements.len();
160    for (index, (span, chunk)) in elements.iter().enumerate() {
161        while next_comment < leading.len() && leading[next_comment].span.start < span.start {
162            push_line(&mut out, format_comment(leading[next_comment]));
163            next_comment += 1;
164        }
165        match element_trailing[index] {
166            Some(comment) => {
167                out.push_str(chunk.strip_suffix('\n').unwrap_or(chunk));
168                out.push_str(&format!("  {}\n", format_comment(comment)));
169            }
170            None => out.push_str(chunk),
171        }
172        if index + 1 < element_count {
173            out.push('\n');
174        }
175    }
176    if next_comment < leading.len() {
177        if element_count > 0 {
178            out.push('\n');
179        }
180        while next_comment < leading.len() {
181            push_line(&mut out, format_comment(leading[next_comment]));
182            next_comment += 1;
183        }
184    }
185
186    // Safety net against silent data loss: in-body comments are left to each
187    // element's body formatter, and some formatters rebuild from the AST (which
188    // drops comments). The idempotency self-check can't catch a *consistent*
189    // drop, so verify here that every source comment survives — refuse otherwise.
190    if lex_comments(&out).len() != comments.len() {
191        return None;
192    }
193    Some(out)
194}
195
196pub(crate) fn format_comment(comment: &Comment) -> String {
197    let marker = match comment.marker {
198        CommentMarker::Hash => "#",
199        CommentMarker::Slash => "//",
200    };
201    let text = comment.text.trim();
202    if text.is_empty() {
203        marker.to_owned()
204    } else {
205        format!("{marker} {text}")
206    }
207}
208
209fn format_syntax(program: Program) -> String {
210    let mut formatted = String::new();
211    if let Some(workflow) = program.workflow {
212        format_tags(&program.workflow_tags, &mut formatted);
213        format_description(program.workflow_description.as_ref(), &mut formatted);
214        push_line(&mut formatted, format!("workflow {}", workflow.name));
215        formatted.push('\n');
216    }
217
218    let mut top_level_items = Vec::new();
219    top_level_items.extend(program.patterns.into_iter().map(Item::Pattern));
220    top_level_items.extend(program.items);
221    format_items(top_level_items, &mut formatted);
222
223    if !formatted.is_empty() && !program.workflows.is_empty() {
224        formatted.push('\n');
225    }
226    let workflow_count = program.workflows.len();
227    for (index, workflow) in program.workflows.into_iter().enumerate() {
228        format_workflow(workflow, &mut formatted);
229        if index + 1 < workflow_count {
230            formatted.push('\n');
231        }
232    }
233
234    formatted
235}
236
237fn format_items(items: Vec<Item>, formatted: &mut String) {
238    let item_count = items.len();
239    for (index, item) in items.into_iter().enumerate() {
240        format_item(item, formatted);
241        if index + 1 < item_count {
242            formatted.push('\n');
243        }
244    }
245}
246
247pub(crate) fn format_item(item: Item, formatted: &mut String) {
248    match item {
249        Item::Include(include) => {
250            push_line(formatted, format!("include {:?}", include.path.value));
251        }
252        Item::Use(use_decl) => {
253            push_line(formatted, format!("use {}", use_decl.name.value));
254        }
255        Item::Tracker(queue) => {
256            push_line(formatted, format!("tracker {} {{", queue.name.name));
257            push_line(formatted, format!("  provider {}", queue.provider.name));
258            push_line(formatted, "}");
259        }
260        Item::Stream(stream) => {
261            push_line(formatted, format!("stream {} {{", stream.name.name));
262            push_line(
263                formatted,
264                format!(
265                    "  members [{}]",
266                    stream
267                        .members
268                        .iter()
269                        .map(|member| member.name.as_str())
270                        .collect::<Vec<_>>()
271                        .join(", ")
272                ),
273            );
274            if let Some(seconds) = stream.staleness_seconds {
275                push_line(formatted, format!("  staleness {seconds}s"));
276            }
277            push_line(formatted, "}");
278        }
279        Item::Mark(mark) => {
280            push_line(
281                formatted,
282                format!("mark {:?} after {}", mark.name.value, mark.site),
283            );
284        }
285        Item::Gauge(gauge) => {
286            let mut header = format!("gauge {}", gauge.name.name);
287            if let Some(site) = &gauge.site {
288                header.push_str(&format!(" on {site}"));
289            }
290            header.push_str(" {");
291            push_line(formatted, header);
292            let judge = match &gauge.judge {
293                GaugeJudge::Coerce(target, args) if args.is_empty() => {
294                    format!("coerce {}", target.name)
295                }
296                GaugeJudge::Coerce(target, args) => {
297                    format!("coerce {}({})", target.name, args.join(", "))
298                }
299                GaugeJudge::Prompt(template) => format!("prompt {:?}", template.value),
300                GaugeJudge::Exec(command) => format!("exec {:?}", command.value),
301                GaugeJudge::Labels(source) => format!("labels {:?}", source.value),
302            };
303            push_line(formatted, format!("  judge via {judge}"));
304            if let Some(bar) = &gauge.expect {
305                let subject = match &bar.subject {
306                    GaugeBarSubject::Chance { field } => format!("P({})", field.name),
307                    GaugeBarSubject::Stat { stat } => stat.name.clone(),
308                };
309                let direction = if bar.at_least { "at least" } else { "at most" };
310                push_line(
311                    formatted,
312                    format!("  expect {subject} {direction} {}", bar.threshold),
313                );
314            }
315            if !gauge.inputs.is_empty() {
316                let names = gauge
317                    .inputs
318                    .iter()
319                    .map(|input| input.name.as_str())
320                    .collect::<Vec<_>>()
321                    .join(", ");
322                push_line(formatted, format!("  inputs {names}"));
323            }
324            push_line(formatted, "}");
325        }
326        Item::Campaign(campaign) => {
327            push_line(formatted, format!("campaign {} {{", campaign.name.name));
328            if !campaign.ascend.is_empty() {
329                let names = campaign
330                    .ascend
331                    .iter()
332                    .map(|gauge| gauge.name.as_str())
333                    .collect::<Vec<_>>()
334                    .join(", ");
335                push_line(formatted, format!("  ascend {names}"));
336            }
337            for reach in &campaign.reach {
338                let direction = if reach.at_least {
339                    "at least"
340                } else {
341                    "at most"
342                };
343                let unit = reach.unit.as_deref().unwrap_or("");
344                push_line(
345                    formatted,
346                    format!(
347                        "  reach {} {direction} {}{unit}",
348                        reach.gauge.name, reach.threshold
349                    ),
350                );
351            }
352            for guard in &campaign.guard {
353                push_line(
354                    formatted,
355                    format!(
356                        "  guard {} within {} percent",
357                        guard.gauge.name, guard.band_percent
358                    ),
359                );
360            }
361            if !campaign.sacrifice.is_empty() {
362                let names = campaign
363                    .sacrifice
364                    .iter()
365                    .map(|gauge| gauge.name.as_str())
366                    .collect::<Vec<_>>()
367                    .join(", ");
368                push_line(formatted, format!("  sacrifice {names}"));
369            }
370            if campaign.proposer_redacted {
371                push_line(formatted, "  proposer redacted");
372            }
373            push_line(formatted, "}");
374        }
375        Item::Channel(channel) => {
376            push_line(formatted, format!("channel {} {{", channel.name.name));
377            push_line(formatted, format!("  provider {}", channel.provider.name));
378            if let Some(workspace) = &channel.workspace {
379                push_line(formatted, format!("  workspace {}", workspace.name));
380            }
381            if let Some(destination) = &channel.destination {
382                push_line(formatted, format!("  destination {:?}", destination.value));
383            }
384            push_line(formatted, "}");
385        }
386        Item::Credential(credential) => {
387            push_line(formatted, format!("credential {} {{", credential.name.name));
388            push_line(formatted, format!("  kind {}", credential.kind.name));
389            push_line(formatted, "}");
390        }
391        Item::FileStore(file_store) => {
392            push_line(formatted, format!("file store {} {{", file_store.name.name));
393            push_line(formatted, format!("  root {:?}", file_store.root));
394            let format_globs = |formatted: &mut String, direction: &str, globs: &[String]| {
395                if !globs.is_empty() {
396                    let rendered = globs
397                        .iter()
398                        .map(|glob| format!("{glob:?}"))
399                        .collect::<Vec<_>>()
400                        .join(", ");
401                    push_line(formatted, format!("  allow {direction} [{rendered}]"));
402                }
403            };
404            format_globs(formatted, "read", &file_store.read_globs);
405            format_globs(formatted, "write", &file_store.write_globs);
406            if let Some(provider) = &file_store.provider {
407                push_line(formatted, format!("  provider {}", provider.name));
408            }
409            push_line(formatted, "}");
410        }
411        Item::MemoryPool(pool) => {
412            push_line(formatted, format!("memory pool {} {{", pool.name.name));
413            if let Some(limit) = pool.context_limit {
414                push_line(formatted, format!("  context limit {limit}"));
415            }
416            push_line(formatted, "}");
417        }
418        Item::Action(action) => {
419            let params = action
420                .params
421                .iter()
422                .map(|param| format!("{} {}", param.name.name, param.ty.to_source()))
423                .collect::<Vec<_>>()
424                .join(", ");
425            push_line(
426                formatted,
427                format!("action {}({params}) {{", action.name.name),
428            );
429            for line in action.body.text.lines() {
430                if line.trim().is_empty() {
431                    push_line(formatted, "");
432                } else {
433                    push_line(formatted, line.trim_end());
434                }
435            }
436            push_line(formatted, "}");
437        }
438        Item::Pattern(pattern) => format_pattern(pattern, formatted),
439        Item::Apply(apply) => format_apply(apply, formatted),
440        Item::WorkflowContract(contract) => {
441            push_line(
442                formatted,
443                format!(
444                    "{} {} {}",
445                    contract.kind.as_str(),
446                    contract.name.name,
447                    contract.ty.to_source()
448                ),
449            );
450        }
451        Item::Harness(harness) => format_harness(harness, formatted),
452        Item::Agent(agent) => format_agent(agent, formatted),
453        Item::Enum(enum_decl) => format_enum(enum_decl, formatted),
454        Item::Event(event) => format_event(event, formatted),
455        Item::Source(source) => format_source(*source, formatted),
456        Item::Test(test) => format_test(test, formatted),
457        Item::Lease(lease) => {
458            push_line(formatted, format!("lease {} {{", lease.name.name));
459            if lease.shared {
460                push_line(formatted, "  shared");
461            }
462            push_line(formatted, format!("  key {}", lease.key_type.name));
463            push_line(formatted, format!("  slots {}", lease.slots));
464            push_line(formatted, format!("  ttl {}s", lease.ttl_seconds));
465            push_line(formatted, "}");
466        }
467        Item::Ledger(ledger) => {
468            push_line(formatted, format!("ledger {} {{", ledger.name.name));
469            if ledger.shared {
470                push_line(formatted, "  shared");
471            }
472            push_line(formatted, format!("  entry {}", ledger.entry_schema.name));
473            push_line(
474                formatted,
475                format!("  partition by {}", ledger.partition_field.name),
476            );
477            push_line(formatted, format!("  retain {}s", ledger.retain_seconds));
478            push_line(formatted, "}");
479        }
480        Item::Counter(counter) => {
481            push_line(formatted, format!("counter {} {{", counter.name.name));
482            if counter.shared {
483                push_line(formatted, "  shared");
484            }
485            push_line(formatted, format!("  key {}", counter.key_type.name));
486            push_line(formatted, format!("  cap {}", counter.cap));
487            push_line(formatted, format!("  reset {}", counter.reset));
488            push_line(formatted, "}");
489        }
490        Item::Class(class_decl) => format_class(class_decl, formatted),
491        Item::Table(table) => format_table(table, formatted),
492        Item::Coerce(coerce) => format_coerce(coerce, formatted),
493        Item::Assert(assertion) => {
494            format_tags(&assertion.tags, formatted);
495            format_description(assertion.description.as_ref(), formatted);
496            push_line(formatted, format!("assert {}", assertion.expr));
497        }
498        Item::Rule(rule) => format_rule(rule, formatted),
499    }
500}
501
502pub(crate) fn format_tags(tags: &[TagDecl], formatted: &mut String) {
503    for tag in tags {
504        push_line(formatted, format!("@{}", tag.name));
505    }
506}
507
508pub(crate) fn format_description(description: Option<&StringLiteral>, formatted: &mut String) {
509    if let Some(description) = description {
510        push_line(formatted, format!("description {:?}", description.value));
511    }
512}
513
514fn format_pattern(pattern: PatternDecl, formatted: &mut String) {
515    let params = if pattern.type_params.is_empty() {
516        String::new()
517    } else {
518        format!(
519            "<{}>",
520            pattern
521                .type_params
522                .iter()
523                .map(|param| param.name.as_str())
524                .collect::<Vec<_>>()
525                .join(", ")
526        )
527    };
528    push_line(
529        formatted,
530        format!("pattern {}{} {{", pattern.name.name, params),
531    );
532    let mut inner = String::new();
533    format_items(pattern.items, &mut inner);
534    for line in inner.lines() {
535        if line.is_empty() {
536            formatted.push('\n');
537        } else {
538            push_line(formatted, format!("  {line}"));
539        }
540    }
541    push_line(formatted, "}");
542}
543
544fn format_apply(apply: ApplyDecl, formatted: &mut String) {
545    let args = if apply.type_args.is_empty() {
546        String::new()
547    } else {
548        format!(
549            "<{}>",
550            apply
551                .type_args
552                .iter()
553                .map(TypeSyntax::to_source)
554                .collect::<Vec<_>>()
555                .join(", ")
556        )
557    };
558    push_line(
559        formatted,
560        format!(
561            "apply {}{} as {} {{",
562            apply.pattern.name, args, apply.alias.name
563        ),
564    );
565    format_block_body(&apply.body.text, formatted);
566    push_line(formatted, "}");
567}
568
569pub(crate) fn format_workflow(workflow: WorkflowDecl, formatted: &mut String) {
570    format_tags(&workflow.tags, formatted);
571    format_description(workflow.description.as_ref(), formatted);
572    push_line(formatted, format!("workflow {} {{", workflow.name.name));
573    let mut inner = String::new();
574    format_items(workflow.items, &mut inner);
575    for line in inner.lines() {
576        if line.is_empty() {
577            formatted.push('\n');
578        } else {
579            push_line(formatted, format!("  {line}"));
580        }
581    }
582    push_line(formatted, "}");
583}
584
585fn format_harness(harness: HarnessDecl, formatted: &mut String) {
586    push_line(
587        formatted,
588        format!("harness {}: {}", harness.name.name, harness.kind.name),
589    );
590}
591
592fn format_agent(agent: AgentDecl, formatted: &mut String) {
593    let harness = agent
594        .harness
595        .as_ref()
596        .map(|harness| format!(" using {}", harness.name))
597        .or_else(|| {
598            agent
599                .delegated_to
600                .as_ref()
601                .map(|delegate| format!(" delegated to {}", delegate.name))
602        })
603        .unwrap_or_default();
604    push_line(
605        formatted,
606        format!("agent {}{} {{", agent.name.name, harness),
607    );
608    for field in agent.fields {
609        match field {
610            AgentField::Provider(provider) => {
611                push_line(formatted, format!("  provider {}", provider.name));
612            }
613            AgentField::Profile(profile) => {
614                push_line(formatted, format!("  profile {:?}", profile.value));
615            }
616            AgentField::Capacity(capacity, _) => {
617                push_line(formatted, format!("  capacity {capacity}"));
618            }
619            AgentField::Skills(skills, _) => {
620                let skills = skills
621                    .into_iter()
622                    .map(|skill| format!("{:?}", skill.value))
623                    .collect::<Vec<_>>()
624                    .join(", ");
625                push_line(formatted, format!("  skills [{skills}]"));
626            }
627            AgentField::Capabilities(capabilities, _) => {
628                let capabilities = capabilities
629                    .into_iter()
630                    .map(|capability| format!("{:?}", capability.value))
631                    .collect::<Vec<_>>()
632                    .join(", ");
633                push_line(formatted, format!("  capabilities [{capabilities}]"));
634            }
635            AgentField::Requires(classes, _) => {
636                let classes = classes
637                    .into_iter()
638                    .map(|class| class.name)
639                    .collect::<Vec<_>>()
640                    .join(", ");
641                push_line(formatted, format!("  requires [{classes}]"));
642            }
643            AgentField::Tools(tools, _) => {
644                let tools = tools
645                    .into_iter()
646                    .map(|tool| tool.name)
647                    .collect::<Vec<_>>()
648                    .join(", ");
649                push_line(formatted, format!("  tools [{tools}]"));
650            }
651            AgentField::Compaction(strategy) => {
652                push_line(formatted, format!("  compaction {}", strategy.name));
653            }
654            AgentField::Thread(mode) => {
655                push_line(formatted, format!("  thread {}", mode.name));
656            }
657            AgentField::Settings(sources) => {
658                push_line(formatted, format!("  settings {}", sources.name));
659            }
660            AgentField::Unknown { name, .. } => {
661                push_line(formatted, format!("  {}", name.name));
662            }
663        }
664    }
665    push_line(formatted, "}");
666}
667
668fn format_enum(enum_decl: EnumDecl, formatted: &mut String) {
669    push_line(formatted, format!("enum {} {{", enum_decl.name.name));
670    for variant in enum_decl.variants {
671        if variant.fields.is_empty() {
672            push_line(formatted, format!("  {}", variant.name.name));
673            continue;
674        }
675        push_line(formatted, format!("  {} {{", variant.name.name));
676        for field in variant.fields {
677            push_line(
678                formatted,
679                format!("    {} {}", field.name.name, field.ty.to_source()),
680            );
681        }
682        push_line(formatted, "  }");
683    }
684    push_line(formatted, "}");
685}
686
687fn format_time_of_day(time: TimeOfDay) -> String {
688    format!("{:02}:{:02}", time.hour, time.minute)
689}
690
691fn format_weekday(day: Weekday) -> &'static str {
692    match day {
693        Weekday::Monday => "monday",
694        Weekday::Tuesday => "tuesday",
695        Weekday::Wednesday => "wednesday",
696        Weekday::Thursday => "thursday",
697        Weekday::Friday => "friday",
698        Weekday::Saturday => "saturday",
699        Weekday::Sunday => "sunday",
700    }
701}
702
703fn format_recurrence(recurrence: &Recurrence) -> String {
704    match recurrence {
705        Recurrence::At { time, .. } => format!("at {}", format_time_of_day(*time)),
706        Recurrence::EveryDuration { source, .. } => format!("every {source}"),
707        Recurrence::EveryCalendar { pattern, time, .. } => {
708            let pattern = match pattern {
709                CalendarPattern::Day => "day".to_owned(),
710                CalendarPattern::Weekday => "weekday".to_owned(),
711                CalendarPattern::Weekly(day) => format_weekday(*day).to_owned(),
712            };
713            format!("every {pattern} at {}", format_time_of_day(*time))
714        }
715    }
716}
717
718fn format_source_value(value: &SourceValue) -> String {
719    match value {
720        SourceValue::Path {
721            binding, segments, ..
722        } => {
723            let mut text = binding.name.clone();
724            for segment in segments {
725                text.push('.');
726                text.push_str(&segment.name);
727            }
728            text
729        }
730        SourceValue::String(literal) => format!("{:?}", literal.value),
731        SourceValue::Number(number, _) => number.clone(),
732    }
733}
734
735fn format_test_fields(fields: &[TestField], formatted: &mut String) {
736    for field in fields {
737        push_line(
738            formatted,
739            format!("    {} {}", field.name.name, field.value),
740        );
741    }
742}
743
744fn format_test(test: TestDecl, formatted: &mut String) {
745    push_line(formatted, format!("test {:?} {{", test.name.value));
746    if let Some(workflow) = &test.workflow {
747        push_line(formatted, format!("  workflow {}", workflow.name));
748    }
749    for clause in &test.clauses {
750        match clause {
751            TestClause::Given(given) => match given {
752                GivenClause::Input { fields, .. } => {
753                    push_line(formatted, "  given input {");
754                    format_test_fields(fields, formatted);
755                    push_line(formatted, "  }");
756                }
757                GivenClause::Fact { ty, fields, .. } => {
758                    push_line(formatted, format!("  given fact {} {{", ty.name));
759                    format_test_fields(fields, formatted);
760                    push_line(formatted, "  }");
761                }
762                GivenClause::Signal { name, fields, .. } => {
763                    push_line(formatted, format!("  given signal {name} {{"));
764                    format_test_fields(fields, formatted);
765                    push_line(formatted, "  }");
766                }
767                GivenClause::Clock { at, .. } => {
768                    push_line(formatted, format!("  given clock at {:?}", at.value));
769                }
770                GivenClause::Tracker {
771                    tracker, fields, ..
772                } => {
773                    push_line(formatted, format!("  given tracker {tracker} issue {{"));
774                    format_test_fields(fields, formatted);
775                    push_line(formatted, "  }");
776                }
777                GivenClause::File {
778                    store,
779                    path,
780                    content,
781                    ..
782                } => {
783                    push_line(
784                        formatted,
785                        format!(
786                            "  given file {store} at {:?} {:?}",
787                            path.value, content.value
788                        ),
789                    );
790                }
791            },
792            TestClause::Stub(stub) => {
793                let surface = stub.surface.join(" ");
794                match &stub.payload {
795                    Some(StubPayload::Message(message)) => push_line(
796                        formatted,
797                        format!("  stub {surface} {} {:?}", stub.outcome, message.value),
798                    ),
799                    Some(StubPayload::Record(fields)) => {
800                        push_line(formatted, format!("  stub {surface} {} {{", stub.outcome));
801                        format_test_fields(fields, formatted);
802                        push_line(formatted, "  }");
803                    }
804                    None => push_line(formatted, format!("  stub {surface} {}", stub.outcome)),
805                }
806            }
807            TestClause::Run(run) => {
808                let text = match &run.kind {
809                    RunKind::UntilIdle => "run until idle".to_owned(),
810                    RunKind::UntilWorkflowCompleted => "run until workflow completed".to_owned(),
811                    RunKind::UntilWorkflowFailed => "run until workflow failed".to_owned(),
812                    RunKind::ForSteps(steps) => format!("run for {steps} steps"),
813                };
814                push_line(formatted, format!("  {text}"));
815            }
816            TestClause::Expect(expect) => {
817                push_line(
818                    formatted,
819                    format!("  {}", format_expect_target(&expect.target)),
820                );
821            }
822        }
823    }
824    push_line(formatted, "}");
825}
826
827fn format_expect_target(target: &ExpectTarget) -> String {
828    match target {
829        ExpectTarget::WorkflowCompleted => "expect workflow completed".to_owned(),
830        ExpectTarget::WorkflowFailed { failure: None } => "expect workflow failed".to_owned(),
831        ExpectTarget::WorkflowFailed {
832            failure: Some(failure),
833        } => format!("expect workflow failed with {}", failure.name),
834        ExpectTarget::Rule { name, status } => {
835            let status = match status {
836                RuleStatus::Fired => "fired".to_owned(),
837                RuleStatus::FiredTimes(count) => format!("fired {count} times"),
838                RuleStatus::DidNotFire => "did not fire".to_owned(),
839            };
840            format!("expect rule {} {status}", name.name)
841        }
842        ExpectTarget::Effect { name, status } => {
843            let status = match status {
844                EffectStatus::Requested => "requested",
845                EffectStatus::Completed => "completed",
846                EffectStatus::Failed => "failed",
847            };
848            format!("expect effect {name} {status}")
849        }
850        ExpectTarget::Diagnostic { code } => format!("expect diagnostic {code}"),
851        ExpectTarget::NoEffect { name } => format!("expect no {name}"),
852        ExpectTarget::Projection(query) => format!("expect {}", format_proj_query(query)),
853    }
854}
855
856fn format_proj_query(query: &ProjQuery) -> String {
857    match &query.kind {
858        ProjQueryKind::Exists => format!("{} exists", query.noun),
859        ProjQueryKind::Count { predicate, count } => {
860            format!("{} count where {predicate} is {count}", query.noun)
861        }
862        ProjQueryKind::Where { predicate } => {
863            format!("{} where {predicate}", query.noun)
864        }
865    }
866}
867
868fn format_source(source: SourceDecl, formatted: &mut String) {
869    push_line(
870        formatted,
871        format!("source {} as {} {{", source.provider.name, source.name.name),
872    );
873    if let Some(clock) = &source.clock {
874        push_line(
875            formatted,
876            format!("  {}", format_recurrence(&clock.recurrence)),
877        );
878        if let Some(timezone) = &clock.timezone {
879            push_line(formatted, format!("  timezone {:?}", timezone.value));
880        }
881        match clock.missed {
882            Some(MissedPolicy::Skip) => push_line(formatted, "  missed skip"),
883            Some(MissedPolicy::Coalesce) => push_line(formatted, "  missed coalesce"),
884            Some(MissedPolicy::CatchUp { limit }) => {
885                push_line(formatted, format!("  missed catch_up limit {limit}"))
886            }
887            None => {}
888        }
889    }
890    if let Some(path) = &source.path {
891        push_line(formatted, format!("  path {:?}", path.value));
892    }
893    if let Some(watch) = &source.watch {
894        push_line(formatted, format!("  watch {:?}", watch.value));
895    }
896    if let Some(url) = &source.url {
897        push_line(formatted, format!("  url {:?}", url.value));
898    }
899    if let Some(dedup) = &source.dedup {
900        push_line(formatted, format!("  dedup {}", format_source_value(dedup)));
901    }
902    push_line(
903        formatted,
904        format!("  observe as {}", source.observe_binding.name),
905    );
906    let from = source
907        .emit
908        .from
909        .as_ref()
910        .map(|ident| format!(" from {}", ident.name))
911        .unwrap_or_default();
912    if source.emit.fields.is_empty() && source.emit.from.is_some() {
913        push_line(formatted, format!("  emit {}{from}", source.emit.signal));
914    } else {
915        push_line(formatted, format!("  emit {}{from} {{", source.emit.signal));
916        for field in &source.emit.fields {
917            push_line(
918                formatted,
919                format!(
920                    "    {} {}",
921                    field.name.name,
922                    format_source_value(&field.value)
923                ),
924            );
925        }
926        push_line(formatted, "  }");
927    }
928    push_line(formatted, "}");
929}
930
931fn format_event(event: EventDecl, formatted: &mut String) {
932    push_line(formatted, format!("signal {} {{", event.name));
933    for field in event.fields {
934        push_line(
935            formatted,
936            format!("  {} {}", field.name.name, field.ty.to_source()),
937        );
938    }
939    push_line(formatted, "}");
940}
941
942fn format_class(class_decl: ClassDecl, formatted: &mut String) {
943    push_line(formatted, format!("class {} {{", class_decl.name.name));
944    for field in class_decl.fields {
945        let key = if field.is_key { " @key" } else { "" };
946        push_line(
947            formatted,
948            format!("  {} {}{key}", field.name.name, field.ty.to_source()),
949        );
950    }
951    push_line(formatted, "}");
952}
953
954fn format_table(table: TableDecl, formatted: &mut String) {
955    format_tags(&table.tags, formatted);
956    format_description(table.description.as_ref(), formatted);
957    push_line(
958        formatted,
959        format!("table {} as {} [", table.name.name, table.schema.name),
960    );
961    for row in table.rows {
962        push_line(formatted, "  {");
963        for line in row.body.text.lines() {
964            if line.trim().is_empty() {
965                formatted.push('\n');
966            } else {
967                // `trim()` (not `trim_end()`): normalize the field to a fixed
968                // 4-space indent rather than prepending to the row's existing
969                // indent, which compounded every pass. Row bodies are flat field
970                // lists, so a fixed indent is the canonical form.
971                push_line(formatted, format!("    {}", line.trim()));
972            }
973        }
974        push_line(formatted, "  }");
975    }
976    push_line(formatted, "]");
977}
978
979fn format_coerce(coerce: CoerceDecl, formatted: &mut String) {
980    let params = coerce
981        .params
982        .into_iter()
983        .map(|param| format!("{} {}", param.name.name, param.ty.to_source()))
984        .collect::<Vec<_>>()
985        .join(", ");
986    push_line(
987        formatted,
988        format!(
989            "coerce {}({}) -> {} {{",
990            coerce.name.name,
991            params,
992            coerce.output.to_source()
993        ),
994    );
995    format_block_body(&coerce.body.text, formatted);
996    push_line(formatted, "}");
997}
998
999fn format_rule(rule: RuleDecl, formatted: &mut String) {
1000    format_tags(&rule.tags, formatted);
1001    format_description(rule.description.as_ref(), formatted);
1002    push_line(formatted, format!("rule {}", rule.name.name));
1003    for when in rule.whens {
1004        push_line(formatted, format!("  when {}", when.text));
1005    }
1006    push_line(formatted, "=> {");
1007    format_block_body(&rule.body.text, formatted);
1008    push_line(formatted, "}");
1009}
1010
1011/// Re-indent a rule/apply body to a canonical form derived from brace nesting,
1012/// so `whip fmt` is idempotent. Two concerns make this non-trivial:
1013///   - **Bracket nesting:** code lines are indented by their `{`/`[`/`(` depth
1014///     (string-aware via `scan_braces`), not by a flat prepend that compounds on
1015///     nested `record`/`complete` blocks.
1016///   - **Multi-line `"""..."""` strings:** the content is dedented to its common
1017///     indent and re-indented to the block depth (preserving relative structure).
1018///     This matches the single-pass canonical form AND is stable across passes,
1019///     where the old flat prepend grew the string content every time.
1020fn format_block_body(body: &str, formatted: &mut String) {
1021    if body.trim().is_empty() {
1022        return;
1023    }
1024    let lines: Vec<&str> = body.lines().collect();
1025    let mut index = 0;
1026    let mut depth: i32 = 1;
1027    while index < lines.len() {
1028        let trimmed = lines[index].trim();
1029        if trimmed.is_empty() {
1030            formatted.push('\n');
1031            index += 1;
1032            continue;
1033        }
1034        let opens_with_closer = trimmed
1035            .chars()
1036            .next()
1037            .is_some_and(|ch| matches!(ch, '}' | ']' | ')'));
1038        let line_depth = if opens_with_closer {
1039            (depth - 1).max(0)
1040        } else {
1041            depth
1042        };
1043        let prefix = "  ".repeat(line_depth as usize);
1044        let (delta, opens_triple) = scan_braces(trimmed);
1045        push_line(formatted, format!("{prefix}{trimmed}"));
1046        if opens_triple {
1047            // Collect the string content up to the closing `"""`.
1048            let mut end = index + 1;
1049            while end < lines.len() && lines[end].matches("\"\"\"").count().is_multiple_of(2) {
1050                end += 1;
1051            }
1052            let content = &lines[index + 1..end];
1053            let common = content
1054                .iter()
1055                .filter(|line| !line.trim().is_empty())
1056                .map(|line| line.len() - line.trim_start().len())
1057                .min()
1058                .unwrap_or(0);
1059            for line in content {
1060                if line.trim().is_empty() {
1061                    formatted.push('\n');
1062                } else {
1063                    push_line(formatted, format!("{prefix}{}", &line[common..]));
1064                }
1065            }
1066            if end < lines.len() {
1067                // The closing-delimiter line, re-indented to the block depth.
1068                push_line(formatted, format!("{prefix}{}", lines[end].trim()));
1069            }
1070            index = end + 1;
1071        } else {
1072            index += 1;
1073        }
1074        depth = (depth + delta).max(0);
1075    }
1076}