whipplescript-parser 0.5.5

Parser and static checks for WhippleScript workflow source
Documentation
//! `then` continuation sugar (R2, language-refinement campaign).
//!
//! `then <binding> <- <effect statement>` chains an effect's SUCCESS into the
//! rest of the enclosing block: everything after the `then` line (to the end of
//! that block) desugars into `after <handle> succeeds as <binding> { … }`,
//! where `<handle>` is a synthetic, hidden effect binding in the reserved
//! `__then_` namespace. Pure source-to-source rewrite over rule-body TEXT
//! before analysis — no new runtime semantics; the kernel only ever sees the
//! traditional nested-`after` form.
//!
//! Design rulings carried here (spec/language-refinement-tracker.md R2):
//! - `then` chains success only. Failure/timeout of a chained step is covered
//!   by the R1 rule-level auto-fail net (the sugar is an explicit opt-in to
//!   auto-fail, so R1a's unhandled-failure warning exempts `__then_` bindings).
//! - The chained binding is the success payload (the `after … succeeds as`
//!   alias); the effect handle is synthetic and hidden — anything needing the
//!   handle (e.g. `cancel`) uses the traditional `as` + `after` form.
//! - `then` composes anywhere a block exists (rule bodies, `after` blocks,
//!   `case` arms).
//!
//! The rewrite is line-based and preserves every byte it does not own (the
//! `action_expand` precedent): only the `then` line itself is re-serialized
//! (its effect statement round-trips through the AST printer), and one closing
//! `}` is inserted where each chained block ends.

use crate::body::{self, BodyStmt};
use crate::body_print::{print_effect, push_stmt_line};
use crate::{Diagnostic, Item, SourceSpan};

/// The reserved namespace for synthetic `then` effect handles. Author bindings
/// may not start with it (checked before expansion), so a `__then_*` binding is
/// always generated — which is what lets R1a exempt it.
pub const THEN_BINDING_PREFIX: &str = "__then_";

pub fn expand_then_statements(items: &mut [Item], diagnostics: &mut Vec<Diagnostic>) {
    for item in items.iter_mut() {
        if let Item::Rule(rule) = item {
            let span = rule.body.span;
            if rule.body.text.contains(THEN_BINDING_PREFIX) {
                diagnostics.push(diag(
                    span,
                    format!(
                        "rule `{}` uses the reserved `{THEN_BINDING_PREFIX}` binding namespace \
                         (generated by `then` expansion)",
                        rule.name.name
                    ),
                    "rename the binding — `__then_*` names are reserved for the compiler",
                ));
                continue;
            }
            if !has_then_line(&rule.body.text) {
                continue;
            }
            rule.body.text = expand_in_text(&rule.body.text, span, diagnostics);
        }
    }
}

fn has_then_line(text: &str) -> bool {
    text.lines()
        .any(|line| parse_then_header(line.trim()).is_some())
}

/// `then <ident> <- …` → (binding, effect text after the arrow). Returns `None`
/// for anything else (including prompt prose that merely starts with "then"),
/// so only the full three-part header is ever treated as the sugar.
fn parse_then_header(trimmed: &str) -> Option<(String, String)> {
    let rest = trimmed.strip_prefix("then ")?;
    let arrow = rest.find("<-")?;
    let binding = rest[..arrow].trim();
    if binding.is_empty() || !crate::is_identifier(binding) {
        return None;
    }
    let effect = rest[arrow + 2..].trim_start();
    Some((binding.to_owned(), effect.to_owned()))
}

