use super::*;

#[test]
fn test_parse_variable() {
    let parser = Parser::new("echo $HOME");
    let script = parser.parse().unwrap().file;

    if let AstCommand::Simple(cmd) = &script.body[0].command {
        assert_eq!(cmd.args.len(), 1);
        assert_eq!(cmd.args[0].parts.len(), 1);
        assert!(matches!(&cmd.args[0].parts[0].kind, WordPart::Variable(v) if v == "HOME"));
    } else {
        panic!("expected simple command");
    }
}

#[test]
fn test_parse_escaped_dollar_expansions_stay_literal_in_script_words() {
    let input = r#"echo \$HOME \${USER}"#;
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    assert_eq!(command.args.len(), 2);

    let expected = [("$HOME", r#"\$HOME"#), ("${USER}", r#"\${USER}"#)];
    for (word, (decoded, syntax)) in command.args.iter().zip(expected) {
        assert_eq!(word.render(input), decoded);
        assert_eq!(word.render_syntax(input), syntax);
        assert!(matches!(
            word.parts.as_slice(),
            [WordPartNode {
                kind: WordPart::Literal(text),
                ..
            }] if text.is_source_backed() && text.as_str(input, word.parts[0].span) == decoded
        ));
    }
}

#[test]
fn test_parse_escaped_braced_parameter_keeps_inner_expansions_live() {
    let input = r#"echo \${x:-$HOME} \${${1}}"#;
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    assert_eq!(command.args.len(), 2);

    let default_template = &command.args[0];
    assert_eq!(default_template.render(input), "${x:-$HOME}");
    assert_eq!(default_template.render_syntax(input), r#"\${x:-$HOME}"#);
    assert!(word_part_tree_contains_variable(
        &default_template.parts,
        "HOME"
    ));

    let indirect_template = &command.args[1];
    assert_eq!(indirect_template.render(input), "${${1}}");
    assert_eq!(indirect_template.render_syntax(input), r#"\${${1}}"#);
    let mut names = Vec::new();
    collect_bourne_parameter_names(&indirect_template.parts, &mut names);
    assert_eq!(names, vec!["1"]);
}

#[test]
fn test_parse_escaped_backslash_then_variable_keeps_variable_live() {
    let input = "echo \\\\$HOME\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];
    assert!(matches!(
        word.parts.as_slice(),
        [
            WordPartNode {
                kind: WordPart::Literal(text),
                ..
            },
            WordPartNode {
                kind: WordPart::Variable(name),
                ..
            }
        ] if text.as_str(input, word.parts[0].span) == "\\"
            && name.as_str() == "HOME"
    ));
}

#[test]
fn test_parse_mixed_quoted_and_cooked_plain_continuation_keeps_variable_live() {
    let input = "echo \"x\"\\\\$HOME\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    assert!(matches!(
        word.parts.as_slice(),
        [
            WordPartNode {
                kind: WordPart::DoubleQuoted { parts, .. },
                ..
            },
            WordPartNode {
                kind: WordPart::Literal(text),
                ..
            },
            WordPartNode {
                kind: WordPart::Variable(name),
                ..
            }
        ] if matches!(
            parts.as_slice(),
            [WordPartNode {
                kind: WordPart::Literal(inner),
                ..
            }] if inner.as_str(input, parts[0].span) == "x"
        ) && text.as_str(input, word.parts[1].span) == "\\"
            && name.as_str() == "HOME"
    ));
}

#[test]
fn test_parse_escaped_quote_before_command_substitution_keeps_substitution_live() {
    let input = "echo TERMUX_SUBPKG_INCLUDE=\\\"$(find ${_ADD_PREFIX}lib{,32})\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    assert!(
        word.parts
            .iter()
            .any(|part| !matches!(part.kind, WordPart::Literal(_))),
        "parts: {:?}",
        word.parts
    );
    assert_eq!(word.brace_syntax().len(), 0);
}

#[test]
fn test_parse_escaped_command_substitution_stays_literal_in_double_quotes() {
    let input = r#"echo "\$(pwd)""#;
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    assert_eq!(word.render(input), "$(pwd)");
    assert_eq!(word.render_syntax(input), r#""\$(pwd)""#);
    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected double-quoted word");
    };
    assert!(matches!(
        parts.as_slice(),
        [WordPartNode {
            kind: WordPart::Literal(text),
            ..
        }] if text.is_source_backed() && text.as_str(input, parts[0].span) == "$(pwd)"
    ));
}

#[test]
fn test_parse_escaped_backslash_before_command_substitution_keeps_substitution_live() {
    let input = "echo \"\\\\$(pwd)\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected double-quoted word");
    };
    let slices: Vec<&str> = parts.iter().map(|part| part.span.slice(input)).collect();
    assert_eq!(slices, vec![r#"\\"#, "$(pwd)"]);

    let WordPart::Literal(text) = &parts[0].kind else {
        panic!("expected literal backslash prefix");
    };
    assert_eq!(text.as_str(input, parts[0].span), r#"\"#);

    let WordPart::CommandSubstitution { body, .. } = &parts[1].kind else {
        panic!("expected live command substitution");
    };
    let inner = expect_simple(&body[0]);
    assert_eq!(inner.name.render(input), "pwd");
}

#[test]
fn test_parse_escaped_literal_before_command_substitution_keeps_following_substitution_live() {
    let input = "echo $VERSION\\_$(echo hi)\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    assert!(matches!(
        word.parts.as_slice(),
        [
            WordPartNode {
                kind: WordPart::Variable(name),
                ..
            },
            WordPartNode {
                kind: WordPart::Literal(text),
                ..
            },
            WordPartNode {
                kind: WordPart::CommandSubstitution { body, .. },
                span,
            }
        ] if name.as_str() == "VERSION"
            && text.as_str(input, word.parts[1].span) == "_"
            && span.slice(input) == "$(echo hi)"
            && matches!(&body[0].command, AstCommand::Simple(inner) if inner.name.render(input) == "echo")
    ));
}

#[test]
fn test_parse_escaped_quotes_before_command_substitution_keep_nested_pipeline_live() {
    let input = "#!/bin/sh\necho -n \"\\\"adp_$(echo $var | tr A-Z a-z)\\\": [\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[1];
    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected double-quoted word");
    };
    let WordPart::CommandSubstitution { body, .. } = &parts[1].kind else {
        panic!("expected command substitution");
    };

    let AstCommand::Binary(binary) = &body[0].command else {
        panic!("expected piped command");
    };
    let first = expect_simple(&binary.left);
    assert_eq!(first.name.render(input), "echo");
    assert_eq!(first.args[0].render(input), "$var");

    let second = expect_simple(&binary.right);
    assert_eq!(second.name.render(input), "tr");
    assert_eq!(second.args[0].render(input), "A-Z");
    assert_eq!(second.args[1].render(input), "a-z");
}

#[test]
fn test_parse_escaped_quotes_after_default_expansion_keep_command_substitution_live() {
    let input = "label=\",label=\\\"${fallback:=value}$(render value $line)\\\"\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
        panic!("expected scalar assignment");
    };

    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected double-quoted assignment value");
    };
    let WordPart::CommandSubstitution { body, .. } = &parts[2].kind else {
        panic!("expected command substitution after default expansion");
    };

    let inner = expect_simple(&body[0]);
    assert_eq!(inner.name.render(input), "render");
    assert_eq!(inner.args[1].render(input), "$line");
}

#[test]
fn test_parse_command_substitution_with_piped_tr_after_quoted_variable_keeps_nested_pipeline_live()
{
    let input = "ATLAS_SHARED=$(echo \"$ATLAS_SHARED\"|cut -b 1|tr a-z A-Z)\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
        panic!("expected scalar assignment");
    };
    let WordPart::CommandSubstitution { body, .. } = &word.parts[0].kind else {
        panic!("expected command substitution");
    };

    let AstCommand::Binary(pipeline) = &body[0].command else {
        panic!("expected piped command");
    };
    let AstCommand::Binary(prefix) = &pipeline.left.command else {
        panic!("expected nested pipeline");
    };
    let first = expect_simple(&prefix.left);
    assert_eq!(first.name.render(input), "echo");
    assert_eq!(first.args[0].render(input), "$ATLAS_SHARED");

    let middle = expect_simple(&prefix.right);
    assert_eq!(middle.name.render(input), "cut");
    assert_eq!(middle.args[0].render(input), "-b");
    assert_eq!(middle.args[1].render(input), "1");

    let tr_command = expect_simple(&pipeline.right);
    assert_eq!(tr_command.name.render(input), "tr");
    assert_eq!(tr_command.args[0].render(input), "a-z");
    assert_eq!(tr_command.args[1].render(input), "A-Z");
}

#[test]
fn test_command_substitution_rebases_for_target_word() {
    let input = r#"echo "$(for i in 1; do echo "$i"; done)""#;
    let script = Parser::new(input).parse().unwrap().file;
    let command = expect_simple(&script.body[0]);
    let word = &command.args[0];

    let [
        WordPartNode {
            kind: WordPart::DoubleQuoted { parts, .. },
            ..
        },
    ] = word.parts.as_slice()
    else {
        panic!("expected double-quoted argument");
    };
    let body = parts
        .iter()
        .find_map(|part| match &part.kind {
            WordPart::CommandSubstitution { body, .. } => Some(body),
            _ => None,
        })
        .expect("expected command substitution");
    let (compound, _) = expect_compound(&body[0]);
    let AstCompoundCommand::For(command) = compound else {
        panic!("expected for loop in command substitution");
    };

    assert_eq!(command.targets[0].name.as_deref(), Some("i"));
    assert_eq!(command.targets[0].word.render_syntax(input), "i");
    assert_eq!(command.targets[0].span.slice(input), "i");
}

#[test]
fn test_parse_positional_parameters() {
    let parser = Parser::new("echo $@ $*");
    let script = parser.parse().unwrap().file;

    if let AstCommand::Simple(cmd) = &script.body[0].command {
        assert_eq!(cmd.args.len(), 2);
        assert!(matches!(&cmd.args[0].parts[0].kind, WordPart::Variable(v) if v == "@"));
        assert!(matches!(&cmd.args[1].parts[0].kind, WordPart::Variable(v) if v == "*"));
    } else {
        panic!("expected simple command");
    }
}

