use crate::body::{self, BodyStmt};
use crate::body_print::{print_effect, push_stmt_line};
use crate::{Diagnostic, Item, SourceSpan};
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())
}
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();
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();
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) {
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());
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;
let dip = depth + brace_min_prefix_outside_strings(line);
if dip < depth {
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;
}
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")
}
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 }"
);
}
#[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 {"));
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");
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:?}"
);
}
}