fn expand_in_text(text: &str, span: SourceSpan, diagnostics: &mut Vec<Diagnostic>) -> String {
    let lines: Vec<&str> = text.lines().collect();
    let mut out: Vec<String> = Vec::new();
    // Original-text brace depth at which each open wrap's `then` line sat; the
    // wrap's inserted `}` goes in when the ORIGINAL depth drops below it (its
    // enclosing block closed) — LIFO, before the closing line itself.
    let mut wraps: Vec<i32> = Vec::new();
    let mut depth: i32 = 0;
    let mut in_raw_string = false;
    let mut i = 0;
    while i < lines.len() {
        let line = lines[i];
        let trimmed = line.trim();
        // Raw `"""` blocks (tell/coerce prompts) are opaque prose: no depth
        // accounting, no `then` detection inside them.
        if in_raw_string {
            out.push(line.to_owned());
            if line.matches("\"\"\"").count() % 2 == 1 {
                in_raw_string = false;
            }
            i += 1;
            continue;
        }
        if let Some((binding, effect_first)) = parse_then_header(trimmed) {
            // The chained effect statement may span lines (a tell prompt, an
            // invoke payload); parse exactly one statement from the arrow on.
            let mut sub = effect_first.clone();
            for later in &lines[i + 1..] {
                sub.push('\n');
                sub.push_str(later);
            }
            let (statement, parse_diagnostics) = body::parse_first_statement(&sub, 0);
            let Some(BodyStmt::Effect(mut effect)) = statement else {
                diagnostics.push(diag(
                    span,
                    format!("`then {binding} <-` must chain an effect statement"),
                    "chain an effect (tell/coerce/exec/timer/invoke/…); \
                     for facts or terminals write the statement directly",
                ));
                diagnostics.extend(parse_diagnostics);
                out.push(line.to_owned());
                i += 1;
                continue;
            };
            if let Some(existing) = &effect.binding {
                diagnostics.push(diag(
                    span,
                    format!(
                        "`then {binding} <-` already binds the result — remove `as {existing}`"
                    ),
                    "the `then` binding IS the success payload; effects needing the \
                     raw handle (e.g. for `cancel`) use the traditional `as` + `after` form",
                ));
            }
            let handle = format!("{THEN_BINDING_PREFIX}{binding}");
            effect.binding = Some(handle.clone());
            // Consumed source lines: the parsed effect's byte extent within
            // `sub` (its first line is the text after `<-` on this line).
            let consumed_extra = sub[..effect.span.end.min(sub.len())].matches('\n').count();
            let level = (depth.max(0) as usize) + wraps.len() + 1;
            let mut printed = String::new();
            print_effect(&effect, level, &|name: &str| name.to_owned(), &mut printed);
            for printed_line in printed.lines() {
                out.push(printed_line.to_owned());
            }
            let mut opener = String::new();
            push_stmt_line(
                &mut opener,
                level,
                &format!("after {handle} succeeds as {binding} {{"),
            );
            out.push(opener.trim_end_matches('\n').to_owned());
            wraps.push(depth);
            i += 1 + consumed_extra;
            continue;
        }
        let delta = brace_delta_outside_strings(line);
        let new_depth = depth + delta;
        // The line's LOWEST intra-line depth, not just its net delta: a
        // net-zero joint line (`} on lapse {`, a case arm's `} X => {`)
        // still LEAVES the block a wrap was opened in, so the wrap must
        // close before it.
        let dip = depth + brace_min_prefix_outside_strings(line);
        if dip < depth {
            // This line closes block(s): any wrap opened at a depth now being
            // left closes first, so its `}` nests inside the original block.
            while let Some(open_depth) = wraps.last().copied() {
                if dip < open_depth {
                    let mut closer = String::new();
                    push_stmt_line(
                        &mut closer,
                        (new_depth.max(0) as usize) + wraps.len() + 1,
                        "}",
                    );
                    out.push(closer.trim_end_matches('\n').to_owned());
                    wraps.pop();
                } else {
                    break;
                }
            }
        }
        out.push(line.to_owned());
        if line.matches("\"\"\"").count() % 2 == 1 {
            in_raw_string = true;
        }
        depth = new_depth;
        i += 1;
    }
    // Wraps still open at end of body close here (the rule body's own closing
    // brace lives outside this text).
    while wraps.pop().is_some() {
        let mut closer = String::new();
        push_stmt_line(&mut closer, wraps.len() + 1, "}");
        out.push(closer.trim_end_matches('\n').to_owned());
    }
    out.join("\n")
}

/// Brace depth delta ignoring braces inside double-quoted string literals on
/// the same line (multi-line `"""` blocks are excluded by the caller).
/// The minimum running brace delta while scanning the line left to right
/// (0 if the line never dips below its starting depth). Distinguishes a
/// net-zero joint line like `} on lapse {` from a truly flat line.
fn brace_min_prefix_outside_strings(line: &str) -> i32 {
    let mut delta = 0i32;
    let mut min = 0i32;
    let mut in_string = false;
    let mut chars = line.chars().peekable();
    while let Some(c) = chars.next() {
        match c {
            '"' => in_string = !in_string,
            '\\' if in_string => {
                let _ = chars.next();
            }
            '{' if !in_string => delta += 1,
            '}' if !in_string => {
                delta -= 1;
                if delta < min {
                    min = delta;
                }
            }
            _ => {}
        }
    }
    min
}

fn brace_delta_outside_strings(line: &str) -> i32 {
    let mut delta = 0i32;
    let mut in_string = false;
    let mut chars = line.chars().peekable();
    while let Some(ch) = chars.next() {
        match ch {
            '"' => in_string = !in_string,
            '\\' if in_string => {
                let _ = chars.next();
            }
            '{' if !in_string => delta += 1,
            '}' if !in_string => delta -= 1,
            _ => {}
        }
    }
    delta
}