#[test]
fn test_substring_offset_preserves_postfix_and_nested_length_arithmetic() {
    let input = "echo \"${chars:spin_i++%${#chars}:1}\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };

    let WordPart::DoubleQuoted { parts, .. } = &command.args[0].parts[0].kind else {
        panic!("expected double-quoted word");
    };

    let (_, offset_ast, length_ast) = expect_substring_part(&parts[0].kind);
    let ArithmeticExpr::Binary { left, op, right } = &offset_ast
        .as_ref()
        .expect("expected offset arithmetic AST")
        .kind
    else {
        panic!("expected modulo offset expression");
    };
    assert_eq!(*op, ArithmeticBinaryOp::Modulo);
    let ArithmeticExpr::Postfix { expr, op } = &left.kind else {
        panic!("expected postfix increment on the left side");
    };
    assert_eq!(*op, ArithmeticPostfixOp::Increment);
    expect_variable(expr, "spin_i");
    expect_shell_word(right, input, "${#chars}");
    expect_number(
        length_ast.as_ref().expect("expected substring length AST"),
        input,
        "1",
    );
}

#[test]
fn test_parameter_forms_preserve_selector_kinds() {
    let input = "echo ${arr[@]} ${arr[*]} ${#arr[@]} ${!arr[*]} ${arr[@]:1:2}\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };

    let reference = expect_array_access(&command.args[0]);
    assert_eq!(
        reference.subscript.as_deref().and_then(Subscript::selector),
        Some(SubscriptSelector::At)
    );

    let reference = expect_array_access(&command.args[1]);
    assert_eq!(
        reference.subscript.as_deref().and_then(Subscript::selector),
        Some(SubscriptSelector::Star)
    );

    let reference = expect_array_length_part(&command.args[2].parts[0].kind);
    assert_eq!(
        reference.subscript.as_deref().and_then(Subscript::selector),
        Some(SubscriptSelector::At)
    );

    let reference = expect_array_indices_part(&command.args[3].parts[0].kind);
    assert_eq!(
        reference.subscript.as_deref().and_then(Subscript::selector),
        Some(SubscriptSelector::Star)
    );

    let (reference, _, _) = expect_array_slice_part(&command.args[4].parts[0].kind);
    assert_eq!(
        reference.subscript.as_deref().and_then(Subscript::selector),
        Some(SubscriptSelector::At)
    );
}

#[test]
fn test_braced_special_parameters_parse_as_parameter_accesses() {
    let input = "echo ${#} ${$} ${!}\n";
    let script = Parser::new(input).parse().unwrap().file;
    let command = expect_simple(&script.body[0]);

    let hash = expect_array_access(&command.args[0]);
    assert_eq!(hash.name.as_str(), "#");
    assert_eq!(hash.name_span.slice(input), "#");

    let pid = expect_array_access(&command.args[1]);
    assert_eq!(pid.name.as_str(), "$");
    assert_eq!(pid.name_span.slice(input), "$");

    let bang = expect_array_access(&command.args[2]);
    assert_eq!(bang.name.as_str(), "!");
    assert_eq!(bang.name_span.slice(input), "!");
}

#[test]
fn test_indirect_expansions_preserve_reference_structure() {
    let input = "echo ${!tools[$target]} ${!var//$'\\n'/' '}\n";
    let script = Parser::new(input).parse().unwrap().file;
    let command = expect_simple(&script.body[0]);

    let (tools, operator, operand, colon_variant) =
        expect_indirect_expansion_part(&command.args[0].parts[0].kind);
    assert_eq!(tools.name.as_str(), "tools");
    assert!(!colon_variant);
    assert!(operator.is_none());
    assert!(operand.is_none());
    let subscript = expect_subscript(tools, input, "$target");
    assert_eq!(subscript.syntax_text(input), "$target");

    let (var, operator, operand, colon_variant) =
        expect_indirect_expansion_part(&command.args[1].parts[0].kind);
    assert_eq!(var.name.as_str(), "var");
    assert!(!colon_variant);
    assert!(operand.is_none());
    match operator {
        Some(ParameterOp::ReplaceAll {
            pattern,
            replacement,
            replacement_word_ast,
        }) => {
            assert_eq!(pattern.render(input), "\n");
            assert_eq!(replacement.slice(input), "' '");
            assert_eq!(replacement_word_ast.render_syntax(input), "' '");
        }
        other => panic!("expected replace-all indirect expansion, got {other:?}"),
    }
}

#[test]
fn test_parse_indirect_special_hash_parameter() {
    let input = "echo ${!#}\n";
    let script = Parser::new(input).parse().unwrap().file;
    let command = expect_simple(&script.body[0]);
    let parameter = expect_parameter(&command.args[0]);

    let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Indirect {
        reference,
        operator,
        operand,
        ..
    }) = &parameter.syntax
    else {
        panic!("expected indirect special-parameter expansion");
    };

    assert_eq!(reference.name.as_str(), "#");
    assert!(operator.is_none());
    assert!(operand.is_none());
}

#[test]
fn test_parse_special_hash_parameter_prefix_removal() {
    let input = "echo ${##*/}\n";
    let script = Parser::new(input).parse().unwrap().file;
    let command = expect_simple(&script.body[0]);
    let parameter = expect_parameter(&command.args[0]);

    let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
        reference,
        operator,
        ..
    }) = &parameter.syntax
    else {
        panic!("expected special-parameter operation expansion");
    };

    assert_eq!(reference.name.as_str(), "#");
    match operator.as_ref() {
        ParameterOp::RemovePrefixShort { pattern } => assert_eq!(pattern.render(input), "*/"),
        other => panic!("expected short prefix removal, got {other:?}"),
    }
}

#[test]
fn test_parse_length_of_special_parameters_after_hash_prefix() {
    let input = "echo ${#-} ${#?} ${##}\n";
    let script = Parser::new(input).parse().unwrap().file;
    let command = expect_simple(&script.body[0]);

    let first = expect_parameter(&command.args[0]);
    assert!(matches!(
        &first.syntax,
        ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Length { reference })
            if reference.name.as_str() == "-"
    ));

    let second = expect_parameter(&command.args[1]);
    assert!(matches!(
        &second.syntax,
        ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Length { reference })
            if reference.name.as_str() == "?"
    ));

    let third = expect_parameter(&command.args[2]);
    assert!(matches!(
        &third.syntax,
        ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Length { reference })
            if reference.name.as_str() == "#"
    ));
}

#[test]
fn test_parse_special_zero_parameter_prefix_removal_inside_multiline_quote() {
    let input = "\
usage=\"
Example:
  ${0##*/} github_repository
Terraform:
  data \\\"external\\\" \\\"github_repos\\\" {
    program = [\\\"/path/to/${0##*/}\\\", \\\"github_repository\\\"]
  }
usage: ${0##*/} <resource_type>
\"
";
    let script = Parser::new(input).parse().unwrap().file;
    let command = expect_simple(&script.body[0]);
    let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
        panic!("expected scalar assignment");
    };

    let mut names = Vec::new();
    collect_bourne_parameter_names(&word.parts, &mut names);
    let mut patterns = Vec::new();
    collect_bourne_parameter_trim_patterns(&word.parts, input, &mut patterns);

    assert_eq!(names, vec!["0", "0", "0"]);
    assert_eq!(patterns, vec!["*/", "*/", "*/"]);
}

#[test]
fn test_word_part_spans_track_mixed_expansions() {
    let input = "echo pre${name:-fallback}$(printf hi)$((1+2))post\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let slices = top_level_part_slices(word, input);

    assert_eq!(
        slices,
        vec![
            "pre",
            "${name:-fallback}",
            "$(printf hi)",
            "$((1+2))",
            "post"
        ]
    );
}

#[test]
fn test_word_part_spans_track_quoted_expansions() {
    let input = "echo \"x$HOME$(pwd)y\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    assert_eq!(
        top_level_part_slices(word, input),
        vec!["\"x$HOME$(pwd)y\""]
    );
    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected double-quoted word");
    };
    let slices: Vec<&str> = parts.iter().map(|part| part.span.slice(input)).collect();
    assert_eq!(slices, vec!["x", "$HOME", "$(pwd)", "y"]);
}

#[test]
fn test_mixed_segment_word_preserves_expansion_boundaries() {
    let input = "echo foo\"$bar\"baz\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let slices = top_level_part_slices(word, input);

    assert_eq!(slices, vec!["foo", "\"$bar\"", "baz"]);
    let WordPart::DoubleQuoted { parts, .. } = &word.parts[1].kind else {
        panic!("expected quoted middle segment");
    };
    assert!(matches!(parts.as_slice(), [part] if matches!(&part.kind, WordPart::Variable(_))));
}

#[test]
fn test_escaped_quote_literal_does_not_truncate_following_variable_name() {
    let input = "echo \\\"$archname\\\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    assert_eq!(
        top_level_part_slices(word, input),
        vec!["\\\"", "$archname", "\\\""]
    );
    assert!(word_part_tree_contains_variable(&word.parts, "archname"));
    assert!(!word_part_tree_contains_variable(&word.parts, "archnam"));
}

#[test]
fn test_backtick_command_substitution_inside_double_quotes_preserves_syntax_form() {
    let input = "echo \"pre `printf hi` post\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    assert!(is_fully_quoted(word));
    assert_eq!(
        top_level_part_slices(word, input),
        vec!["\"pre `printf hi` post\""]
    );

    let WordPart::DoubleQuoted { parts, dollar } = &word.parts[0].kind else {
        panic!("expected double-quoted word");
    };
    assert!(!dollar);

    let slices: Vec<&str> = parts.iter().map(|part| part.span.slice(input)).collect();
    assert_eq!(slices, vec!["pre ", "`printf hi`", " post"]);

    let WordPart::CommandSubstitution {
        body: commands,
        syntax,
    } = &parts[1].kind
    else {
        panic!("expected command substitution");
    };
    assert_eq!(*syntax, CommandSubstitutionSyntax::Backtick);

    let inner = expect_simple(&commands[0]);
    assert_eq!(inner.name.render(input), "printf");
    assert_eq!(inner.args[0].render(input), "hi");
}

#[test]
fn test_backtick_command_substitution_inside_multiline_double_quotes_preserves_syntax_form() {
    let input = "echo \"\\\n*** ERROR\n`cat lockfile 2>/dev/null`\n\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let WordPart::DoubleQuoted { parts, dollar } = &word.parts[0].kind else {
        panic!("expected double-quoted word");
    };
    assert!(!dollar);

    let slices: Vec<&str> = parts.iter().map(|part| part.span.slice(input)).collect();
    assert_eq!(
        slices,
        vec!["\\\n*** ERROR\n", "`cat lockfile 2>/dev/null`", "\n"]
    );

    let WordPart::CommandSubstitution {
        body: commands,
        syntax,
    } = &parts[1].kind
    else {
        panic!("expected command substitution");
    };
    assert_eq!(*syntax, CommandSubstitutionSyntax::Backtick);

    let inner = expect_simple(&commands[0]);
    assert_eq!(inner.name.render(input), "cat");
    assert_eq!(inner.args[0].render(input), "lockfile");
    assert_eq!(commands[0].redirects[0].fd, Some(2));
    assert_eq!(
        redirect_word_target(&commands[0].redirects[0]).render(input),
        "/dev/null"
    );
}

