use insta::assert_snapshot;
use kaish_kernel::ast::sexpr::format_program;
use kaish_kernel::parser::parse;
use rstest::rstest;
fn parse_and_snapshot(name: &str, input: &str) {
let program = parse(input).unwrap_or_else(|errors| {
let error_msg = errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; ");
panic!("Parse error for '{}': {}", name, error_msg);
});
let sexpr = format_program(&program);
assert_snapshot!(name, sexpr);
}
fn expect_parse_error(input: &str) {
let result = parse(input);
assert!(result.is_err(), "Expected error for input: {:?}", input);
}
#[rstest]
#[case("find .")]
#[case("ls .")]
#[case("echo .")]
#[case("find . -name x")]
fn bare_dot_argument_is_one_command(#[case] input: &str) {
let program = parse(input).expect("should parse");
assert_eq!(
program.statements.len(),
1,
"`{input}` must be a single statement, got {}: {}",
program.statements.len(),
format_program(&program),
);
}
#[rstest]
#[case(". script.kai", "script.kai")]
#[case(". /etc/profile", "/etc/profile")]
fn leading_dot_is_still_source(#[case] input: &str, #[case] expected_file: &str) {
let program = parse(input).expect("should parse");
assert_eq!(program.statements.len(), 1, "`{input}` should be one statement");
let sexpr = format_program(&program);
assert!(
sexpr.contains(expected_file),
"source's file argument should attach to the command: {sexpr}",
);
}
#[test]
fn parser_assign_int() {
parse_and_snapshot("assign_int", "X=5");
}
#[test]
fn parser_assign_negative_int() {
parse_and_snapshot("assign_negative_int", "X=-42");
}
#[test]
fn parser_assign_float() {
parse_and_snapshot("assign_float", "PI=3.14159");
}
#[test]
fn parser_assign_bool_true() {
parse_and_snapshot("assign_bool_true", "FLAG=true");
}
#[test]
fn parser_assign_bool_false() {
parse_and_snapshot("assign_bool_false", "FLAG=false");
}
#[test]
fn parser_assign_string() {
parse_and_snapshot("assign_string", r#"NAME="alice""#);
}
#[test]
fn parser_assign_string_with_spaces() {
parse_and_snapshot("assign_string_with_spaces", r#"MSG="hello world""#);
}
#[test]
fn parser_assign_string_with_escapes() {
parse_and_snapshot("assign_string_with_escapes", r#"MSG="line\nbreak""#);
}
#[test]
fn parser_assign_varref() {
parse_and_snapshot("assign_varref", "Y=${X}");
}
#[test]
fn parser_assign_simple_varref() {
parse_and_snapshot("assign_simple_varref", "Y=$X");
}
#[test]
fn parser_assign_interpolated() {
parse_and_snapshot("assign_interpolated", r#"MSG="hello ${NAME}""#);
}
#[test]
fn parser_assign_interpolated_simple() {
parse_and_snapshot("assign_interpolated_simple", r#"MSG="hello $NAME""#);
}
#[test]
fn parser_assign_single_quoted() {
parse_and_snapshot("assign_single_quoted", "MSG='hello $NAME'");
}
#[test]
fn parser_bash_assign_int() {
parse_and_snapshot("bash_assign_int", "X=5");
}
#[test]
fn parser_bash_assign_string() {
parse_and_snapshot("bash_assign_string", r#"NAME="alice""#);
}
#[test]
fn parser_local_assign_int() {
parse_and_snapshot("local_assign_int", "local X = 5");
}
#[test]
fn parser_local_assign_string() {
parse_and_snapshot("local_assign_string", r#"local MSG = "hello""#);
}
#[test]
fn parser_cmd_simple() {
parse_and_snapshot("cmd_simple", "echo");
}
#[test]
fn parser_cmd_positional_string() {
parse_and_snapshot("cmd_positional_string", r#"echo "hello""#);
}
#[test]
fn parser_cmd_positional_multiple() {
parse_and_snapshot("cmd_positional_multiple", r#"echo "hello" "world""#);
}
#[test]
fn parser_cmd_named_int() {
parse_and_snapshot("cmd_named_int", "fetch count=10");
}
#[test]
fn parser_cmd_named_string() {
parse_and_snapshot("cmd_named_string", r#"search query="rust""#);
}
#[test]
fn parser_cmd_named_multiple() {
parse_and_snapshot("cmd_named_multiple", r#"api endpoint="/users" limit=50 verbose=true"#);
}
#[test]
fn parser_cmd_mixed_args() {
parse_and_snapshot("cmd_mixed_args", r#"grep "pattern" path="/src" context=3"#);
}
#[test]
fn parser_pipe_two() {
parse_and_snapshot("pipe_two", "a | b");
}
#[test]
fn parser_pipe_background() {
parse_and_snapshot("pipe_background", "slow-task &");
}
#[test]
fn parser_pipe_chain_background() {
parse_and_snapshot("pipe_chain_background", "a | b | c &");
}
#[test]
fn parser_pipe_three() {
parse_and_snapshot("pipe_three", r#"cat file | grep "pattern" | head 10"#);
}
#[rstest]
#[case::redirect_stdout(r#"echo "hello" > /tmp/out"#)]
#[case::redirect_append(r#"echo "more" >> /tmp/out"#)]
#[case::redirect_stdin("wc < /tmp/input")]
#[case::redirect_stderr("risky-cmd 2> /tmp/err")]
#[case::redirect_both("cmd &> /tmp/all")]
#[case::redirect_multiple("cmd < /in > /out 2> /err")]
#[case::redirect_in_pipeline("a | b > /out")]
#[case::redirect_merge_stderr("cmd 2>&1")]
#[case::redirect_merge_pipe("cmd 2>&1 | tee /tmp/log")]
#[case::redirect_herestring_bare("cat <<< hi")]
#[case::redirect_herestring_interpolated(r#"cat <<< "$R""#)]
#[case::redirect_herestring_literal(r#"cat <<< 'raw $VAR'"#)]
#[case::redirect_herestring_with_stdout(r#"cat <<< hi > /tmp/out"#)]
#[case::redirect_herestring_in_pipeline(r#"jq '.x' <<< "$J" | wc -l"#)]
fn parser_redirects(#[case] input: &str) {
let name = format!("redirect_{}", input.chars().take(20).filter(|c| c.is_alphanumeric()).collect::<String>());
parse_and_snapshot(&name, input);
}
#[rstest]
#[case::herestring_no_operand("cat <<<")]
#[case::herestring_then_stdin("cat <<< hi < /in")]
#[case::stdin_then_herestring("cat < /in <<< hi")]
#[case::two_herestrings("cat <<< a <<< b")]
fn parser_herestring_errors(#[case] input: &str) {
expect_parse_error(input);
}
#[rstest]
#[case("cat <<< a <<< b")]
#[case("cat < /in <<< hi")]
#[case("cat <<< hi < /in")]
fn ambiguous_stdin_surfaces_actionable_message(#[case] input: &str) {
let errors = parse(input).expect_err("expected a parse error");
let joined = errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; ");
assert!(
joined.contains("multiple stdin redirects"),
"`{input}` should surface the multiple-stdin message, got: {joined}"
);
}
#[test]
fn parser_if_simple() {
parse_and_snapshot("if_simple", "if [[ $? -eq 0 ]]; then\n echo \"yes\"\nfi");
}
#[test]
fn parser_if_else() {
parse_and_snapshot("if_else", "if [[ $? -eq 0 ]]; then\n echo \"yes\"\nelse\n echo \"no\"\nfi");
}
#[test]
fn parser_if_command_condition() {
parse_and_snapshot("if_command_condition", "if test-something; then\n echo \"passed\"\nfi");
}
#[test]
fn parser_if_comparison() {
parse_and_snapshot("if_comparison", "if [[ ${X} -gt 5 ]]; then\n echo \"big\"\nfi");
}
#[test]
fn parser_for_simple() {
parse_and_snapshot("for_simple", "for X in ${LIST}; do\n echo ${X}\ndone");
}
#[test]
fn parser_and_chain() {
parse_and_snapshot("and_chain", "a && b && c");
}
#[test]
fn parser_or_chain() {
parse_and_snapshot("or_chain", "a || b || c");
}
#[test]
fn parser_mixed_chain() {
parse_and_snapshot("mixed_chain", "a && b || c");
}
#[test]
fn parser_precedence_or_then_and() {
parse_and_snapshot("precedence_or_then_and", "a || b && c");
}
#[test]
fn parser_precedence_complex() {
parse_and_snapshot("precedence_complex", "a && b || c && d");
}
#[test]
fn parser_precedence_deeply_chained() {
parse_and_snapshot("precedence_deeply_chained", "a || b || c && d && e");
}
#[test]
fn parser_if_command_with_args() {
parse_and_snapshot("if_command_with_args", "if grep -q pattern file; then\n echo \"found\"\nfi");
}
#[test]
fn parser_if_test_and_command() {
parse_and_snapshot("if_test_and_command", "if [[ -f file ]] && process-file; then\n echo \"processed\"\nfi");
}
#[test]
fn parser_if_command_or_test() {
parse_and_snapshot("if_command_or_test", "if check-cache || [[ -f backup ]]; then\n echo \"data available\"\nfi");
}
#[test]
fn parser_while_command_with_args() {
parse_and_snapshot("while_command_with_args", "while read-line input; do\n echo ${line}\ndone");
}
#[test]
fn parser_posix_function_minimal() {
parse_and_snapshot("posix_function_minimal", "greet() {\n}");
}
#[test]
fn parser_posix_function_with_body() {
parse_and_snapshot("posix_function_with_body", "greet() {\n echo \"Hello, $1!\"\n}");
}
#[test]
fn parser_posix_function_single_line() {
parse_and_snapshot("posix_function_single_line", "double() { echo $1 }");
}
#[test]
fn parser_bash_function_minimal() {
parse_and_snapshot("bash_function_minimal", "function greet {\n}");
}
#[test]
fn parser_bash_function_with_body() {
parse_and_snapshot("bash_function_with_body", "function greet {\n echo \"Hello, $1!\"\n}");
}
#[test]
fn parser_bash_function_single_line() {
parse_and_snapshot("bash_function_single_line", "function double { echo $1 }");
}
#[test]
fn parser_scatter_basic() {
parse_and_snapshot("scatter_basic", "cat input | scatter | process | gather");
}
#[test]
fn parser_scatter_with_as() {
parse_and_snapshot("scatter_with_as", "cat input | scatter as=ITEM | process ${ITEM} | gather");
}
#[test]
fn parser_scatter_with_limit() {
parse_and_snapshot("scatter_with_limit", "cat input | scatter as=X limit=4 | process ${X} | gather");
}
#[test]
fn parser_gather_with_options() {
parse_and_snapshot("gather_with_options", r#"cat input | scatter | process | gather progress=true errors="/tmp/err""#);
}
#[test]
fn parser_test_string_empty() {
parse_and_snapshot("test_string_empty", "[[ -z $VAR ]]");
}
#[test]
fn parser_test_string_nonempty() {
parse_and_snapshot("test_string_nonempty", "[[ -n $VAR ]]");
}
#[test]
fn parser_test_comparison_eq() {
parse_and_snapshot("test_comparison_eq", r#"[[ $X == "value" ]]"#);
}
#[test]
fn parser_test_comparison_neq() {
parse_and_snapshot("test_comparison_neq", r#"[[ $X != "other" ]]"#);
}
#[test]
fn parser_test_comparison_gt() {
parse_and_snapshot("test_comparison_gt", "[[ $NUM -gt 5 ]]");
}
#[test]
fn parser_test_comparison_lt() {
parse_and_snapshot("test_comparison_lt", "[[ $NUM -lt 10 ]]");
}
#[test]
fn parser_test_comparison_ge() {
parse_and_snapshot("test_comparison_ge", "[[ $NUM -ge 5 ]]");
}
#[test]
fn parser_test_comparison_le() {
parse_and_snapshot("test_comparison_le", "[[ $NUM -le 10 ]]");
}
#[test]
fn parser_test_file_exists() {
parse_and_snapshot("test_file_exists", "[[ -f /etc/hosts ]]");
}
#[test]
fn parser_test_file_dir() {
parse_and_snapshot("test_file_dir", "[[ -d /tmp ]]");
}
#[test]
fn parser_test_file_exists_quoted() {
parse_and_snapshot("test_file_exists_quoted", r#"[[ -e "/path" ]]"#);
}
#[test]
fn parser_test_file_is_file() {
parse_and_snapshot("test_file_is_file", r#"[[ -f "/path/file" ]]"#);
}
#[test]
fn parser_test_file_is_dir() {
parse_and_snapshot("test_file_is_dir", r#"[[ -d "/path/dir" ]]"#);
}
#[test]
fn parser_test_regex_match() {
parse_and_snapshot("test_regex_match", r#"[[ $filename =~ "\.rs$" ]]"#);
}
#[test]
fn parser_test_regex_not_match() {
parse_and_snapshot("test_regex_not_match", r#"[[ $name !~ "^test_" ]]"#);
}
#[test]
fn parser_test_and() {
parse_and_snapshot("test_and", "[[ -f file && -d dir ]]");
}
#[test]
fn parser_test_or() {
parse_and_snapshot("test_or", r#"[[ -z "$VAR" || -n "$DEFAULT" ]]"#);
}
#[test]
fn parser_test_not() {
parse_and_snapshot("test_not", "[[ ! -f /tmp/lock ]]");
}
#[test]
fn parser_test_not_double() {
parse_and_snapshot("test_not_double", "[[ ! ! -f file ]]");
}
#[test]
fn parser_test_and_three() {
parse_and_snapshot("test_and_three", "[[ -f a && -f b && -f c ]]");
}
#[test]
fn parser_test_and_or_precedence() {
parse_and_snapshot("test_and_or_precedence", "[[ -f a || -d b && -e c ]]");
}
#[test]
fn parser_test_not_with_and() {
parse_and_snapshot("test_not_with_and", "[[ ! -f a && -d b ]]");
}
#[test]
fn parser_test_complex_compound() {
parse_and_snapshot("test_complex_compound", r#"[[ ! -z "$X" && $Y == "value" || -f /tmp/flag ]]"#);
}
#[test]
fn parser_stmt_and_chain() {
parse_and_snapshot("stmt_and_chain", "cmd1 && cmd2");
}
#[test]
fn parser_stmt_or_chain() {
parse_and_snapshot("stmt_or_chain", "cmd1 || cmd2");
}
#[test]
fn parser_stmt_chain_three() {
parse_and_snapshot("stmt_chain_three", "mkdir dir && cd dir && init");
}
#[test]
fn parser_stmt_chain_mixed() {
parse_and_snapshot("stmt_chain_mixed", r#"try-primary || try-fallback || echo "failed""#);
}
#[test]
fn parser_non_keyword_works() {
parse_and_snapshot("non_keyword_works", r#"myif="value""#);
}
#[test]
fn parser_keyword_at_stmt_start() {
parse_and_snapshot("keyword_at_stmt_start", "if true; then echo; fi");
}
#[test]
fn parser_keyword_if_rejected() {
expect_parse_error(r#"if="value""#);
}
#[test]
fn parser_keyword_while_rejected() {
expect_parse_error("while=true");
}
#[test]
fn parser_keyword_then_rejected() {
expect_parse_error(r#"then="next""#);
}
#[test]
fn parser_test_expr_empty_error() {
expect_parse_error("[[ ]]");
}
#[test]
fn parser_set_command_with_flag_e() {
parse_and_snapshot("set_command_with_flag_e", "set -e");
}
#[test]
fn parser_set_command_multiple_flags() {
parse_and_snapshot("set_command_multiple_flags", "set -e -u");
}
#[test]
fn parser_set_command_no_args() {
parse_and_snapshot("set_command_no_args", "set");
}
#[test]
fn parser_set_command_with_plus_flag() {
parse_and_snapshot("set_command_with_plus_flag", "set +e");
}
#[test]
fn parser_set_in_chain() {
parse_and_snapshot("set_in_chain", r#"set -e && echo "strict mode""#);
}
#[test]
fn parser_true_as_command() {
parse_and_snapshot("true_as_command", "true");
}
#[test]
fn parser_false_as_command() {
parse_and_snapshot("false_as_command", "false");
}
#[test]
fn parser_dot_as_source_alias() {
parse_and_snapshot("dot_as_source_alias", ". script.kai");
}
#[test]
fn parser_source_command() {
parse_and_snapshot("source_command", "source utils.kai");
}
#[test]
fn parser_true_in_condition() {
parse_and_snapshot("true_in_condition", "if true; then echo \"yes\"; fi");
}
#[test]
fn parser_false_in_condition() {
parse_and_snapshot("false_in_condition", "if false; then echo \"no\"; fi");
}
#[test]
fn parser_named_arg_no_spaces() {
parse_and_snapshot("named_arg_no_spaces", "cmd key=value");
}
#[test]
fn parser_named_arg_with_spaces_error() {
expect_parse_error("cmd key = value");
}
#[test]
fn parser_long_flag_with_value() {
parse_and_snapshot("long_flag_with_value", r#"git commit --message="hello""#);
}
#[test]
fn parser_short_flag_then_value() {
parse_and_snapshot("short_flag_then_value", r#"git commit -m "msg""#);
}
#[test]
fn parser_double_dash_ends_flags() {
parse_and_snapshot("double_dash_ends_flags", "cmd -- -not-a-flag");
}
#[test]
fn parser_case_simple() {
parse_and_snapshot("case_simple", "case \"hello\" in\n hello) echo \"matched\" ;;\nesac");
}
#[test]
fn parser_case_multiple_branches() {
parse_and_snapshot("case_multiple_branches", "case ${X} in\n foo) echo \"foo\" ;;\n bar) echo \"bar\" ;;\nesac");
}
#[test]
fn parser_case_with_patterns() {
parse_and_snapshot("case_with_patterns", "case \"test.rs\" in\n \"*.py\") echo \"Python\" ;;\n \"*.rs\") echo \"Rust\" ;;\nesac");
}
#[test]
fn parser_case_multiple_patterns() {
parse_and_snapshot("case_multiple_patterns", "case \"y\" in\n \"y\"|\"yes\") echo \"yes\" ;;\nesac");
}
#[test]
fn parser_case_with_default() {
parse_and_snapshot("case_with_default", "case \"x\" in\n \"*\") echo \"default\" ;;\nesac");
}
#[test]
fn parser_case_optional_lparen() {
parse_and_snapshot("case_optional_lparen", "case \"x\" in\n (foo) echo \"foo\" ;;\nesac");
}
#[test]
fn parser_case_with_path_pattern() {
parse_and_snapshot("case_path_pattern", "case $file in\n /tmp/*) echo \"temp\" ;;\nesac");
}
#[test]
fn parser_case_with_varref_pattern() {
parse_and_snapshot("case_varref_pattern", "case $input in\n $expected) echo \"match\" ;;\nesac");
}
#[test]
fn parser_pipe_with_args() {
parse_and_snapshot("pipe_with_args", r#"ls path="/src" | grep pattern="\.rs$" | wc"#);
}
#[test]
fn parser_cd_dotdot() {
parse_and_snapshot("cd_dotdot", "cd ..");
}
#[test]
fn parser_cd_tilde() {
parse_and_snapshot("cd_tilde", "cd ~");
}
#[test]
fn parser_cd_tilde_path() {
parse_and_snapshot("cd_tilde_path", "cd ~/foo");
}
#[test]
fn parser_cd_relative_path() {
parse_and_snapshot("cd_relative_path", "cd ../bar");
}
#[test]
fn parser_bare_dotdot() {
parse_and_snapshot("bare_dotdot", "echo ..");
}
#[test]
fn parser_cd_dot_slash() {
parse_and_snapshot("cd_dot_slash", "cd ./crates");
}
#[test]
fn parser_dot_slash_exec() {
parse_and_snapshot("dot_slash_exec", "./script.sh");
}
#[test]
fn parser_colon_double() {
parse_and_snapshot("colon_double", "echo foo::bar");
}
#[test]
fn parser_colon_port() {
parse_and_snapshot("colon_port", "echo host:8080");
}
#[test]
fn parser_colon_in_cargo_test() {
parse_and_snapshot("colon_cargo_test", "cargo test -- ls::tests");
}
#[test]
fn parser_glob_star_txt() {
parse_and_snapshot("glob_star_txt", "ls *.txt");
}
#[test]
fn parser_glob_cp_mixed() {
parse_and_snapshot("glob_cp_mixed", "cp *.rs /tmp");
}
#[test]
fn parser_glob_bracket_class() {
parse_and_snapshot("glob_bracket_class", "rm [a-z].txt");
}
#[test]
fn parser_glob_bare_star() {
parse_and_snapshot("glob_bare_star", "echo *");
}
#[test]
fn parser_glob_quoted_stays_literal() {
parse_and_snapshot("glob_quoted_literal", "ls \"*.txt\"");
}
#[test]
fn parser_glob_double_star() {
parse_and_snapshot("glob_double_star", "ls **/*.rs");
}
#[test]
fn parser_glob_question_mark() {
parse_and_snapshot("glob_question_mark", "ls file?.log");
}
#[test]
fn parser_glob_for_loop() {
parse_and_snapshot("glob_for_loop", "for f in *.txt; do echo $f; done");
}
#[test]
fn parser_glob_bare_question() {
parse_and_snapshot("glob_bare_question", "echo ?");
}
#[test]
fn parser_glob_in_case() {
parse_and_snapshot("glob_in_case", "case $x in\n *.txt) echo text ;;\nesac");
}
#[test]
fn parser_glob_in_named_arg() {
parse_and_snapshot("glob_in_named_arg", "cmd file=*.txt");
}
#[test]
fn parser_glob_in_test() {
parse_and_snapshot("glob_in_test", "[[ *.txt == foo ]]");
}