#![allow(rustdoc::invalid_codeblock_attributes)]
#![doc = include_str!("../docs/command_reference.md")]
extern crate self as oxdock_parser;
pub mod ast;
pub mod command;
pub mod commands;
pub mod constants;
pub mod error;
mod lexer;
#[cfg(feature = "proc-macro-api")]
mod macro_input;
pub mod markdown;
pub mod parser;
pub mod strip_flags;
pub mod value;
pub use ast::*;
pub use command::{
ArgSpec, ArgType, CommandMeta, CommandSpec, Example, FlagSpec, FlagValueType, IoDirection,
Stream,
};
pub use commands::{all_metadata, all_structural_metadata, lower_command};
pub use constants::*;
pub use error::{ParseError, ParseErrorKind, ParseResult, SpanContext};
pub use lexer::LANGUAGE_SPEC;
#[cfg(feature = "proc-macro-api")]
pub use macro_input::{
DslMacroInput, ScriptSource, parse_braced_tokens, script_from_braced_tokens,
split_modules_prefix,
};
pub use markdown::{BlockMetadata, FencedBlock, expect_error_from_info, extract_fenced_blocks};
pub use parser::{
parse_guard_expr_str, parse_script, parse_script_with_modules, parse_script_with_preseed,
};
pub use strip_flags::strip_flags;
pub mod test_lower_mock {
use crate::error::{ParseError, SpanContext};
use crate::{Arg, AssertTarget, ParseResult, StepKind, WorkspaceTarget};
fn validation(cmd: &str, msg: &str) -> ParseError {
ParseError::validation(cmd, msg.to_string(), &SpanContext::line_only(0))
}
pub fn lower(name: &str, args: Vec<Arg>) -> ParseResult<StepKind> {
match name {
"CWD" => Ok(StepKind::Cwd),
"WRITE" => {
let mut it = args.into_iter();
let path = it
.next()
.ok_or_else(|| validation("WRITE", "WRITE requires path"))?;
let remaining: Vec<_> = it.collect();
let contents = if remaining.is_empty() {
None
} else {
let joined = remaining
.iter()
.map(|a| a.as_str())
.collect::<Vec<_>>()
.join(" ");
Some(Arg::String(joined, false))
};
Ok(StepKind::Write { path, contents })
}
"HASH_SHA256" => {
let mut a = args;
if a.first().map(|a| a.as_str()) == Some("--hash") {
a.remove(0);
let hash = a
.first()
.ok_or_else(|| validation("HASH_SHA256", "--hash requires value"))?
.as_str()
.to_string();
a.remove(0);
let path = a
.first()
.ok_or_else(|| validation("HASH_SHA256", "HASH_SHA256 requires path"))?
.clone();
Ok(StepKind::AssertEq {
hash: Some(hash),
actual: AssertTarget::Value(path),
expected: None,
})
} else {
let path = a
.first()
.ok_or_else(|| validation("HASH_SHA256", "HASH_SHA256 requires path"))?
.clone();
let contents = a.get(1).cloned();
Ok(StepKind::AssertEq {
hash: None,
actual: AssertTarget::Value(path),
expected: contents,
})
}
}
"ENV" => crate::commands::lower_env_assignment(args)
.map_err(|e| validation("ENV", &e.to_string())),
"WORKSPACE" => {
let target = args
.into_iter()
.next()
.ok_or_else(|| validation("WORKSPACE", "requires target"))?;
match target.as_str() {
"SNAPSHOT" | "snapshot" | "A" => {
Ok(StepKind::Workspace(WorkspaceTarget::Snapshot))
}
"LOCAL" | "local" | "B" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
_ => Err(validation("WORKSPACE", "unknown workspace target")),
}
}
"INHERIT_ENV" => {
let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
Ok(StepKind::InheritEnv { keys })
}
"ECHO" => {
let msg = args
.into_iter()
.next()
.ok_or_else(|| validation("ECHO", "ECHO requires arg"))?;
Ok(StepKind::Echo(msg))
}
"RUN" => {
let cmd = args
.into_iter()
.next()
.ok_or_else(|| validation("RUN", "RUN requires arg"))?;
Ok(StepKind::Run(cmd))
}
"WORKDIR" => {
let path = args
.into_iter()
.next()
.ok_or_else(|| validation("WORKDIR", "requires path"))?;
Ok(StepKind::Workdir(path))
}
_ => Err(ParseError::unknown_command(
name,
format!("unknown command: {name}"),
None,
&SpanContext::line_only(0),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
#[cfg(feature = "proc-macro-api")]
use quote::quote;
use std::collections::HashMap;
fn test_lower(name: &str, args: Vec<Arg>) -> ParseResult<StepKind> {
crate::test_lower_mock::lower(name, args)
}
fn guard_text(step: &Step) -> Option<String> {
step.guard.as_ref().map(|g| g.to_string())
}
#[test]
fn commands_are_case_sensitive() {
for bad in ["cwd hi", "Cwd hi", "cwd foo"] {
parse_script(bad, test_lower).expect_err("mixed/lowercase commands must fail");
}
}
#[test]
fn string_dsl_supports_rust_style_comments() {
let script = indoc! {r#"
// leading comment line
CWD // inline comment
WRITE 'echo "keep // literal"'
/* block comment
CWD ignored
/* nested inner */
WRITE ignored as well
*/
WRITE "echo final"
WRITE "echo 'literal /* stay */ value'"
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 4, "expected 4 executable steps");
assert!(matches!(&steps[0].kind, StepKind::Cwd));
assert!(matches!(&steps[1].kind, StepKind::Write { .. }));
assert!(matches!(&steps[2].kind, StepKind::Write { .. }));
assert!(matches!(&steps[3].kind, StepKind::Write { .. }));
}
#[test]
fn string_dsl_errors_on_unclosed_block_comment() {
let script = indoc! {r#"
WRITE echo hi
/* unclosed
"#};
parse_script(script, test_lower).expect_err("should fail");
}
#[test]
fn semicolon_splits_instructions() {
let script = "WRITE \"echo hi\"; WRITE \"echo bye\"";
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 2);
}
#[test]
fn guard_supports_colon_separator() {
let script = "[env:FOO] WRITE \"echo hi\"";
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:FOO"));
}
#[test]
fn guard_lines_chain_before_block() {
let script = indoc! {r#"
[env:A]
[env:B]
{
WRITE ok.txt hi
}
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
}
#[test]
fn guard_block_must_contain_command() {
let script = indoc! {r#"
[env.A] {
}
"#};
parse_script(script, test_lower).expect_err("empty block should fail");
}
#[test]
fn with_io_rejects_non_variable_bindings() {
let err = parse_script(
"WITH_IO [stdin, stdout=pipe:setup, stderr=pipe:errors] WRITE \"echo hi\"",
test_lower,
)
.expect_err("non-variable bindings must fail");
let msg = err.to_string();
assert!(
msg.contains("pipe:setup"),
"error must name the bad binding: {msg}"
);
assert_eq!(err.line(), 1, "error must name the failing line");
}
#[test]
fn with_io_supports_variable_pipes() {
let script = "WITH_IO [stdout=$p, stdin=$q] WRITE \"echo hi\"";
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::WithIo { bindings, cmd } => {
assert_eq!(bindings.len(), 2);
assert!(bindings.iter().any(|b| matches!(b.stream, IoStream::Stdout)
&& b.pipe == Some(PipeTarget::Var("p".to_string()))));
assert!(bindings.iter().any(|b| matches!(b.stream, IoStream::Stdin)
&& b.pipe == Some(PipeTarget::Var("q".to_string()))));
assert!(matches!(cmd.as_ref(), StepKind::Write { .. }));
}
other => panic!("expected WITH_IO, saw {:?}", other),
}
assert_eq!(
steps[0].kind.to_string(),
"WITH_IO [stdout=$p, stdin=$q] WRITE \"echo hi\""
);
}
#[test]
fn colon_text_in_expression_fails() {
let err = parse_script("LET $p: PIPE = pipe:ch", test_lower)
.expect_err("colon text in expression must fail");
assert_eq!(err.line(), 1, "error must name the failing line");
}
#[test]
fn colon_text_in_assert_is_a_plain_value() {
let steps = parse_script("ASSERT_EQ pipe:ch \"x\"", crate::commands::lower_command)
.expect("colon text parses as a literal");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::AssertEq { actual, .. } => {
assert_eq!(
actual,
&AssertTarget::Value(Arg::String("pipe:ch".to_string(), false)),
"colon text must stay a literal value, got {actual:?}"
);
}
other => panic!("expected AssertEq, got {other:?}"),
}
}
#[test]
fn bare_let_pipe_declares_fresh_backend() {
let steps = parse_script("LET $p: PIPE", test_lower).expect("bare LET $p: PIPE parses");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::Assign {
var,
decl_type,
expr,
} => {
assert_eq!(var, "p");
assert_eq!(decl_type, "PIPE");
assert!(
matches!(expr, Expr::FreshPipe),
"expected FreshPipe, got {expr:?}"
);
}
other => panic!("expected Assign, got {other:?}"),
}
assert_eq!(steps[0].kind.to_string(), "LET $p: PIPE");
let again =
parse_script(&steps[0].kind.to_string(), test_lower).expect("Display round-trips");
assert_eq!(again, steps);
let err = parse_script("LET $x: STRING", test_lower).expect_err("bare STRING must fail");
assert!(
err.to_string().contains("requires an expression"),
"unexpected error: {err}"
);
}
#[test]
fn brace_blocks_require_guard() {
let script = indoc! {r#"
{
WRITE nope.txt hi
}
"#};
parse_script(script, test_lower).expect_err("unguarded block should fail");
}
#[test]
fn multi_line_guard_blocks_apply_to_next_command() {
let script = indoc! {r#"
[
env:A,
env:B
]
WRITE "echo guarded"
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
}
#[test]
fn guarded_brace_blocks_apply_to_all_inner_steps() {
let script = indoc! {r#"
[env:A] {
WRITE one.txt 1
WRITE two.txt 2
}
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 2);
assert!(steps.iter().all(|s| s.guard.is_some()));
}
#[test]
fn nested_guard_blocks_stack() {
let script = indoc! {r#"
[env:A] {
WRITE outer.txt no
[env:B] {
WRITE nested.txt yes
}
}
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 2);
assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A"));
assert_eq!(guard_text(&steps[1]).as_deref(), Some("env:A, env:B"));
}
#[test]
fn nested_guard_block_scopes_stack_counts() {
let script = indoc! {r#"
[env:A] {
WRITE outer.txt ok
[env:B] {
WRITE deep.txt ok
}
WRITE outer_again.txt ok
}
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 3);
assert_eq!(steps[0].scope_enter, 1);
assert_eq!(steps[0].scope_exit, 0);
assert_eq!(steps[1].scope_enter, 1);
assert_eq!(steps[1].scope_exit, 1);
assert_eq!(steps[2].scope_enter, 0);
assert_eq!(steps[2].scope_exit, 1);
}
#[test]
fn guard_or_and_and_compose_as_expected() {
let script = indoc! {r#"
[env:A]
[any(env:B, env:C)]
WRITE "echo complex"
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
let guard = steps[0].guard.as_ref().expect("missing guard");
assert_eq!(guard.to_string(), "env:A, any(env:B, env:C)");
let mut env = HashMap::new();
env.insert("A".into(), "1".into());
env.insert("B".into(), "1".into());
assert!(guard_expr_allows(guard, &env), "A && B should pass");
env.remove("B");
env.insert("C".into(), "1".into());
assert!(guard_expr_allows(guard, &env), "A && C should pass");
env.remove("C");
assert!(!guard_expr_allows(guard, &env), "A without B/C should fail");
}
#[test]
fn guard_or_requires_at_least_one_branch() {
let expr = GuardExpr::or(vec![
Guard::EnvExists {
key: "MISSING".into(),
}
.into(),
Guard::EnvExists {
key: "ALSO_MISSING".into(),
}
.into(),
]);
assert!(!guard_expr_allows(&expr, &HashMap::new()));
let mut env = HashMap::new();
env.insert("MISSING".into(), "1".into());
assert!(guard_expr_allows(&expr, &env));
}
#[test]
fn guard_or_can_chain_with_additional_predicates() {
let script = "[any(env:A, linux), mac] WRITE \"echo hi\"";
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
let guard = steps[0].guard.as_ref().expect("missing guard");
assert_eq!(guard.to_string(), "any(env:A, linux), macos");
let GuardExpr::All(children) = guard else {
panic!("expected ALL guard");
};
assert!(matches!(children[0], GuardExpr::Or(_)));
match &children[1] {
GuardExpr::Predicate(Guard::Platform {
target: PlatformGuard::Macos,
}) => {}
other => panic!("unexpected trailing guard: {other:?}"),
}
}
#[test]
fn guard_or_guard_line_parses() {
use crate::lexer::{LanguageParser, Rule};
use pest::Parser;
LanguageParser::parse(Rule::guard_line, "[any(linux, env:FOO)]")
.expect("guard guard line should parse");
}
#[test]
fn env_equals_guard_with_not_wrapper() {
let g = GuardExpr::Not(Box::new(GuardExpr::Predicate(Guard::EnvEquals {
key: "A".into(),
value: "1".into(),
})));
let mut env = HashMap::new();
env.insert("A".into(), "1".into());
assert!(!guard_expr_allows(&g, &env));
env.insert("A".into(), "2".into());
assert!(guard_expr_allows(&g, &env));
}
#[test]
fn guard_block_emits_scope_markers() {
let script = indoc! {r#"
ENV RUN=1
[env:RUN] {
WRITE one.txt 1
WRITE two.txt 2
}
WRITE three.txt 3
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 4);
assert_eq!(steps[1].scope_enter, 1);
assert_eq!(steps[1].scope_exit, 0);
assert_eq!(steps[2].scope_enter, 0);
assert_eq!(steps[2].scope_exit, 1);
assert_eq!(steps[3].scope_enter, 0);
assert_eq!(steps[3].scope_exit, 0);
}
#[test]
fn mock_hash_form_parses() {
let script = "HASH_SHA256 --hash aabb path.txt";
let steps = parse_script(script, test_lower).expect("parse ok");
match &steps[0].kind {
StepKind::AssertEq {
hash,
actual,
expected,
} => {
assert_eq!(hash.as_deref(), Some("aabb"));
assert_eq!(
actual,
&AssertTarget::Value(Arg::String("path.txt".to_string(), false))
);
assert_eq!(expected, &None);
}
other => panic!("expected AssertEq, saw {:?}", other),
}
}
#[test]
fn mock_commands_parse_and_round_trip() {
let script = indoc! {r#"
WRITE "dist/hello.txt" "Built with OxDock"
WRITE "deeply/nested/tree"
WRITE "chained.txt"
WRITE "visible-after-comments"
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 4);
for step in &steps {
assert!(
matches!(&step.kind, StepKind::Write { .. }),
"expected Write variant"
);
}
}
#[test]
fn quoted_string_content_preserved() {
let script = "WRITE 'echo \"a; b\"'";
let steps = parse_script(script, test_lower).expect("parse ok");
match &steps[0].kind {
StepKind::Write { path, .. } => assert_eq!(path, "echo \"a; b\""),
other => panic!("expected Write, saw {:?}", other),
}
}
#[test]
fn templated_argument_with_spaces() {
let script = "WRITE {{ env:OXBOOK_RUNNER_DIR }}";
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::Write { path, .. } => assert_eq!(path, "{{ env:OXBOOK_RUNNER_DIR }}"),
other => panic!("expected Write, saw {:?}", other),
}
}
#[test]
#[cfg(feature = "proc-macro-api")]
fn string_and_braced_scripts_produce_identical_ast() {
let mut cases = Vec::new();
cases.push((
indoc! {r#"
WRITE /tmp
WRITE hello
"#}
.trim()
.to_string(),
quote! {
WRITE /tmp
WRITE hello
},
));
cases.push((
indoc! {r#"
[not(env:SKIP)]
[windows] WRITE win
[eq(env:MODE, beta), linux] WRITE combo
"#}
.trim()
.to_string(),
quote! {
[not(env:SKIP)]
[windows] WRITE win
[eq(env:MODE, beta), linux] WRITE combo
},
));
cases.push((
indoc! {r#"
[env:OUTER] {
WRITE nested
[env:INNER] WRITE deep
}
"#}
.trim()
.to_string(),
quote! {
[env:OUTER] {
WRITE nested
[env:INNER] WRITE deep
}
},
));
cases.push((
indoc! {r#"
[eq(env:TEST, 1)]
WITH_IO [stdout=$capture_case] WRITE hi
WITH_IO [stdin=$capture_case] WRITE out.txt
"#}
.trim()
.to_string(),
quote! {
[eq(env:TEST, 1)]
WITH_IO [stdout=$capture_case] WRITE hi
WITH_IO [stdin=$capture_case] WRITE out.txt
},
));
for (idx, (literal, tokens)) in cases.iter().enumerate() {
let text = literal.trim();
let string_steps = parse_script(text, test_lower)
.unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
let braced_steps = parse_braced_tokens(tokens, test_lower)
.unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
assert_eq!(
string_steps, braced_steps,
"AST mismatch for case {idx} literal:\n{text}"
);
}
}
#[test]
fn let_assign_with_bare_word() {
let script = r#"LET $x: STRING = hello"#;
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::Assign {
var,
decl_type: _,
expr,
} => {
assert_eq!(var, "x");
assert_eq!(expr, &Expr::Literal(Value::string("hello".to_string())));
}
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn let_assign_with_quoted_string() {
let script = r#"LET $x: STRING = "hello world""#;
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::Assign {
var,
decl_type: _,
expr,
} => {
assert_eq!(var, "x");
assert_eq!(
expr,
&Expr::Literal(Value::string("hello world".to_string()))
);
}
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn let_assign_with_list_literal() {
let script = r#"LET $x: LIST = ["a", "b", "c"]"#;
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::Assign {
var,
decl_type: _,
expr,
} => {
assert_eq!(var, "x");
assert_eq!(
expr,
&Expr::List(vec![
Expr::Literal(Value::string("a".to_string())),
Expr::Literal(Value::string("b".to_string())),
Expr::Literal(Value::string("c".to_string()))
])
);
}
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn let_assign_with_variable_ref() {
let script = r#"LET $x: STRING = $y"#;
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::Assign {
var,
decl_type: _,
expr,
} => {
assert_eq!(var, "x");
assert_eq!(expr, &Expr::Var("y".to_string()));
}
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn let_assign_with_block() {
let script = r#"LET $a: STRING = { RETURN "hello" }"#;
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::Assign { var, expr, .. } => {
assert_eq!(var, "a");
match expr {
Expr::Block(body) => {
assert_eq!(body.len(), 1);
assert!(matches!(body[0].kind, StepKind::Return { .. }));
}
other => panic!("expected Block, got {:?}", other),
}
}
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn let_assign_multiline_block_with_nesting() {
let script = indoc! {r#"
LET $a: STRING = {
LET $b: STRING = { RETURN "hi" }
RETURN $b
}
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::Assign { expr, .. } => match expr {
Expr::Block(body) => {
assert_eq!(body.len(), 2);
assert!(matches!(body[0].kind, StepKind::Assign { .. }));
assert!(matches!(body[1].kind, StepKind::Return { .. }));
let StepKind::Assign { expr: inner, .. } = &body[0].kind else {
panic!("expected inner Assign");
};
assert!(matches!(inner, Expr::Block(_)));
}
other => panic!("expected Block, got {:?}", other),
},
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn let_assign_map_literal_stays_map() {
let script = r#"LET $m: MAP = {a: 1, b: 2}"#;
let steps = parse_script(script, test_lower).expect("parse ok");
match &steps[0].kind {
StepKind::Assign { expr, .. } => {
assert!(matches!(expr, Expr::Map(entries) if entries.len() == 2));
}
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn map_literal_spans_lines_with_comments() {
let script = indoc! {r#"
LET $m: MAP = {
// leading comment
"a": 1, /* trailing */
// own line
b: 2
}
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
match &steps[0].kind {
StepKind::Assign { expr, .. } => match expr {
Expr::Map(entries) => {
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].0, "a");
assert_eq!(entries[1].0, "b");
}
other => panic!("expected Map, got {:?}", other),
},
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn call_args_accept_comments_between_lines() {
let script = indoc! {r#"
FUNC SERVE($h: STRING, $u: STRING, $p: STRING, $o: MAP) {
RETURN $h
}
LET $s: STRING = SERVE(
// host port
"127.0.0.1:2251",
"test",
"test123", {
// workspace-relative key
key_path: "/temp/test_key"
}
)
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 2);
match &steps[1].kind {
StepKind::Assign { expr, .. } => match expr {
Expr::Call { name, args } => {
assert_eq!(name, "SCRIPT::SERVE");
assert_eq!(args.len(), 4);
assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
}
other => panic!("expected Call, got {:?}", other),
},
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn hash_comments_trail_map_entries() {
let script = indoc! {r#"
# 1. Ephemeral server setup
LET $m: MAP = {
key_path: "temp/test_key" # Fixed: Relative workspace pathing
}
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
match &steps[0].kind {
StepKind::Assign { expr, .. } => match expr {
Expr::Map(entries) => {
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, "key_path");
}
other => panic!("expected Map, got {:?}", other),
},
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn hash_comments_span_call_args_like_slash_comments() {
let script = indoc! {r#"
FUNC SERVE($h: STRING, $o: MAP) {
RETURN $h
}
# leading hash comment
LET $s: STRING = SERVE(
# host port
"127.0.0.1:2251", {
# workspace-relative key
key_path: "temp/test_key" # trailing hash comment
}
)
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 2);
match &steps[1].kind {
StepKind::Assign { expr, .. } => match expr {
Expr::Call { name, args } => {
assert_eq!(name, "SCRIPT::SERVE");
assert_eq!(args.len(), 2);
assert!(matches!(&args[1], Expr::Map(entries) if entries.len() == 1));
}
other => panic!("expected Call, got {:?}", other),
},
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn for_loop_parses() {
let script = indoc! {r#"
FOR $f: STRING IN ["x", "y"] {
WRITE $f
}
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::For {
key_var,
var,
in_expr,
body,
..
} => {
assert!(key_var.is_none());
assert_eq!(var, "f");
assert_eq!(
in_expr,
&Expr::List(vec![
Expr::Literal(Value::string("x".to_string())),
Expr::Literal(Value::string("y".to_string()))
])
);
assert_eq!(body.len(), 1);
}
other => panic!("expected For, got {:?}", other),
}
}
#[test]
fn for_map_iteration_parses() {
let script = indoc! {r#"
FOR $k: STRING, $v: STRING IN $map {
WRITE $k
}
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::For {
key_var,
var,
in_expr,
body,
..
} => {
assert_eq!(key_var.as_deref(), Some("k"));
assert_eq!(var, "v");
assert_eq!(in_expr, &Expr::Var("map".to_string()));
assert_eq!(body.len(), 1);
}
other => panic!("expected For, got {:?}", other),
}
}
#[test]
fn if_statement_parses() {
let script = "IF true { WRITE yes }\n";
let steps = parse_script(script, test_lower).expect("parse ok");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::If { .. } => {}
other => panic!("expected If, got {:?}", other),
}
}
#[test]
fn if_keyword_matches_directly() {
use crate::lexer::{LanguageParser, Rule};
use pest::Parser;
let result = LanguageParser::parse(Rule::if_keyword, "IF ");
assert!(
result.is_ok(),
"if_keyword should match 'IF ': {:?}",
result.err()
);
}
#[test]
fn if_statement_pest_matches() {
use crate::lexer::{LanguageParser, Rule};
use pest::Parser;
let result = LanguageParser::parse(Rule::if_statement, "IF true {\n WRITE yes\n}");
assert!(
result.is_ok(),
"if_statement should match: {:?}",
result.err()
);
}
#[test]
fn not_expression_parses() {
let script = r#"LET $x: BOOL = !true"#;
let steps = parse_script(script, test_lower).expect("parse ok");
match &steps[0].kind {
StepKind::Assign {
var,
decl_type: _,
expr,
} => {
assert_eq!(var, "x");
assert_eq!(expr, &Expr::Not(Box::new(Expr::Literal(Value::bool(true)))));
}
other => panic!("expected Assign, got {:?}", other),
}
let steps = parse_script(r#"LET $x: BOOL = !!false"#, test_lower).expect("parse ok");
match &steps[0].kind {
StepKind::Assign { expr, .. } => {
assert_eq!(
expr,
&Expr::Not(Box::new(Expr::Not(Box::new(Expr::Literal(Value::bool(
false
))))))
);
}
other => panic!("expected Assign, got {:?}", other),
}
let steps = parse_script(r#"LET $x: BOOL = !true == false"#, test_lower).expect("parse ok");
match &steps[0].kind {
StepKind::Assign { expr, .. } => {
assert!(matches!(expr, Expr::Compare { .. }), "got {expr:?}");
if let Expr::Compare { left, .. } = expr {
assert!(matches!(left.as_ref(), Expr::Not(_)), "got {left:?}");
}
}
other => panic!("expected Assign, got {:?}", other),
}
let steps =
parse_script(r#"LET $x: BOOL = !(true == false)"#, test_lower).expect("parse ok");
match &steps[0].kind {
StepKind::Assign { expr, .. } => {
assert!(matches!(expr, Expr::Not(_)), "got {expr:?}");
}
other => panic!("expected Assign, got {:?}", other),
}
}
#[test]
fn not_expression_display_round_trips() {
for script in [
"LET $x: BOOL = !true",
"LET $x: BOOL = !!false",
"LET $x: BOOL = !(true == false)",
"IF !true {\n WRITE yes\n}",
] {
let steps = parse_script(script, test_lower).expect("parse");
let rendered: Vec<String> = steps.iter().map(|s| s.to_string()).collect();
let reparsed = parse_script(&rendered.join("\n"), test_lower).expect("reparse");
assert_eq!(steps, reparsed, "Display round-trip failed for {script}");
}
}
#[test]
fn guard_block_with_mock_command() {
let script = "CWD\n[env:GATE] {\n WRITE gated\n}\n[eq(env:A, 1)] WRITE eq\n";
let steps = parse_script(script, test_lower).expect("parse should succeed");
assert!(
steps.len() >= 2,
"expected at least 2 steps, got {}",
steps.len()
);
}
#[test]
fn single_line_blocks() {
let test_cases = [
"IF true { WRITE \"hello\" }",
"IF true { WRITE \"cargo test\" }",
"IF true { WRITE \"/app\" }",
];
for script in test_cases {
assert!(
parse_script(script, test_lower).is_ok(),
"Failed to parse: {}",
script
);
}
}
#[test]
fn async_run_parses() {
let script = "ASYNC RUN \"echo hello\"";
let steps = parse_script(script, test_lower).expect("parse should succeed");
assert_eq!(steps.len(), 1);
assert!(matches!(&steps[0].kind, StepKind::AsyncBlock { .. }));
}
#[test]
fn async_block_parses() {
let script = indoc! {r#"
ASYNC {
RUN "echo one"
RUN "echo two"
}
"#};
let steps = parse_script(script, test_lower).expect("parse should succeed");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::AsyncBlock { body } => {
assert_eq!(body.len(), 2);
}
other => panic!("expected AsyncBlock, got {:?}", other),
}
}
#[test]
fn nested_async_parses() {
let script = "ASYNC ASYNC RUN \"echo nested\"";
let steps = parse_script(script, test_lower).expect("parse should succeed");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::AsyncBlock { body } => {
assert_eq!(body.len(), 1);
match &body[0].kind {
StepKind::AsyncBlock { body } => {
assert_eq!(body.len(), 1);
assert!(matches!(&body[0].kind, StepKind::Run(_)));
}
other => panic!("expected inner AsyncBlock, got {:?}", other),
}
}
other => panic!("expected outer AsyncBlock, got {:?}", other),
}
}
#[test]
fn nested_async_block_form_parses() {
let script = indoc! {r#"
ASYNC {
ASYNC {
RUN "echo nested"
}
}
"#};
let steps = parse_script(script, test_lower).expect("parse should succeed");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::AsyncBlock { body } => {
assert_eq!(body.len(), 1);
match &body[0].kind {
StepKind::AsyncBlock { body } => {
assert_eq!(body.len(), 1);
assert!(matches!(&body[0].kind, StepKind::Run(_)));
}
other => panic!("expected inner AsyncBlock, got {:?}", other),
}
}
other => panic!("expected outer AsyncBlock, got {:?}", other),
}
}
#[test]
fn with_io_wrapping_async_parses() {
let script = "WITH_IO [stdout] ASYNC RUN \"echo test\"";
let steps = parse_script(script, test_lower).expect("parse should succeed");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::WithIo { cmd, .. } => {
assert!(matches!(cmd.as_ref(), StepKind::AsyncBlock { .. }));
}
other => panic!("expected WithIo, got {:?}", other),
}
}
#[test]
fn with_io_async_block_nested_for_parses() {
let script = indoc! {r#"
WITH_IO [stdout=$out] ASYNC {
FOR $x: INT IN [0, 1] {
ECHO hi
}
}
"#};
let steps = parse_script(script, test_lower).expect("parse should succeed");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::WithIo { bindings, cmd } => {
assert_eq!(bindings.len(), 1);
assert!(matches!(bindings[0].stream, IoStream::Stdout));
assert_eq!(bindings[0].pipe, Some(PipeTarget::Var("out".to_string())));
match cmd.as_ref() {
StepKind::AsyncBlock { body } => {
assert_eq!(body.len(), 1);
match &body[0].kind {
StepKind::For { var, body, .. } => {
assert_eq!(var, "x");
assert_eq!(body.len(), 1);
assert!(matches!(&body[0].kind, StepKind::Echo(_)));
}
other => panic!("expected For, got {:?}", other),
}
}
other => panic!("expected AsyncBlock, got {:?}", other),
}
}
other => panic!("expected WithIo, got {:?}", other),
}
}
#[test]
fn for_int_key_parses_for_list_enumeration() {
let script = indoc! {r#"
FOR $i: INT, $v: STRING IN $items {
WRITE $v
}
"#};
let steps = parse_script(script, test_lower).expect("parse ok");
match &steps[0].kind {
StepKind::For {
key_var,
key_type,
var,
var_type,
..
} => {
assert_eq!(key_var.as_deref(), Some("i"));
assert_eq!(*key_type, Some("INT".to_string()));
assert_eq!(var, "v");
assert_eq!(*var_type, "STRING".to_string());
}
other => panic!("expected For, got {:?}", other),
}
}
#[test]
fn for_non_index_key_type_is_rejected() {
let err = parse_script(
"FOR $k: BOOL, $v: STRING IN $map { WRITE $v }\n",
test_lower,
)
.expect_err("BOOL key must fail");
assert!(
err.to_string().contains("must be INT or STRING"),
"unexpected error: {err}"
);
}
#[test]
fn with_io_async_block_nested_if_else_parses() {
let script = indoc! {r#"
WITH_IO [stdout=$out] ASYNC {
IF $a == $b {
ECHO yes
} ELSE {
ECHO no
}
}
"#};
let steps = parse_script(script, test_lower).expect("parse should succeed");
match &steps[0].kind {
StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
StepKind::AsyncBlock { body } => {
assert!(matches!(&body[0].kind, StepKind::If { .. }));
match &body[0].kind {
StepKind::If { else_body, .. } => {
assert_eq!(else_body.as_ref().map(Vec::len), Some(1));
}
other => panic!("expected If, got {:?}", other),
}
}
other => panic!("expected AsyncBlock, got {:?}", other),
},
other => panic!("expected WithIo, got {:?}", other),
}
}
#[test]
fn timeout_block_nested_for_parses() {
let script = indoc! {r#"
TIMEOUT 30s {
FOR $x: INT IN [1] {
ECHO hi
}
}
"#};
let steps = parse_script(script, test_lower).expect("parse should succeed");
match &steps[0].kind {
StepKind::Timeout { body, .. } => {
assert_eq!(body.len(), 1);
assert!(matches!(&body[0].kind, StepKind::For { .. }));
}
other => panic!("expected Timeout, got {:?}", other),
}
}
#[test]
fn with_io_async_block_nested_let_map_parses() {
let script = indoc! {r#"
WITH_IO [stdout] ASYNC {
LET $m: MAP = {a: 1, b: 2}
}
"#};
let steps = parse_script(script, test_lower).expect("parse should succeed");
match &steps[0].kind {
StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
StepKind::AsyncBlock { body } => {
assert!(matches!(&body[0].kind, StepKind::Assign { .. }));
}
other => panic!("expected AsyncBlock, got {:?}", other),
},
other => panic!("expected WithIo, got {:?}", other),
}
}
#[test]
fn variable_sigil_binds_tightly() {
parse_script("ECHO $ x", test_lower).expect_err("spaced sigil must fail");
parse_script("LET $x: STRING = $ y", test_lower).expect_err("spaced sigil must fail");
let steps = parse_script("ECHO $x", test_lower).expect("tight sigil parses");
assert!(matches!(&steps[0].kind, StepKind::Echo(_)));
}
#[test]
fn let_async_block_parses() {
let script = indoc! {r#"
LET $task: HANDLE = ASYNC {
RUN "echo hello"
}
"#};
let steps = parse_script(script, test_lower).expect("parse should succeed");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::AssignAsync { var, body, .. } => {
assert_eq!(var, "task");
assert_eq!(body.len(), 1);
assert!(matches!(&body[0].kind, StepKind::Run(_)));
}
other => panic!("expected AssignAsync, got {:?}", other),
}
}
#[test]
fn let_async_inline_parses() {
let script = "LET $t: HANDLE = ASYNC RUN \"echo hi\"";
let steps = parse_script(script, test_lower).expect("parse should succeed");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::AssignAsync { var, body, .. } => {
assert_eq!(var, "t");
assert_eq!(body.len(), 1);
assert!(matches!(&body[0].kind, StepKind::Run(_)));
}
other => panic!("expected AssignAsync, got {:?}", other),
}
}
#[test]
fn await_parses() {
let script = "AWAIT $task";
let steps = parse_script(script, test_lower).expect("parse should succeed");
assert_eq!(steps.len(), 1);
match &steps[0].kind {
StepKind::Await { var } => {
assert_eq!(var, "task");
}
other => panic!("expected Await, got {:?}", other),
}
}
}