use super::*;

#[test]
fn word_static_text_preserves_multi_part_literals() {
    let source = "echo foo\"bar\" 'baz'qux\n";
    with_facts(source, None, |_output, facts| {
        let mut static_args = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .map(|fact| {
                (
                    fact.span().slice(source),
                    fact.static_text()
                        .expect("expected static argument text")
                        .into_owned(),
                )
            });

        assert_eq!(
            static_args.next(),
            Some(("foo\"bar\"", "foobar".to_owned()))
        );
        assert_eq!(static_args.next(), Some(("'baz'qux", "bazqux".to_owned())));
        assert_eq!(static_args.next(), None);
    });
}

#[test]
fn structural_commands_skip_synthetic_semantic_commands() {
    let source = "case \"$x\" in $(echo \"$v\")) ;; esac\n";
    with_facts(source, None, |_output, facts| {
        let commands = facts
            .command_facts()
            .structural_commands()
            .collect::<Vec<_>>();

        assert!(!commands.is_empty());
        assert!(commands.iter().all(|command| {
            facts
                .commands()
                .iter()
                .any(|candidate| candidate.id() == command.id())
        }));
        let pattern_command = facts
            .commands()
            .iter()
            .find(|command| command.span().slice(source).starts_with("echo"))
            .unwrap();
        let parent = facts
            .command_facts()
            .command_parent(pattern_command.id())
            .unwrap();
        assert!(parent.span().slice(source).starts_with("case"));
    });
}

#[test]
fn builds_duplicate_redirect_deletion_spans_only_for_fully_overwritten_redirects() {
    let source =
        "#!/bin/sh\nprintf '%s\\n' value >first >second\nprintf '%s\\n' value &>both 2>stderr\n";

    with_facts(source, None, |_, facts| {
        let redirect_facts = facts.command_facts().duplicate_redirect_facts();
        assert_eq!(redirect_facts.len(), 4);
        assert_eq!(redirect_facts[0].diagnostic_span().slice(source), ">");
        assert_eq!(
            redirect_facts[0]
                .deletion_span()
                .map(|span| span.slice(source)),
            Some(" >first")
        );
        assert!(redirect_facts[1].deletion_span().is_none());
        assert_eq!(redirect_facts[2].diagnostic_span().slice(source), ">");
        assert!(redirect_facts[2].deletion_span().is_none());
        assert!(redirect_facts[3].deletion_span().is_none());
    });
}

#[test]
fn summarizes_command_options_and_invokers() {
    let source = "#!/bin/bash\nread -r name\necho -ne hi\necho '-I' hi\necho \"\\\\n\"\necho \\x41\necho \"prefix $VAR \\\\0 suffix\"\ncommand echo \\n\nsed 's/foo/bar/'\nsed -e 's/foo/bar/'\nsed -e 's:\\([0-9][0-9]*\\)[a-z]*\\$:\\1:'\nsed 's/[]\\[^$.*/]/\\\\&/g'\nsed 's/\\([/&]\\)/\\\\\\1/g'\nsed -n 's/foo/bar/p'\nsed --expression 's/foo/bar/'\nsed -r 's/foo/bar/'\nsed \\\"s/foo/bar/\\\"\ntr -ds a-z A-Z\ntr -- 'a-z' xyz\nprintf -v out \"$fmt\" value\nprintf '%q\\n' foo\nprintf '%*q\\n' 10 bar\nunset -f curl other\nfind . -print0 | xargs -0 rm\nfind . -type d -name CVS | xargs -iX rm -rf X\nfind . -type d -name CVS | xargs --replace rm -rf {}\nfind . -name a -o -name b -print\nfind . -name *.cfg\nfind . -name \"$prefix\"*.jar\nfind . -wholename */tmp/*\nfind . -name \\*.ignore\nfind . -type f*\nrm -rf \"$dir\"/*\nrm -rf \"$dir\"/sub/*\nrm -rf \"$dir\"/lib\nrm -rf \"$dir\"/*.log\nrm -rf \"$rootdir/$md_type/$to\"\nrm -rf \"$configdir/all/retroarch/$dir\"\nrm -rf \"$md_inst/\"*\nwait -n\nwait -- -n\ngrep -o content file | wc -l\nexit foo\nset -eETo pipefail\nset euox pipefail\n./configure --with-optmizer=${CFLAGS}\nconfigure \"--enable-optmizer=${CFLAGS}\"\n./configure --with-optimizer=${CFLAGS}\nps -p 1 -o comm=\nps p 123 -o comm=\nps -ef\ndoas printf '%s\\n' hi\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let read = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("read"))
        .expect("expected read fact");
    assert_eq!(
        read.options().read().map(|read| read.uses_raw_input),
        Some(true)
    );

    let echo = facts
        .commands()
        .iter()
        .find(|fact| {
            fact.effective_name_is("echo")
                && fact
                    .options()
                    .echo()
                    .and_then(|echo| echo.portability_flag_word())
                    .is_some()
        })
        .and_then(|fact| fact.options().echo())
        .expect("expected echo facts");
    assert_eq!(
        echo.portability_flag_word()
            .map(|word| word.span.slice(source)),
        Some("-ne")
    );
    assert_eq!(
        facts
            .commands()
            .iter()
            .filter(|fact| fact.effective_name_is("echo"))
            .nth(1)
            .and_then(|fact| fact.options().echo())
            .and_then(|echo| echo.portability_flag_word())
            .map(|word| word.span.slice(source)),
        None
    );
    assert_eq!(
        facts
            .words()
            .echo_backslash_escape_word_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["\"\\\\n\"", "\\x41", "\"prefix $VAR \\\\0 suffix\""]
    );

    let sed_commands = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("sed"))
        .collect::<Vec<_>>();
    assert_eq!(sed_commands.len(), 9);
    assert!(
        sed_commands[0]
            .options()
            .sed()
            .is_some_and(|sed| sed.has_single_substitution_script())
    );
    assert!(
        sed_commands[1]
            .options()
            .sed()
            .is_some_and(|sed| sed.has_single_substitution_script())
    );
    assert!(
        sed_commands[2]
            .options()
            .sed()
            .is_some_and(|sed| sed.has_single_substitution_script())
    );
    assert!(
        !sed_commands[3]
            .options()
            .sed()
            .is_some_and(|sed| sed.has_single_substitution_script())
    );
    assert!(
        !sed_commands[4]
            .options()
            .sed()
            .is_some_and(|sed| sed.has_single_substitution_script())
    );
    assert!(
        !sed_commands[5]
            .options()
            .sed()
            .is_some_and(|sed| sed.has_single_substitution_script())
    );
    assert!(
        !sed_commands[6]
            .options()
            .sed()
            .is_some_and(|sed| sed.has_single_substitution_script())
    );
    assert!(
        !sed_commands[7]
            .options()
            .sed()
            .is_some_and(|sed| sed.has_single_substitution_script())
    );
    assert!(
        !sed_commands[8]
            .options()
            .sed()
            .is_some_and(|sed| sed.has_single_substitution_script())
    );

    let tr = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("tr") && fact.options().tr().is_some())
        .and_then(|fact| fact.options().tr())
        .expect("expected tr facts");
    assert_eq!(
        tr.operand_words()
            .iter()
            .map(|word| word.span.slice(source))
            .collect::<Vec<_>>(),
        vec!["a-z", "A-Z"]
    );
    let quoted_tr = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("tr"))
        .nth(1)
        .and_then(|fact| fact.options().tr())
        .expect("expected second tr facts");
    assert_eq!(
        quoted_tr
            .operand_words()
            .iter()
            .map(|word| word.span.slice(source))
            .collect::<Vec<_>>(),
        vec!["'a-z'", "xyz"]
    );

    let printf = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("printf") && fact.options().printf().is_some())
        .expect("expected printf fact");
    assert_eq!(
        printf
            .options()
            .printf()
            .and_then(|printf| printf.format_word)
            .map(|word| word.span.slice(source)),
        Some("\"$fmt\"")
    );
    assert!(
        printf
            .options()
            .printf()
            .is_some_and(|printf| !printf.format_word_has_literal_percent)
    );
    assert!(
        !printf
            .options()
            .printf()
            .is_some_and(|printf| printf.uses_q_format)
    );

    let q_printf = facts
        .commands()
        .iter()
        .find(|fact| {
            fact.effective_name_is("printf")
                && fact
                    .options()
                    .printf()
                    .is_some_and(|printf| printf.uses_q_format)
        })
        .and_then(|fact| fact.options().printf())
        .expect("expected q printf facts");
    assert!(q_printf.uses_q_format);
    assert_eq!(
        q_printf.format_word.map(|word| word.span.slice(source)),
        Some("'%q\\n'")
    );

    let star_q_printf = facts
        .commands()
        .iter()
        .find(|fact| {
            fact.effective_name_is("printf")
                && fact
                    .options()
                    .printf()
                    .and_then(|printf| printf.format_word)
                    .map(|word| word.span.slice(source))
                    == Some("'%*q\\n'")
        })
        .and_then(|fact| fact.options().printf())
        .expect("expected star-width q printf facts");
    assert!(star_q_printf.uses_q_format);
    assert!(star_q_printf.format_word_has_literal_percent);

    let unset = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("unset"))
        .and_then(|fact| fact.options().unset())
        .expect("expected unset facts");
    assert!(unset.function_mode);
    assert!(unset.targets_function_name(source, "curl"));
    assert!(!unset.targets_function_name(source, "missing"));

    let find = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("find"))
        .and_then(|fact| fact.options().find())
        .expect("expected find facts");
    assert!(find.has_print0);
    let find_or_without_grouping_spans = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("find"))
        .filter_map(|fact| fact.options().find())
        .flat_map(|find| find.or_without_grouping_spans().iter().copied())
        .map(|span| span.slice(source))
        .collect::<Vec<_>>();
    assert_eq!(find_or_without_grouping_spans, vec!["-print"]);
    let find_or_without_grouping_fix_spans = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("find"))
        .filter_map(|fact| fact.options().find())
        .flat_map(|find| find.or_without_grouping_fix_spans().iter().copied())
        .map(|fix| {
            (
                fix.diagnostic_span.slice(source),
                fix.branch_start.slice(source),
                fix.action_span.slice(source),
            )
        })
        .collect::<Vec<_>>();
    assert_eq!(
        find_or_without_grouping_fix_spans,
        vec![("-print", "-name", "-print")]
    );
    let find_glob_pattern_operand_spans = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("find"))
        .filter_map(|fact| fact.options().find())
        .flat_map(|find| find.glob_pattern_operand_spans().iter().copied())
        .map(|span| span.slice(source))
        .collect::<Vec<_>>();
    assert_eq!(
        find_glob_pattern_operand_spans,
        vec!["*.cfg", "\"$prefix\"*.jar", "*/tmp/*"]
    );

    let find_exec_shell = facts
        .commands()
        .iter()
        .filter(|fact| {
            fact.has_wrapper(WrapperKind::FindExec) || fact.has_wrapper(WrapperKind::FindExecDir)
        })
        .find_map(|fact| fact.options().find_exec_shell());
    assert!(
        find_exec_shell.is_none(),
        "fixture without a shell-backed find exec should not match"
    );

    let xargs = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("xargs"))
        .and_then(|fact| fact.options().xargs())
        .expect("expected xargs facts");
    assert!(xargs.uses_null_input);
    let inline_replace_option_spans = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("xargs"))
        .filter_map(|fact| fact.options().xargs())
        .flat_map(|xargs| xargs.inline_replace_option_spans())
        .map(|span| span.slice(source))
        .collect::<Vec<_>>();
    assert_eq!(inline_replace_option_spans, vec!["-iX"]);

    let wait = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("wait") && fact.options().wait().is_some())
        .and_then(|fact| fact.options().wait())
        .expect("expected wait facts");
    assert_eq!(
        wait.option_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["-n"]
    );

    let set = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("set"))
        .and_then(|fact| fact.options().set())
        .expect("expected set facts");
    assert_eq!(set.errexit_change, Some(true));
    assert_eq!(set.errtrace_change, Some(true));
    assert_eq!(set.functrace_change, Some(true));
    assert_eq!(set.pipefail_change, Some(true));
    assert_eq!(
        set.errtrace_flag_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["-eETo"]
    );
    assert_eq!(
        set.functrace_flag_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["-eETo"]
    );
    assert_eq!(
        set.pipefail_option_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["pipefail"]
    );
    assert_eq!(
        set.non_posix_option_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["pipefail"]
    );
    let set_without_prefix_spans = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("set"))
        .filter_map(|fact| fact.options().set())
        .flat_map(|set| set.flags_without_prefix_spans().iter().copied())
        .collect::<Vec<_>>();
    assert_eq!(
        set_without_prefix_spans
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["euox"]
    );
    let ps_pid_selector_flags = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("ps"))
        .filter_map(|fact| fact.options().ps().map(|ps| ps.has_pid_selector))
        .collect::<Vec<_>>();
    assert_eq!(ps_pid_selector_flags, vec![true, true, false]);
    let rm_spans = facts
        .commands()
        .iter()
        .filter_map(|fact| fact.options().rm())
        .flat_map(|rm| rm.dangerous_path_spans().iter().copied())
        .collect::<Vec<_>>();
    assert_eq!(
        rm_spans
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec![
            "\"$dir\"/*",
            "\"$dir\"/lib",
            "\"$rootdir/$md_type/$to\"",
            "\"$md_inst/\"*"
        ]
    );
    let grep = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("grep"))
        .and_then(|fact| fact.options().grep())
        .expect("expected grep facts");
    assert!(grep.uses_only_matching);
    assert!(!grep.uses_fixed_strings);
    assert_eq!(
        grep.patterns()
            .iter()
            .map(|pattern| pattern.span().slice(source))
            .collect::<Vec<_>>(),
        vec!["content"]
    );

    let exit = facts
        .commands()
        .iter()
        .find(|fact| fact.options().exit().is_some())
        .and_then(|fact| fact.options().exit())
        .expect("expected exit facts");
    assert_eq!(
        exit.status_word.map(|word| word.span.slice(source)),
        Some("foo")
    );
    assert!(exit.has_static_status());
    assert!(!exit.is_numeric_literal);
    assert!(exit.has_invalid_status_argument());

    let doas = facts
        .commands()
        .iter()
        .find(|fact| fact.has_wrapper(WrapperKind::SudoFamily))
        .and_then(|fact| fact.options().sudo_family())
        .expect("expected sudo-family facts");
    assert_eq!(doas.invoker, SudoFamilyInvoker::Doas);
}

