//! Parser tests using rstest for parameterization and insta for snapshots.
//!
//! These tests replace the custom statements.test file format with native Rust tests.

use insta::assert_snapshot;
use kaish_kernel::ast::sexpr::format_program;
use kaish_kernel::parser::parse;
use rstest::rstest;

/// Run a parser test that expects successful parsing and compare with snapshot.
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);
}

/// Run a parser test that expects a parse error.
fn expect_parse_error(input: &str) {
    let result = parse(input);
    assert!(result.is_err(), "Expected error for input: {:?}", input);
}

/// Regression: a bare `.` *argument* must stay part of its command, not start
/// a new `source` statement. `find .` used to parse as TWO statements —
/// `find` (defaulting to cwd) plus `.` (source, no file) — which silently
/// broke `find .`, `ls .`, `echo .`, etc.
#[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),
    );
}

/// The `.` source alias in *command* position must still parse as a single
/// command (with its file argument attached), not split or error.
#[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}",
    );
}

/// Keyword tokens are accepted as the *key* of a `key=value` argv assignment,
/// so `dd if=…` parses (`if` is `Token::If`). The classic `dd` idiom and the
/// other operands must come through as one command.
#[rstest]
#[case("dd if=/dev/urandom of=/dev/null bs=1024 count=10")]
#[case("dd if=/dev/zero of=/tmp/z.bin bs=512 count=2")]
#[case("tool in=a do=b for=c")]
fn keyword_key_argv_assignment_parses(#[case] input: &str) {
    let program = parse(input).expect("should parse");
    assert_eq!(
        program.statements.len(),
        1,
        "`{input}` must be a single command: {}",
        format_program(&program),
    );
}

/// The `if=` key fix must NOT shadow a real `if` conditional — a leading `if`
/// with a space still starts an if-statement.
#[test]
fn leading_if_conditional_still_parses() {
    let program = parse("if true; then echo hi; fi").expect("should parse");
    assert_eq!(program.statements.len(), 1);
    let sexpr = format_program(&program);
    assert!(sexpr.contains("(if"), "should be an if-statement: {sexpr}");
}

/// A spaced `if = x` is still the "no spaces around =" error — only the
/// span-adjacent `if=x` is the assignment form.
#[test]
fn spaced_keyword_assignment_still_errors() {
    expect_parse_error("dd if = x");
}

// =============================================================================
// ASSIGNMENTS
// =============================================================================

#[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""#);
}

// =============================================================================
// COMMANDS
// =============================================================================

#[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"#);
}

// =============================================================================
// PIPELINES
// =============================================================================

#[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"#);
}

// =============================================================================
// REDIRECTS (Known failures - slash in paths needs path token type)
// =============================================================================

#[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);
}

/// When two stdin sources appear on one command, the actionable
/// "multiple stdin redirects" message must surface — not the generic
/// "expected '=', or '('" from a competing statement-level alternative.
/// Chumsky keeps the furthest-position error, so the custom error must be
/// anchored at the offending (second) redirect to win the merge.
#[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}"
    );
}

// =============================================================================
// CONTROL FLOW
// =============================================================================

#[test]
fn parser_if_simple() {
    // POSIX-shaped: compare $? to 0
    parse_and_snapshot("if_simple", "if [[ $? -eq 0 ]]; then\n    echo \"yes\"\nfi");
}

#[test]
fn parser_if_else() {
    // POSIX-shaped: compare $? to 0
    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() {
    // POSIX: `&&` and `||` are EQUAL precedence, left-associative, so this parses
    // as ((a || b) && c) — NOT a || (b && c).
    parse_and_snapshot("precedence_or_then_and", "a || b && c");
}

#[test]
fn parser_precedence_complex() {
    // Left-to-right, equal precedence: a && b || c && d → (((a && b) || c) && d).
    parse_and_snapshot("precedence_complex", "a && b || c && d");
}

#[test]
fn parser_precedence_deeply_chained() {
    // Left-to-right, equal precedence:
    // a || b || c && d && e → ((((a || b) || c) && d) && e).
    parse_and_snapshot("precedence_deeply_chained", "a || b || c && d && e");
}

#[test]
fn parser_if_command_with_args() {
    // Command with arguments as condition
    parse_and_snapshot("if_command_with_args", "if grep -q pattern file; then\n    echo \"found\"\nfi");
}

#[test]
fn parser_if_test_and_command() {
    // Mixed: test expression && 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() {
    // Mixed: command || test expression
    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() {
    // while with command that has arguments
    parse_and_snapshot("while_command_with_args", "while read-line input; do\n    echo ${line}\ndone");
}

// =============================================================================
// SHELL-STYLE FUNCTIONS (use $1, $2 positional params)
// =============================================================================

#[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 }");
}


// =============================================================================
// SCATTER/GATHER
// =============================================================================

#[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 EXPRESSIONS [[ ]]
// =============================================================================

#[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_shape_guard_list() {
    parse_and_snapshot("test_shape_guard_list", "[[ -list $x ]]");
}