fn diag(span: SourceSpan, message: String, suggestion: &str) -> Diagnostic {
    Diagnostic {
        related: Vec::new(),
        span,
        message,
        suggestion: Some(suggestion.to_owned()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn expand(text: &str) -> (String, Vec<Diagnostic>) {
        let mut diagnostics = Vec::new();
        let span = SourceSpan { start: 0, end: 0 };
        let out = expand_in_text(text, span, &mut diagnostics);
        (out, diagnostics)
    }

    #[test]
    fn single_then_wraps_the_rest_of_the_body() {
        let (out, diagnostics) = expand(
            "  exec \"true\" as pre\n  then v <- exec \"deploy\"\n  complete result { note \"ok\" }",
        );
        assert_eq!(diagnostics, Vec::new());
        assert_eq!(
            out,
            "  exec \"true\" as pre\n  exec \"deploy\" as __then_v\n  after __then_v succeeds as v {\n  complete result { note \"ok\" }\n  }"
        );
    }

    /// Regression: `finish <item> { … }` printed its payload block WITHOUT the
    /// trailing binding, so a `then`-chained finish desugared to a bindingless
    /// effect and the generated `after __then_x succeeds` block referenced an
    /// unknown binding (a check error on the tracker round-trip idiom).
    #[test]
    fn then_chained_finish_keeps_its_synthetic_binding() {
        let (out, diagnostics) = expand(
            "  then closed <- finish item {\n    summary \"applied\"\n  }\n  complete result { note \"ok\" }",
        );
        assert_eq!(diagnostics, Vec::new());
        assert!(
            out.contains("} as __then_closed"),
            "the re-serialized finish must carry the synthetic handle: {out}"
        );
        assert!(out.contains("after __then_closed succeeds as closed {"));
    }

    #[test]
    fn chained_thens_nest() {
        let (out, diagnostics) = expand(
            "  then a <- exec \"one\"\n  then b <- exec \"two\"\n  complete result { note b.note }",
        );
        assert_eq!(diagnostics, Vec::new());
        assert!(out.contains("exec \"one\" as __then_a"));
        assert!(out.contains("after __then_a succeeds as a {"));
        assert!(out.contains("exec \"two\" as __then_b"));
        assert!(out.contains("after __then_b succeeds as b {"));
        // Both wraps close at end, innermost first.
        assert!(out.trim_end().ends_with('}'));
        assert_eq!(out.matches('{').count(), out.matches('}').count());
    }

    #[test]
    fn then_inside_an_after_block_closes_before_the_block() {
        let (out, diagnostics) = expand(
            "  after x succeeds {\n    then v <- exec \"go\"\n    record Seen { note v.note }\n  }\n  complete result { note \"ok\" }",
        );
        assert_eq!(diagnostics, Vec::new());
        let closing = out
            .lines()
            .position(|line| line.trim() == "}" && out.lines().count() > 0)
            .expect("wrap closer");
        // The wrap's `}` appears before the enclosing block's `}` line, and the
        // trailing complete stays at top level (outside every brace).
        let after_pos = out.find("after __then_v succeeds as v {").expect("wrap");
        let complete_pos = out.find("complete result").expect("terminal");
        assert!(after_pos < complete_pos);
        let _ = closing;
        let mut depth = 0i32;
        for line in out.lines() {
            if line.contains("complete result") {
                assert_eq!(depth, 0, "terminal must sit at top level:\n{out}");
            }
            depth += brace_delta_outside_strings(line);
        }
        assert_eq!(depth, 0, "balanced braces:\n{out}");
    }

    #[test]
    fn prompt_prose_is_not_mistaken_for_then() {
        let source = "  tell worker as turn \"\"\"markdown\n  then x <- do the thing\n  \"\"\"\n  complete result { note \"ok\" }";
        let (out, diagnostics) = expand(source);
        assert_eq!(diagnostics, Vec::new());
        assert_eq!(out, source);
    }

    #[test]
    fn then_with_explicit_as_is_rejected() {
        let (_, diagnostics) =
            expand("  then v <- exec \"go\" as w\n  complete result { note \"ok\" }");
        assert!(
            diagnostics
                .iter()
                .any(|d| d.message.contains("already binds the result")),
            "{diagnostics:?}"
        );
    }

    #[test]
    fn then_of_a_non_effect_is_rejected() {
        let (_, diagnostics) = expand("  then v <- record Seen { note \"x\" }\n");
        assert!(
            diagnostics
                .iter()
                .any(|d| d.message.contains("must chain an effect statement")),
            "{diagnostics:?}"
        );
    }
}