#[test]
fn test_dollar_paren_command_substitution_inside_double_quotes_preserves_nested_quoted_argument() {
    let input = "echo \"$(cmd \"$arg\")\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    assert!(is_fully_quoted(word));
    assert_eq!(
        top_level_part_slices(word, input),
        vec!["\"$(cmd \"$arg\")\""]
    );

    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected double-quoted word");
    };
    assert_eq!(
        parts
            .iter()
            .map(|part| part.span.slice(input))
            .collect::<Vec<_>>(),
        vec!["$(cmd \"$arg\")"]
    );

    let WordPart::CommandSubstitution { body, syntax } = &parts[0].kind else {
        panic!("expected command substitution");
    };
    assert_eq!(*syntax, CommandSubstitutionSyntax::DollarParen);

    let inner = expect_simple(&body[0]);
    assert_eq!(inner.name.render(input), "cmd");
    assert_eq!(inner.args[0].render_syntax(input), "\"$arg\"");
}

#[test]
fn test_dollar_paren_command_substitution_inside_double_quotes_with_prefix_keeps_nested_spans_absolute()
 {
    let input = "echo \"pre $(echo hi) post\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];
    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected double-quoted word");
    };
    let WordPart::CommandSubstitution { body, syntax } = &parts[1].kind else {
        panic!("expected command substitution");
    };
    assert_eq!(*syntax, CommandSubstitutionSyntax::DollarParen);

    let inner = expect_simple(&body[0]);
    assert_eq!(inner.name.render(input), "echo");
    assert_eq!(inner.name.span.slice(input), "echo");
    assert_eq!(inner.args[0].render(input), "hi");
}

#[test]
fn test_dollar_paren_command_substitution_inside_quoted_prefix_with_pipeline_keeps_nested_spans_absolute()
 {
    let input = "echo -n \"\\\"adp_$(echo $var | tr A-Z a-z)\\\": [\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    assert_eq!(command.name.render(input), "echo");
    assert_eq!(command.args[0].render(input), "-n");
    let word = &command.args[1];
    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected double-quoted word");
    };
    let WordPart::Literal(text) = &parts[0].kind else {
        panic!("expected literal prefix");
    };
    assert_eq!(text.as_str(input, parts[0].span), "\"adp_");
    let WordPart::CommandSubstitution { body, syntax } = &parts[1].kind else {
        panic!("expected command substitution");
    };
    assert_eq!(*syntax, CommandSubstitutionSyntax::DollarParen);

    let AstCommand::Binary(binary) = &body[0].command else {
        panic!("expected pipeline");
    };
    let left = expect_simple(&binary.left);
    assert_eq!(left.name.render(input), "echo");
    assert_eq!(left.args[0].render(input), "$var");

    let right = expect_simple(&binary.right);
    assert_eq!(right.name.render(input), "tr");
    assert_eq!(right.name.span.slice(input), "tr");
    assert_eq!(right.args[0].render(input), "A-Z");
    assert_eq!(right.args[1].render(input), "a-z");
}

#[test]
fn test_dollar_paren_command_substitution_after_multiline_escaped_quote_keeps_nested_spans_absolute()
 {
    let input = "\
echo \"script
  LEFT=\"$left\":\\$base \\
    CHILD=\\$base/$(basename $child) \\
    PATH=$path
    run\" > out
";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let body = first_command_substitution_body(&command.args[0].parts)
        .expect("expected nested command substitution");
    let inner = expect_simple(&body[0]);

    assert_eq!(inner.name.span.slice(input), "basename");
    assert_eq!(inner.args[0].span.slice(input), "$child");
    assert_eq!(inner.args[0].span.start.line(), 3);
    assert_eq!(inner.args[0].span.start.column(), 29);
}

#[test]
fn test_dollar_paren_command_substitution_inside_double_quotes_handles_nested_arithmetic_with_quoted_right_paren()
 {
    let input = "echo \"$(echo \"$(( $(printf ')') + 1 ))\")\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected double-quoted word");
    };
    let WordPart::CommandSubstitution { body, syntax } = &parts[0].kind else {
        panic!("expected command substitution");
    };
    assert_eq!(*syntax, CommandSubstitutionSyntax::DollarParen);

    let inner = expect_simple(&body[0]);
    assert_eq!(inner.name.render(input), "echo");
    assert_eq!(
        inner.args[0].render_syntax(input),
        "\"$(( $(printf ')') + 1 ))\""
    );
}

#[test]
fn test_process_substitution_like_regex_inside_nested_command_substitution_stays_literal() {
    let input = "value=$(printf '%s\\n' \"<record_id>([^<]*)</record_id>\")\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let AssignmentValue::Scalar(value) = &command.assignments[0].value else {
        panic!("expected scalar assignment");
    };
    let WordPart::CommandSubstitution { body, .. } = &value.parts[0].kind else {
        panic!("expected command substitution");
    };

    let inner = expect_simple(&body[0]);
    for word in &inner.args {
        assert!(
            !word
                .parts
                .iter()
                .any(|part| matches!(part.kind, WordPart::ProcessSubstitution { .. })),
            "{:#?}",
            word.parts
        );
    }
}

#[test]
fn test_process_substitution_like_regex_inside_nested_pipeline_command_substitution_stays_literal()
{
    let input = "_record_id=$(echo \"$response\" | _egrep_o \"<record_id>([^<]*)</record_id><type>TXT</type><host>$fulldomain</host>\" | _egrep_o \"<record_id>([^<]*)</record_id>\" | sed -r \"s/<record_id>([^<]*)<\\/record_id>/\\1/\" | tail -n 1)\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let AssignmentValue::Scalar(value) = &command.assignments[0].value else {
        panic!("expected scalar assignment");
    };
    let WordPart::CommandSubstitution { body, .. } = &value.parts[0].kind else {
        panic!("expected command substitution");
    };

    fn word_has_process_substitution(word: &Word) -> bool {
        word.parts.iter().any(|part| match &part.kind {
            WordPart::ProcessSubstitution { .. } => true,
            WordPart::DoubleQuoted { parts, .. } => parts
                .iter()
                .any(|part| matches!(part.kind, WordPart::ProcessSubstitution { .. })),
            _ => false,
        })
    }

    fn stmt_has_process_substitution(stmt: &Stmt) -> bool {
        command_has_process_substitution(&stmt.command)
    }

    fn command_has_process_substitution(command: &AstCommand) -> bool {
        match command {
            AstCommand::Simple(command) => command.args.iter().any(word_has_process_substitution),
            AstCommand::Binary(binary)
                if matches!(binary.op, BinaryOp::Pipe | BinaryOp::PipeAll) =>
            {
                stmt_has_process_substitution(&binary.left)
                    || stmt_has_process_substitution(&binary.right)
            }
            _ => false,
        }
    }

    for stmt in body.iter() {
        assert!(!stmt_has_process_substitution(stmt), "{:#?}", stmt);
    }
}

#[test]
fn test_brace_syntax_marks_unquoted_expansion_candidates() {
    let list_input = "{a,b}";
    let list = Parser::parse_word_string(list_input);
    assert_eq!(brace_slices(&list, list_input), vec!["{a,b}"]);
    assert_eq!(
        list.brace_syntax(),
        &[BraceSyntax {
            kind: BraceSyntaxKind::Expansion(BraceExpansionKind::CommaList),
            span: list.span,
            quote_context: BraceQuoteContext::Unquoted,
        }]
    );
    assert!(list.has_active_brace_expansion());

    let sequence_input = "{1..3}";
    let sequence = Parser::parse_word_string(sequence_input);
    assert_eq!(brace_slices(&sequence, sequence_input), vec!["{1..3}"]);
    assert_eq!(
        sequence.brace_syntax()[0].kind,
        BraceSyntaxKind::Expansion(BraceExpansionKind::Sequence)
    );
    assert!(sequence.brace_syntax()[0].expands());
}

#[test]
fn test_brace_syntax_preserves_brace_expansion_suffix_forms() {
    for input in [
        "{a,b}}",
        "{~,~root}/pwd",
        "\"\"{~,~root}/pwd",
        "\\{~,~root}/pwd",
    ] {
        let word = Parser::parse_word_string(input);
        assert_eq!(word.render_syntax(input), input);
        if input == "\\{~,~root}/pwd" {
            assert_eq!(brace_slices(&word, input), Vec::<&str>::new());
        } else {
            let expected = if input == "{a,b}}" {
                "{a,b}"
            } else {
                "{~,~root}"
            };
            assert_eq!(brace_slices(&word, input), vec![expected]);
        }
    }
}

#[test]
fn test_brace_syntax_marks_nested_expansions_separately() {
    let input = "{EGL,GLES{,2,3}}";
    let word = Parser::parse_word_string(input);

    assert_eq!(
        brace_slices(&word, input),
        vec!["{EGL,GLES{,2,3}}", "{,2,3}"]
    );
    assert!(word.brace_syntax().iter().all(|brace| brace.expands()));
}

#[test]
fn test_brace_syntax_does_not_merge_dots_across_skipped_expansion_parts() {
    let input = "{1.$x.3}";
    let word = Parser::parse_word_string(input);

    assert_eq!(brace_slices(&word, input), vec!["{1.$x.3}"]);
    assert!(
        word.brace_syntax()
            .iter()
            .all(|brace| brace.treated_literally())
    );
    assert!(!word.has_active_brace_expansion());
}