#[test]
fn parser_test_shape_guard_record() {
    parse_and_snapshot("test_shape_guard_record", "[[ -record $x ]]");
}

#[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 EXPRESSIONS: Compound (&&, ||, !)
// =============================================================================

#[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() {
    // Precedence: ! > && > ||, so this is: (-f a) || ((-d b) && (-e c))
    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 ]]"#);
}

// ── Collection membership: `in` / `not in` ─────────────────────────────────
// See docs/arrays-and-hashes.md — "Membership `in` is collection-only".

#[test]
fn parser_test_in() {
    parse_and_snapshot("test_in", "[[ x in $c ]]");
}

#[test]
fn parser_test_not_in() {
    parse_and_snapshot("test_not_in", "[[ x not in $c ]]");
}

#[test]
fn parser_test_in_nested_path() {
    parse_and_snapshot("test_in_nested_path", "[[ 443 in ${servers[web]} ]]");
}

#[test]
fn parser_test_in_compound_and() {
    parse_and_snapshot("test_in_compound_and", "[[ apple in $fruits && web in $services ]]");
}

// =============================================================================
// STATEMENT CHAINING
// =============================================================================

#[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""#);
}

// =============================================================================
// EDGE CASES: Keywords
// =============================================================================

#[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""#);
}

// =============================================================================
// EDGE CASES: Test Expressions
// =============================================================================

#[test]
fn parser_test_expr_empty_error() {
    expect_parse_error("[[ ]]");
}

// =============================================================================
// SET COMMAND VS ASSIGNMENT
// =============================================================================

#[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""#);
}

// =============================================================================
// COMMAND NAMES
// =============================================================================

#[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");
}

// =============================================================================
// ARGUMENT PARSING
// =============================================================================

#[test]
fn parser_named_arg_no_spaces() {
    parse_and_snapshot("named_arg_no_spaces", "cmd key=value");
}

/// Post-`test`-grammar-relief: a *spaced* `key = value` is no longer a
/// "no spaces around =" parse error. `=` is now a literal argv operator (so
/// POSIX `test a = b` can reach the `test` command), which makes
/// `cmd key = value` parse as `cmd` with three positional args — exactly like
/// bash. A *glued* `key=value` still binds as a WordAssign
/// (see `parser_named_arg_no_spaces`), so the useful distinction survives.
#[test]
fn parser_spaced_equals_is_literal_argv_operator() {
    assert_eq!(
        one_stmt_sexpr("cmd key = value"),
        r#"(cmd cmd (pos (string "key")) (pos (string "=")) (pos (string "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() {
    // `-not-a-flag` is a single shell word (the lexer keeps internal hyphens),
    // so after `--` it is one positional string argument, not three.
    parse_and_snapshot("double_dash_ends_flags", "cmd -- -not-a-flag");
}

// =============================================================================
// CASE STATEMENTS
// =============================================================================

#[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() {
    // Bug M: paths should work in case patterns
    parse_and_snapshot("case_path_pattern", "case $file in\n    /tmp/*) echo \"temp\" ;;\nesac");
}

#[test]
fn parser_case_with_varref_pattern() {
    // Bug M: variable refs should work in case patterns
    parse_and_snapshot("case_varref_pattern", "case $input in\n    $expected) echo \"match\" ;;\nesac");
}

// =============================================================================
// KNOWN FAILURES - Issues to be fixed
// =============================================================================

#[test]
fn parser_pipe_with_args() {
    parse_and_snapshot("pipe_with_args", r#"ls path="/src" | grep pattern="\.rs$" | wc"#);
}

// =============================================================================
// Navigation: cd .., cd ~, cd ~/foo, cd ../bar
// =============================================================================

#[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");
}

// =============================================================================
// COLON IN UNQUOTED WORDS
// =============================================================================

#[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");
}

// =============================================================================
// Glob patterns
// =============================================================================

#[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 ]]");
}

// ---------------------------------------------------------------------------
// Grammar relief for the `test` builtin (Phase 1). POSIX `test`/`[` is a
// *command*, so its comparison and negation operators (`=`, `==`, `!=`, `!`)
// must reach it as ordinary positional argv words. kaish's grammar treats
// these as shell-significant tokens, so they used to parse-error before ever
// reaching a command. This relief makes them literal positionals — but ONLY
// these four: `<` `>` `<=` `>=` stay redirection (see the redirect-unaffected
// test below) and remain `[[ ]]`-only. See signoff.md / project_test_builtin.
// ---------------------------------------------------------------------------