#[test]
fn bracket_command_name_separator_fact_tracks_split_test_commands() {
    let source = "\
#!/bin/sh
amoeba=\"\" [ \"${AMOEBA:-yes}\" = \"yes\" ]
>out [ \"$1\" = yes ]
[
  \"$1\" = yes
]
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let line2 = facts
        .commands()
        .iter()
        .find(|fact| fact.literal_name() == Some("[") && fact.span().start.line() == 2)
        .expect("expected inline test command");
    let line3 = facts
        .commands()
        .iter()
        .find(|fact| fact.literal_name() == Some("[") && fact.span().start.line() == 3)
        .expect("expected redirected bracket command");
    let line4 = facts
        .commands()
        .iter()
        .find(|fact| fact.literal_name() == Some("[") && fact.span().start.line() == 4)
        .expect("expected plain bracket command");

    assert!(line2.bracket_command_name_needs_separator(source));
    assert!(!line3.bracket_command_name_needs_separator(source));
    assert!(!line4.bracket_command_name_needs_separator(source));
}

#[test]
fn set_command_facts_track_non_posix_set_o_options() {
    let source = "\
#!/bin/sh
set -o pipefail
set +o posix
set -eo emacs
set -o bogus -- bogus
set -o vi
set -o allexport
set -o \"$mode\"
set -- -o posix
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .commands()
            .iter()
            .filter(|fact| fact.effective_name_is("set"))
            .filter_map(|fact| fact.options().set())
            .flat_map(|set| set.non_posix_option_spans().iter().copied())
            .collect::<Vec<_>>();

        assert_eq!(
            spans
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["pipefail", "posix", "emacs", "bogus"]
        );
    });
}

#[test]
fn command_facts_surface_command_name_shape_helpers() {
    let source = "\
#!/bin/sh
\"$root/pkg/{{name}}/bin/{{cmd}}\"
\"ERROR: missing first arg for name to docker_compose_version_test()\"
\"${loader:?}\"
\"${cmd:-\\}}\"
\"${cmd:-`printf '}'`}\"
\"$(printf cmd)\"
\"$(printf ')')\"
\"$(echo `printf ')'`)\"
printf#
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let command = |text: &str| {
        facts
            .commands()
            .iter()
            .find(|command| {
                command
                    .body_word_span()
                    .is_some_and(|span| span.slice(source) == text)
            })
            .expect("expected command fact")
    };

    let placeholder = command("\"$root/pkg/{{name}}/bin/{{cmd}}\"");
    let suspicious_quote =
        command("\"ERROR: missing first arg for name to docker_compose_version_test()\"");
    let parameter_expansion = command("\"${loader:?}\"");
    let escaped_brace_parameter_expansion = command("\"${cmd:-\\}}\"");
    let backtick_brace_parameter_expansion = command("\"${cmd:-`printf '}'`}\"");
    let command_substitution = command("\"$(printf cmd)\"");
    let quoted_paren_command_substitution = command("\"$(printf ')')\"");
    let backtick_paren_command_substitution = command("\"$(echo `printf ')'`)\"");
    let hash_suffix = command("printf#");

    assert!(placeholder.body_word_contains_template_placeholder(source));
    assert!(suspicious_quote.body_word_has_suspicious_quoted_command_trailer(source, None));
    assert!(!parameter_expansion.body_word_has_suspicious_quoted_command_trailer(source, None));
    assert!(
        !escaped_brace_parameter_expansion
            .body_word_has_suspicious_quoted_command_trailer(source, None)
    );
    assert!(
        !backtick_brace_parameter_expansion
            .body_word_has_suspicious_quoted_command_trailer(source, None)
    );
    assert!(!command_substitution.body_word_has_suspicious_quoted_command_trailer(source, None));
    assert!(
        !quoted_paren_command_substitution
            .body_word_has_suspicious_quoted_command_trailer(source, None)
    );
    assert!(
        !backtick_paren_command_substitution
            .body_word_has_suspicious_quoted_command_trailer(source, None)
    );
    assert!(hash_suffix.body_word_has_hash_suffix(source));
}

#[test]
fn ssh_command_facts_match_shellcheck_command_shape() {
    let source = "\
#!/bin/bash
ssh host \"echo $HOME\"
\\ssh host \"echo $USER\"
ssh -i key host \"echo $PATH\"
ssh host -t \"echo $PWD\"
ssh host ls -l \"$TMPDIR\"
ssh host cmd \"--flag\" \"$HOME\"
ssh host cmd '-t' \"$USER\"
";

    with_facts(source, None, |_, facts| {
        let ssh_commands = facts
            .commands()
            .iter()
            .filter(|fact| fact.effective_name_is("ssh") && fact.wrappers().is_empty())
            .collect::<Vec<_>>();

        assert_eq!(ssh_commands.len(), 7);
        assert_eq!(
            ssh_commands[0]
                .options()
                .ssh()
                .map(|ssh| ssh.local_expansion_spans().len()),
            Some(1)
        );
        assert_eq!(
            ssh_commands[0]
                .options()
                .ssh()
                .map(|ssh| ssh.remote_command_arg_span().slice(source)),
            Some("\"echo $HOME\"")
        );
        assert_eq!(
            ssh_commands[1]
                .options()
                .ssh()
                .map(|ssh| ssh.local_expansion_spans().len()),
            Some(1)
        );
        assert_eq!(
            ssh_commands[1]
                .options()
                .ssh()
                .map(|ssh| ssh.remote_command_arg_span().slice(source)),
            Some("\"echo $USER\"")
        );
        assert!(ssh_commands[2].options().ssh().is_none());
        assert!(ssh_commands[3].options().ssh().is_none());
        assert!(ssh_commands[4].options().ssh().is_none());
        assert!(ssh_commands[5].options().ssh().is_none());
        assert!(ssh_commands[6].options().ssh().is_none());
    });
}

#[test]
fn summarizes_echo_options_for_path_qualified_echo() {
    let source = "#!/bin/sh\nvalue=$(/usr/ucb/echo -n hi)\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let echo = facts
        .commands()
        .iter()
        .find(|fact| fact.literal_name() == Some("/usr/ucb/echo"))
        .and_then(|fact| fact.options().echo())
        .expect("expected path-qualified echo facts");

    assert_eq!(
        echo.portability_flag_word()
            .map(|word| word.span.slice(source)),
        Some("-n")
    );
}

#[test]
fn builds_tr_facts_inside_escaped_quoted_command_substitutions() {
    let source = "#!/bin/sh\necho -n \"\\\"adp_$(echo $var | tr A-Z a-z)\\\": [\"\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let tr = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("tr"))
        .and_then(|fact| fact.options().tr())
        .expect("expected escaped quoted command substitution tr facts");
    assert_eq!(
        tr.operand_words()
            .iter()
            .map(|word| word.span.slice(source))
            .collect::<Vec<_>>(),
        vec!["A-Z", "a-z"]
    );
}

#[test]
fn builds_tr_facts_inside_piped_command_substitutions_with_quoted_operands() {
    let source = "#!/bin/sh\nATLAS_SHARED=$(echo \"$ATLAS_SHARED\"|cut -b 1|tr a-z A-Z)\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let tr = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("tr"))
        .expect("expected tr command fact");
    assert!(tr.wrappers().is_empty());
    let tr_options = tr.options().tr().expect("expected tr options");
    assert_eq!(
        tr_options
            .operand_words()
            .iter()
            .map(|word| word.span.slice(source))
            .collect::<Vec<_>>(),
        vec!["a-z", "A-Z"]
    );
}

#[test]
fn tracks_quote_like_echo_escapes_inside_double_quotes_from_syntax_text() {
    let source = "#!/bin/bash\necho \"  echo Remember to run \\\\\\`updatedb\\\\'.\"\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert_eq!(
        facts
            .words()
            .echo_backslash_escape_word_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["\"  echo Remember to run \\\\\\`updatedb\\\\'.\""]
    );
}

#[test]
fn tracks_echo_backslash_double_quote_escape_shapes() {
    let source = "#!/bin/bash\necho -DLATEX=\\\\\"$(which latex)\\\\\"\necho \"  .TargetPath = \\\"\\\\\\\\host.lan\\\\Data\\\"\"\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert_eq!(
        facts
            .words()
            .echo_backslash_escape_word_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec![
            "-DLATEX=\\\\\"$(which latex)\\\\\"",
            "\"  .TargetPath = \\\"\\\\\\\\host.lan\\\\Data\\\"\""
        ]
    );
}

#[test]
fn ignores_json_like_backslash_quote_wrappers_around_variables() {
    let source = "#!/bin/bash\necho \"LABEL com.dokku.docker-image-labeler/alternate-tags=[\\\\\\\"$DOCKER_IMAGE\\\\\\\"]\"\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert!(facts.words().echo_backslash_escape_word_spans().is_empty());
}

#[test]
fn ignores_quote_plumbing_around_adjacent_quoted_fragments() {
    let source = "\
#!/bin/bash
echo \"$1\"=\\\"\"${PWD}\"\\\"
echo pin=\\'\"${new_pinned[*]}\"\\'
echo 'set -gx PATH '\\''\"${PYENV_ROOT}/shims\"\\'' $PATH'
echo SHOBJ_CC=\\'\"$SHOBJ_CC\"\\'
echo aws cloudwatch put-metric-alarm --alarm-actions \\'\"$ALARMACTION\"\\' --output=json
echo \"Rule \"\\#$COUNT
echo Saved to \\\"\"$FILENAME\"\\\" \\(\"$(du -h \"$OUTPUT\" | cut -f1)\"\\)
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert!(facts.words().echo_backslash_escape_word_spans().is_empty());
}

#[test]
fn tracks_find_print0_without_treating_it_as_formatted_output() {
    let source = "\
#!/bin/bash
find . -print0 | xargs rm
find . -printf '%h\\n' | xargs mv -t dest
find . -print | xargs rm
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let find_facts = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("find"))
        .filter_map(|fact| fact.options().find())
        .collect::<Vec<_>>();

    assert_eq!(find_facts.len(), 3);
    assert!(find_facts[0].has_print0);
    assert!(!find_facts[0].has_formatted_output_action());
    assert!(find_facts[1].has_formatted_output_action());
    assert!(!find_facts[2].has_formatted_output_action());
}

#[test]
fn tracks_expr_string_helper_kinds_and_spans() {
    let source = "\
#!/bin/sh
expr length \"$mode\"
expr index \"$mode\" w
expr match \"$mode\" 'w'
expr substr \"$mode\" 1 1
expr \"$a\" = \"$b\"
expr 1 + 2
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let exprs = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("expr"))
        .filter_map(|fact| fact.options().expr())
        .collect::<Vec<_>>();

    assert_eq!(
        exprs
            .iter()
            .map(|expr| expr.string_helper_kind())
            .collect::<Vec<_>>(),
        vec![
            Some(ExprStringHelperKind::Length),
            Some(ExprStringHelperKind::Index),
            Some(ExprStringHelperKind::Match),
            Some(ExprStringHelperKind::Substr),
            None,
            None,
        ]
    );
    assert_eq!(
        exprs
            .iter()
            .map(|expr| expr.string_helper_span().map(|span| span.slice(source)))
            .collect::<Vec<_>>(),
        vec![
            Some("length"),
            Some("index"),
            Some("match"),
            Some("substr"),
            None,
            None,
        ]
    );
    assert!(exprs[3].uses_substr_string_form());
    assert_eq!(
        exprs
            .iter()
            .map(|expr| expr.uses_arithmetic_operator())
            .collect::<Vec<_>>(),
        vec![false, false, false, false, false, true]
    );
}

#[test]
fn tracks_printf_formats_with_and_without_literal_percents() {
    let source = "printf \"$fmt\" value\nprintf \"${left}${right}\" value\nprintf \"${fmt:-%s}\" value\nprintf \"$(echo %s)\" value\nprintf \"pre$foo\" value\nprintf \"%${width}s\\n\" value\nprintf \"${color}%s${reset}\" value\nprintf \"$fmt%s\" value\nprintf '%s\\n' value\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let printfs = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("printf"))
        .filter_map(|fact| fact.options().printf())
        .map(|printf| {
            (
                printf
                    .format_word
                    .map(|word| word.span.slice(source))
                    .expect("expected format word"),
                printf.format_word_has_literal_percent,
            )
        })
        .collect::<Vec<_>>();

    assert_eq!(
        printfs,
        vec![
            ("\"$fmt\"", false),
            ("\"${left}${right}\"", false),
            ("\"${fmt:-%s}\"", false),
            ("\"$(echo %s)\"", false),
            ("\"pre$foo\"", false),
            ("\"%${width}s\\n\"", true),
            ("\"${color}%s${reset}\"", true),
            ("\"$fmt%s\"", true),
            ("'%s\\n'", true),
        ]
    );
}