#[test]
fn test_parse_brace_expansion_with_single_quoted_backslash_member_keeps_following_args() {
    let input = "echo {'a\\',b} next\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    assert_eq!(command.args.len(), 2);
    assert_eq!(command.args[0].span.slice(input), r#"{'a\',b}"#);
    assert_eq!(command.args[1].span.slice(input), "next");
    assert!(command.args[0].has_active_brace_expansion());
}

#[test]
fn test_word_part_spans_track_parenthesized_arithmetic_expansion() {
    let input = "echo $((a <= (1 || 2)))\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    assert_eq!(word.parts.len(), 1);
    assert_eq!(
        word.part_span(0).unwrap().slice(input),
        "$((a <= (1 || 2)))"
    );

    let WordPart::ArithmeticExpansion {
        expression,
        expression_ast,
        syntax,
        ..
    } = &word.parts[0].kind
    else {
        panic!("expected arithmetic expansion");
    };
    assert_eq!(*syntax, ArithmeticExpansionSyntax::DollarParenParen);
    assert!(expression.is_source_backed());
    assert_eq!(expression.slice(input), "a <= (1 || 2)");
    let expr = expression_ast
        .as_ref()
        .expect("expected typed arithmetic AST");
    let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
        panic!("expected binary arithmetic expression");
    };
    assert_eq!(*op, ArithmeticBinaryOp::LessThanOrEqual);
    expect_variable(left, "a");
    let ArithmeticExpr::Parenthesized { expression } = &right.kind else {
        panic!("expected parenthesized right operand");
    };
    let ArithmeticExpr::Binary {
        left: inner_left,
        op: inner_op,
        right: inner_right,
    } = &expression.kind
    else {
        panic!("expected logical-or inside parentheses");
    };
    assert_eq!(*inner_op, ArithmeticBinaryOp::LogicalOr);
    expect_number(inner_left, input, "1");
    expect_number(inner_right, input, "2");
}

#[test]
fn test_word_part_spans_track_nested_arithmetic_expansion() {
    let input = "echo $(((a) + ((b))))\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    assert_eq!(word.parts.len(), 1);
    assert_eq!(word.part_span(0).unwrap().slice(input), "$(((a) + ((b))))");

    let WordPart::ArithmeticExpansion {
        expression,
        expression_ast,
        syntax,
        ..
    } = &word.parts[0].kind
    else {
        panic!("expected arithmetic expansion");
    };
    assert_eq!(*syntax, ArithmeticExpansionSyntax::DollarParenParen);
    assert!(expression.is_source_backed());
    assert_eq!(expression.slice(input), "(a) + ((b))");
    let expr = expression_ast
        .as_ref()
        .expect("expected typed arithmetic AST");
    let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
        panic!("expected binary arithmetic expression");
    };
    assert_eq!(*op, ArithmeticBinaryOp::Add);
    assert!(matches!(left.kind, ArithmeticExpr::Parenthesized { .. }));
    assert!(matches!(right.kind, ArithmeticExpr::Parenthesized { .. }));
}

#[test]
fn test_arithmetic_expansion_inside_double_quotes_preserves_legacy_and_modern_syntax() {
    let input = "echo \"$((1 + 2))\" \"$[3 + 4]\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    assert_eq!(command.args.len(), 2);

    let modern = &command.args[0];
    assert!(is_fully_quoted(modern));
    let WordPart::DoubleQuoted { parts, dollar } = &modern.parts[0].kind else {
        panic!("expected double-quoted modern arithmetic");
    };
    assert!(!dollar);
    assert_eq!(parts[0].span.slice(input), "$((1 + 2))");
    let WordPart::ArithmeticExpansion {
        expression,
        expression_ast,
        syntax,
        ..
    } = &parts[0].kind
    else {
        panic!("expected arithmetic expansion");
    };
    assert_eq!(*syntax, ArithmeticExpansionSyntax::DollarParenParen);
    assert!(expression.is_source_backed());
    assert_eq!(expression.slice(input), "1 + 2");
    let expr = expression_ast
        .as_ref()
        .expect("expected typed arithmetic AST");
    let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
        panic!("expected binary arithmetic expression");
    };
    assert_eq!(*op, ArithmeticBinaryOp::Add);
    expect_number(left, input, "1");
    expect_number(right, input, "2");

    let legacy = &command.args[1];
    assert!(is_fully_quoted(legacy));
    let WordPart::DoubleQuoted { parts, dollar } = &legacy.parts[0].kind else {
        panic!("expected double-quoted legacy arithmetic");
    };
    assert!(!dollar);
    assert_eq!(parts[0].span.slice(input), "$[3 + 4]");
    let WordPart::ArithmeticExpansion {
        expression,
        expression_ast,
        syntax,
        ..
    } = &parts[0].kind
    else {
        panic!("expected arithmetic expansion");
    };
    assert_eq!(*syntax, ArithmeticExpansionSyntax::LegacyBracket);
    assert!(expression.is_source_backed());
    assert_eq!(expression.slice(input), "3 + 4");
    let expr = expression_ast
        .as_ref()
        .expect("expected typed arithmetic AST");
    let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
        panic!("expected binary arithmetic expression");
    };
    assert_eq!(*op, ArithmeticBinaryOp::Add);
    expect_number(left, input, "3");
    expect_number(right, input, "4");
}

#[test]
fn test_arithmetic_expansion_after_escaped_quote_inside_double_quotes_stays_live() {
    let input = "echo \"prefix \\\" $((++attempt))\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected double-quoted word");
    };
    let WordPart::ArithmeticExpansion {
        expression,
        expression_ast,
        syntax,
        ..
    } = &parts[1].kind
    else {
        panic!("expected arithmetic expansion after escaped quote");
    };
    assert_eq!(*syntax, ArithmeticExpansionSyntax::DollarParenParen);
    assert!(expression.is_source_backed());
    assert_eq!(expression.slice(input), "++attempt");
    let ArithmeticExpr::Unary { op, expr } = &expression_ast
        .as_ref()
        .expect("expected typed arithmetic AST")
        .kind
    else {
        panic!("expected prefix update expression");
    };
    assert_eq!(*op, ArithmeticUnaryOp::PreIncrement);
    expect_variable(expr, "attempt");
}

#[test]
fn test_arithmetic_expansion_keeps_parameter_in_numeric_literal_shell_word() {
    let input = "echo $((10#$HOUR)) $((0x$byte6 + summand))\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let base_literal = &command.args[0];
    let WordPart::ArithmeticExpansion {
        expression_ast,
        syntax,
        ..
    } = &base_literal.parts[0].kind
    else {
        panic!("expected base-literal arithmetic expansion");
    };
    assert_eq!(*syntax, ArithmeticExpansionSyntax::DollarParenParen);
    let ArithmeticExpr::ShellWord(word) = &expression_ast
        .as_ref()
        .expect("expected typed arithmetic AST")
        .kind
    else {
        panic!("expected shell-word numeric literal");
    };
    assert!(word_part_tree_contains_variable(&word.parts, "HOUR"));

    let mixed = &command.args[1];
    let WordPart::ArithmeticExpansion { expression_ast, .. } = &mixed.parts[0].kind else {
        panic!("expected mixed arithmetic expansion");
    };
    let ArithmeticExpr::Binary { left, op, right } = &expression_ast
        .as_ref()
        .expect("expected typed arithmetic AST")
        .kind
    else {
        panic!("expected arithmetic addition");
    };
    assert_eq!(*op, ArithmeticBinaryOp::Add);
    let ArithmeticExpr::ShellWord(word) = &left.kind else {
        panic!("expected shell-word numeric literal on left");
    };
    assert!(word_part_tree_contains_variable(&word.parts, "byte6"));
    expect_variable(right, "summand");
}

#[test]
fn test_word_part_spans_track_unquoted_legacy_arithmetic_expansion() {
    let input = "i=$[ $i - 1 ]\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    assert!(command.args.is_empty());
    let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
        panic!("expected scalar assignment");
    };
    let WordPart::ArithmeticExpansion {
        syntax, expression, ..
    } = &word.parts[0].kind
    else {
        panic!("expected arithmetic expansion");
    };
    assert_eq!(*syntax, ArithmeticExpansionSyntax::LegacyBracket);
    assert!(expression.is_source_backed());
    assert_eq!(word.parts[0].span.slice(input), "$[ $i - 1 ]");
}

#[test]
fn test_parameter_expansion_operand_stays_source_backed() {
    let input = "echo ${var:-$(pwd)}\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let (_, _, operand) = expect_parameter_operation_part(&word.parts[0].kind);
    let operand = operand.expect("expected operand");
    assert!(operand.is_source_backed());
    assert_eq!(operand.slice(input), "$(pwd)");

    let parameter = expect_parameter(word);
    let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
        operand_word_ast: Some(operand_word_ast),
        ..
    }) = &parameter.syntax
    else {
        panic!("expected unified bourne operation");
    };
    assert_eq!(operand_word_ast.render(input), "$(pwd)");
    assert_eq!(operand_word_ast.span.slice(input), "$(pwd)");
}

#[test]
fn test_parameter_default_operand_keeps_nested_command_substitution_spans() {
    let input = "NUMJOBS=${NUMJOBS:-\\\" -j $(expr $(nproc) + 1) \\\"}\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
        panic!("expected scalar assignment");
    };

    let parameter = expect_parameter(word);
    let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
        operand_word_ast: Some(operand_word_ast),
        ..
    }) = &parameter.syntax
    else {
        panic!("expected parameter operation");
    };
    let body = operand_word_ast
        .parts
        .iter()
        .find_map(|part| match &part.kind {
            WordPart::CommandSubstitution { body, .. } => Some(body),
            _ => None,
        })
        .expect("expected outer command substitution");
    let expr = expect_simple(&body[0]);
    assert_eq!(expr.name.render(input), "expr");

    let WordPart::CommandSubstitution { .. } = &expr.args[0].parts[0].kind else {
        panic!("expected nested command substitution");
    };
    assert_eq!(expr.args[0].parts[0].span.slice(input), "$(nproc)");
}

#[test]
fn test_parameter_quoted_default_operand_keeps_nested_command_substitution_spans() {
    let input = "NUMJOBS=${NUMJOBS:-\" -j $(expr $(nproc) + 1) \"}\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
        panic!("expected scalar assignment");
    };

    let parameter = expect_parameter(word);
    let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
        operand_word_ast: Some(operand_word_ast),
        ..
    }) = &parameter.syntax
    else {
        panic!("expected parameter operation");
    };
    let WordPart::DoubleQuoted { parts, .. } = &operand_word_ast.parts[0].kind else {
        panic!("expected quoted default operand");
    };
    let body = parts
        .iter()
        .find_map(|part| match &part.kind {
            WordPart::CommandSubstitution { body, .. } => Some(body),
            _ => None,
        })
        .expect("expected outer command substitution");
    let expr = expect_simple(&body[0]);
    assert_eq!(expr.name.render(input), "expr");

    let WordPart::CommandSubstitution { .. } = &expr.args[0].parts[0].kind else {
        panic!("expected nested command substitution");
    };
    assert_eq!(expr.args[0].parts[0].span.slice(input), "$(nproc)");
}