/// Parse `input`, require exactly one statement, and return its s-expr.
/// A parse error (or a split into multiple statements) fails loudly here —
/// that IS the pre-relief bug we're fixing.
fn one_stmt_sexpr(input: &str) -> String {
    let program = parse(input).unwrap_or_else(|errors| {
        let msg = errors
            .iter()
            .map(|e| e.to_string())
            .collect::<Vec<_>>()
            .join("; ");
        panic!("parse error for {input:?}: {msg}");
    });
    assert_eq!(
        program.statements.len(),
        1,
        "{input:?} must be ONE statement, got {}: {}",
        program.statements.len(),
        format_program(&program),
    );
    format_program(&program)
}

#[rstest]
// The canonical string-equality idiom — THE reason a flag-form-only `test`
// was judged not-credible.
#[case(
    "test a = b",
    r#"(cmd test (pos (string "a")) (pos (string "=")) (pos (string "b")))"#
)]
// bash-refugee `==` alias.
#[case(
    "test a == b",
    r#"(cmd test (pos (string "a")) (pos (string "==")) (pos (string "b")))"#
)]
// Inequality.
#[case(
    "test a != b",
    r#"(cmd test (pos (string "a")) (pos (string "!=")) (pos (string "b")))"#
)]
// Leading-`!` negation sugar (kaish has no `! cmd` pipeline negation, so this
// is the only negation path for the builtin).
#[case(
    "test ! -f x",
    r#"(cmd test (pos (string "!")) (shortflag f) (pos (string "x")))"#
)]
// Operators as ordinary argv on any command, not just `test` (the relief is
// name-agnostic — no fragile special-casing of the `test` command name).
#[case(
    "echo a = b",
    r#"(cmd echo (pos (string "a")) (pos (string "=")) (pos (string "b")))"#
)]
fn test_operators_reach_command_as_positionals(#[case] input: &str, #[case] expected: &str) {
    assert_eq!(one_stmt_sexpr(input), expected);
}

/// The most common real-world form: quoted variable operands around `=`.
/// We only assert the operator lands as a literal positional between them;
/// the operand rendering is exercised elsewhere.
#[test]
fn test_quoted_var_equality_parses() {
    let sexpr = one_stmt_sexpr(r#"test "$a" = "$b""#);
    assert!(
        sexpr.starts_with("(cmd test "),
        "expected a `test` command, got: {sexpr}"
    );
    assert!(
        sexpr.contains(r#"(pos (string "="))"#),
        "`=` must land as a literal positional, got: {sexpr}"
    );
}

// --- Regressions: the relief must not disturb assignment / argv-assign /
// long-flag-value / redirection parsing. ---

/// A glued `x=y` at statement level is still an ASSIGNMENT, not a command
/// `x` with a bare `=` argument.
#[test]
fn glued_assignment_still_assignment_not_command() {
    let sexpr = one_stmt_sexpr("x=y");
    assert!(
        !sexpr.starts_with("(cmd "),
        "`x=y` must stay an assignment, not become a command: {sexpr}"
    );
}

/// A glued `key=value` in argv position (`cat foo=bar`) stays the WordAssign
/// production (bash: opens a file literally named `foo=bar`), NOT split into
/// `foo` `=` `bar`.
#[test]
fn glued_argv_assign_stays_wordassign() {
    let sexpr = one_stmt_sexpr("cat foo=bar");
    assert!(
        sexpr.contains("(wordassign foo"),
        "glued `foo=bar` must stay a WordAssign, got: {sexpr}"
    );
    assert!(
        !sexpr.contains(r#"(pos (string "="))"#),
        "glued `foo=bar` must not fragment into a bare `=` positional: {sexpr}"
    );
}

/// A glued `--name=value` long flag still binds its value.
#[test]
fn long_flag_with_value_still_binds() {
    let sexpr = one_stmt_sexpr("grep --context=3 pat");
    assert!(
        sexpr.contains("(named context"),
        "`--context=3` must stay a named flag, got: {sexpr}"
    );
}

/// `<` / `>` stay REDIRECTION — the relief deliberately excludes the angle
/// brackets so it can't shadow redirects.
#[test]
fn angle_brackets_stay_redirection() {
    let sexpr = one_stmt_sexpr("echo x > f");
    assert!(
        sexpr.contains("redir") && !sexpr.contains(r#"(pos (string ">"))"#),
        "`>` must remain a redirect, not become a positional: {sexpr}"
    );
}
