use std::collections::{HashMap, HashSet};
use crate::parser::ast::*;
use crate::parser::errors::ParseError;
use crate::parser::tokens::{Token, TokenType};
fn is_keyword_token(ty: &TokenType) -> bool {
matches!(
ty,
TokenType::Version
| TokenType::Workflow
| TokenType::Run
| TokenType::If
| TokenType::Not
| TokenType::While
| TokenType::ParAnd
| TokenType::Exec
| TokenType::Harness
| TokenType::Prompt
| TokenType::PromptFile
| TokenType::Args
| TokenType::FailPolicy
| TokenType::Match
| TokenType::Else
)
}
struct Parser {
tokens: Vec<Token>,
pos: usize,
filename: String,
errors: Vec<ParseError>,
}
impl Parser {
fn new(tokens: Vec<Token>, filename: &str) -> Self {
Parser {
tokens,
pos: 0,
filename: filename.to_string(),
errors: Vec::new(),
}
}
fn peek(&self) -> &Token {
self.tokens
.get(self.pos)
.unwrap_or_else(|| self.eof_token())
}
#[allow(dead_code)]
fn peek_at(&self, n: usize) -> &Token {
self.tokens
.get(self.pos + n)
.unwrap_or_else(|| self.eof_token())
}
fn advance(&mut self) -> Token {
let tok = self
.tokens
.get(self.pos)
.cloned()
.unwrap_or_else(|| self.eof_token().clone());
if self.pos < self.tokens.len() {
self.pos += 1;
}
tok
}
fn is_at_end(&self) -> bool {
self.peek().ty == TokenType::Eof
}
fn check(&self, ty: &TokenType) -> bool {
self.peek().ty == *ty
}
#[allow(dead_code)]
fn match_token(&mut self, ty: &TokenType) -> Option<Token> {
if self.check(ty) {
Some(self.advance())
} else {
None
}
}
fn expect(&mut self, ty: &TokenType, error_msg: &str) -> Option<Token> {
if self.check(ty) {
Some(self.advance())
} else {
let tok = self.peek().clone();
self.add_error(&tok, error_msg);
None
}
}
fn skip_newlines(&mut self) {
while self.check(&TokenType::Newline) {
self.advance();
}
}
fn consume_newline(&mut self) {
if self.check(&TokenType::Newline) {
self.advance();
}
}
fn add_error(&mut self, at: &Token, message: impl Into<String>) {
self.errors
.push(ParseError::new(&self.filename, at.line, at.column, message));
}
fn eof_token(&self) -> &Token {
if let Some(last) = self.tokens.last() {
if last.ty == TokenType::Eof {
return last;
}
}
static FALLBACK_EOF: Token = Token {
ty: TokenType::Eof,
value: String::new(),
line: 1,
column: 1,
};
&FALLBACK_EOF
}
fn expect_name(&mut self, context: &str) -> Option<String> {
let tok = self.peek().clone();
if tok.ty == TokenType::Name {
self.advance();
return Some(tok.value);
}
if is_keyword_token(&tok.ty) {
self.add_error(
&tok,
format!(
"reserved keyword \"{}\" cannot be used as {}",
tok.value, context
),
);
return None;
}
self.add_error(&tok, format!("expected name for {}", context));
None
}
}
pub fn parse(tokens: Vec<Token>, filename: &str) -> ParseResult {
let mut p = Parser::new(tokens, filename);
p.skip_newlines();
if !parse_version_header(&mut p) {
return ParseResult::Err { errors: p.errors };
}
let mut workflows = Vec::new();
p.skip_newlines();
while !p.is_at_end() {
let tok = p.peek().clone();
match tok.ty {
TokenType::Workflow => {
if let Some(wf) = parse_workflow(&mut p) {
workflows.push(wf);
} else {
skip_to_newline(&mut p);
}
}
TokenType::Newline => {
p.advance();
}
TokenType::Dedent => {
p.advance();
}
_ => {
p.add_error(
&tok,
format!("unexpected token \"{}\" at top level", tok.value),
);
skip_to_newline(&mut p);
}
}
}
if !p.errors.is_empty() {
return ParseResult::Err { errors: p.errors };
}
ParseResult::Ok { workflows }
}
fn parse_version_header(p: &mut Parser) -> bool {
let tok = p.peek().clone();
if tok.ty != TokenType::Version {
p.add_error(&tok, "missing version header");
return false;
}
p.advance();
let num_tok = p.peek().clone();
if num_tok.ty != TokenType::Number {
p.add_error(&num_tok, "expected version number after 'version'");
return false;
}
p.advance();
if num_tok.value != "1" {
p.add_error(&num_tok, format!("unrecognized version: {}", num_tok.value));
return false;
}
p.consume_newline();
true
}
fn parse_workflow(p: &mut Parser) -> Option<WorkflowDecl> {
let kw_tok = p.advance(); let line = kw_tok.line;
let column = kw_tok.column;
let name = p.expect_name("workflow name")?;
p.consume_newline();
let body = parse_block(p);
Some(WorkflowDecl {
name,
body,
file: p.filename.clone(),
line,
column,
})
}
fn parse_block(p: &mut Parser) -> Vec<Statement> {
if !p.check(&TokenType::Indent) {
return Vec::new();
}
p.advance();
let mut statements = Vec::new();
while !p.is_at_end() && !p.check(&TokenType::Dedent) {
if p.check(&TokenType::Newline) {
p.advance();
continue;
}
if let Some(stmt) = parse_statement(p) {
statements.push(stmt);
} else {
skip_to_newline(p);
}
}
if p.check(&TokenType::Dedent) {
p.advance(); }
statements
}
fn parse_statement(p: &mut Parser) -> Option<Statement> {
let tok = p.peek().clone();
match tok.ty {
TokenType::Run => parse_run(p).map(Statement::Run),
TokenType::If => parse_if(p),
TokenType::While => parse_while(p),
TokenType::ParAnd => parse_par_and(p).map(Statement::ParAnd),
TokenType::Exec => parse_exec(p).map(Statement::Exec),
TokenType::Match => parse_match(p).map(Statement::Match),
_ => {
p.add_error(&tok, format!("unexpected token \"{}\" in block", tok.value));
None
}
}
}
fn parse_run(p: &mut Parser) -> Option<RunStatement> {
let kw_tok = p.advance(); let line = kw_tok.line;
let column = kw_tok.column;
let name = p.expect_name("run target")?;
p.consume_newline();
Some(RunStatement {
workflow_name: name,
line,
column,
})
}
fn parse_conditional(
p: &mut Parser,
keyword_label: &str,
make_pos: fn(String, Vec<Statement>, usize, usize) -> Statement,
make_neg: fn(String, Vec<Statement>, usize, usize) -> Statement,
) -> Option<Statement> {
let kw_tok = p.advance(); let line = kw_tok.line;
let column = kw_tok.column;
let is_not = p.check(&TokenType::Not);
if is_not {
p.advance(); }
let context = if is_not {
format!("{} not check name", keyword_label)
} else {
format!("{} check name", keyword_label)
};
let check_name = p.expect_name(&context)?;
p.consume_newline();
let body = parse_block(p);
if is_not {
Some(make_neg(check_name, body, line, column))
} else {
Some(make_pos(check_name, body, line, column))
}
}
fn parse_if(p: &mut Parser) -> Option<Statement> {
parse_conditional(
p,
"if",
|name, body, line, col| {
Statement::If(IfStatement {
check_name: name,
body,
line,
column: col,
})
},
|name, body, line, col| {
Statement::IfNot(IfNotStatement {
check_name: name,
body,
line,
column: col,
})
},
)
}
fn parse_while(p: &mut Parser) -> Option<Statement> {
parse_conditional(
p,
"while",
|name, body, line, col| {
Statement::While(WhileStatement {
check_name: name,
body,
line,
column: col,
})
},
|name, body, line, col| {
Statement::WhileNot(WhileNotStatement {
check_name: name,
body,
line,
column: col,
})
},
)
}
fn parse_par_and(p: &mut Parser) -> Option<ParAndStatement> {
let kw_tok = p.advance(); let line = kw_tok.line;
let column = kw_tok.column;
let join_name = p.expect_name("par-and join workflow name")?;
let mut fail_policy: Option<FailPolicy> = None;
if p.check(&TokenType::FailPolicy) {
p.advance();
if p.expect(&TokenType::Colon, "expected ':' after fail-policy")
.is_none()
{
return None;
}
let policy_tok = p.peek().clone();
if policy_tok.ty != TokenType::Name {
p.add_error(
&policy_tok,
"expected fail policy value after 'fail-policy:'",
);
return None;
}
p.advance();
match policy_tok.value.as_str() {
"fail-fast" => fail_policy = Some(FailPolicy::FailFast),
"wait-then-fail" => fail_policy = Some(FailPolicy::WaitThenFail),
_ => {
p.add_error(
&policy_tok,
format!(
"invalid fail policy \"{}\"; expected \"fail-fast\" or \"wait-then-fail\"",
policy_tok.value
),
);
return None;
}
}
}
p.consume_newline();
let mut branches: Vec<RunStatement> = Vec::new();
if p.check(&TokenType::Indent) {
p.advance();
while !p.is_at_end() && !p.check(&TokenType::Dedent) {
if p.check(&TokenType::Newline) {
p.advance();
continue;
}
let tok = p.peek().clone();
if tok.ty != TokenType::Run {
p.add_error(
&tok,
format!(
"only \"run\" statements are allowed inside par-and, got \"{}\"",
tok.value
),
);
skip_to_newline(p);
continue;
}
if let Some(run_stmt) = parse_run(p) {
branches.push(run_stmt);
} else {
skip_to_newline(p);
}
}
if p.check(&TokenType::Dedent) {
p.advance();
}
}
Some(ParAndStatement {
join_workflow_name: join_name,
branches,
fail_policy,
line,
column,
})
}
fn parse_field_value(p: &mut Parser, field_name: &str) -> Option<String> {
p.advance(); if p.expect(
&TokenType::Colon,
&format!("expected ':' after {}", field_name),
)
.is_none()
{
skip_to_newline(p);
return None;
}
let val_tok = p.peek().clone();
if matches!(
val_tok.ty,
TokenType::Name | TokenType::Str | TokenType::BareValue
) {
p.advance();
Some(val_tok.value)
} else {
p.add_error(&val_tok, format!("expected value after '{}:'", field_name));
skip_to_newline(p);
None
}
}
fn parse_exec(p: &mut Parser) -> Option<ExecBlock> {
let kw_tok = p.advance(); let line = kw_tok.line;
let column = kw_tok.column;
p.consume_newline();
let mut harness: Option<String> = None;
let mut prompt: Option<String> = None;
let mut prompt_file: Option<String> = None;
let mut args: Option<HashMap<String, String>> = None;
if !p.check(&TokenType::Indent) {
let tok = p.peek().clone();
p.add_error(&tok, "expected indented block after exec");
return None;
}
p.advance();
while !p.is_at_end() && !p.check(&TokenType::Dedent) {
if p.check(&TokenType::Newline) {
p.advance();
continue;
}
let field_tok = p.peek().clone();
match field_tok.ty {
TokenType::Harness => {
if let Some(val) = parse_field_value(p, "harness") {
harness = Some(val);
} else {
continue;
}
}
TokenType::PromptFile => {
if let Some(val) = parse_field_value(p, "prompt_file") {
prompt_file = Some(val);
} else {
continue;
}
}
TokenType::Prompt => {
if let Some(val) = parse_field_value(p, "prompt") {
prompt = Some(val);
} else {
continue;
}
}
TokenType::Args => {
p.advance(); if p.expect(&TokenType::Colon, "expected ':' after args")
.is_none()
{
skip_to_newline(p);
continue;
}
if !p.check(&TokenType::Newline)
&& !p.check(&TokenType::Indent)
&& !p.check(&TokenType::Eof)
&& !p.check(&TokenType::Dedent)
{
let tok = p.peek().clone();
p.add_error(&tok, "args must be a nested block, not an inline value");
skip_to_newline(p);
continue;
}
p.consume_newline();
args = Some(parse_args_block(p));
}
_ => {
p.add_error(
&field_tok,
format!("unknown exec field \"{}\"", field_tok.value),
);
skip_to_newline(p);
continue;
}
}
p.consume_newline();
}
if p.check(&TokenType::Dedent) {
p.advance(); }
if harness.is_none() {
p.add_error(&kw_tok, "exec block is missing required field 'harness'");
return None;
}
if prompt.is_some() && prompt_file.is_some() {
p.add_error(
&kw_tok,
"exec block has both 'prompt' and 'prompt_file'; only one is allowed",
);
return None;
}
if prompt.is_none() && prompt_file.is_none() {
p.add_error(
&kw_tok,
"exec block must have either 'prompt' or 'prompt_file'",
);
return None;
}
Some(ExecBlock {
harness: harness.unwrap(),
prompt,
prompt_file,
args,
line,
column,
})
}
fn parse_args_block(p: &mut Parser) -> HashMap<String, String> {
let mut result = HashMap::new();
if !p.check(&TokenType::Indent) {
return result;
}
p.advance();
while !p.is_at_end() && !p.check(&TokenType::Dedent) {
if p.check(&TokenType::Newline) {
p.advance();
continue;
}
let key_tok = p.peek().clone();
if key_tok.ty != TokenType::Name {
p.add_error(
&key_tok,
format!("expected argument name, got \"{}\"", key_tok.value),
);
skip_to_newline(p);
continue;
}
p.advance();
if p.expect(
&TokenType::Colon,
&format!("expected ':' after argument name \"{}\"", key_tok.value),
)
.is_none()
{
skip_to_newline(p);
continue;
}
let val_tok = p.peek().clone();
if val_tok.ty == TokenType::Name
|| val_tok.ty == TokenType::Str
|| val_tok.ty == TokenType::Number
|| val_tok.ty == TokenType::BareValue
{
result.insert(key_tok.value, val_tok.value);
p.advance();
} else {
p.add_error(
&val_tok,
format!("expected value for argument \"{}\"", key_tok.value),
);
skip_to_newline(p);
continue;
}
p.consume_newline();
}
if p.check(&TokenType::Dedent) {
p.advance(); }
result
}
fn parse_match(p: &mut Parser) -> Option<MatchStatement> {
let kw_tok = p.advance(); let line = kw_tok.line;
let column = kw_tok.column;
let check_name = p.expect_name("match check name")?;
p.consume_newline();
if !p.check(&TokenType::Indent) {
let tok = p.peek().clone();
p.add_error(&tok, "expected indented block of arms after match");
return None;
}
p.advance();
let mut arms: Vec<MatchArm> = Vec::new();
let mut else_body: Option<Vec<Statement>> = None;
let mut else_line: Option<usize> = None;
let mut else_column: Option<usize> = None;
let mut seen_variants: HashSet<String> = HashSet::new();
let mut seen_else = false;
while !p.is_at_end() && !p.check(&TokenType::Dedent) {
if p.check(&TokenType::Newline) {
p.advance();
continue;
}
let arm_tok = p.peek().clone();
let is_else;
let variant_name: String;
if arm_tok.ty == TokenType::Else {
is_else = true;
variant_name = "else".to_string();
p.advance(); } else if arm_tok.ty == TokenType::Name {
is_else = false;
variant_name = arm_tok.value.clone();
p.advance(); } else if is_keyword_token(&arm_tok.ty) {
p.add_error(
&arm_tok,
format!(
"reserved keyword \"{}\" cannot be used as a variant name",
arm_tok.value
),
);
skip_to_newline(p);
continue;
} else {
p.add_error(&arm_tok, "expected variant name or 'else'");
skip_to_newline(p);
continue;
}
if seen_else {
p.add_error(
&arm_tok,
"'else' arm must be the last arm in a match statement",
);
skip_to_newline(p);
continue;
}
if !is_else && seen_variants.contains(&variant_name) {
p.add_error(
&arm_tok,
format!("duplicate variant \"{}\" in match arms", variant_name),
);
skip_to_newline(p);
continue;
}
if p.expect(&TokenType::Arrow, "expected '->' after variant name")
.is_none()
{
skip_to_newline(p);
continue;
}
let body: Vec<Statement>;
if p.check(&TokenType::Newline) {
p.consume_newline();
body = parse_block(p);
} else if p.check(&TokenType::Run) {
if let Some(run_stmt) = parse_run(p) {
body = vec![Statement::Run(run_stmt)];
} else {
skip_to_newline(p);
continue;
}
} else {
let tok = p.peek().clone();
p.add_error(
&tok,
"expected 'run' statement or indented block after '->'",
);
skip_to_newline(p);
continue;
}
if is_else {
seen_else = true;
else_body = Some(body);
else_line = Some(arm_tok.line);
else_column = Some(arm_tok.column);
} else {
seen_variants.insert(variant_name.clone());
arms.push(MatchArm {
variant: variant_name,
body,
line: arm_tok.line,
column: arm_tok.column,
});
}
}
if p.check(&TokenType::Dedent) {
p.advance(); }
if arms.is_empty() && else_body.is_none() {
p.add_error(&kw_tok, "match statement must have at least one arm");
return None;
}
Some(MatchStatement {
check_name,
arms,
else_body,
else_line,
else_column,
line,
column,
})
}
fn skip_to_newline(p: &mut Parser) {
while !p.is_at_end() && !p.check(&TokenType::Newline) && !p.check(&TokenType::Dedent) {
p.advance();
}
p.consume_newline();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::lexer::lex;
fn parse_source(source: &str) -> ParseResult {
let lex_result = lex(source, "test.7");
assert!(
lex_result.errors.is_empty(),
"Lex errors: {:?}",
lex_result.errors
);
parse(lex_result.tokens, "test.7")
}
fn parse_ok(source: &str) -> Vec<WorkflowDecl> {
let result = parse_source(source);
assert!(result.is_ok(), "errors: {:?}", result.errors());
result.workflows().unwrap().clone()
}
fn find_wf<'a>(wfs: &'a [WorkflowDecl], name: &str) -> &'a WorkflowDecl {
wfs.iter()
.find(|w| w.name == name)
.unwrap_or_else(|| panic!("workflow \"{}\" not found", name))
}
#[test]
fn test_parse_minimal_workflow() {
let wfs = parse_ok("version 1\n\nworkflow main\n run greet\n");
assert_eq!(wfs.len(), 1);
assert_eq!(wfs[0].name, "main");
assert_eq!(wfs[0].body.len(), 1);
}
#[test]
fn test_parse_missing_version() {
let result = parse_source("workflow main\n run greet\n");
assert!(!result.is_ok());
assert!(result.errors().unwrap()[0].message.contains("version"));
}
#[test]
fn test_parse_wrong_version() {
let result = parse_source("version 2\n\nworkflow main\n");
assert!(!result.is_ok());
assert!(
result.errors().unwrap()[0]
.message
.contains("unrecognized version")
);
}
#[test]
fn test_parse_if_not() {
parse_ok(
"version 1\nworkflow check\n exec\n harness: h\n prompt: \"x\"\nworkflow main\n if not check\n run check\n",
);
}
#[test]
fn test_parse_par_and() {
let wfs = parse_ok(
"version 1\nworkflow a\n exec\n harness: h\n prompt: \"x\"\nworkflow b\n exec\n harness: h\n prompt: \"x\"\nworkflow join\n exec\n harness: h\n prompt: \"x\"\nworkflow main\n par-and join\n run a\n run b\n",
);
let main_wf = find_wf(&wfs, "main");
assert_eq!(main_wf.body.len(), 1);
match &main_wf.body[0] {
Statement::ParAnd(p) => {
assert_eq!(p.join_workflow_name, "join");
assert_eq!(p.branches.len(), 2);
}
_ => panic!("Expected par-and statement"),
}
}
#[test]
fn test_parse_exec_with_args() {
let wfs = parse_ok(
"version 1\nworkflow main\n exec\n harness: claude\n prompt_file: prompts/foo.md\n args:\n mode: implement\n model: sonnet\n",
);
match &wfs[0].body[0] {
Statement::Exec(e) => {
assert_eq!(e.harness, "claude");
assert_eq!(e.prompt_file.as_deref(), Some("prompts/foo.md"));
let args = e.args.as_ref().unwrap();
assert_eq!(args.get("mode").unwrap(), "implement");
assert_eq!(args.get("model").unwrap(), "sonnet");
}
_ => panic!("Expected exec statement"),
}
}
#[test]
fn test_parse_exec_both_prompt_error() {
let result = parse_source(
"version 1\nworkflow main\n exec\n harness: h\n prompt: \"x\"\n prompt_file: foo\n",
);
assert!(!result.is_ok());
assert!(result.errors().unwrap()[0].message.contains("both"));
}
#[test]
fn test_parse_exec_neither_prompt_error() {
let result = parse_source("version 1\nworkflow main\n exec\n harness: h\n");
assert!(!result.is_ok());
assert!(
result.errors().unwrap()[0]
.message
.contains("must have either")
);
}
#[test]
fn test_parse_par_and_non_run_child() {
let result = parse_source(
"version 1\nworkflow a\n exec\n harness: h\n prompt: \"x\"\nworkflow main\n par-and a\n exec\n harness: h\n prompt: \"x\"\n",
);
assert!(!result.is_ok());
assert!(result.errors().unwrap()[0].message.contains("only \"run\""));
}
#[test]
fn test_parse_reserved_keyword_as_name() {
let result =
parse_source("version 1\nworkflow run\n exec\n harness: h\n prompt: \"x\"\n");
assert!(!result.is_ok());
assert!(
result.errors().unwrap()[0]
.message
.contains("reserved keyword")
);
}
#[test]
fn test_parse_empty_workflow() {
let wfs = parse_ok("version 1\nworkflow empty\n");
assert_eq!(wfs.len(), 1);
assert_eq!(wfs[0].name, "empty");
assert!(wfs[0].body.is_empty());
}
#[test]
fn test_parse_multiple_workflows() {
let wfs = parse_ok(
"version 1\nworkflow a\n exec\n harness: h\n prompt: \"x\"\nworkflow b\n exec\n harness: h\n prompt: \"y\"\n",
);
assert_eq!(wfs.len(), 2);
assert_eq!(wfs[0].name, "a");
assert_eq!(wfs[1].name, "b");
}
#[test]
fn test_parse_while_loop() {
let wfs = parse_ok(
"version 1\nworkflow check\n exec\n harness: h\n prompt: \"x\"\nworkflow main\n while check\n run check\n",
);
match &find_wf(&wfs, "main").body[0] {
Statement::While(w) => {
assert_eq!(w.check_name, "check");
assert_eq!(w.body.len(), 1);
}
_ => panic!("Expected while statement"),
}
}
#[test]
fn test_parse_while_not_loop() {
let wfs = parse_ok(
"version 1\nworkflow done\n exec\n harness: h\n prompt: \"x\"\nworkflow main\n while not done\n run done\n",
);
match &find_wf(&wfs, "main").body[0] {
Statement::WhileNot(w) => {
assert_eq!(w.check_name, "done");
}
_ => panic!("Expected while-not statement"),
}
}
#[test]
fn test_parse_if_statement() {
let wfs = parse_ok(
"version 1\nworkflow check\n exec\n harness: h\n prompt: \"x\"\nworkflow main\n if check\n run check\n",
);
match &find_wf(&wfs, "main").body[0] {
Statement::If(i) => {
assert_eq!(i.check_name, "check");
assert_eq!(i.body.len(), 1);
}
_ => panic!("Expected if statement"),
}
}
#[test]
fn test_parse_exec_with_prompt_string() {
let wfs = parse_ok(
"version 1\nworkflow main\n exec\n harness: claude\n prompt: \"hello world\"\n",
);
match &wfs[0].body[0] {
Statement::Exec(e) => {
assert_eq!(e.harness, "claude");
assert_eq!(e.prompt.as_deref(), Some("hello world"));
assert!(e.prompt_file.is_none());
}
_ => panic!("Expected exec statement"),
}
}
#[test]
fn test_parse_par_and_with_fail_policy() {
let wfs = parse_ok(
"version 1\nworkflow a\n exec\n harness: h\n prompt: \"x\"\nworkflow join\n exec\n harness: h\n prompt: \"x\"\nworkflow main\n par-and join fail-policy: fail-fast\n run a\n",
);
match &find_wf(&wfs, "main").body[0] {
Statement::ParAnd(pa) => {
assert_eq!(pa.fail_policy, Some(FailPolicy::FailFast));
}
_ => panic!("Expected par-and statement"),
}
}
#[test]
fn test_parse_unexpected_token_at_top_level() {
let result = parse_source("version 1\nrun greet\n");
assert!(!result.is_ok());
assert!(
result.errors().unwrap()[0]
.message
.contains("unexpected token")
);
}
#[test]
fn test_parse_unexpected_token_in_block() {
let result = parse_source("version 1\nworkflow main\n version 1\n");
assert!(!result.is_ok());
assert!(
result.errors().unwrap()[0]
.message
.contains("unexpected token")
);
}
#[test]
fn test_parse_namespaced_workflow() {
let wfs = parse_ok(
"version 1\nworkflow reviews::security\n exec\n harness: h\n prompt: \"x\"\n",
);
assert_eq!(wfs[0].name, "reviews::security");
}
#[test]
fn test_parse_exec_missing_harness() {
let result = parse_source("version 1\nworkflow main\n exec\n prompt: \"x\"\n");
assert!(!result.is_ok());
assert!(result.errors().unwrap()[0].message.contains("harness"));
}
#[test]
fn test_parse_workflow_line_column() {
let wfs = parse_ok("version 1\nworkflow main\n run greet\n");
assert_eq!(wfs[0].line, 2);
assert_eq!(wfs[0].column, 1);
}
#[test]
fn test_parse_run_statement_line_column() {
let wfs = parse_ok("version 1\nworkflow main\n run greet\n");
match &wfs[0].body[0] {
Statement::Run(r) => {
assert_eq!(r.line, 3);
assert_eq!(r.column, 3);
}
_ => panic!("Expected run statement"),
}
}
#[test]
fn test_parse_match_basic() {
let wfs = parse_ok(
"version 1\nworkflow check\n exec\n harness: h\n prompt: \"x\"\nworkflow main\n match check\n small -> run check\n large -> run check\n",
);
let main_wf = find_wf(&wfs, "main");
match &main_wf.body[0] {
Statement::Match(m) => {
assert_eq!(m.check_name, "check");
assert_eq!(m.arms.len(), 2);
assert_eq!(m.arms[0].variant, "small");
assert_eq!(m.arms[1].variant, "large");
assert!(m.else_body.is_none());
}
_ => panic!("Expected match statement"),
}
}
#[test]
fn test_parse_match_with_else() {
let wfs = parse_ok(
"version 1\nworkflow check\n exec\n harness: h\n prompt: \"x\"\nworkflow main\n match check\n small -> run check\n else -> run check\n",
);
match &find_wf(&wfs, "main").body[0] {
Statement::Match(m) => {
assert_eq!(m.arms.len(), 1);
assert_eq!(m.arms[0].variant, "small");
assert!(m.else_body.is_some());
assert_eq!(m.else_body.as_ref().unwrap().len(), 1);
}
_ => panic!("Expected match statement"),
}
}
#[test]
fn test_parse_match_multi_statement_arm() {
let wfs = parse_ok(
"version 1\nworkflow a\n exec\n harness: h\n prompt: \"x\"\nworkflow main\n match a\n big ->\n run a\n run a\n small -> run a\n",
);
match &find_wf(&wfs, "main").body[0] {
Statement::Match(m) => {
assert_eq!(m.arms[0].variant, "big");
assert_eq!(m.arms[0].body.len(), 2);
assert_eq!(m.arms[1].variant, "small");
assert_eq!(m.arms[1].body.len(), 1);
}
_ => panic!("Expected match statement"),
}
}
#[test]
fn test_parse_match_duplicate_variant_error() {
let result = parse_source(
"version 1\nworkflow main\n match check\n small -> run check\n small -> run check\n",
);
assert!(!result.is_ok());
assert!(result.errors().unwrap()[0].message.contains("duplicate"));
}
#[test]
fn test_parse_match_else_not_last_error() {
let result = parse_source(
"version 1\nworkflow main\n match check\n else -> run check\n small -> run check\n",
);
assert!(!result.is_ok());
assert!(result.errors().unwrap()[0].message.contains("else"));
}
#[test]
fn test_parse_match_keyword_variant_error() {
let result =
parse_source("version 1\nworkflow main\n match check\n run -> run check\n");
assert!(!result.is_ok());
assert!(
result.errors().unwrap()[0]
.message
.contains("reserved keyword")
);
}
#[test]
fn test_parse_match_empty_arms_error() {
let result = parse_source("version 1\nworkflow main\n match check\n");
assert!(
!result.is_ok(),
"expected error for empty match, but got ok"
);
let errors = result.errors().unwrap();
assert!(
errors
.iter()
.any(|e| e.message.contains("at least one arm") || e.message.contains("arm")),
"expected 'at least one arm' error, got: {:?}",
errors
);
}
#[test]
fn test_parse_match_serialization_elseBody_absent_when_no_else() {
use crate::parser::lexer::lex;
let source = "version 1\nworkflow check\n exec\n harness: h\n prompt: \"x\"\nworkflow main\n match check\n small -> run check\n";
let lex_result = lex(source, "test.7");
let parse_result = parse(lex_result.tokens, "test.7");
let wfs = parse_result.workflows().unwrap();
let stmt = &wfs.iter().find(|w| w.name == "main").unwrap().body[0];
let json = serde_json::to_string(stmt).unwrap();
assert!(
json.contains("\"kind\":\"match\""),
"missing kind: {}",
json
);
assert!(
json.contains("\"checkName\":\"check\""),
"missing checkName: {}",
json
);
assert!(
!json.contains("elseBody"),
"elseBody should be absent: {}",
json
);
}
}