#[test]
fn test_parameter_default_operand_does_not_absorb_later_double_quoted_expansion() {
    let input = "echo \"${home:-\"${default}\"}'${foo}'\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let [
        WordPartNode {
            kind: WordPart::DoubleQuoted { parts, .. },
            ..
        },
    ] = word.parts.as_slice()
    else {
        panic!("expected one double-quoted argument");
    };

    let [first, second, third, fourth] = parts.as_slice() else {
        panic!("expected parameter, quote, parameter, quote parts: {parts:#?}");
    };

    let WordPart::Parameter(parameter) = &first.kind else {
        panic!("expected leading parameter expansion, got {:?}", first.kind);
    };
    let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
        operand: Some(operand),
        operand_word_ast: Some(operand_word_ast),
        ..
    }) = &parameter.syntax
    else {
        panic!("expected parameter operation with parsed operand");
    };
    assert_eq!(operand.slice(input), "\"${default}\"");
    assert_eq!(operand_word_ast.render(input), "${default}");
    let [
        WordPartNode {
            kind:
                WordPart::DoubleQuoted {
                    parts: operand_parts,
                    ..
                },
            ..
        },
    ] = operand_word_ast.parts.as_slice()
    else {
        panic!("expected quoted operand word");
    };
    let [
        WordPartNode {
            kind: WordPart::Parameter(parameter),
            ..
        },
    ] = operand_parts.as_slice()
    else {
        panic!("expected nested parameter expansion inside operand");
    };
    let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) =
        &parameter.syntax
    else {
        panic!("expected operand to preserve the nested default expansion");
    };
    assert_eq!(reference.name.as_str(), "default");

    assert_eq!(second.span.slice(input), "'");
    let WordPart::Parameter(parameter) = &third.kind else {
        panic!("expected later parameter expansion, got {:?}", third.kind);
    };
    let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) =
        &parameter.syntax
    else {
        panic!("expected simple access expansion");
    };
    assert_eq!(reference.name.as_str(), "foo");
    assert_eq!(fourth.span.slice(input), "'");
}

#[test]
fn test_literal_brace_before_quoted_nested_default_does_not_absorb_later_expansion() {
    let input = "echo \"${outer:-{{\"${inner}}\"}}${after}\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let [
        WordPartNode {
            kind: WordPart::DoubleQuoted { parts, .. },
            ..
        },
    ] = word.parts.as_slice()
    else {
        panic!("expected one double-quoted argument");
    };

    let [first, second] = parts.as_slice() else {
        panic!("expected outer and later parameter expansions: {parts:#?}");
    };

    let WordPart::Parameter(parameter) = &first.kind else {
        panic!("expected leading parameter expansion, got {:?}", first.kind);
    };
    let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Operation {
        operand: Some(operand),
        operand_word_ast: Some(operand_word_ast),
        ..
    }) = &parameter.syntax
    else {
        panic!("expected outer parameter operation with parsed operand");
    };
    assert_eq!(operand.slice(input), "{{\"${inner}}\"}");

    let [literal_prefix, quoted_suffix, literal_suffix] = operand_word_ast.parts.as_slice() else {
        panic!("expected literal, quoted, literal operand structure: {operand_word_ast:#?}");
    };
    assert_eq!(literal_prefix.span.slice(input), "{{");
    let WordPart::DoubleQuoted {
        parts: quoted_parts,
        ..
    } = &quoted_suffix.kind
    else {
        panic!(
            "expected quoted middle segment in outer operand, got {:?}",
            quoted_suffix.kind
        );
    };
    let [nested, quoted_literal_suffix] = quoted_parts.as_slice() else {
        panic!("expected nested parameter and literal brace in quoted suffix");
    };
    let WordPart::Parameter(parameter) = &nested.kind else {
        panic!("expected nested parameter expansion, got {:?}", nested.kind);
    };
    let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) =
        &parameter.syntax
    else {
        panic!("expected inner access expansion");
    };
    assert_eq!(reference.name.as_str(), "inner");
    assert_eq!(quoted_literal_suffix.span.slice(input), "}");
    assert_eq!(literal_suffix.span.slice(input), "}");

    let WordPart::Parameter(parameter) = &second.kind else {
        panic!("expected later parameter expansion, got {:?}", second.kind);
    };
    let ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) =
        &parameter.syntax
    else {
        panic!("expected later access expansion");
    };
    assert_eq!(reference.name.as_str(), "after");
}

#[test]
fn test_case_modification_operands_consume_nested_parameter_expansions() {
    let input = "echo ${name^^${pat}} ${arr[1],,${pat}}\n";
    let script = Parser::new(input).parse().unwrap().file;
    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };

    let (_, upper_operator, upper_operand) =
        expect_parameter_operation_part(&command.args[0].parts[0].kind);
    assert!(matches!(upper_operator, ParameterOp::UpperAll));
    assert_eq!(
        upper_operand
            .expect("expected upper case-modification operand")
            .slice(input),
        "${pat}"
    );

    let (lower_reference, lower_operator, lower_operand) =
        expect_parameter_operation_part(&command.args[1].parts[0].kind);
    expect_subscript(lower_reference, input, "1");
    assert!(matches!(lower_operator, ParameterOp::LowerAll));
    assert_eq!(
        lower_operand
            .expect("expected lower case-modification operand")
            .slice(input),
        "${pat}"
    );
}

#[test]
fn test_parameter_expansion_trim_operand_accepts_literal_left_brace_after_multiline_quote() {
    let input = "dns_servercow_info='ServerCow.de\nSite: ServerCow.de\n'\n\nf(){\n  if true; then\n    txtvalue_old=${response#*{\\\"name\\\":\\\"\"$_sub_domain\"\\\",\\\"ttl\\\":20,\\\"type\\\":\\\"TXT\\\",\\\"content\\\":\\\"}\n  fi\n}\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Function(function) = &script.body[1].command else {
        panic!("expected function definition");
    };
    let (compound, redirects) = expect_compound(function.body.as_ref());
    let AstCompoundCommand::BraceGroup(body) = compound else {
        panic!("expected brace-group function body");
    };
    assert!(redirects.is_empty());
    let (if_compound, redirects) = expect_compound(&body[0]);
    let AstCompoundCommand::If(if_command) = if_compound else {
        panic!("expected if command");
    };
    assert!(redirects.is_empty());
    let command = expect_simple(&if_command.then_branch[0]);
    let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
        panic!("expected scalar assignment");
    };
    let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
    let ParameterOp::RemovePrefixShort { pattern } = operator else {
        panic!("expected short-prefix trim operator");
    };
    assert!(pattern.render(input).contains("$_sub_domain"));
    assert!(pattern.parts.iter().any(|part| {
        matches!(
            &part.kind,
            PatternPart::Word(word)
                if word_part_tree_contains_variable(&word.parts, "_sub_domain")
        )
    }));
}

#[test]
fn test_parameter_expansion_trim_operand_accepts_balanced_literal_braces() {
    let input = "echo ${var#foo{bar}}\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
    let ParameterOp::RemovePrefixShort { pattern } = operator else {
        panic!("expected short-prefix trim operator");
    };
    assert_eq!(pattern.render(input), "foo{bar}");
}

#[test]
fn test_parameter_expansion_trim_operand_tracks_nested_parameter_expansions() {
    let input = "echo ${var#${prefix:-fallback}}\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
    let ParameterOp::RemovePrefixShort { pattern } = operator else {
        panic!("expected short-prefix trim operator");
    };
    assert_eq!(pattern.render(input), "${prefix:-fallback}");
    assert!(matches!(
        &pattern.parts[..],
        [PatternPartNode {
            kind: PatternPart::Word(word),
            ..
        }] if matches!(
            &word.parts[..],
            [WordPartNode {
                kind: WordPart::Parameter(_) | WordPart::ParameterExpansion { .. },
                ..
            }]
        )
    ));
}

#[test]
fn test_parameter_replacement_pattern_stays_source_backed() {
    let input = "echo ${var/foo/bar}\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
    let ParameterOp::ReplaceFirst {
        pattern,
        replacement,
        replacement_word_ast,
    } = operator
    else {
        panic!("expected replace-first operator");
    };

    assert_eq!(pattern.render(input), "foo");
    assert_eq!(pattern.parts.len(), 1);
    assert!(matches!(
        &pattern.parts[0].kind,
        PatternPart::Literal(text) if text.is_source_backed()
    ));
    assert!(replacement.is_source_backed());
    assert_eq!(replacement.slice(input), "bar");
    assert_eq!(replacement_word_ast.render(input), "bar");
    assert_eq!(replacement_word_ast.span.slice(input), "bar");
}

#[test]
fn test_parameter_trim_pattern_preserves_quoted_fragments_around_expansions() {
    let input = "echo ${var#\"pre\"$suffix'-'*}\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
    let ParameterOp::RemovePrefixShort { pattern } = operator else {
        panic!("expected short-prefix trim operator");
    };

    assert!(matches!(
        &pattern.parts[..],
        [
            PatternPartNode {
                kind: PatternPart::Word(first),
                ..
            },
            PatternPartNode {
                kind: PatternPart::Word(second),
                ..
            },
            PatternPartNode {
                kind: PatternPart::Word(third),
                ..
            },
            PatternPartNode {
                kind: PatternPart::AnyString,
                ..
            }
        ] if first.is_fully_quoted()
            && matches!(
                &second.parts[..],
                [WordPartNode {
                    kind: WordPart::Variable(name),
                    ..
                }] if name.as_str() == "suffix"
            )
            && third.is_fully_quoted()
    ));
}

#[test]
fn test_parameter_replacement_pattern_preserves_mixed_quote_fragments() {
    let input = "echo ${var//\"pre\"$suffix'-'/x}\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
    let ParameterOp::ReplaceAll {
        pattern,
        replacement,
        replacement_word_ast,
    } = operator
    else {
        panic!("expected replace-all operator");
    };

    assert_eq!(
        pattern_part_slices(pattern, input),
        vec!["\"pre\"", "$suffix", "'-'"]
    );
    assert_eq!(replacement.slice(input), "x");
    assert_eq!(replacement_word_ast.render(input), "x");
    assert!(matches!(
        &pattern.parts[..],
        [
            PatternPartNode {
                kind: PatternPart::Word(first),
                ..
            },
            PatternPartNode {
                kind: PatternPart::Word(second),
                ..
            },
            PatternPartNode {
                kind: PatternPart::Word(third),
                ..
            }
        ] if first.is_fully_quoted()
            && matches!(
                &second.parts[..],
                [WordPartNode {
                    kind: WordPart::Variable(name),
                    ..
                }] if name.as_str() == "suffix"
            )
            && third.is_fully_quoted()
    ));
}