#[test]
fn builds_echo_to_sed_substitution_spans() {
    let source = "\
#!/bin/bash
echo $value | sed 's/foo/bar/'
echo \"$value\" | sed 's/foo/bar/g'
echo \"$value\" | sed 's§foo§bar§'
echo ${items[@]} | sed -e 's/foo/bar/2'
result=$(echo \"$(printf %s foo)\" | sed 's/foo/bar/')
COMMAND=$(echo \"$COMMAND\" | sed \"s#\\(--appendconfig *[^ $]*\\)#\\1'|'$conf#\")
RUNTIME=$(echo $OUT | sed \"s|$OUT|\\$this_dir|g\")
escaped_hostname=$(echo \"$hostname\" | sed 's/[]\\[\\.^$*+?{}()|]/\\\\&/g')
value=$(sed 's/[\\.|$(){}?+*^]/\\\\&/g' <<<\"$value\")
echo \"$value\" | sed 's/a/b/' <<<\"$shadow\"
CFLAGS=\"`echo \\\"$CFLAGS\\\" | sed \\\"s/ $COVERAGE_FLAGS//\\\"`\"
OPTFLAG=\"`echo \\\"$CFLAGS\\\" | sed 's/^.*\\(-O[^ ]\\).*$/\\1/'`\"
EC2_REGION=\"`echo \\\"$EC2_AVAIL_ZONE\\\" | sed -e 's:\\([0-9][0-9]*\\)[a-z]*\\$:\\\\1:'`\"
ESCAPED_REGION=\"`echo \\\"$EC2_AVAIL_ZONE\\\" | sed -e \\\"s/foo/bar/\\\"`\"
FLAGGED_REGION=\"`echo \\\"$EC2_REGION\\\" | sed 's/foo/bar/g'`\"
echo \"$caps_add\" | sed 's/^/  /' \t
trimmed=$(sed 's/[[:space:]]*$//' <<<\"$value\")
literal=$(sed 's/[[:space:]]*$//' <<<literal)
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .echo_to_sed_substitution_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "echo $value | sed 's/foo/bar/'",
                "echo \"$value\" | sed 's/foo/bar/g'",
                "echo \"$value\" | sed 's§foo§bar§'",
                "echo ${items[@]} | sed -e 's/foo/bar/2'",
                "echo \"$(printf %s foo)\" | sed 's/foo/bar/'",
                "echo \"$COMMAND\" | sed \"s#\\(--appendconfig *[^ $]*\\)#\\1'|'$conf#\"",
                "echo $OUT | sed \"s|$OUT|\\$this_dir|g\"",
                "echo \"$hostname\" | sed 's/[]\\[\\.^$*+?{}()|]/\\\\&/g'",
                "sed 's/[\\.|$(){}?+*^]/\\\\&/g' <<<\"$value\"",
                "echo \"$value\" | sed 's/a/b/' <<<\"$shadow\"",
                "echo \\\"$CFLAGS\\\" | sed \\\"s/ $COVERAGE_FLAGS",
                "echo \\\"$CFLAGS\\\" | sed 's/^.*\\(-O[^ ]\\).*$/\\1",
                "echo \\\"$EC2_AVAIL_ZONE\\\" | sed -e 's:\\([0-9][0-9]*\\)[a-z]*\\$:\\",
                "echo \\\"$EC2_AVAIL_ZONE\\\" | sed -e \\\"s/foo/bar",
                "echo \\\"$EC2_REGION\\\" | sed 's/foo/bar/",
                "echo \"$caps_add\" | sed 's/^/  /' \t",
                "sed 's/[[:space:]]*$//' <<<\"$value\"",
                "sed 's/[[:space:]]*$//' <<<literal",
            ]
        );
    });
}

#[test]
fn skips_nonmatching_echo_to_sed_substitution_shapes() {
    let source = "\
#!/bin/bash
echo literal | sed 's/foo/bar/'
echo prefix${value}suffix | sed 's/foo/bar/'
echo \"prefix${value}\" | sed 's/foo/bar/'
echo $left $right | sed 's/foo/bar/'
echo \"$left $right\" | sed 's/foo/bar/'
echo -n $value | sed 's/foo/bar/'
echo $value | sed -n 's/foo/bar/p'
echo $value | sed --expression 's/foo/bar/'
echo $value | sed -es/foo/bar/
echo $value | sed 's/foo/bar/' | cat
echo \"$ENDPOINT\" | sed 's/[:\\/]/_/g'
echo \"$PAYLOAD\" | sed 's/\\//-/g'
echo $PACKAGE_NAME | sed 's/\\./\\//g'
echo \"$value\" | sed \\\"s/foo/bar/\\\"
echo \"$key\" | sed 's/[]\\[^$.*/]/\\\\&/g'
echo \"${ENTRY}\" | sed 's/\\([/&]\\)/\\\\\\1/g'
echo \\\"$value\\\" | sed 's/foo/bar/'
sed 's/[]\\[^$.*/]/\\\\&/g' <<<\"$key\"
sed 's/\\([/&]\\)/\\\\\\1/g' <<<\"${ENTRY}\"
printf '%s\\n' \"$value\" | sed 's/foo/bar/'
echo \"prefix$(printf %s foo)\" | sed 's/foo/bar/'
";

    with_facts(source, None, |_, facts| {
        assert!(facts.words().echo_to_sed_substitution_spans().is_empty());
    });
}

#[test]
fn backtick_echo_to_sed_substitution_matches_shellcheck_columns_for_escaped_dollar_patterns() {
    let source = "\
#!/bin/bash
EC2_REGION=\"`echo \\\"$EC2_AVAIL_ZONE\\\" | sed -e 's:\\([0-9][0-9]*\\)[a-z]*\\$:\\\\1:'`\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts.words().echo_to_sed_substitution_spans();
        assert_eq!(spans.len(), 1);
        let span = spans[0];
        assert_eq!(span.start.line(), 2);
        assert_eq!(span.start.column(), 14);
        assert_eq!(span.end.line(), 2);
        assert_eq!(span.end.column(), 76);
    });
}

#[test]
fn backtick_echo_to_sed_substitution_keeps_utf8_trim_offsets_on_char_boundaries() {
    let source = "\
#!/bin/bash
A=\"`echo \\\"$A\\\" | sed 's/foo\\\\$/é/'`\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts.words().echo_to_sed_substitution_spans();
        assert_eq!(spans.len(), 1);
        let span = spans[0];
        assert_eq!(span.start.line(), 2);
        assert_eq!(span.start.column(), 5);
        assert_eq!(span.end.line(), 2);
        assert_eq!(span.end.column(), 33);
        assert_eq!(span.slice(source), "echo \\\"$A\\\" | sed 's/foo\\\\$/");
    });
}

#[test]
fn preserves_dynamic_unset_operands_after_option_parsing_stops() {
    let source = "\
#!/bin/bash
declare -A parts
key=one
unset parts[\"$key\"] extra
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let unset = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("unset"))
        .and_then(|fact| fact.options().unset())
        .expect("expected unset facts");

    assert_eq!(
        unset
            .operand_words()
            .iter()
            .map(|word| word.span.slice(source))
            .collect::<Vec<_>>(),
        vec!["parts[\"$key\"]", "extra"]
    );
}

#[test]
fn parses_unset_nameref_mode_separately_from_variable_mode() {
    let source = "\
#!/bin/bash
unset -n ref
unset -v value
unset -xn unknown
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let unsets = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("unset"))
        .filter_map(|fact| fact.options().unset())
        .collect::<Vec<_>>();

    assert_eq!(unsets.len(), 3);
    assert!(unsets[0].nameref_mode());
    assert!(!unsets[0].function_mode);
    assert!(unsets[0].options_parseable());
    assert!(!unsets[1].nameref_mode());
    assert!(unsets[1].options_parseable());
    assert!(unsets[2].nameref_mode());
    assert!(!unsets[2].options_parseable());
}

#[test]
fn records_unset_array_subscript_details_in_operand_facts() {
    let source = "\
#!/bin/bash
declare -A parts
declare -a nums
key=one
unset parts[\"$key\"] plain \"parts[safe]\" 'parts[also_safe]' nums[1]
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let unset = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("unset"))
        .and_then(|fact| fact.options().unset())
        .expect("expected unset facts");

    let operand_subscripts = unset
        .operand_facts()
        .iter()
        .map(|operand| {
            operand
                .array_subscript()
                .map(|_| operand.word().span.slice(source))
        })
        .collect::<Vec<_>>();

    assert_eq!(
        operand_subscripts,
        vec![Some("parts[\"$key\"]"), None, None, None, Some("nums[1]"),]
    );
}

#[test]
fn precomputes_command_containment_and_barrier_flags_for_nested_commands() {
    let source = "\
#!/bin/bash
if init_if; then
  { brace_inner; }
fi
left && right
time timed
";

    with_facts(source, None, |_, facts| {
        let init_if = facts
            .commands()
            .iter()
            .find(|fact| fact.effective_name_is("init_if"))
            .expect("expected init_if command");
        let brace_inner = facts
            .commands()
            .iter()
            .find(|fact| fact.effective_name_is("brace_inner"))
            .expect("expected brace_inner command");
        let timed = facts
            .commands()
            .iter()
            .find(|fact| fact.effective_name_is("timed"))
            .expect("expected timed command");
        let binary = facts
            .commands()
            .iter()
            .find(|fact| matches!(fact.command(), shuck_ast::Command::Binary(_)))
            .expect("expected binary command");

        let init_parent = facts
            .command_facts()
            .command_parent(init_if.id())
            .expect("expected if parent");
        assert!(matches!(
            init_parent.command(),
            shuck_ast::Command::Compound(shuck_ast::CompoundCommand::If(_))
        ));
        assert!(
            facts
                .command_facts()
                .command_is_dominance_barrier(init_parent.id())
        );

        let brace_parent = facts
            .command_facts()
            .command_parent(brace_inner.id())
            .expect("expected brace-group parent");
        assert!(matches!(
            brace_parent.command(),
            shuck_ast::Command::Compound(shuck_ast::CompoundCommand::BraceGroup(_))
        ));
        assert!(
            !facts
                .command_facts()
                .command_is_dominance_barrier(brace_parent.id())
        );

        let timed_parent = facts
            .command_facts()
            .command_parent(timed.id())
            .expect("expected time parent");
        assert!(matches!(
            timed_parent.command(),
            shuck_ast::Command::Compound(shuck_ast::CompoundCommand::Time(_))
        ));
        assert!(
            !facts
                .command_facts()
                .command_is_dominance_barrier(timed_parent.id())
        );
        assert!(
            facts
                .command_facts()
                .command_is_dominance_barrier(binary.id())
        );

        let brace_offset = source.find("brace_inner").expect("brace_inner offset");
        let right_offset = source.find("right").expect("right offset");
        assert_eq!(
            facts
                .command_facts()
                .innermost_command_at(brace_offset)
                .and_then(|fact| fact.effective_name()),
            Some("brace_inner")
        );
        assert_eq!(
            facts
                .command_facts()
                .innermost_command_at(right_offset)
                .and_then(|fact| fact.effective_name()),
            Some("right")
        );
        assert_eq!(
            facts
                .command_facts()
                .innermost_command_id_containing_offset(brace_offset + 2)
                .map(|id| facts.command_facts().command(id))
                .and_then(|fact| fact.effective_name()),
            Some("brace_inner")
        );
        assert_eq!(
            facts
                .command_facts()
                .innermost_command_id_containing_offset(right_offset + 2)
                .map(|id| facts.command_facts().command(id))
                .and_then(|fact| fact.effective_name()),
            Some("right")
        );
    });
}

#[test]
fn collects_prefix_match_spans_from_unset_operands() {
    let source = "\
#!/bin/sh
unset -v \"${!prefix_@}\" x${!prefix_*} \"${!name}\" \"${!arr[@]}\"
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let unset = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("unset"))
        .and_then(|fact| fact.options().unset())
        .expect("expected unset facts");

    assert_eq!(
        unset
            .prefix_match_operand_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["${!prefix_@}", "${!prefix_*}"]
    );
}

#[test]
fn tracks_mapfile_input_fd_and_grouped_find_or_branches() {
    let source = "#!/bin/bash\nmapfile -u 3 -t files 3< <(printf '%s\\n' hi)\nmapfile -C cb -c 1 lines\nfind . \\( -name a -o -name b -print \\)\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let mapfile = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("mapfile"))
        .and_then(|fact| fact.options().mapfile())
        .expect("expected mapfile facts");
    assert_eq!(mapfile.input_fd(), Some(3));
    assert_eq!(
        mapfile
            .target_name_uses()
            .iter()
            .map(|target| target.span().slice(source))
            .collect::<Vec<_>>(),
        vec!["files"]
    );

    let callback_mapfile = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("mapfile"))
        .nth(1)
        .and_then(|fact| fact.options().mapfile())
        .expect("expected callback mapfile facts");
    assert_eq!(
        callback_mapfile
            .target_name_uses()
            .iter()
            .map(|target| target.span().slice(source))
            .collect::<Vec<_>>(),
        vec!["lines"]
    );

    let dynamic_source = "#!/bin/bash\nmapfile -u \"$fd\" -t files < <(printf '%s\\n' hi)\n";
    let dynamic_output = Parser::new(dynamic_source).parse().unwrap();
    let dynamic_indexer = Indexer::new(dynamic_source, &dynamic_output);
    let dynamic_semantic =
        LinterSemanticArtifacts::build(&dynamic_output.file, dynamic_source, &dynamic_indexer);
    let dynamic_facts = LinterFacts::build(
        &dynamic_output.file,
        dynamic_source,
        &dynamic_semantic,
        &dynamic_indexer,
    );

    let dynamic_mapfile = dynamic_facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("mapfile"))
        .and_then(|fact| fact.options().mapfile())
        .expect("expected dynamic mapfile facts");
    assert_eq!(dynamic_mapfile.input_fd(), None);

    let find_or_without_grouping_spans = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("find"))
        .filter_map(|fact| fact.options().find())
        .flat_map(|find| find.or_without_grouping_spans().iter().copied())
        .map(|span| span.slice(source))
        .collect::<Vec<_>>();
    assert_eq!(find_or_without_grouping_spans, vec!["-print"]);
}