#[test]
fn test_parameter_replacement_pattern_cooks_escaped_slash() {
    let input = r#"echo ${var/foo\/bar/baz}"#;
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
    let ParameterOp::ReplaceFirst {
        pattern,
        replacement,
        replacement_word_ast,
    } = operator
    else {
        panic!("expected replace-first operator");
    };

    assert_eq!(pattern.render(input), "foo/bar");
    assert_eq!(pattern.parts.len(), 1);
    assert!(matches!(
        &pattern.parts[0].kind,
        PatternPart::Literal(text) if !text.is_source_backed() && text == "foo/bar"
    ));
    assert!(replacement.is_source_backed());
    assert_eq!(replacement_word_ast.render(input), "baz");
    assert_eq!(replacement.slice(input), "baz");
}

#[test]
fn test_parameter_replacement_pattern_keeps_escaped_dollar_literal() {
    let input = r#"echo "${d//\$ORIGIN/$origin}""#;
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected double-quoted argument");
    };
    let (_, operator, _) = expect_parameter_operation_part(&parts[0].kind);
    let ParameterOp::ReplaceAll { pattern, .. } = operator else {
        panic!("expected replace-all operator");
    };

    assert_eq!(pattern.render(input), "$ORIGIN");
    assert!(matches!(
        &pattern.parts[..],
        [PatternPartNode {
            kind: PatternPart::Literal(text),
            ..
        }] if text == "$ORIGIN"
    ));
}

#[test]
fn test_parameter_replacement_word_keeps_escaped_single_quotes_literal() {
    let input = r#"echo ${dest_dir//\'/\'\\\'\'}"#;
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let word = &command.args[0];

    let (_, operator, _) = expect_parameter_operation_part(&word.parts[0].kind);
    let ParameterOp::ReplaceAll {
        replacement: _,
        replacement_word_ast,
        ..
    } = operator
    else {
        panic!("expected replace-all operator");
    };

    assert!(!replacement_word_ast.parts.iter().any(|part| {
        matches!(part.kind, WordPart::SingleQuoted { .. })
            && part.span.slice(input).ends_with("\\'")
    }));
}

#[test]
fn test_parameter_replacement_spans_cover_complex_pattern_and_replacement_bodies() {
    let input = "\
echo ${dest_dir//\\'/\\'\\\\\\'\\'} ${TERMUX_PKG_VERSION_EDITED//${INCORRECT_SYMBOLS:0:1}${INCORRECT_SYMBOLS:1:1}/${INCORRECT_SYMBOLS:0:1}.${INCORRECT_SYMBOLS:1:1}} ${GITHUB_GRAPHQL_QUERIES[$BATCH * $BATCH_SIZE]//\\\\/} ${run_depends/${i}/${dep}}\n\
";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };

    assert_eq!(
        command.args[0]
            .parts
            .iter()
            .map(|part| part.span.slice(input))
            .collect::<Vec<_>>(),
        vec![r#"${dest_dir//\'/\'\\\'\'}"#]
    );

    let spans = command
        .args
        .iter()
        .map(|word| word.parts[0].span.slice(input))
        .collect::<Vec<_>>();
    assert_eq!(
        spans,
        vec![
            "${dest_dir//\\'/\\'\\\\\\'\\'}",
            "${TERMUX_PKG_VERSION_EDITED//${INCORRECT_SYMBOLS:0:1}${INCORRECT_SYMBOLS:1:1}/${INCORRECT_SYMBOLS:0:1}.${INCORRECT_SYMBOLS:1:1}}",
            "${GITHUB_GRAPHQL_QUERIES[$BATCH * $BATCH_SIZE]//\\\\/}",
            "${run_depends/${i}/${dep}}",
        ]
    );

    let (_, operator, _) = expect_parameter_operation_part(&command.args[1].parts[0].kind);
    let ParameterOp::ReplaceAll {
        pattern,
        replacement,
        replacement_word_ast,
    } = operator
    else {
        panic!("expected replace-all operator");
    };
    assert_eq!(
        pattern.render(input),
        "${INCORRECT_SYMBOLS:0:1}${INCORRECT_SYMBOLS:1:1}"
    );
    assert_eq!(
        replacement.slice(input),
        "${INCORRECT_SYMBOLS:0:1}.${INCORRECT_SYMBOLS:1:1}"
    );
    assert_eq!(
        replacement_word_ast.render(input),
        "${INCORRECT_SYMBOLS:0:1}.${INCORRECT_SYMBOLS:1:1}"
    );

    let (_, operator, _) = expect_parameter_operation_part(&command.args[3].parts[0].kind);
    let ParameterOp::ReplaceFirst {
        pattern,
        replacement,
        replacement_word_ast,
    } = operator
    else {
        panic!("expected replace-first operator");
    };
    assert_eq!(pattern.render(input), "${i}");
    assert_eq!(replacement.slice(input), "${dep}");
    assert_eq!(replacement_word_ast.render(input), "${dep}");
}

#[test]
fn test_read_replacement_pattern_stops_before_unescaped_delimiter() {
    let input = "crypt=${crypt//\\\\/\\\\\\\\}\n";
    let parser = Parser::new(input);
    let offset = input.find("//").expect("expected replacement operator") + 2;
    let mut chars = input[offset..].chars().peekable();
    let mut cursor = Position::new().advanced_by(&input[..offset]);

    let pattern = parser.read_replacement_pattern(&mut chars, &mut cursor, true);
    assert_eq!(pattern.slice(input), "\\\\");
    assert_eq!(cursor.offset(), offset + 2);
}

#[test]
fn test_decode_cooked_word_keeps_variable_after_literal_backslash() {
    let cooked = r#"\$HOME"#;
    let span = Span::from_positions(Position::new(), Position::new().advanced_by(cooked));
    let word = Parser::new("").decode_word_text(cooked, span, span.start, false);

    assert_eq!(word.parts.len(), 2);
    let WordPart::Literal(text) = &word.parts[0].kind else {
        panic!("expected literal backslash prefix");
    };
    assert_eq!(text.as_str("", word.parts[0].span), "\\");
    assert!(matches!(
        &word.parts[1].kind,
        WordPart::Variable(name) if name.as_str() == "HOME"
    ));
}

#[test]
fn test_parse_arithmetic_command_with_command_substitution() {
    let input = "(($(date -u) > DATE))\n";
    let script = Parser::new(input).parse().unwrap().file;

    let (compound, redirects) = expect_compound(&script.body[0]);
    let AstCompoundCommand::Arithmetic(command) = compound else {
        panic!("expected arithmetic compound command");
    };

    assert!(redirects.is_empty());
    assert_eq!(command.left_paren_span.slice(input), "((");
    assert_eq!(command.right_paren_span.slice(input), "))");
    assert_eq!(command.expr_span.unwrap().slice(input), "$(date -u) > DATE");
    let expr = command
        .expr_ast
        .as_ref()
        .expect("expected typed arithmetic AST");
    let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
        panic!("expected binary arithmetic expression");
    };
    assert_eq!(*op, ArithmeticBinaryOp::GreaterThan);
    expect_shell_word(left, input, "$(date -u)");
    expect_variable(right, "DATE");
}

#[test]
fn test_parse_arithmetic_command_accepts_command_substitutions_and_quoted_words() {
    let input = "(( \"$(date -u)\" + '3' ))\n";
    let script = Parser::new(input).parse().unwrap().file;

    let (compound, _) = expect_compound(&script.body[0]);
    let AstCompoundCommand::Arithmetic(command) = compound else {
        panic!("expected arithmetic compound command");
    };

    let expr = command
        .expr_ast
        .as_ref()
        .expect("expected typed arithmetic AST");
    let ArithmeticExpr::Binary { left, op, right } = &expr.kind else {
        panic!("expected binary arithmetic expression");
    };
    assert_eq!(*op, ArithmeticBinaryOp::Add);
    let ArithmeticExpr::ShellWord(left_word) = &left.kind else {
        panic!("expected quoted shell word on left");
    };
    assert_eq!(left_word.span.slice(input), "\"$(date -u)\"");
    let ArithmeticExpr::ShellWord(right_word) = &right.kind else {
        panic!("expected quoted shell word on right");
    };
    assert_eq!(right_word.span.slice(input), "'3'");
}

#[test]
fn test_parse_parameter_expansion_operands_allow_quoted_and_escaped_right_brace() {
    let input = r###"echo "${var#\}}"
echo "${var#'}'}"
echo "${var#"}"}"
echo "${var-\}}"
echo "${var-'}'}"
echo "${var-"}"}"
"###;

    let script = Parser::new(input).parse().unwrap().file;
    assert_eq!(script.body.len(), 6);
}

#[test]
fn test_parse_parameter_slices_preserve_shell_style_offsets() {
    let input = "echo \"${arg:$index:1}\" \"${@:1:$package_type_nargs}\" \"${@:$(( $package_type_nargs + 1 ))}\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };

    let WordPart::DoubleQuoted {
        parts: first_parts, ..
    } = &command.args[0].parts[0].kind
    else {
        panic!("expected first double-quoted word");
    };
    let (_, first_offset_ast, first_length_ast) = expect_substring_part(&first_parts[0].kind);
    let ArithmeticExpr::ShellWord(first_offset_word) =
        &first_offset_ast.as_ref().expect("expected offset AST").kind
    else {
        panic!("expected shell-word offset");
    };
    assert_eq!(first_offset_word.span.slice(input), "$index");
    assert_eq!(
        first_length_ast
            .as_ref()
            .expect("expected first length AST")
            .span
            .slice(input),
        "1"
    );

    let WordPart::DoubleQuoted {
        parts: second_parts,
        ..
    } = &command.args[1].parts[0].kind
    else {
        panic!("expected second double-quoted word");
    };
    let (_, second_offset_ast, second_length_ast) = expect_substring_part(&second_parts[0].kind);
    assert_eq!(
        second_offset_ast
            .as_ref()
            .expect("expected second offset AST")
            .span
            .slice(input),
        "1"
    );
    let ArithmeticExpr::ShellWord(second_length_word) = &second_length_ast
        .as_ref()
        .expect("expected second length AST")
        .kind
    else {
        panic!("expected shell-word length");
    };
    assert_eq!(second_length_word.span.slice(input), "$package_type_nargs");

    let WordPart::DoubleQuoted {
        parts: third_parts, ..
    } = &command.args[2].parts[0].kind
    else {
        panic!("expected third double-quoted word");
    };
    let (_, third_offset_ast, third_length_ast) = expect_substring_part(&third_parts[0].kind);
    assert!(third_length_ast.is_none());
    let ArithmeticExpr::ShellWord(third_offset_word) = &third_offset_ast
        .as_ref()
        .expect("expected third offset AST")
        .kind
    else {
        panic!("expected shell-word arithmetic expansion");
    };
    assert_eq!(
        third_offset_word.span.slice(input),
        "$(( $package_type_nargs + 1 ))"
    );
}

#[test]
fn test_command_substitution_spans_are_absolute() {
    let script = Parser::new("out=$(\n  printf '%s\\n' $x\n)\n")
        .parse()
        .unwrap()
        .file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
        panic!("expected scalar assignment");
    };
    let WordPart::CommandSubstitution {
        body: commands,
        syntax,
    } = &word.parts[0].kind
    else {
        panic!("expected command substitution");
    };
    assert_eq!(*syntax, CommandSubstitutionSyntax::DollarParen);
    let inner = expect_simple(&commands[0]);

    assert_eq!(inner.name.span.start.line(), 2);
    assert_eq!(inner.name.span.start.column(), 3);
    assert_eq!(inner.args[0].span.start.line(), 2);
    assert_eq!(inner.args[1].span.start.column(), 17);
}

#[test]
fn test_prefixed_nested_command_substitution_keeps_command_names() {
    let input = "cp -v $filename $OUT/$(echo $(basename $filename .fuzz))\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };

    let echo_body = command.args[2]
        .parts
        .iter()
        .find_map(|part| match &part.kind {
            WordPart::CommandSubstitution { body, .. } => Some(body),
            _ => None,
        })
        .expect("expected outer command substitution");
    let echo = expect_simple(&echo_body[0]);
    assert_eq!(echo.name.render(input), "echo");

    let basename_body = echo.args[0]
        .parts
        .iter()
        .find_map(|part| match &part.kind {
            WordPart::CommandSubstitution { body, .. } => Some(body),
            _ => None,
        })
        .expect("expected nested basename command substitution");
    let basename = expect_simple(&basename_body[0]);
    assert_eq!(basename.name.render(input), "basename");
}

#[test]
fn test_prefixed_nested_quoted_command_substitution_keeps_command_names() {
    let input = "\
value=\"$(\n\
       [[ \"$config_file\" == *\"$theme.cfg\" ]] && echo \"$(basename \"$config_file\")\"\n\
    )\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
        panic!("expected scalar assignment");
    };
    let WordPart::DoubleQuoted { parts, .. } = &word.parts[0].kind else {
        panic!("expected quoted assignment value");
    };
    let WordPart::CommandSubstitution { body, .. } = &parts[0].kind else {
        panic!("expected outer command substitution");
    };
    let AstCommand::Binary(binary) = &body[0].command else {
        panic!("expected binary command");
    };

    let echo = expect_simple(&binary.right);
    assert_eq!(echo.name.render(input), "echo");

    let WordPart::DoubleQuoted {
        parts: echo_parts, ..
    } = &echo.args[0].parts[0].kind
    else {
        panic!("expected quoted echo argument");
    };
    let WordPart::CommandSubstitution {
        body: basename_body,
        ..
    } = &echo_parts[0].kind
    else {
        panic!("expected nested basename substitution");
    };
    let basename = expect_simple(&basename_body[0]);
    assert_eq!(basename.name.render(input), "basename");
}

#[test]
fn test_prefixed_nested_escaped_quoted_command_substitution_keeps_command_names() {
    let input = "#!/bin/sh\necho -n \"\\\"adp_$(echo $var | tr A-Z a-z)\\\": [\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let WordPart::DoubleQuoted { parts, .. } = &command.args[1].parts[0].kind else {
        panic!("expected quoted echo argument");
    };
    let body = parts
        .iter()
        .find_map(|part| match &part.kind {
            WordPart::CommandSubstitution { body, .. } => Some(body),
            _ => None,
        })
        .expect("expected nested command substitution");

    let AstCommand::Binary(pipeline) = &body[0].command else {
        panic!("expected piped command");
    };
    let echo = expect_simple(&pipeline.left);
    assert_eq!(echo.name.render(input), "echo");

    let tr_command = expect_simple(&pipeline.right);
    assert_eq!(tr_command.name.render(input), "tr");
    assert_eq!(tr_command.args[0].render(input), "A-Z");
    assert_eq!(tr_command.args[1].render(input), "a-z");
}

#[test]
fn test_prefixed_nested_escaped_quoted_command_substitution_keeps_command_names_after_apostrophe() {
    let input = "#!/bin/sh\necho -n \"it's \\\"adp_$(echo $var | tr A-Z a-z)\\\": [\"\n";
    let script = Parser::new(input).parse().unwrap().file;

    let AstCommand::Simple(command) = &script.body[0].command else {
        panic!("expected simple command");
    };
    let WordPart::DoubleQuoted { parts, .. } = &command.args[1].parts[0].kind else {
        panic!("expected quoted echo argument");
    };
    let body = parts
        .iter()
        .find_map(|part| match &part.kind {
            WordPart::CommandSubstitution { body, .. } => Some(body),
            _ => None,
        })
        .expect("expected nested command substitution");

    let AstCommand::Binary(pipeline) = &body[0].command else {
        panic!("expected piped command");
    };
    let echo = expect_simple(&pipeline.left);
    assert_eq!(echo.name.render(input), "echo");

    let tr_command = expect_simple(&pipeline.right);
    assert_eq!(tr_command.name.render(input), "tr");
    assert_eq!(tr_command.args[0].render(input), "A-Z");
    assert_eq!(tr_command.args[1].render(input), "a-z");
}

#[test]
fn test_parse_command_substitution_with_open_paren_inside_double_quotes() {
    Parser::new("x=$(echo \"(\")\n").parse().unwrap();
}

#[test]
fn test_parse_command_substitution_with_case_pattern_right_paren() {
    let input = "echo $(foo=a; case $foo in [0-9]) echo number;; [a-z]) echo letter ;; esac)\n";
    Parser::new(input).parse().unwrap();
}

#[test]
fn test_alias_expansion_can_form_a_for_loop_header() {
    let input = "\
shopt -s expand_aliases
alias FOR1='for '
alias FOR2='FOR1 '
alias eye1='i '
alias eye2='eye1 '
alias IN='in '
alias onetwo='1 2 '
FOR2 eye2 IN onetwo 3; do echo $i; done
";
    let script = Parser::new(input).parse().unwrap().file;

    let Some(command) = script.body.last() else {
        panic!("expected final command to be a for loop");
    };
    let (compound, _) = expect_compound(command);
    let AstCompoundCommand::For(command) = compound else {
        panic!("expected final command to be a for loop");
    };
    assert_eq!(command.targets[0].name.as_deref(), Some("i"));
    assert_eq!(command.words.as_ref().map(|words| words.len()), Some(3));
}

#[test]
fn test_alias_expansion_can_open_a_brace_group() {
    let input = "\
shopt -s expand_aliases
alias LEFT='{'
LEFT echo one; echo two; }
";
    let script = Parser::new(input).parse().unwrap().file;

    let Some(command) = script.body.last() else {
        panic!("expected final command to be a brace group");
    };
    let (compound, _) = expect_compound(command);
    let AstCompoundCommand::BraceGroup(commands) = compound else {
        panic!("expected final command to be a brace group");
    };
    assert_eq!(commands.len(), 2);
    assert!(matches!(commands[0].command, AstCommand::Simple(_)));
    assert!(matches!(commands[1].command, AstCommand::Simple(_)));
}

#[test]
fn test_alias_expansion_can_open_a_subshell() {
    let input = "\
shopt -s expand_aliases
alias LEFT='('
LEFT echo one; echo two )
";
    let script = Parser::new(input).parse().unwrap().file;

    let Some(command) = script.body.last() else {
        panic!("expected final command to be a subshell");
    };
    let (compound, _) = expect_compound(command);
    let AstCompoundCommand::Subshell(commands) = compound else {
        panic!("expected final command to be a subshell");
    };
    assert_eq!(commands.len(), 2);
    assert!(matches!(commands[0].command, AstCommand::Simple(_)));
    assert!(matches!(commands[1].command, AstCommand::Simple(_)));
}

#[test]
fn test_alias_expansion_with_trailing_space_expands_next_word() {
    let input = "\
shopt -s expand_aliases
alias greet='echo '
alias subject='hello'
greet subject
";
    let script = Parser::new(input).parse().unwrap().file;

    let Some(stmt) = script.body.last() else {
        panic!("expected final command to be a simple command");
    };
    let command = expect_simple(stmt);

    assert_eq!(command.name.render(input), "echo");
    assert_eq!(command.args.len(), 1);
    assert_eq!(command.args[0].render(input), "hello");

    let WordPart::Literal(name_text) = &command.name.parts[0].kind else {
        panic!("expected alias-expanded command name to stay literal");
    };
    let WordPart::Literal(arg_text) = &command.args[0].parts[0].kind else {
        panic!("expected alias-expanded arg to stay literal");
    };

    assert!(!name_text.is_source_backed());
    assert!(!arg_text.is_source_backed());
}

#[test]
fn test_alias_expansion_with_trailing_space_waits_until_replay_finishes() {
    let input = "\
shopt -s expand_aliases
alias e_='for i in 1 2 3; do echo '
e_ $i; done
";
    let script = Parser::new(input).parse().unwrap().file;

    let Some(stmt) = script.body.last() else {
        panic!("expected final command to be a for loop");
    };
    let (compound, _) = expect_compound(stmt);
    let AstCompoundCommand::For(command) = compound else {
        panic!("expected final command to be a for loop");
    };

    assert_eq!(command.targets[0].name.as_deref(), Some("i"));
    assert_eq!(command.words.as_ref().map(|words| words.len()), Some(3));

    let Some(body_stmt) = command.body.first() else {
        panic!("expected loop body command");
    };
    let body_command = expect_simple(body_stmt);
    assert_eq!(body_command.name.render(input), "echo");
    assert_eq!(body_command.args.len(), 1);
    assert_eq!(body_command.args[0].render(input), "$i");
}

#[test]
fn test_alias_expansion_recursive_guard_stops_self_reference() {
    let input = "\
shopt -s expand_aliases
alias loop='loop echo'
loop
";
    let script = Parser::new(input).parse().unwrap().file;

    let Some(stmt) = script.body.last() else {
        panic!("expected final command to be a simple command");
    };
    let command = expect_simple(stmt);

    assert_eq!(command.name.render(input), "loop");
    assert_eq!(command.args.len(), 1);
    assert_eq!(command.args[0].render(input), "echo");
}