#[test]
fn tracks_find_exec_or_branches_without_action_only_false_positives() {
    let source = "\
#!/bin/bash
find . -name a -o -name b -exec rm -f {} \\;
find . -name a -o -name b -o -name c -exec cp {} out \\;
find . \\( -name a -o \\( -name b -exec rm -f {} \\; \\) \\) -print
find . -type l -o -print
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let find_or_without_grouping_spans = facts
        .commands()
        .iter()
        .filter_map(|fact| fact.options().find())
        .flat_map(|find| find.or_without_grouping_spans().iter().copied())
        .map(|span| span.slice(source))
        .collect::<Vec<_>>();

    assert_eq!(find_or_without_grouping_spans, vec!["-exec", "-exec"]);
}

#[test]
fn parses_grep_pattern_words_from_flags_and_operands() {
    let source = "\
#!/bin/bash
grep item,[0-4] data.txt
grep -e item* data.txt
grep -eitem* data.txt
grep -oe item* data.txt
grep --regexp='a[b]c' data.txt
grep --regexp item? data.txt
grep --regexp=foo* data.txt
grep -eo item* data.txt
grep -F -- item* data.txt
grep -f patterns.txt item* data.txt
grep -F -E foo*bar data.txt
grep -E -F foo*bar data.txt
grep --exclude '*.txt' foo* data.txt
grep --label stdin foo* data.txt
grep --color foo* data.txt
grep --context 3 foo* data.txt
grep --regexp='*start' data.txt
grep -e'*start' data.txt
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let grep_patterns = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("grep"))
        .filter_map(|fact| fact.options().grep())
        .map(|grep| {
            (
                grep.patterns()
                    .iter()
                    .map(|pattern| {
                        (
                            pattern.span().slice(source),
                            pattern.static_text(),
                            pattern.source_kind(),
                        )
                    })
                    .collect::<Vec<_>>(),
                grep.uses_fixed_strings,
            )
        })
        .collect::<Vec<_>>();

    assert_eq!(
        grep_patterns,
        vec![
            (
                vec![(
                    "item,[0-4]",
                    Some("item,[0-4]"),
                    GrepPatternSourceKind::ImplicitOperand,
                )],
                false,
            ),
            (
                vec![(
                    "item*",
                    Some("item*"),
                    GrepPatternSourceKind::ShortOptionSeparate,
                )],
                false,
            ),
            (
                vec![(
                    "-eitem*",
                    Some("item*"),
                    GrepPatternSourceKind::ShortOptionAttached,
                )],
                false,
            ),
            (
                vec![(
                    "item*",
                    Some("item*"),
                    GrepPatternSourceKind::ShortOptionSeparate,
                )],
                false,
            ),
            (
                vec![(
                    "--regexp='a[b]c'",
                    Some("a[b]c"),
                    GrepPatternSourceKind::LongOptionAttached,
                )],
                false,
            ),
            (
                vec![(
                    "item?",
                    Some("item?"),
                    GrepPatternSourceKind::LongOptionSeparate,
                )],
                false,
            ),
            (
                vec![(
                    "--regexp=foo*",
                    Some("foo*"),
                    GrepPatternSourceKind::LongOptionAttached,
                )],
                false,
            ),
            (
                vec![("-eo", Some("o"), GrepPatternSourceKind::ShortOptionAttached,)],
                false,
            ),
            (
                vec![(
                    "item*",
                    Some("item*"),
                    GrepPatternSourceKind::ImplicitOperand,
                )],
                true,
            ),
            (Vec::new(), false),
            (
                vec![(
                    "foo*bar",
                    Some("foo*bar"),
                    GrepPatternSourceKind::ImplicitOperand,
                )],
                false,
            ),
            (
                vec![(
                    "foo*bar",
                    Some("foo*bar"),
                    GrepPatternSourceKind::ImplicitOperand,
                )],
                true,
            ),
            (
                vec![("foo*", Some("foo*"), GrepPatternSourceKind::ImplicitOperand,)],
                false,
            ),
            (
                vec![("foo*", Some("foo*"), GrepPatternSourceKind::ImplicitOperand,)],
                false,
            ),
            (
                vec![("foo*", Some("foo*"), GrepPatternSourceKind::ImplicitOperand,)],
                false,
            ),
            (
                vec![("foo*", Some("foo*"), GrepPatternSourceKind::ImplicitOperand,)],
                false,
            ),
            (
                vec![(
                    "--regexp='*start'",
                    Some("*start"),
                    GrepPatternSourceKind::LongOptionAttached,
                )],
                false,
            ),
            (
                vec![(
                    "-e'*start'",
                    Some("*start"),
                    GrepPatternSourceKind::ShortOptionAttached,
                )],
                false,
            ),
        ]
    );
}

#[test]
fn grep_pattern_facts_track_leading_glob_style_star_prefixes() {
    let source = "\
#!/bin/bash
grep '*start' data.txt
grep ''*user data.txt
grep '^*' data.txt
grep '^*foo' data.txt
grep --regexp='*start' data.txt
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let grep_patterns = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("grep"))
        .filter_map(|fact| fact.options().grep())
        .flat_map(|grep| grep.patterns().iter())
        .map(|pattern| {
            (
                pattern.span().slice(source),
                pattern.static_text(),
                pattern.starts_with_glob_style_star(),
            )
        })
        .collect::<Vec<_>>();

    assert_eq!(
        grep_patterns,
        vec![
            ("'*start'", Some("*start"), true),
            ("''*user", Some("*user"), true),
            ("'^*'", Some("^*"), true),
            ("'^*foo'", Some("^*foo"), false),
            ("--regexp='*start'", Some("*start"), true),
        ]
    );
}

#[test]
fn grep_pattern_facts_track_shellcheck_style_pattern_position() {
    let source = "\
#!/bin/bash
grep -e '*first' -e '*second' data.txt
grep -m 1 '*implicit-after-option' data.txt
grep -m1 '*implicit-after-attached-option' data.txt
grep -A 1 -e '*explicit-after-option' data.txt
grep -e '*explicit-before-option' -A 1 data.txt
grep -$dynamic_option 1 -e '*explicit-after-dynamic-option' data.txt
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let grep_patterns = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("grep"))
        .filter_map(|fact| fact.options().grep())
        .flat_map(|grep| grep.patterns().iter())
        .map(|pattern| {
            (
                pattern.span().slice(source),
                pattern.is_first_pattern(),
                pattern.follows_separate_option_argument(),
            )
        })
        .collect::<Vec<_>>();

    assert_eq!(
        grep_patterns,
        vec![
            ("'*first'", true, false),
            ("'*second'", false, false),
            ("'*implicit-after-option'", true, true),
            ("'*implicit-after-attached-option'", true, false),
            ("'*explicit-after-option'", true, true),
            ("'*explicit-before-option'", true, false),
            ("'*explicit-after-dynamic-option'", true, true),
        ]
    );
}

#[test]
fn grep_pattern_facts_track_glob_style_star_confusion() {
    let source = "\
#!/bin/bash
grep start* data.txt
grep 'foo*bar' data.txt
grep 'foo\\*bar*' data.txt
grep '^#* OPTIONS #*$' data.txt
grep -Eo 'https?://[[:alnum:]./?&!$#%@*;:+~_=-]+' data.txt
grep '^root:[:!*]' data.txt
grep -e 'Swarm:*\\sactive\\s*' data.txt
grep 'foo*bar+' data.txt
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let grep_patterns = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("grep"))
        .filter_map(|fact| fact.options().grep())
        .flat_map(|grep| grep.patterns().iter())
        .map(|pattern| {
            (
                pattern.span().slice(source),
                pattern.has_glob_style_star_confusion(),
            )
        })
        .collect::<Vec<_>>();

    assert_eq!(
        grep_patterns,
        vec![
            ("start*", true),
            ("'foo*bar'", true),
            ("'foo\\*bar*'", true),
            ("'^#* OPTIONS #*$'", false),
            ("'https?://[[:alnum:]./?&!$#%@*;:+~_=-]+'", false),
            ("'^root:[:!*]'", false),
            ("'Swarm:*\\sactive\\s*'", false),
            ("'foo*bar+'", false),
        ]
    );
}

#[test]
fn grep_pattern_facts_track_glob_style_star_replacement_spans() {
    let source = "\
#!/bin/bash
grep 'foo\\*bar*' data.txt
grep item\\* data.txt
grep start* data.txt
grep --regexp='start*' data.txt
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let grep_patterns = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("grep"))
        .filter_map(|fact| fact.options().grep())
        .flat_map(|grep| grep.patterns().iter())
        .map(|pattern| {
            (
                pattern.span().slice(source),
                pattern
                    .glob_style_star_replacement_spans()
                    .iter()
                    .map(|span| span.slice(source))
                    .collect::<Vec<_>>(),
            )
        })
        .collect::<Vec<_>>();

    assert_eq!(
        grep_patterns,
        vec![
            ("'foo\\*bar*'", vec!["*"]),
            ("item\\*", vec!["\\*"]),
            ("start*", vec!["*"]),
            ("--regexp='start*'", vec!["*"]),
        ]
    );
}

#[test]
fn attached_short_e_patterns_do_not_accidentally_toggle_only_matching() {
    let source = "\
#!/bin/bash
grep -oe item* data.txt
grep -eo item* data.txt
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let grep_modes = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("grep"))
        .filter_map(|fact| fact.options().grep())
        .map(|grep| grep.uses_only_matching)
        .collect::<Vec<_>>();

    assert_eq!(grep_modes, vec![true, false]);
}

#[test]
fn tracks_dynamic_ps_pid_selectors() {
    let source = "\
#!/bin/bash
ps -p\"$pid\" -o comm=
ps --pid=\"$pid\" -o comm=
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let ps_commands = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("ps"))
        .collect::<Vec<_>>();

    assert_eq!(ps_commands.len(), 2);
    assert!(
        ps_commands
            .iter()
            .all(|fact| fact.options().ps().is_some_and(|ps| ps.has_pid_selector))
    );
}

#[test]
fn tracks_bare_ps_pid_operands() {
    let source = "\
#!/bin/bash
ps 1 -o comm=
ps 1,2 -o comm=
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let ps_commands = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("ps"))
        .collect::<Vec<_>>();

    assert_eq!(ps_commands.len(), 2);
    assert!(
        ps_commands
            .iter()
            .all(|fact| fact.options().ps().is_some_and(|ps| ps.has_pid_selector))
    );
}

#[test]
fn tracks_ps_pid_selectors_after_bsd_style_clusters() {
    let source = "\
#!/bin/bash
ps aux -p 1 -o comm=
ps ax -q 1 -o comm=
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let ps_commands = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("ps"))
        .collect::<Vec<_>>();

    assert_eq!(ps_commands.len(), 2);
    assert!(
        ps_commands
            .iter()
            .all(|fact| fact.options().ps().is_some_and(|ps| ps.has_pid_selector))
    );
}

#[test]
fn collects_base_prefix_arithmetic_spans_across_arithmetic_nodes() {
    let source = "\
#!/bin/bash
echo $((10#123))
echo ${foo:10#1:2}
: > \"$((10#1))\"
echo ${foo:-$((10#1))}
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert_eq!(
        facts
            .words()
            .arithmetic_literal_facts()
            .iter()
            .filter(|literal| literal.kind() == ArithmeticLiteralKind::ExplicitBasePrefix)
            .map(|literal| literal.span())
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["10#123", "10#1", "10#1", "10#1"]
    );
}

#[test]
fn base_prefix_arithmetic_literals_record_arithmetic_literal_behavior() {
    let source = "\
#!/bin/zsh
echo $((10#1))
echo $((010))
setopt c_bases
echo $((10#2))
unsetopt c_bases
setopt octal_zeroes
echo $((10#3))
echo $((010))
setopt c_bases
echo $((10#4))
unsetopt c_bases
echo $((10#5))
opt=octal_zeroes
unsetopt \"$opt\"
echo $((10#6))
";
    let output = Parser::with_dialect(source, shuck_parser::parser::ShellDialect::Zsh)
        .parse()
        .unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert_eq!(
        facts
            .words()
            .arithmetic_literal_facts()
            .iter()
            .map(|literal| {
                (
                    literal.span().slice(source),
                    literal.kind(),
                    literal.behavior(),
                )
            })
            .collect::<Vec<_>>(),
        vec![
            (
                "10#1",
                ArithmeticLiteralKind::ExplicitBasePrefix,
                ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
            ),
            (
                "010",
                ArithmeticLiteralKind::LeadingZeroInteger,
                ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
            ),
            (
                "10#2",
                ArithmeticLiteralKind::ExplicitBasePrefix,
                ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
            ),
            (
                "10#3",
                ArithmeticLiteralKind::ExplicitBasePrefix,
                ArithmeticLiteralBehavior::LeadingZeroOctal,
            ),
            (
                "010",
                ArithmeticLiteralKind::LeadingZeroInteger,
                ArithmeticLiteralBehavior::LeadingZeroOctal,
            ),
            (
                "10#4",
                ArithmeticLiteralKind::ExplicitBasePrefix,
                ArithmeticLiteralBehavior::LeadingZeroOctal,
            ),
            (
                "10#5",
                ArithmeticLiteralKind::ExplicitBasePrefix,
                ArithmeticLiteralBehavior::LeadingZeroOctal,
            ),
            (
                "10#6",
                ArithmeticLiteralKind::ExplicitBasePrefix,
                ArithmeticLiteralBehavior::Ambiguous,
            ),
        ]
    );
}

#[test]
fn base_prefix_arithmetic_literals_record_bash_arithmetic_literal_behavior() {
    let source = "\
#!/bin/bash
echo $((10#1))
echo $((010))
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert_eq!(
        facts
            .words()
            .arithmetic_literal_facts()
            .iter()
            .map(|literal| {
                (
                    literal.span().slice(source),
                    literal.kind(),
                    literal.behavior(),
                )
            })
            .collect::<Vec<_>>(),
        vec![
            (
                "10#1",
                ArithmeticLiteralKind::ExplicitBasePrefix,
                ArithmeticLiteralBehavior::CStyleAndLeadingZeroOctal,
            ),
            (
                "010",
                ArithmeticLiteralKind::LeadingZeroInteger,
                ArithmeticLiteralBehavior::CStyleAndLeadingZeroOctal,
            )
        ]
    );
}

#[test]
fn arithmetic_literals_record_leading_zeroes_in_nested_arithmetic_fragments() {
    let source = "\
#!/bin/zsh
echo ${value:010:1}
echo ${value:-$((010))}
echo ${value:010#7:1}
echo ${value:-$((010#7))}
";
    let output = Parser::with_dialect(source, shuck_parser::parser::ShellDialect::Zsh)
        .parse()
        .unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert_eq!(
        facts
            .words()
            .arithmetic_literal_facts()
            .iter()
            .map(|literal| {
                (
                    literal.span().slice(source),
                    literal.kind(),
                    literal.behavior(),
                )
            })
            .collect::<Vec<_>>(),
        vec![
            (
                "010",
                ArithmeticLiteralKind::LeadingZeroInteger,
                ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
            ),
            (
                "010",
                ArithmeticLiteralKind::LeadingZeroInteger,
                ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
            ),
            (
                "010#7",
                ArithmeticLiteralKind::ExplicitBasePrefix,
                ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
            ),
            (
                "010#7",
                ArithmeticLiteralKind::ExplicitBasePrefix,
                ArithmeticLiteralBehavior::DecimalUnlessExplicitBase,
            ),
        ]
    );
}

#[test]
fn collects_base_prefix_arithmetic_spans_from_semantic_nested_command_visits() {
    let source = "\
#!/bin/bash
echo \"$(printf '%s' \"$((10#1))\")\"
case $mode in
  run) echo $((10#2)) ;;
esac
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert_eq!(
        facts
            .words()
            .arithmetic_literal_facts()
            .iter()
            .filter(|literal| literal.kind() == ArithmeticLiteralKind::ExplicitBasePrefix)
            .map(|literal| literal.span())
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["10#1", "10#2"]
    );
}

#[test]
fn collects_arithmetic_update_operator_spans_in_nested_heredoc_command_bodies() {
    let source = "\
#!/bin/bash
cat <<EOF
$(printf '%s' \"$(printf '%s' \"$((i++))\")\")
${value:-$(printf '%s' \"$(printf '%s' \"$((--j))\")\")}
EOF
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert_eq!(
        facts
            .words()
            .arithmetic_update_operator_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["++", "--"]
    );
}

#[test]
fn collects_arithmetic_update_operator_spans_in_nested_redirect_target_bodies() {
    let source = "\
#!/bin/bash
cat <<EOF
$(printf '%s' \"$((i++))\" > \"$(printf '%s' \"$((--j))\")\")
EOF
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert_eq!(
        facts
            .words()
            .arithmetic_update_operator_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["++", "--"]
    );
}

#[test]
fn ignores_base_prefix_like_parameter_trim_operands() {
    let source = "\
#!/bin/bash
: \"${progname:=\"${0##*/}\"}\"
echo ${foo:-${1##*/}}
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert!(
        facts
            .words()
            .arithmetic_literal_facts()
            .iter()
            .all(|literal| literal.kind() != ArithmeticLiteralKind::ExplicitBasePrefix)
    );
}

#[test]
fn ignores_positional_parameter_trim_in_arithmetic_shell_words() {
    let source = "\
#!/bin/sh
echo $((42949 - ${1#-} / 100000))
";
    let output = Parser::with_dialect(source, shuck_parser::parser::ShellDialect::Posix)
        .parse()
        .unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert!(
        facts
            .words()
            .arithmetic_literal_facts()
            .iter()
            .all(|literal| literal.kind() != ArithmeticLiteralKind::ExplicitBasePrefix)
    );
}

#[test]
fn collects_base_prefixes_in_arithmetic_parameter_defaults() {
    let source = "\
#!/bin/sh
echo $(( ${foo:-10#1} ))
";
    let output = Parser::with_dialect(source, shuck_parser::parser::ShellDialect::Posix)
        .parse()
        .unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert_eq!(
        facts
            .words()
            .arithmetic_literal_facts()
            .iter()
            .filter(|literal| literal.kind() == ArithmeticLiteralKind::ExplicitBasePrefix)
            .map(|literal| literal.span())
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["10#1"]
    );
}

#[test]
fn builds_find_exec_shell_command_facts_for_execdir_shell_targets() {
    let source = "\
#!/bin/sh
# shellcheck disable=2086,2154
find $dir -type f -name \"rename*\" -execdir sh -c 'mv {} $(echo {} | sed \"s|rename|perl-rename|\")' \\;
";

    with_facts(source, None, |_, facts| {
        let find = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExecDir))
            .expect("expected find -execdir fact");

        assert_eq!(find.effective_name(), Some("sh"));
        assert_eq!(find.wrappers(), &[WrapperKind::FindExecDir]);

        let find_exec_shell = find
            .options()
            .find_exec_shell()
            .expect("expected shell command fact for find -execdir");
        assert_eq!(
            find_exec_shell
                .shell_command_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["'mv {} $(echo {} | sed \"s|rename|perl-rename|\")'"]
        );
    });
}

#[test]
fn builds_find_exec_shell_command_facts_for_exec_shell_targets() {
    let source = "\
#!/bin/sh
find . -exec bash -c 'hash=($(sha1sum {})); mv {} fuzz/corpus/$hash' \\;
";

    with_facts(source, None, |_, facts| {
        let find = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExec) && fact.effective_name_is("bash"))
            .expect("expected find -exec fact");

        let find_exec_shell = find
            .options()
            .find_exec_shell()
            .expect("expected shell command fact for find -exec");
        assert_eq!(
            find_exec_shell
                .shell_command_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["'hash=($(sha1sum {})); mv {} fuzz/corpus/$hash'"]
        );
    });
}

#[test]
fn builds_find_exec_shell_command_facts_for_later_exec_shell_targets() {
    let source = "\
#!/bin/sh
find . -exec echo {} + -name '*.cfg' -exec sh -c 'printf \"%s\\n\" {}' \\;
";

    with_facts(source, None, |_, facts| {
        let find = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExec) && fact.effective_name_is("echo"))
            .expect("expected first find -exec fact");

        let find_exec_shell = find
            .options()
            .find_exec_shell()
            .expect("expected shell command fact for later find -exec");
        assert_eq!(
            find_exec_shell
                .shell_command_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["'printf \"%s\\n\" {}'"]
        );
    });
}

#[test]
fn builds_find_exec_shell_command_facts_for_wrapped_shell_targets() {
    let source = "\
#!/bin/sh
find . -exec busybox sh -c 'printf \"%s\\n\" {}' \\;
find . -exec sudo sh -c 'printf \"%s\\n\" {}' \\;
";

    with_facts(source, None, |_, facts| {
        let shell_spans = facts
            .commands()
            .iter()
            .filter(|fact| fact.has_wrapper(WrapperKind::FindExec))
            .filter_map(|fact| fact.options().find_exec_shell())
            .flat_map(|find_exec_shell| find_exec_shell.shell_command_spans().iter())
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(
            shell_spans,
            vec!["'printf \"%s\\n\" {}'", "'printf \"%s\\n\" {}'"]
        );
    });
}

#[test]
fn ignores_find_ok_shell_targets_for_find_exec_shell_command_facts() {
    let source = "\
#!/bin/sh
find . -ok sh -c 'printf \"%s\\n\" {}' \\;
find . -okdir bash -c 'printf \"%s\\n\" {}' \\;
";

    with_facts(source, None, |_, facts| {
        let shell_spans = facts
            .commands()
            .iter()
            .filter_map(|fact| fact.options().find_exec_shell())
            .flat_map(|find_exec_shell| find_exec_shell.shell_command_spans().iter())
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert!(
            shell_spans.is_empty(),
            "unexpected shell spans: {shell_spans:?}"
        );
    });
}

#[test]
fn ignores_nested_find_exec_wrappers_for_find_exec_shell_command_facts() {
    let source = "\
#!/bin/sh
find . -exec find {} -ok sh -c 'printf \"%s\\n\" {}' \\; \\;
find . -execdir busybox find {} -okdir bash -c 'printf \"%s\\n\" {}' \\; \\;
";

    with_facts(source, None, |_, facts| {
        let shell_spans = facts
            .commands()
            .iter()
            .filter_map(|fact| fact.options().find_exec_shell())
            .flat_map(|find_exec_shell| find_exec_shell.shell_command_spans().iter())
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert!(
            shell_spans.is_empty(),
            "unexpected shell spans: {shell_spans:?}"
        );
    });
}

#[test]
fn ignores_exec_tokens_nested_inside_find_ok_segments() {
    let source = "\
#!/bin/sh
find . -ok find {} -exec sh -c 'printf \"%s\\n\" {}' \\; \\;
find . -okdir busybox find {} -execdir bash -c 'printf \"%s\\n\" {}' \\; \\;
";

    with_facts(source, None, |_, facts| {
        let shell_spans = facts
            .commands()
            .iter()
            .filter_map(|fact| fact.options().find_exec_shell())
            .flat_map(|find_exec_shell| find_exec_shell.shell_command_spans().iter())
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert!(
            shell_spans.is_empty(),
            "unexpected shell spans: {shell_spans:?}"
        );
    });
}

#[test]
fn builds_find_exec_argument_word_spans_for_wrapped_commands() {
    let source = "\
#!/bin/sh
find \"$root\"/*.py -exec echo \"$prefix\"*.tmp {} \\; -name '*.cfg'
result=$(find . -type d -name fuzz -exec dirname $(readlink -f {}) \\;)
find . -execdir sh -c 'printf \"%s\\n\" {}' {} \\;
";

    with_facts(source, None, |_, facts| {
        let top_level_find_exec = facts
            .commands()
            .iter()
            .find(|fact| {
                fact.has_wrapper(WrapperKind::FindExec)
                    && !fact.is_nested_word_command()
                    && fact.effective_name_is("echo")
            })
            .expect("expected top-level find -exec fact");
        assert_eq!(
            top_level_find_exec
                .options()
                .find_exec()
                .expect("expected find -exec facts")
                .argument_word_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["echo", "\"$prefix\"*.tmp", "{}"]
        );

        let nested_find_exec = facts
            .commands()
            .iter()
            .find(|fact| {
                fact.has_wrapper(WrapperKind::FindExec)
                    && fact.is_nested_word_command()
                    && fact.effective_name_is("dirname")
            })
            .expect("expected nested find -exec fact");
        assert_eq!(
            nested_find_exec
                .options()
                .find_exec()
                .expect("expected nested find -exec facts")
                .argument_word_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["dirname", "$(readlink -f {})"]
        );

        let plus_argument_find_exec = facts
            .commands()
            .iter()
            .find(|fact| {
                fact.has_wrapper(WrapperKind::FindExec)
                    && !fact.is_nested_word_command()
                    && fact.effective_name_is("echo")
                    && fact.options().find_exec().is_some_and(|find_exec| {
                        find_exec
                            .argument_word_spans()
                            .iter()
                            .any(|span| span.slice(source) == "\"$prefix\"*.tmp")
                    })
            })
            .expect("expected semicolon-terminated find -exec fact");
        assert_eq!(
            plus_argument_find_exec
                .options()
                .find_exec()
                .expect("expected find -exec facts")
                .argument_word_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["echo", "\"$prefix\"*.tmp", "{}"]
        );

        let find_execdir = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExecDir))
            .expect("expected find -execdir fact");
        assert_eq!(
            find_execdir
                .options()
                .find_exec()
                .expect("expected find -exec facts for execdir wrapper")
                .argument_word_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["sh", "-c", "'printf \"%s\\n\" {}'", "{}"]
        );
    });
}

#[test]
fn keeps_plus_arguments_before_semicolon_terminated_find_exec() {
    let source = "#!/bin/sh\nfind . -exec echo + *.tmp {} \\;\n";

    with_facts(source, None, |_, facts| {
        let find_exec = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExec))
            .expect("expected find -exec fact");

        assert_eq!(
            find_exec
                .options()
                .find_exec()
                .expect("expected find -exec facts")
                .argument_word_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["echo", "+", "*.tmp", "{}"]
        );
    });
}

#[test]
fn keeps_quoted_backslash_semicolon_arguments_before_find_exec_terminator() {
    let source = "#!/bin/sh\nfind . -exec echo '\\;' *.tmp {} \\;\n";

    with_facts(source, None, |_, facts| {
        let find_exec = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExec))
            .expect("expected find -exec fact");

        assert_eq!(
            find_exec
                .options()
                .find_exec()
                .expect("expected find -exec facts")
                .argument_word_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["echo", "'\\;'", "*.tmp", "{}"]
        );
    });
}

#[test]
fn treats_quoted_semicolon_as_find_exec_terminator() {
    let source = "#!/bin/sh\nfind . -exec echo {} ';' -name *.cfg\n";

    with_facts(source, None, |_, facts| {
        let find_exec = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExec))
            .expect("expected find -exec fact");

        assert_eq!(
            find_exec
                .options()
                .find_exec()
                .expect("expected find -exec facts")
                .argument_word_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["echo", "{}"]
        );
    });
}

#[test]
fn stops_at_plus_terminator_before_later_exec_semicolon() {
    let source = "#!/bin/sh\nfind . -exec echo {} + -name *.cfg -exec rm {} \\;\n";

    with_facts(source, None, |_, facts| {
        let first_find_exec = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExec) && fact.effective_name_is("echo"))
            .expect("expected first find -exec fact");

        assert_eq!(
            first_find_exec
                .options()
                .find_exec()
                .expect("expected find -exec facts")
                .argument_word_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["echo", "{}", "rm", "{}"]
        );
    });
}

#[test]
fn stops_at_first_plus_terminator_before_later_exec_plus() {
    let source = "#!/bin/sh\nfind . -exec echo {} + -name *.cfg -exec rm {} +\n";

    with_facts(source, None, |_, facts| {
        let first_find_exec = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExec) && fact.effective_name_is("echo"))
            .expect("expected first find -exec fact");

        assert_eq!(
            first_find_exec
                .options()
                .find_exec()
                .expect("expected find -exec facts")
                .argument_word_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["echo", "{}", "rm", "{}"]
        );
    });
}

#[test]
fn builds_find_exec_argument_word_spans_for_dynamic_command_names() {
    let source = "#!/bin/sh\nfind . -exec \"$tool\" *.tmp {} \\;\n";

    with_facts(source, None, |_, facts| {
        let find_exec = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExec))
            .expect("expected find -exec fact");

        assert_eq!(find_exec.effective_name(), None);
        assert_eq!(
            find_exec
                .options()
                .find_exec()
                .expect("expected find -exec facts")
                .argument_word_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["\"$tool\"", "*.tmp", "{}"]
        );
    });
}

#[test]
fn builds_find_exec_argument_word_spans_for_later_exec_clauses() {
    let source = "#!/bin/sh\nfind . -exec echo {} + -exec rm *.tmp {} +\n";

    with_facts(source, None, |_, facts| {
        let find_exec = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExec) && fact.effective_name_is("echo"))
            .expect("expected find -exec fact");

        assert_eq!(
            find_exec
                .options()
                .find_exec()
                .expect("expected find -exec facts")
                .argument_word_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["echo", "{}", "rm", "*.tmp", "{}"]
        );
    });
}

#[test]
fn builds_find_exec_shell_command_facts_for_bundled_execdir_shell_c_flags() {
    let source = "\
#!/bin/sh
find . -execdir sh -ec 'mv {} \"$target\"' \\;
";

    with_facts(source, None, |_, facts| {
        let find = facts
            .commands()
            .iter()
            .find(|fact| fact.has_wrapper(WrapperKind::FindExecDir))
            .expect("expected find -execdir fact");

        let find_exec_shell = find
            .options()
            .find_exec_shell()
            .expect("expected shell command fact for bundled -c flags");
        assert_eq!(
            find_exec_shell
                .shell_command_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["'mv {} \"$target\"'"]
        );
    });
}

#[test]
fn summarizes_builtin_wrapped_reads() {
    let source = "#!/bin/bash\nbuiltin read response\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let read = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("read"))
        .expect("expected builtin-wrapped read fact");

    assert_eq!(read.wrappers(), &[WrapperKind::Builtin]);
    assert_eq!(
        read.options().read().map(|read| read.uses_raw_input),
        Some(false)
    );
}

#[test]
fn summarizes_read_array_targets_without_trailing_names() {
    let source = "\
#!/bin/bash
read first second
read -a arr name
read -aarr name
read -ar name
read -- -a name
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let reads = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("read"))
        .collect::<Vec<_>>();

    assert_eq!(
        reads[0]
            .options()
            .read()
            .expect("expected plain read facts")
            .target_name_uses()
            .iter()
            .map(|name_use| name_use.span().slice(source))
            .collect::<Vec<_>>(),
        vec!["first", "second"]
    );
    assert_eq!(
        reads[1]
            .options()
            .read()
            .expect("expected read -a facts")
            .target_name_uses()
            .iter()
            .map(|name_use| name_use.span().slice(source))
            .collect::<Vec<_>>(),
        vec!["arr"]
    );
    assert_eq!(
        reads[2]
            .options()
            .read()
            .expect("expected read -aNAME facts")
            .target_name_uses()
            .iter()
            .map(|name_use| name_use.span().slice(source))
            .collect::<Vec<_>>(),
        vec!["arr"]
    );
    assert_eq!(
        reads[3]
            .options()
            .read()
            .expect("expected read -ar facts")
            .target_name_uses()
            .iter()
            .map(|name_use| name_use.span().slice(source))
            .collect::<Vec<_>>(),
        vec!["r"]
    );
    assert_eq!(
        reads[4]
            .options()
            .read()
            .expect("expected read -- facts")
            .target_name_uses()
            .iter()
            .map(|name_use| name_use.span().slice(source))
            .collect::<Vec<_>>(),
        vec!["name"]
    );
}

#[test]
fn summarizes_quoted_read_target_names() {
    let source = "\
#!/bin/bash
read \"path\" 'name'
read -a \"items\" trailing
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let reads = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("read"))
        .collect::<Vec<_>>();

    assert_eq!(
        reads[0]
            .options()
            .read()
            .expect("expected quoted read facts")
            .target_name_uses()
            .iter()
            .map(|name_use| name_use.span().slice(source))
            .collect::<Vec<_>>(),
        vec!["\"path\"", "'name'"]
    );
    assert_eq!(
        reads[1]
            .options()
            .read()
            .expect("expected quoted read -a facts")
            .target_name_uses()
            .iter()
            .map(|name_use| name_use.span().slice(source))
            .collect::<Vec<_>>(),
        vec!["\"items\""]
    );
}

#[test]
fn summarizes_su_login_forms() {
    let source = "\
#!/bin/bash
su root
su root -c id
su \"$user\" -s /bin/sh -c \"$cmd\"
su -s /bin/sh root
su -
su --login root
su -l
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let su_login_flags = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("su"))
        .map(|fact| fact.options().su().map(|su| su.has_login_flag()))
        .collect::<Vec<_>>();

    assert_eq!(
        su_login_flags,
        vec![
            Some(false),
            Some(false),
            Some(false),
            Some(false),
            Some(true),
            Some(true),
            Some(true)
        ]
    );
}

#[test]
fn keeps_only_login_aliases_marked_as_login_forms() {
    let source = "\
#!/bin/bash
su -c
su --command
su -m root
su -- root
su root -s /bin/sh
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let su_login_flags = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("su"))
        .map(|fact| fact.options().su().map(|su| su.has_login_flag()))
        .collect::<Vec<_>>();

    assert_eq!(
        su_login_flags,
        vec![
            Some(false),
            Some(false),
            Some(false),
            Some(false),
            Some(false)
        ]
    );
}

#[test]
fn summarizes_first_nonportable_sh_builtin_option_words() {
    let source = "\
#!/bin/sh
read -r name
read -p prompt name
read -\"$mode\" name
printf -v out '%s' foo
printf -- -v out
export -p
export -fn foo
command export -fn foo
trap -p EXIT
trap -- -p EXIT
wait -n
wait -p jobid -n
ulimit -f
ulimit -n
type -P printf
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Bash,
        ShellDialect::Sh,
        |_, facts| {
            let spans = facts
                .commands()
                .iter()
                .filter_map(|fact| {
                    fact.options()
                        .nonportable_sh_builtin_option_span()
                        .map(|span| span.slice(source))
                })
                .collect::<Vec<_>>();

            assert_eq!(
                spans,
                vec![
                    "-p",
                    "-\"$mode\"",
                    "-v",
                    "-fn",
                    "-fn",
                    "-p",
                    "-n",
                    "-p",
                    "-n",
                    "-P"
                ]
            );
        },
    );
}

#[test]
fn set_command_flags_without_prefix_ignore_quoted_literals() {
    let source = "\
set foo bar
set \"foo\" bar
set f\"oo\" bar
set 'foo' bar
";

    with_facts(source, None, |_, facts| {
        let set_without_prefix_spans = facts
            .commands()
            .iter()
            .filter(|fact| fact.effective_name_is("set"))
            .filter_map(|fact| fact.options().set())
            .flat_map(|set| set.flags_without_prefix_spans().iter().copied())
            .collect::<Vec<_>>();

        assert_eq!(
            set_without_prefix_spans
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["foo"]
        );
    });
}

#[test]
fn summarizes_directory_change_commands_and_errexit_hints() {
    let source = "\n#!/bin/bash -eu\ncd ../..\ncd -\nbuiltin cd /\npushd ..\npopd\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert!(facts.source_facts().errexit_enabled_anywhere());

    let directory_changes = facts
        .commands()
        .iter()
        .filter_map(|fact| {
            fact.options().directory_change().map(|directory_change| {
                (
                    directory_change.command_name(),
                    directory_change.is_plain_directory_stack_marker(),
                    directory_change.is_manual_restore_candidate(),
                    fact.wrappers().to_vec(),
                )
            })
        })
        .collect::<Vec<_>>();

    assert_eq!(
        directory_changes,
        vec![
            ("cd", true, false, vec![]),
            ("cd", false, true, vec![]),
            ("cd", false, false, vec![WrapperKind::Builtin]),
            ("pushd", true, false, vec![]),
            ("popd", false, false, vec![])
        ]
    );
}

#[test]
fn does_not_treat_long_shebang_options_as_errexit() {
    let source = "#!/bin/bash --noprofile\ncd /tmp\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert!(!facts.source_facts().errexit_enabled_anywhere());
}

#[test]
fn does_not_treat_indented_shebang_options_as_errexit() {
    let source = "  #!/bin/bash -e\ncd /tmp\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    assert!(!facts.source_facts().errexit_enabled_anywhere());
}

#[test]
fn keeps_read_raw_input_when_option_flags_are_dynamic() {
    let source = "#!/bin/bash\nbuiltin read -${_read_char_flag} 1 -s -r anykey\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let read = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("read"))
        .expect("expected dynamic-option read fact");

    assert_eq!(read.wrappers(), &[WrapperKind::Builtin]);
    assert_eq!(
        read.options().read().map(|read| read.uses_raw_input),
        Some(true)
    );
}

#[test]
fn resolves_sudo_family_invokers_through_outer_wrappers() {
    let source = "#!/bin/bash\ncommand sudo tee out.txt\ncommand run0 tee out.txt\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let invokers = facts
        .commands()
        .iter()
        .filter_map(|fact| fact.options().sudo_family().map(|sudo| sudo.invoker))
        .collect::<Vec<_>>();

    assert_eq!(
        invokers,
        vec![SudoFamilyInvoker::Sudo, SudoFamilyInvoker::Run0]
    );
}

#[test]
fn resolves_sudo_family_invokers_when_wrapper_names_are_escaped() {
    let source = "#!/bin/bash\n\\command \\doas tee out.txt\n\\command \\sudo tee out.txt\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let invokers = facts
        .commands()
        .iter()
        .filter_map(|fact| fact.options().sudo_family().map(|sudo| sudo.invoker))
        .collect::<Vec<_>>();

    assert_eq!(
        invokers,
        vec![SudoFamilyInvoker::Doas, SudoFamilyInvoker::Sudo]
    );
}

#[test]
fn resolves_sudo_family_invokers_when_wrapper_target_is_unresolved() {
    let source = "\
#!/bin/bash
sudo \"$tool\" > out.txt
sudo -V
command run0 --version
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let invokers = facts
        .commands()
        .iter()
        .filter_map(|fact| fact.options().sudo_family().map(|sudo| sudo.invoker))
        .collect::<Vec<_>>();

    assert_eq!(
        invokers,
        vec![
            SudoFamilyInvoker::Sudo,
            SudoFamilyInvoker::Sudo,
            SudoFamilyInvoker::Run0,
        ]
    );
}

#[test]
fn parses_long_xargs_null_mode_and_numeric_exit_status() {
    let source = "#!/bin/bash\nfind . -print0 | xargs --null rm\nexit 42\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let xargs = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("xargs"))
        .and_then(|fact| fact.options().xargs())
        .expect("expected xargs facts");
    assert!(xargs.uses_null_input);
    assert!(xargs.inline_replace_options().is_empty());

    let exit = facts
        .commands()
        .iter()
        .find(|fact| fact.options().exit().is_some())
        .and_then(|fact| fact.options().exit())
        .expect("expected exit facts");
    assert_eq!(
        exit.status_word.map(|word| word.span.slice(source)),
        Some("42")
    );
    assert!(exit.has_static_status());
    assert!(exit.is_numeric_literal);
    assert!(!exit.has_invalid_status_argument());
}

#[test]
fn parses_mixed_and_pure_dynamic_exit_status_shapes() {
    let source = "\
#!/bin/bash
code=3
other=4
exit \"message $code\"
exit \"123$code\"
exit \"$code\"
exit \"${code}${other}\"
exit \"$(printf '%s' 3)\"
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let exits = facts
        .commands()
        .iter()
        .filter_map(|fact| fact.options().exit())
        .collect::<Vec<_>>();

    assert_eq!(
        exits
            .iter()
            .map(|exit| exit.status_word.map(|word| word.span.slice(source)))
            .collect::<Vec<_>>(),
        vec![
            Some("\"message $code\""),
            Some("\"123$code\""),
            Some("\"$code\""),
            Some("\"${code}${other}\""),
            Some("\"$(printf '%s' 3)\""),
        ]
    );
    assert_eq!(
        exits
            .iter()
            .map(|exit| exit.has_invalid_status_argument())
            .collect::<Vec<_>>(),
        vec![true, true, false, false, false]
    );
}

#[test]
fn keeps_parsing_xargs_flags_after_optional_argument_forms() {
    let source = "\
#!/bin/bash
find . -print0 | xargs -l -0 rm
find . -print0 | xargs --eof --null rm
xargs -i0 echo
xargs -i bash -c 'echo {}'
xargs -0i echo '-----> Configuring {}'
xargs -i echo \"-----> Configuring {} with $template\"
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let xargs_facts = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("xargs"))
        .filter_map(|fact| fact.options().xargs())
        .collect::<Vec<_>>();

    assert_eq!(xargs_facts.len(), 6);
    assert!(xargs_facts[0].uses_null_input);
    assert!(xargs_facts[1].uses_null_input);
    assert!(!xargs_facts[2].uses_null_input);
    assert_eq!(
        xargs_facts[2]
            .inline_replace_option_spans()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["-i0"]
    );
    assert_eq!(
        xargs_facts[3]
            .inline_replace_option_spans()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["-i"]
    );
    assert_eq!(
        xargs_facts[4]
            .inline_replace_option_spans()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["-0i"]
    );
    assert_eq!(
        xargs_facts[5]
            .inline_replace_option_spans()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["-i"]
    );
}

#[test]
fn does_not_consume_null_mode_after_optional_long_eof() {
    let source = "#!/bin/bash\nfind . -print0 | xargs --eof --null rm\n";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let xargs = facts
        .commands()
        .iter()
        .find(|fact| fact.effective_name_is("xargs"))
        .and_then(|fact| fact.options().xargs())
        .expect("expected xargs facts");

    assert!(xargs.uses_null_input);
}

#[test]
fn keeps_parsing_xargs_flags_after_arg_file() {
    let source = "\
#!/bin/bash
find . -print0 | xargs -a inputs -0 rm
xargs -a inputs -iX echo X
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let xargs_facts = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("xargs"))
        .filter_map(|fact| fact.options().xargs())
        .collect::<Vec<_>>();

    assert_eq!(xargs_facts.len(), 2);
    assert!(xargs_facts[0].uses_null_input);
    assert_eq!(
        xargs_facts[1]
            .inline_replace_option_spans()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>(),
        vec!["-iX"]
    );
    assert_eq!(xargs_facts[0].max_procs(), None);
    assert_eq!(xargs_facts[1].max_procs(), None);
}

#[test]
fn parses_xargs_max_procs_values() {
    let source = "\
#!/bin/bash
find . | xargs -P10 echo
find . | xargs -P1 echo
find . | xargs -P 0 echo
find . | xargs --max-procs=10 echo
find . | xargs --max-procs \"$N\" echo
find . | xargs -P11 echo
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let xargs_facts = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("xargs"))
        .filter_map(|fact| fact.options().xargs())
        .collect::<Vec<_>>();

    assert_eq!(xargs_facts.len(), 6);
    assert_eq!(
        xargs_facts
            .iter()
            .map(|xargs| xargs.max_procs())
            .collect::<Vec<_>>(),
        vec![Some(10), Some(1), Some(0), Some(10), None, Some(11)]
    );
    assert_eq!(
        xargs_facts
            .iter()
            .map(|xargs| xargs.has_zero_digit_option_word())
            .collect::<Vec<_>>(),
        vec![true, false, false, true, false, false]
    );
}

#[test]
fn handles_xargs_options_with_missing_required_arguments() {
    let source = "\
#!/bin/bash
ls *.txt | xargs -n
find . | xargs --max-procs
";
    let output = Parser::new(source).parse().unwrap();
    let indexer = Indexer::new(source, &output);
    let semantic = LinterSemanticArtifacts::build(&output.file, source, &indexer);
    let facts = LinterFacts::build(&output.file, source, &semantic, &indexer);

    let xargs_facts = facts
        .commands()
        .iter()
        .filter(|fact| fact.effective_name_is("xargs"))
        .filter_map(|fact| fact.options().xargs())
        .collect::<Vec<_>>();

    assert_eq!(xargs_facts.len(), 2);
    assert!(
        xargs_facts
            .iter()
            .all(|xargs| xargs.command_operand_words().is_empty())
    );
}

#[test]
fn rm_command_facts_track_shellcheck_style_variable_path_hazards() {
    let source = "\
#!/bin/bash
PKG=/pkg
PRGNAM=demo
DESTDIR=/dest
PYDIR=/py
SUFFIX=
LIBDIRSUFFIX=64
rm -rf $PKG/usr
rm -rf $PKG/usr/share/$PRGNAM
rm -rf \"$DESTDIR\"/usr
rm -rf $PKG/usr/{bin,include,libexec,man,share}
rm -rf \"$PKG/$PYDIR/usr\"
rm -rf $PKG/$PYDIR/*
rm -rf \"$DESTDIR\"/${PRGNAM}*
rm -rf \"$DESTDIR\"/usr${SUFFIX}
rm -rf \"$DESTDIR\"/usr${SUFFIX}/$PRGNAM
rm -rf \"$DESTDIR\"/usr/${PRGNAM}*
rm -rf \"$DESTDIR\"/lib/${PRGNAM}*
rm -rf $PKG/$PYDIR/lib*
rm -rf \"$DESTDIR\"/lib*
rm -rf \"$DESTDIR\"/opt
rm -rf \"$DESTDIR\"/opt/$PRGNAM
rm -rf $PKG/usr/share/doc
rm -rf $PKG/usr/share/icons
rm -rf $PKG/usr/doc/$PRGNAM
rm -rf $PKG/usr/lib${LIBDIRSUFFIX}/*.la
rm -rf $PKG/usr/share/$PRGNAM/icons
rm -rf $PKG/opt/$PRGNAM/bin
";

    with_facts(source, None, |_, facts| {
        let rm_spans = facts
            .commands()
            .iter()
            .filter_map(|fact| fact.options().rm())
            .flat_map(|rm| rm.dangerous_path_spans().iter().copied())
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(
            rm_spans,
            vec![
                "$PKG/usr",
                "$PKG/usr/share/$PRGNAM",
                "\"$DESTDIR\"/usr",
                "$PKG/usr/{bin,include,libexec,man,share}",
                "$PKG/usr/{bin,include,libexec,man,share}",
                "\"$PKG/$PYDIR/usr\"",
                "$PKG/$PYDIR/*",
                "\"$DESTDIR\"/${PRGNAM}*",
                "\"$DESTDIR\"/usr${SUFFIX}",
                "\"$DESTDIR\"/usr${SUFFIX}/$PRGNAM",
                "\"$DESTDIR\"/usr/${PRGNAM}*",
                "\"$DESTDIR\"/lib/${PRGNAM}*",
            ]
        );
    });
}

#[test]
fn rm_command_facts_match_shellcheck_only_k001_shapes() {
    let source = "\
#!/bin/bash
PKG=/pkg
PRGNAM=demo
ITEM='*.exe'
DESTDIR=/dest
SYSROOT=/target
PACKAGE=/archive
rm -rf /usr/share/$PRGNAM
rm -rf $PKG/usr/share/$PRGNAM/$ITEM
rm -rf $PACKAGE/
rm -rf $PKG/usr/{bin,include,share}
rm -rf ${DESTDIR}/${SYSROOT}/{sbin,etc,var,libexec}
";

    with_facts(source, None, |_, facts| {
        let rm_spans = facts
            .commands()
            .iter()
            .filter_map(|fact| fact.options().rm())
            .flat_map(|rm| rm.dangerous_path_spans().iter().copied())
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(
            rm_spans,
            vec![
                "/usr/share/$PRGNAM",
                "$PKG/usr/share/$PRGNAM/$ITEM",
                "$PACKAGE/",
                "$PKG/usr/{bin,include,share}",
                "$PKG/usr/{bin,include,share}",
                "${DESTDIR}/${SYSROOT}/{sbin,etc,var,libexec}",
                "${DESTDIR}/${SYSROOT}/{sbin,etc,var,libexec}",
            ]
        );
    });
}

#[test]
fn rm_command_facts_flag_literal_system_prefix_globs() {
    let source = "\
#!/bin/bash
rm -rf /usr/*
rm -rf /usr/share/*
";

    with_facts(source, None, |_, facts| {
        let rm_spans = facts
            .commands()
            .iter()
            .filter_map(|fact| fact.options().rm())
            .flat_map(|rm| rm.dangerous_path_spans().iter().copied())
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(rm_spans, vec!["/usr/*", "/usr/share/*"]);
    });
}

#[test]
fn rm_command_facts_track_rootish_targets() {
    let source = "\
#!/bin/sh
dir=/tmp
rm -rf /
rm -rf /*
rm -rf /**
rm -rf /./
rm -rf /./*
rm -rf /..
rm -rf /../*
rm -rf \"$HOME\"
rm -rf \"${HOME}\"/*
rm -rf \"${HOME:?}\"
rm -rf \"${HOME:?}\"/*
rm -rf \"$HOME\"/.
rm -rf \"$HOME\"/./*
rm -rf ~
rm -rf ~/*
rm -rf ~/**
rm -rf ~/.
rm -rf ~/./*
rm -rf ~root/*
rm -f \"$HOME\"/*
rm -rf \"$HOME\"/.cache
rm -rf \"${HOME%/*}\"
rm -rf \"${HOME%%/*}\"
rm -rf \"$HOME\"/..
rm -rf \"$HOME\"/../*
rm -rf ~/..
rm -rf ~/../*
rm -rf ~0
rm -rf ~1/*
rm -rf ~/Downloads/*
rm -rf /tmp/*
rm -rf /tmp/../*
rm -rf ~/tmp/../*
rm -rf ~root/tmp/../*
rm -rf \"$dir\"/*
rm -rf \"~\"/*
rm -rf \"$HOME\"/\"*\"
";

    with_facts(source, None, |_, facts| {
        let rm_spans = facts
            .commands()
            .iter()
            .filter_map(|fact| fact.options().rm())
            .flat_map(|rm| rm.rootish_path_spans(source).iter().copied())
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(
            rm_spans,
            vec![
                "/",
                "/*",
                "/**",
                "/./",
                "/./*",
                "/..",
                "/../*",
                "\"$HOME\"",
                "\"${HOME}\"/*",
                "\"${HOME:?}\"",
                "\"${HOME:?}\"/*",
                "\"$HOME\"/.",
                "\"$HOME\"/./*",
                "~",
                "~/*",
                "~/**",
                "~/.",
                "~/./*",
                "~root/*"
            ]
        );
    });
}

#[test]
fn chmod_command_facts_track_world_writable_sensitive_paths() {
    let source = "\
#!/bin/sh
chmod -R 777 ~/.ssh
chmod o+w \"$HOME/.gnupg\"
chmod a=rw /etc/ssh/sshd_config
chmod =777 ~/.ssh/config
chmod +002 ~/.aws
chmod o-w,o+w ~/.kube
chmod o-w+w ~/.docker
chmod 700 ~/.ssh
chmod 755 ~/.ssh
chmod u+w ~/.ssh/config
chmod +w ~/.ssh/config
chmod o+w,o-w ~/.ssh
chmod o+w-w ~/.docker
chmod a=rw,o-w ~/.gnupg
chmod -002 ~/.aws
chmod 777 ./tmp
chmod 777 /tmp
chmod --reference ref ~/.ssh
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .commands()
            .iter()
            .filter_map(|fact| fact.options().chmod())
            .flat_map(|chmod| {
                chmod
                    .world_writable_sensitive_path_spans(source)
                    .iter()
                    .copied()
            })
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(
            spans,
            vec![
                "~/.ssh",
                "\"$HOME/.gnupg\"",
                "/etc/ssh/sshd_config",
                "~/.ssh/config",
                "~/.aws",
                "~/.kube",
                "~/.docker"
            ]
        );
    });
}

#[test]
fn builds_redirect_facts_with_cached_target_analysis() {
    let source = "#!/bin/bash\necho hi 2>&3 >/dev/null >> \"$((i++))\"\necho hi > \"$((i + 1))\"\n";

    with_facts(source, None, |_, facts| {
        let command = facts
            .command_facts()
            .structural_commands()
            .find(|fact| fact.effective_name_is("echo"))
            .expect("expected echo fact");

        let redirects = command.redirect_facts();
        assert_eq!(redirects.len(), 3);

        let descriptor_dup = &redirects[0];
        assert_eq!(descriptor_dup.operator_span().slice(source), ">&");
        assert!(
            descriptor_dup
                .analysis()
                .is_some_and(|analysis| analysis.is_descriptor_dup())
        );
        assert_eq!(
            descriptor_dup
                .analysis()
                .and_then(|analysis| analysis.numeric_descriptor_target),
            Some(3)
        );

        let dev_null = &redirects[1];
        assert_eq!(dev_null.operator_span().slice(source), ">");
        assert_eq!(
            dev_null.target_span().map(|span| span.slice(source)),
            Some("/dev/null")
        );
        assert!(
            dev_null
                .analysis()
                .is_some_and(|analysis| analysis.is_definitely_dev_null())
        );

        let arithmetic = &redirects[2];
        assert_eq!(arithmetic.operator_span().slice(source), ">>");
        assert_eq!(
            arithmetic.target_span().map(|span| span.slice(source)),
            Some("\"$((i++))\"")
        );
        assert!(
            arithmetic
                .analysis()
                .is_some_and(|analysis| { analysis.expansion.hazards.arithmetic_expansion })
        );
        assert_eq!(
            arithmetic
                .arithmetic_update_operator_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["++"]
        );

        let pure_arithmetic = facts
            .command_facts()
            .structural_commands()
            .filter(|fact| fact.effective_name_is("echo"))
            .nth(1)
            .expect("expected second echo fact");
        let pure_redirect = &pure_arithmetic.redirect_facts()[0];
        assert!(
            pure_redirect
                .analysis()
                .is_some_and(|analysis| { analysis.expansion.hazards.arithmetic_expansion })
        );
        assert!(pure_redirect.arithmetic_update_operator_spans().is_empty());
    });
}

#[test]
fn builds_substitution_facts_with_intent_and_host_kinds() {
    let source = "\
#!/bin/bash
printf '%s\\n' $(printf arg) \"$(printf quoted)\"
local decl_assign=$(printf decl-assign)
name[$(printf assign)]=1
declare arr[$(printf decl-name)]
declare other=$(printf decl-assign-2)
declare -A map=([$(printf key)]=1)
bucket[$(ls | wc -l)]=1
branch[$(if true; then ls | wc -l; fi)]=1
nested[$(echo \"$(ls | wc -l)\")]=1
cat <<<$(printf here)
out=$(printf hi > out.txt)
drop=$(printf hi >/dev/null 2>&1)
quiet=$(printf hi &>/dev/null)
mixed=$(jq -r . <<< \"$status\" || die >&2)
x=$(echo direct)
y=$(foo $(echo nested))
z=$(ls layout.*.h | cut -d. -f2 | xargs echo)
";

    with_facts(source, None, |_, facts| {
        let substitutions = facts
            .commands()
            .iter()
            .flat_map(|fact| fact.substitution_facts().iter().cloned())
            .map(|fact| {
                (
                    fact.span().slice(source).to_owned(),
                    fact.stdout_intent(),
                    fact.host_kind(),
                    fact.unquoted_in_host(),
                    fact.body_contains_ls(),
                    fact.body_contains_echo(),
                )
            })
            .collect::<Vec<_>>();

        assert!(substitutions.contains(&(
            "$(printf arg)".to_owned(),
            SubstitutionOutputIntent::Captured,
            SubstitutionHostKind::CommandArgument,
            true,
            false,
            false,
        )));
        assert!(substitutions.contains(&(
            "$(printf decl-assign)".to_owned(),
            SubstitutionOutputIntent::Captured,
            SubstitutionHostKind::DeclarationAssignmentValue,
            true,
            false,
            false,
        )));
        assert!(substitutions.contains(&(
            "$(printf quoted)".to_owned(),
            SubstitutionOutputIntent::Captured,
            SubstitutionHostKind::CommandArgument,
            false,
            false,
            false,
        )));
        assert!(substitutions.contains(&(
            "$(printf here)".to_owned(),
            SubstitutionOutputIntent::Captured,
            SubstitutionHostKind::HereStringOperand,
            true,
            false,
            false,
        )));
        assert!(substitutions.contains(&(
            "$(printf assign)".to_owned(),
            SubstitutionOutputIntent::Captured,
            SubstitutionHostKind::AssignmentTargetSubscript,
            true,
            false,
            false,
        )));
        assert!(substitutions.contains(&(
            "$(printf decl-name)".to_owned(),
            SubstitutionOutputIntent::Captured,
            SubstitutionHostKind::DeclarationNameSubscript,
            true,
            false,
            false,
        )));
        assert!(substitutions.contains(&(
            "$(printf key)".to_owned(),
            SubstitutionOutputIntent::Captured,
            SubstitutionHostKind::ArrayKeySubscript,
            true,
            false,
            false,
        )));
        assert_eq!(
            facts
                .commands()
                .iter()
                .flat_map(|fact| fact.substitution_facts())
                .find(|fact| fact.span().slice(source) == "$(ls | wc -l)")
                .expect("expected assignment subscript ls pipeline substitution")
                .body_processed_ls_pipeline_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["ls"]
        );
        assert_eq!(
            facts
                .commands()
                .iter()
                .flat_map(|fact| fact.substitution_facts())
                .find(|fact| fact.span().slice(source) == "$(if true; then ls | wc -l; fi)")
                .expect("expected assignment subscript branch ls pipeline substitution")
                .body_processed_ls_pipeline_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["ls"]
        );
        assert_eq!(
            facts
                .commands()
                .iter()
                .flat_map(|fact| fact.substitution_facts())
                .find(|fact| fact.span().slice(source) == "$(echo \"$(ls | wc -l)\")")
                .expect("expected assignment subscript nested ls pipeline substitution")
                .body_processed_ls_pipeline_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["ls"]
        );
        assert!(substitutions.contains(&(
            "$(printf hi > out.txt)".to_owned(),
            SubstitutionOutputIntent::Rerouted,
            SubstitutionHostKind::Other,
            true,
            false,
            false,
        )));
        assert!(substitutions.contains(&(
            "$(printf hi >/dev/null 2>&1)".to_owned(),
            SubstitutionOutputIntent::Discarded,
            SubstitutionHostKind::Other,
            true,
            false,
            false,
        )));
        assert!(substitutions.contains(&(
            "$(printf hi &>/dev/null)".to_owned(),
            SubstitutionOutputIntent::Discarded,
            SubstitutionHostKind::Other,
            true,
            false,
            false,
        )));
        assert!(substitutions.contains(&(
            "$(jq -r . <<< \"$status\" || die >&2)".to_owned(),
            SubstitutionOutputIntent::Mixed,
            SubstitutionHostKind::Other,
            true,
            false,
            false,
        )));
        assert!(substitutions.contains(&(
            "$(echo direct)".to_owned(),
            SubstitutionOutputIntent::Captured,
            SubstitutionHostKind::Other,
            true,
            false,
            true,
        )));
        assert!(substitutions.contains(&(
            "$(foo $(echo nested))".to_owned(),
            SubstitutionOutputIntent::Captured,
            SubstitutionHostKind::Other,
            true,
            false,
            false,
        )));
        assert!(substitutions.contains(&(
            "$(echo nested)".to_owned(),
            SubstitutionOutputIntent::Captured,
            SubstitutionHostKind::CommandArgument,
            true,
            false,
            true,
        )));
        assert!(substitutions.contains(&(
            "$(ls layout.*.h | cut -d. -f2 | xargs echo)".to_owned(),
            SubstitutionOutputIntent::Captured,
            SubstitutionHostKind::Other,
            true,
            true,
            false,
        )));
    });
}

#[test]
fn excludes_output_both_dev_null_redirects_from_c058_fix_spans() {
    let source = "\
#!/bin/bash
drop=$(printf hi >/dev/null 2>&1)
quiet=$(printf hi &>/dev/null)
";

    with_facts(source, None, |_, facts| {
        let substitutions = facts
            .commands()
            .iter()
            .flat_map(|fact| fact.substitution_facts().iter())
            .collect::<Vec<_>>();

        let drop = substitutions
            .iter()
            .copied()
            .find(|fact| fact.span().slice(source) == "$(printf hi >/dev/null 2>&1)")
            .expect("expected explicit redirect substitution fact");
        assert_eq!(
            drop.stdout_dev_null_redirect_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec![">/dev/null"]
        );

        let quiet = substitutions
            .iter()
            .copied()
            .find(|fact| fact.span().slice(source) == "$(printf hi &>/dev/null)")
            .expect("expected output-both substitution fact");
        assert!(quiet.stdout_redirect_spans().is_empty());
        assert!(quiet.stdout_dev_null_redirect_spans().is_empty());
    });
}

#[test]
fn uses_pipeline_tail_redirects_for_substitution_output_intent() {
    let source = "\
#!/bin/sh
out=$(printf '%s\\n' \"$pkg\" | grep '^ok$' >/dev/null 2>&1)
";

    with_facts(source, None, |_, facts| {
        let substitution = facts
            .commands()
            .iter()
            .flat_map(|fact| fact.substitution_facts().iter())
            .find(|fact| {
                fact.span().slice(source)
                    == "$(printf '%s\\n' \"$pkg\" | grep '^ok$' >/dev/null 2>&1)"
            })
            .expect("expected pipeline substitution fact");

        assert_eq!(
            substitution.stdout_intent(),
            SubstitutionOutputIntent::Discarded
        );
        assert_eq!(
            substitution
                .stdout_dev_null_redirect_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec![">/dev/null"]
        );
    });
}

#[test]
fn outer_stderr_capture_keeps_grouped_substitution_output_captured() {
    let source = "\
#!/bin/sh
error=$({ printf '%s\\n' boom >/dev/null; } 2>&1)
";

    with_facts(source, None, |_, facts| {
        let substitution = facts
            .commands()
            .iter()
            .flat_map(|fact| fact.substitution_facts().iter())
            .find(|fact| fact.span().slice(source) == "$({ printf '%s\\n' boom >/dev/null; } 2>&1)")
            .expect("expected grouped substitution fact");

        assert_eq!(
            substitution.stdout_intent(),
            SubstitutionOutputIntent::Captured
        );
        assert!(substitution.stdout_redirect_spans().is_empty());
        assert!(substitution.stdout_dev_null_redirect_spans().is_empty());
    });
}

#[test]
fn treats_stderr_capture_before_stdout_redirect_as_captured_substitution_output() {
    let source = "#!/bin/sh\nchoice=$(printf hi 2>&1 >/dev/tty)\n";

    with_facts(source, None, |_, facts| {
        let substitution = facts
            .commands()
            .iter()
            .flat_map(|fact| fact.substitution_facts().iter())
            .find(|fact| fact.span().slice(source) == "$(printf hi 2>&1 >/dev/tty)")
            .expect("expected substitution fact");

        assert_eq!(
            substitution.stdout_intent(),
            SubstitutionOutputIntent::Captured
        );
        assert_eq!(
            substitution
                .stdout_redirect_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec![">/dev/tty"]
        );
    });
}

#[test]
fn applies_outer_compound_redirects_to_substitution_output() {
    let source = "\
#!/bin/sh
quiet=$({ printf hi; } >/dev/null)
shown=$({ printf hi; } >/dev/tty)
";

    with_facts(source, None, |_, facts| {
        let quiet = facts
            .commands()
            .iter()
            .flat_map(|fact| fact.substitution_facts().iter())
            .find(|fact| fact.span().slice(source) == "$({ printf hi; } >/dev/null)")
            .expect("expected grouped substitution fact");
        let shown = facts
            .commands()
            .iter()
            .flat_map(|fact| fact.substitution_facts().iter())
            .find(|fact| fact.span().slice(source) == "$({ printf hi; } >/dev/tty)")
            .expect("expected grouped substitution fact");

        assert_eq!(quiet.stdout_intent(), SubstitutionOutputIntent::Discarded);
        assert_eq!(
            quiet
                .stdout_redirect_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec![">/dev/null"]
        );
        assert_eq!(
            quiet
                .stdout_dev_null_redirect_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec![">/dev/null"]
        );

        assert_eq!(shown.stdout_intent(), SubstitutionOutputIntent::Rerouted);
        assert_eq!(
            shown
                .stdout_redirect_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec![">/dev/tty"]
        );
        assert!(shown.stdout_dev_null_redirect_spans().is_empty());
    });
}

#[test]
fn leaves_inner_compound_redirects_out_of_substitution_output_warnings() {
    let source = "\
#!/bin/sh
file=$({ printf hi >out.txt; })
fd=$({ printf hi >&5; })
";

    with_facts(source, None, |_, facts| {
        let substitutions = facts
            .commands()
            .iter()
            .flat_map(|fact| fact.substitution_facts().iter())
            .collect::<Vec<_>>();

        for substitution in substitutions {
            assert_eq!(
                substitution.stdout_intent(),
                SubstitutionOutputIntent::Captured
            );
            assert!(substitution.stdout_redirect_spans().is_empty());
            assert!(substitution.stdout_dev_null_redirect_spans().is_empty());
        }
    });
}

#[test]
fn builds_docker_ps_substitution_facts_without_pgrep_exemptions() {
    let source = "\
#!/bin/bash
docker inspect -f '{{ if ne \"true\" (index .Config.Labels \"com.dokku.devcontainer\") }}{{.ID}} {{ end }}' $(docker ps -q)
";

    with_facts(source, None, |_, facts| {
        let substitution = facts
            .commands()
            .iter()
            .flat_map(|fact| fact.substitution_facts().iter())
            .find(|fact| fact.span().slice(source) == "$(docker ps -q)")
            .expect("expected docker ps substitution fact");

        assert_eq!(
            substitution.host_kind(),
            SubstitutionHostKind::CommandArgument
        );
        assert!(substitution.unquoted_in_host());
        assert!(substitution.body_has_commands());
        assert!(!substitution.body_is_pgrep_lookup());
        assert!(!substitution.body_is_seq_utility());
    });
}

#[test]
fn tracks_backtick_syntax_in_substitution_facts() {
    let source = "\
#!/bin/sh
printf '%s\\n' `date` $(uname) <(cat /etc/hosts)
";

    with_facts(source, None, |_, facts| {
        let substitutions = facts
            .commands()
            .iter()
            .flat_map(|fact| fact.substitution_facts().iter().cloned())
            .map(|fact| {
                (
                    fact.span().slice(source).to_owned(),
                    fact.command_syntax(),
                    fact.uses_backtick_syntax(),
                )
            })
            .collect::<Vec<_>>();

        assert!(substitutions.contains(&(
            "`date`".to_owned(),
            Some(CommandSubstitutionSyntax::Backtick),
            true,
        )));
        assert!(substitutions.contains(&(
            "$(uname)".to_owned(),
            Some(CommandSubstitutionSyntax::DollarParen),
            false,
        )));
        assert!(substitutions.contains(&("<(cat /etc/hosts)".to_owned(), None, false,)));
    });
}

#[test]
fn builds_recovered_nested_command_substitutions_without_duplicate_lookup_panics() {
    let source = "x=${(#} {($(e";

    with_facts_dialect(
        source,
        None,
        shuck_parser::parser::ShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            assert!(
                facts
                    .commands()
                    .iter()
                    .any(|fact| fact.span().slice(source) == "e"),
                "expected the recovered command substitution body to stay discoverable",
            );
        },
    );
}