#[test]
fn test_parse_malformed_parameter_replacement_fuzz_regression_does_not_panic() {
    const INPUT: &[u8] = &[
        0x23, 0x21, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x62, 0x61, 0x73, 0x68, 0x0a, 0x0a, 0x23, 0x20,
        0x49, 0x6e, 0x76, 0x64, 0x66, 0x22, 0x20, 0x22, 0x64, 0x6f, 0x63, 0x22, 0x29, 0x0a, 0x65,
        0x78, 0x74, 0x73, 0x3d, 0x22, 0x24, 0x7b, 0x65, 0x78, 0x74, 0x73, 0x5b, 0x2a, 0x5d, 0x7d,
        0x22, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x2b, 0x3d, 0x22, 0x20, 0x24, 0x7b, 0x65, 0x78, 0x74,
        0x73, 0x5e, 0x5e, 0x7d, 0x22, 0x0a, 0x65, 0x63, 0xdd, 0x8d, 0x65, 0x75, 0x20, 0x65, 0x61,
        0x73, 0x64, 0x20, 0x73, 0x73, 0x63, 0x61, 0x6c, 0x61, 0x72, 0x20, 0x74, 0x65, 0x78, 0x74,
        0x40, 0x2e, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x3d, 0x28, 0x5d, 0x74, 0x78, 0x74, 0x22, 0x20,
        0x22, 0x70, 0x64, 0x66, 0x22, 0x20, 0x22, 0x64, 0x6f, 0x63, 0x22, 0x29, 0x0a, 0x65, 0x78,
        0x74, 0x73, 0x3d, 0x22, 0x24, 0x7b, 0x65, 0x78, 0x74, 0x69, 0x6e, 0x2f, 0x62, 0x61, 0x73,
        0x68, 0x0a, 0x0a, 0x23, 0x20, 0x49, 0x6e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x76, 0x64, 0x66, 0x22, 0x20,
        0x22, 0x64, 0x6f, 0x63, 0x22, 0x29, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x3d, 0x22, 0x24, 0x7b,
        0x65, 0x78, 0x74, 0x73, 0x5b, 0x2a, 0x5d, 0x7d, 0x22, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x2b,
        0x3d, 0x22, 0x20, 0x24, 0x7b, 0x65, 0x78, 0x74, 0x73, 0x5e, 0x2f, 0x00, 0x00, 0x00, 0x65,
        0x63, 0xdd, 0x8d, 0x65, 0x75, 0x20, 0x65, 0x61, 0x73, 0x64, 0x20, 0x73, 0x73, 0x63, 0x61,
        0x6c, 0x61, 0x72, 0x20, 0x74, 0x65, 0x78, 0x74, 0x2e, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x3d,
        0x28, 0x5d, 0x74, 0x78, 0x74, 0x22, 0x20, 0x22, 0x70, 0x64, 0x66, 0x22, 0x20, 0x22, 0x64,
        0x6f, 0x63, 0x22, 0x29, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x3d, 0x22, 0x24, 0x7b, 0x65, 0x78,
        0x74, 0x2f, 0x5b, 0x2a, 0x5d, 0x7d, 0x22, 0x0a, 0x65, 0x78, 0x74, 0x73, 0x2b, 0x3d, 0x31,
        0x20, 0x24, 0x7b, 0x65, 0x78, 0x74, 0x73, 0x5e, 0x5e, 0x7d, 0x22, 0x0a, 0x65, 0x63, 0x68,
        0x6f, 0x20, 0x22, 0x24, 0x65, 0x78, 0x74, 0x73, 0x22, 0x2a, 0x0a, 0x23, 0x20, 0x56, 0x61,
        0x6c, 0x2b, 0x2b, 0x3a, 0x20, 0x66, 0x6c, 0x61, 0x74, 0x74, 0x65, 0x6e, 0x69, 0x6e, 0x5f,
        0x00, 0x69, 0x6e, 0x74, 0x6f, 0x2f, 0x5b, 0x2a, 0x5d, 0x7d, 0x22, 0x0a, 0x65, 0x78, 0x74,
        0x73, 0x2b, 0x3d, 0x32, 0x20, 0x24, 0x7b, 0x65, 0x78, 0x74, 0x73, 0x5e, 0x5e, 0x7d, 0x22,
        0x0a, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x22, 0x24, 0x65, 0x78, 0x74, 0x73, 0x22, 0x2a, 0x0a,
        0x23, 0x20, 0x56, 0x61, 0x6c, 0x2b, 0x2b, 0x3a, 0x20, 0x66, 0x6c, 0x61, 0x74, 0x74, 0x65,
        0x6e, 0x69, 0x6e, 0x5f, 0x00, 0x69, 0x6e, 0x74, 0x6f, 0x20, 0x61, 0x20, 0x64, 0x4e, 0x66,
        0x66, 0x65, 0x72, 0x65, 0x6e, 0x74, 0x20, 0x73,
    ];

    let input = std::str::from_utf8(INPUT).unwrap();

    let case_script = format!(
        "case \"test.txt\" in {}) echo match;; *) echo no;; esac\n",
        input
    );
    let _ = Parser::new(&case_script).parse();

    let conditional_script = format!("if [[ \"hello.world\" == {} ]]; then echo y; fi\n", input);
    let _ = Parser::new(&conditional_script).parse();
}

#[test]
fn test_parse_arithmetic_utf8_parameter_replacement_fuzz_regression_does_not_panic() {
    const INPUT: &str = "#!/ di[@]}\")\")#!/bin/$x)\narr=($(]x ib) echo#b;;\n#!/bi/nbash\n\narr=${in/bas\nunc(#!${in/sa\nar\0\0\0)\0\0<\0<\0=\u{2018}woin/sa\u{2019}es\0 \"two worac\ntr";

    let script = format!("echo $(({}))\n", INPUT);
    let _ = Parser::new(&script).parse();
}

#[test]
fn test_parse_glob_utf8_parameter_replacement_fuzz_regression_does_not_panic() {
    const INPUT: &str = "'a\0b'\nar=(\n\n{)${!f/e¢}\")\ni";

    let case_script = format!(
        "case \"test.txt\" in {}) echo match;; *) echo no;; esac\n",
        INPUT
    );
    let _ = Parser::new(&case_script).parse();

    let conditional_script = format!("if [[ \"hello.world\" == {} ]]; then echo y; fi\n", INPUT);
    let _ = Parser::new(&conditional_script).parse();
}

#[test]
fn test_parse_glob_malformed_case_and_conditional_recovery_fuzz_regression_finishes() {
    const INPUT: &[u8] = &[
        0x41, 0x23, 0x21, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x62, 0x61, 0x3d, 0x20, 0x3c, 0x3c, 0x54,
        0x41, 0x47, 0x0a, 0x78, 0x0a, 0x54, 0x41, 0x47, 0x20, 0x20, 0x0a, 0x54, 0x41, 0x47, 0x0a,
        0x0a, 0x63, 0x61, 0x74, 0x20, 0x3c, 0x3c, 0x45, 0x4f, 0x46, 0x6c, 0x20, 0x63, 0x6f, 0x6d,
        0x6d, 0x61, 0x6e, 0x64, 0x20, 0x73, 0x75, 0x64, 0x6f, 0x20, 0x5c, 0x22, 0x5c, 0x24, 0x7b,
        0x73, 0x75, 0x64, 0x6f, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x5b, 0x40, 0x4d, 0x4d, 0x4d, 0x4d,
        0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d,
        0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x4d, 0x5d, 0x7d, 0x5c, 0x22, 0x0a, 0x65, 0x63, 0x68,
        0x6f, 0x20, 0x5b, 0x30, 0x2d, 0x39, 0x61, 0x2d, 0x66, 0x5d, 0x7b, 0x24, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x62, 0x61, 0x3d, 0x20, 0x3c, 0x3c, 0x54, 0x41, 0x47, 0x0a, 0x78, 0x0a, 0x54,
        0x41, 0x47, 0x20, 0x20, 0x0a, 0x54, 0x41, 0x47, 0x0a, 0x0a, 0x63, 0x61, 0x74, 0x20, 0x3c,
        0x3c, 0x45, 0x4f, 0x46, 0x6c, 0x20, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x20, 0x73,
        0x75, 0x64, 0x6f, 0x20, 0x5c, 0x22, 0x5c, 0x24, 0x7b, 0x73, 0x00, 0x48, 0x41, 0x53, 0x48,
        0x4c, 0x45, 0x4e, 0x7d, 0x0a, 0x66, 0x69, 0x6e, 0x64, 0x20, 0x2e, 0x20, 0x2d, 0x65, 0x78,
        0x65, 0x63, 0x20, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x7b, 0x6e, 0x20, 0x20, 0x0a, 0x66, 0x69,
        0x0a, 0x0a, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x22, 0x48, 0x45, 0x41, 0x44, 0x40, 0x7b, 0x31,
        0x0a, 0x20, 0x20, 0x65, 0x63, 0x68, 0x6f, 0xdb, 0x90, 0x6b, 0x0a, 0x66, 0x69, 0x7d, 0x22,
        0x0a, 0x65, 0x63, 0x68, 0x6f, 0x7d, 0x20, 0x20, 0x74, 0x0a, 0x68, 0x3a, 0x65, 0x6e, 0x20,
        0x20, 0x0a, 0x66, 0x69, 0x0a, 0x0a, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x22, 0x48, 0x45, 0x41,
        0x44, 0x40, 0x7b, 0x31, 0x0a, 0x20, 0x20, 0x65, 0x63, 0x68, 0x6f, 0xdb, 0x90, 0x6b, 0x0a,
        0x66, 0x69, 0x7d, 0x22, 0x0a, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x78, 0x7b, 0x20, 0x5d, 0x5d,
        0x7d, 0x79, 0x0a, 0x65, 0x63, 0x20, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x7b, 0x7d, 0x20, 0x20,
        0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x3a, 0x0a, 0x66, 0x69, 0x0a, 0x0a, 0x65, 0x63,
        0x68, 0x6f, 0x20, 0x22, 0x48, 0x45, 0x41, 0x44, 0x40, 0x7b, 0x31, 0x0a, 0x20, 0x20, 0x65,
        0x63, 0x68, 0x6f, 0xdb, 0x90, 0x6b, 0x0a, 0x66, 0x69, 0x7d, 0x22, 0x0a, 0x65, 0x63, 0x68,
        0x6f, 0x20, 0x78, 0x7b, 0x20, 0x5d, 0x5d, 0x7d, 0x79, 0x0a,
    ];
    let input = std::str::from_utf8(INPUT).unwrap();

    let case_script = format!(
        "case \"test.txt\" in {}) echo match;; *) echo no;; esac\n",
        input
    );
    let _ = Parser::new(&case_script).parse();

    let conditional_script = format!("if [[ \"hello.world\" == {} ]]; then echo y; fi\n", input);
    let _ = Parser::new(&conditional_script).parse();
}
