use super::*;
use crate::{
    FieldSplittingBehavior, GlobDotBehavior, GlobFailureBehavior,
    IndexedArrayReferenceFragmentFact, PathnameExpansionBehavior, PatternOperatorBehavior,
    PlainUnindexedArrayReferenceFact, SubscriptIndexBehavior,
};

fn indexed_array_reference_parts<'a>(
    fragment: &IndexedArrayReferenceFragmentFact,
    source: &'a str,
) -> (&'a str, bool, SubscriptIndexBehavior) {
    match fragment {
        IndexedArrayReferenceFragmentFact::OneBased(fragment) => (
            fragment.span().slice(source),
            fragment.is_plain(),
            SubscriptIndexBehavior::OneBased,
        ),
        IndexedArrayReferenceFragmentFact::ZeroBased(fragment) => (
            fragment.span().slice(source),
            fragment.is_plain(),
            SubscriptIndexBehavior::ZeroBased,
        ),
        IndexedArrayReferenceFragmentFact::OneBasedWithZeroAlias(fragment) => (
            fragment.span().slice(source),
            fragment.is_plain(),
            SubscriptIndexBehavior::OneBasedWithZeroAlias,
        ),
        IndexedArrayReferenceFragmentFact::Ambiguous(fragment) => (
            fragment.span().slice(source),
            fragment.is_plain(),
            SubscriptIndexBehavior::Ambiguous,
        ),
    }
}

#[test]
fn builds_surface_fragment_facts_and_static_utility_names() {
    let source = "\
#!/bin/bash
echo \"prefix `date` suffix\"
echo \"$[1 + 2]\"
arr[$10]=1
declare other[$10]=1
echo \"$(( x $1 y ))\"
PS4='$prompt'
command jq '$__loc__'
test -v '$name'
printf '%s\n' $'tab\t'
echo $\"Usage: $0 {start|stop}\"
printf '%s\n' \"${!name}\" \"${!arr[*]}\"
printf '%s\n' \"${arr[0]}\" \"${arr[@]}\" \"${arr[*]}\" \"${#arr[0]}\" \"${#arr[@]}\" \"${arr[0]%x}\" \"${arr[0]:2}\" \"${arr[0]//x/y}\" \"${arr[0]:-fallback}\" \"${!arr[0]}\"
printf '%s\n' \"${name:2}\" \"${1:1}\" \"${name::2}\" \"${@:1}\" \"${*:1:2}\" \"${arr[@]:1}\" \"${arr[0]:1}\"
printf '%s\n' \"${@:-fallback}\" \"${name:-$10}\"
printf '%s\n' \"${name:-${@}}\"
printf '%s\n' \"${name^}\" \"${name^^pattern}\" \"${name,}\" \"${arr[0]^^}\" \"${arr[@],,}\" \"${!name^^}\" \"${name//x/y}\"
printf '%s\n' \"${name/a/b}\" \"${name//a}\" \"${arr[0]//a/b}\" \"${arr[@]/a/b}\" \"${arr[*]//a}\" \"${!name//a/b}\"
if [ \"$(dpkg-query -W -f '${db:Status-Status}\\n' package 2>/dev/null)\" != \"installed\" ]; then :; fi
cat <<EOF
Expected: '${expected_commit::7}'
#define LAST_COMMIT_POSITION \"2311 ${GN_COMMIT:0:12}\"
Replacement: '${name//before/after}'
EOF
printf '%s\\n' 123 | command kill -9
echo \"#!/bin/bash
if [[ \"$@\" =~ x ]]; then :; fi
\"
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .backtick_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec!["`date`"]
        );
        assert_eq!(
            facts
                .words()
                .legacy_arithmetic_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec!["$[1 + 2]"]
        );
        assert_eq!(
            facts
                .words()
                .positional_parameter_fragments()
                .iter()
                .map(|fragment| {
                    (
                        fragment.span().slice(source),
                        fragment.kind(),
                        fragment.is_above_nine(),
                        fragment.is_guarded(),
                    )
                })
                .collect::<Vec<_>>(),
            vec![
                (
                    "$10",
                    PositionalParameterFragmentKind::AboveNine,
                    true,
                    false
                ),
                (
                    "$10",
                    PositionalParameterFragmentKind::AboveNine,
                    true,
                    false
                ),
                (
                    "${@:1}",
                    PositionalParameterFragmentKind::General,
                    false,
                    false
                ),
                (
                    "${*:1:2}",
                    PositionalParameterFragmentKind::General,
                    false,
                    false
                ),
                (
                    "${@:-fallback}",
                    PositionalParameterFragmentKind::General,
                    false,
                    true
                ),
                (
                    "$10",
                    PositionalParameterFragmentKind::AboveNine,
                    true,
                    true
                ),
                (
                    "${@}",
                    PositionalParameterFragmentKind::General,
                    false,
                    true
                ),
            ]
        );
        assert_eq!(
            facts
                .words()
                .open_double_quote_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec![""]
        );
        assert_eq!(
            facts
                .words()
                .open_double_quote_fragments()
                .iter()
                .map(|fragment| fragment.replacement_span().slice(source))
                .collect::<Vec<_>>(),
            vec!["\"#!/bin/bash\nif [[ \"$@\" =~ x ]]; then :; fi\n\""]
        );
        assert_eq!(
            facts
                .words()
                .open_double_quote_fragments()
                .iter()
                .map(|fragment| fragment.replacement().to_owned())
                .collect::<Vec<_>>(),
            vec!["\"#!/bin/bash\nif [[ ${@} =~ x ]]; then :; fi\n\"".to_owned()]
        );
        assert_eq!(
            facts
                .words()
                .suspect_closing_quote_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec![""]
        );
        assert_eq!(facts.words().positional_parameter_operator_spans().len(), 1);
        let operator_span = facts.words().positional_parameter_operator_spans()[0];
        assert_eq!(operator_span.start.line(), 6);
        assert_eq!(operator_span.start.column(), 7);
        assert_eq!(operator_span.end, operator_span.start);

        let single_quoted = facts
            .words()
            .single_quoted_fragments()
            .iter()
            .map(|fragment| {
                (
                    fragment.span().slice(source).to_owned(),
                    fragment.dollar_quoted(),
                    fragment.command_name().map(str::to_owned),
                    fragment.assignment_target().map(str::to_owned),
                    fragment.variable_set_operand(),
                )
            })
            .collect::<Vec<_>>();
        assert!(single_quoted.iter().any(
            |(text, _, _, assignment_target, variable_set_operand)| {
                text == "'$prompt'"
                    && assignment_target.as_deref() == Some("PS4")
                    && !variable_set_operand
            }
        ));
        assert!(single_quoted.contains(&(
            "'$__loc__'".to_owned(),
            false,
            Some("jq".to_owned()),
            None,
            false,
        )));
        assert!(single_quoted.contains(&(
            "'$name'".to_owned(),
            false,
            Some("test".to_owned()),
            None,
            true,
        )));
        assert!(
            single_quoted
                .iter()
                .any(|(text, dollar_quoted, _, _, variable_set_operand)| {
                    text.starts_with("$'tab") && *dollar_quoted && !variable_set_operand
                })
        );
        assert_eq!(
            facts
                .words()
                .dollar_double_quoted_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec!["$\"Usage: $0 {start|stop}\""]
        );
        assert_eq!(
            facts
                .words()
                .indirect_expansion_fragments()
                .iter()
                .map(|fragment| (fragment.span().slice(source), fragment.array_keys()))
                .collect::<Vec<_>>(),
            vec![
                ("${!name}", false),
                ("${!arr[*]}", true),
                ("${!arr[0]}", false),
                ("${!name//a/b}", false),
            ]
        );
        assert_eq!(
            facts
                .words()
                .indexed_array_reference_fragments()
                .iter()
                .map(|fragment| {
                    let (span, plain, _) = indexed_array_reference_parts(fragment, source);
                    (span, plain)
                })
                .collect::<Vec<_>>(),
            vec![
                ("${!arr[*]}", false),
                ("${arr[0]}", true),
                ("${arr[@]}", true),
                ("${arr[*]}", true),
                ("${#arr[0]}", false),
                ("${#arr[@]}", false),
                ("${arr[0]%x}", false),
                ("${arr[0]:2}", false),
                ("${arr[0]//x/y}", false),
                ("${arr[0]:-fallback}", false),
                ("${!arr[0]}", false),
                ("${arr[@]:1}", false),
                ("${arr[0]:1}", false),
                ("${arr[0]^^}", false),
                ("${arr[@],,}", false),
                ("${arr[0]//a/b}", false),
                ("${arr[@]/a/b}", false),
                ("${arr[*]//a}", false),
            ]
        );
        assert!(
            facts
                .words()
                .zsh_parameter_index_flag_fragments()
                .is_empty(),
            "did not expect quoted-target index facts in the baseline surface fixture"
        );
        assert_eq!(
            facts
                .words()
                .substring_expansion_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec![
                "${name:2}",
                "${1:1}",
                "${name::2}",
                "${@:1}",
                "${*:1:2}",
                "${expected_commit::7}",
                "${GN_COMMIT:0:12}",
            ]
        );
        assert_eq!(
            facts
                .words()
                .case_modification_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec![
                "${name^}",
                "${name^^pattern}",
                "${name,}",
                "${arr[0]^^}",
                "${arr[@],,}",
            ]
        );
        assert_eq!(
            facts
                .words()
                .replacement_expansion_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec![
                "${arr[0]//x/y}",
                "${name//x/y}",
                "${name/a/b}",
                "${name//a}",
                "${arr[0]//a/b}",
                "${arr[@]/a/b}",
                "${arr[*]//a}",
                "${name//before/after}",
            ]
        );

        let jq = facts
            .command_facts()
            .structural_commands()
            .find(|fact| fact.static_utility_name_is("jq"))
            .expect("expected jq command fact");
        assert_eq!(jq.static_utility_name(), Some("jq"));

        let tail = facts
            .command_facts()
            .pipelines()
            .first()
            .and_then(|pipeline| pipeline.last_segment())
            .expect("expected pipeline tail");
        assert_eq!(tail.static_utility_name(), Some("kill"));
        assert!(tail.static_utility_name_is("kill"));
    });
}

#[test]
fn surface_facts_ignore_single_quoted_payload_text_in_expanding_heredocs() {
    let source = "\
#!/bin/sh
cat <<EOF
'$HOME' and '$(pwd)'
EOF
cat <<-EOF
\t'${USER}'
EOF
";

    with_facts(source, None, |_, facts| {
        assert!(
            facts.words().single_quoted_fragments().is_empty(),
            "expected heredoc payload quotes to stay out of single-quoted shell fragments"
        );
    });
}

#[test]
fn surface_facts_keep_command_context_for_here_string_operands() {
    let source = "\
#!/bin/bash
bash --init-file \"${BASH_IT?}/bash_it.sh\" -i <<< '_bash-it-flash-term \"${#BASH_IT_THEME}\" \"${BASH_IT_THEME}\"'
";

    with_facts(source, None, |_, facts| {
        let fragment = facts
            .words()
            .single_quoted_fragments()
            .iter()
            .find(|fragment| {
                fragment
                    .span()
                    .slice(source)
                    .contains("_bash-it-flash-term")
            })
            .expect("expected here-string single-quoted fragment");

        assert_eq!(fragment.command_name(), Some("bash"));
    });
}

#[test]
fn surface_facts_do_not_apply_command_context_to_plain_redirect_targets() {
    let source = "\
#!/bin/bash
bash > '$HOME'
";

    with_facts(source, None, |_, facts| {
        let fragment = facts
            .words()
            .single_quoted_fragments()
            .iter()
            .find(|fragment| fragment.span().slice(source) == "'$HOME'")
            .expect("expected redirect-target single-quoted fragment");

        assert_eq!(fragment.command_name(), None);
    });
}

#[test]
fn surface_facts_mark_single_quoted_backslash_sequences_that_continue_into_literals() {
    let source = "\
#!/bin/sh
grep ^start'\\s'end file.txt
printf '%s\\n' '\\n'foo
printf '%s\\n' 'ab\\n'c
printf '%s\\n' '\\\\n'foo
printf '%s\\n' 'foo\\nbar'
printf '%s\\n' '\\x'41
printf '%s\\n' '\\0'foo
printf '%s\\n' '\\n'_
printf '%s\\n' $'\\n'foo
printf '%s\\n' a'\\'bc
";

    with_facts(source, None, |_, facts| {
        let flagged = facts
            .words()
            .single_quoted_fragments()
            .iter()
            .filter_map(|fragment| {
                fragment
                    .literal_backslash_in_single_quotes_span()
                    .map(|span| {
                        (
                            fragment.span().slice(source),
                            span.start.line(),
                            span.start.column(),
                        )
                    })
            })
            .collect::<Vec<_>>();

        assert_eq!(
            flagged,
            vec![
                ("'\\s'", 2, 15),
                ("'\\n'", 3, 18),
                ("'ab\\n'", 4, 20),
                ("'\\\\n'", 5, 19),
            ]
        );
    });
}

#[test]
fn positional_parameter_surface_facts_ignore_single_digit_suffixes_in_nested_substitutions() {
    let source = r#"#!/bin/sh
eval "$(printf '%s\n' x | "$2_rework")"
eval "$(printf '%s\n' x | "$10_rework")"
"#;

    with_facts(source, None, |_, facts| {
        let above_nine = facts
            .words()
            .positional_parameter_fragments()
            .iter()
            .filter(|fragment| fragment.is_above_nine())
            .map(|fragment| fragment.span().slice(source))
            .collect::<Vec<_>>();

        assert_eq!(above_nine.len(), 1, "fragments: {above_nine:?}");
        assert!(above_nine[0].contains("$10"), "fragments: {above_nine:?}");
        assert!(
            !above_nine
                .iter()
                .any(|fragment| fragment.contains("$2_rework")),
            "fragments: {above_nine:?}"
        );
    });
}

#[test]
fn builds_nested_legacy_arithmetic_fragments_from_surface_words() {
    let source = "\
#!/bin/bash
echo $[$[1 + 2] + 3]
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .legacy_arithmetic_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec!["$[$[1 + 2] + 3]", "$[1 + 2]"]
        );
    });
}

#[test]
fn open_double_quote_surface_facts_track_live_expansion_gaps() {
    let source = "\
#!/bin/bash
echo \"#!/bin/bash

# LLVMFuzzerTestOneInput for fuzzer detection.
this_dir=\\$(dirname \"\\$0\")
if [[ \"\\$@\" =~ (^| )-runs=[0-9]+($| ) ]]
then
  mem_settings='-Xmx1900m:-Xss900k'
else
  mem_settings='-Xmx2048m:-Xss1024k'
fi

LD_LIBRARY_PATH=\"$JVM_LD_LIBRARY_PATH\":\\$this_dir \\
  \\$this_dir/jazzer_driver                        \\
  --agent_path=\\$this_dir/jazzer_agent_deploy.jar \\
  --cp=$RUNTIME_CLASSPATH                         \\
  --target_class=$fuzzer_basename                 \\
  --jvm_args=\"\\$mem_settings\"                     \\
  \\$@\" > $OUT/$fuzzer_basename
";

    with_facts(source, None, |_, facts| {
        let open = facts
            .words()
            .open_double_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();
        let close = facts
            .words()
            .suspect_closing_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();

        assert_eq!(open, vec![(6, 11)]);
        assert_eq!(close, vec![(13, 17)]);
    });
}

#[test]
fn open_double_quote_surface_facts_ignore_escaped_literal_gaps() {
    let source = "\
#!/bin/bash
echo \"#!/bin/bash
# LLVMFuzzerTestOneInput for fuzzer detection.
this_dir=\\$(dirname \"\\$0\")
mem_settings='-Xmx2048m:-Xss1024k'
if [[ \"\\$@\" =~ (^| )-runs=[0-9]+($| ) ]]; then
  mem_settings='-Xmx1900m:-Xss900k'
fi
LD_LIBRARY_PATH=\\\"\\$JVM_LD_LIBRARY_PATH\\\":\\$this_dir \\
\\$this_dir/jazzer_driver --agent_path=\\$this_dir/jazzer_agent_deploy.jar \\
--cp=$RUNTIME_CLASSPATH \\
--target_class=$fuzzer_basename \\
--jvm_args=\"\\$mem_settings\" \\
\"\\$@\"\" > $OUT/$fuzzer_basename
";

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

#[test]
fn open_double_quote_surface_facts_track_literal_gap_fragments() {
    let source = "\
#!/bin/sh
echo \"help text
say \"configure\" now
\"
";

    with_facts(source, None, |_, facts| {
        let open = facts
            .words()
            .open_double_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();
        let close = facts
            .words()
            .suspect_closing_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();

        assert_eq!(open, vec![(2, 6)]);
        assert_eq!(close, vec![(3, 5)]);
        assert_eq!(
            facts.words().open_double_quote_fragments()[0].replacement(),
            "\"help text\nsay configure now\n\""
        );
    });
}

#[test]
fn open_double_quote_surface_facts_keep_assignment_fixes_value_scoped() {
    let source = "\
#!/bin/bash
value='alpha
beta''tail'
";

    with_facts(source, None, |_, facts| {
        let fragment = &facts.words().open_double_quote_fragments()[0];

        assert_eq!(
            fragment.replacement_span().slice(source),
            "'alpha\nbeta''tail'"
        );
        assert_eq!(fragment.replacement(), "\"alpha\nbetatail\"");
    });
}

#[test]
fn open_double_quote_surface_facts_track_backslash_prefixed_literal_gap_fragments() {
    let source = "\
#!/bin/sh
echo \"line one
line two\"\\foo\"tail\"
";

    with_facts(source, None, |_, facts| {
        let open = facts
            .words()
            .open_double_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();
        let close = facts
            .words()
            .suspect_closing_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();

        assert_eq!(open, vec![(2, 6)]);
        assert_eq!(close, vec![(3, 9)]);
    });
}

#[test]
fn open_double_quote_surface_facts_track_multiline_quotes_with_literal_suffix() {
    let source = "\
#!/bin/sh
echo \"\"\"line one
line two\"suffix
";

    with_facts(source, None, |_, facts| {
        let open = facts
            .words()
            .open_double_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();
        let close = facts
            .words()
            .suspect_closing_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();

        assert_eq!(open, vec![(2, 8)]);
        assert_eq!(close, vec![(3, 9)]);
    });
}

#[test]
fn open_double_quote_surface_facts_track_multiline_quotes_with_suffix_expansion() {
    let source = "\
#!/bin/sh
echo \"line one
line two\"$suffix
";

    with_facts(source, None, |_, facts| {
        let open = facts
            .words()
            .open_double_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();
        let close = facts
            .words()
            .suspect_closing_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();

        assert_eq!(open, vec![(2, 6)]);
        assert_eq!(close, vec![(3, 9)]);
    });
}

#[test]
fn open_double_quote_surface_facts_track_empty_prefix_multiline_quotes_with_suffix_expansion() {
    let source = "\
#!/bin/sh
echo \"\"\"line one
line two\"$suffix
";

    with_facts(source, None, |_, facts| {
        let open = facts
            .words()
            .open_double_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();
        let close = facts
            .words()
            .suspect_closing_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();

        assert_eq!(open, vec![(2, 8)]);
        assert_eq!(close, vec![(3, 9)]);
    });
}

#[test]
fn open_double_quote_surface_facts_track_reopened_single_quote_fragments() {
    let source = "\
#!/bin/sh
echo 'line one
line two''tail'
";

    with_facts(source, None, |_, facts| {
        let open = facts
            .words()
            .open_double_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();
        let close = facts
            .words()
            .suspect_closing_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();

        assert_eq!(open, vec![(2, 6)]);
        assert_eq!(close, vec![(3, 9)]);
    });
}

#[test]
fn open_double_quote_surface_facts_track_recovered_single_quoted_assignment_shape() {
    let source = "\
#!/bin/sh
archive_cmds='$CC -o $output_objdir/$soname $libobjs $compiler_flags $deplibs -Wl,-dll~linknames='
archive_expsym_cmds='if test \"x`$SED 1q $export_symbols`\" = xEXPORTS; then
    sed -n -e 's/\\\\\\\\\\\\\\(.*\\\\\\\\\\\\\\)/-link\\\\\\ -EXPORT:\\\\\\\\\\\\\\\u{1}/' -e '1\\\\\\!p' < $export_symbols > $output_objdir/$soname.exp;
  else
    sed -e 's/\\\\\\\\\\\\\\(.*\\\\\\\\\\\\\\)/-link\\\\\\ -EXPORT:\\\\\\\\\\\\\\\u{1}/' < $export_symbols > $output_objdir/$soname.exp;
  fi~
  $CC -o $tool_output_objdir$soname $libobjs $compiler_flags $deplibs \"@$tool_output_objdir$soname.exp\" -Wl,-DLL,-IMPLIB:\"$tool_output_objdir$libname.dll.lib\"~
  linknames='
enable_shared_with_static_runtimes=yes
";

    with_facts(source, None, |_, facts| {
        let open = facts
            .words()
            .open_double_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();
        let close = facts
            .words()
            .suspect_closing_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();

        assert_eq!(open, vec![(3, 21), (4, 75)]);
        assert_eq!(close, vec![(4, 15), (6, 12)]);
    });
}

#[test]
fn open_double_quote_surface_facts_track_each_reopened_fragment_per_word() {
    let source = "\
#!/bin/sh
echo \"help text
say \"configure\" now
then \"install\" later
\"\"\"
";

    with_facts(source, None, |_, facts| {
        let open = facts
            .words()
            .open_double_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();
        let close = facts
            .words()
            .suspect_closing_quote_fragments()
            .iter()
            .map(|fragment| (fragment.span().start.line(), fragment.span().start.column()))
            .collect::<Vec<_>>();

        assert_eq!(open, vec![(2, 6), (3, 15), (4, 14)]);
        assert_eq!(close, vec![(3, 5), (4, 6), (5, 1)]);
    });
}

#[test]
fn builds_double_paren_grouping_spans() {
    let source = "\
#!/bin/sh
((ps aux | grep foo) || kill \"$pid\") 2>/dev/null
(( i += 1 ))
";

    with_facts(source, None, |_, facts| {
        let anchors = facts
            .words()
            .double_paren_grouping_spans()
            .iter()
            .map(|span| {
                (
                    span.start.line(),
                    span.start.column(),
                    span.end.line(),
                    span.end.column(),
                )
            })
            .collect::<Vec<_>>();

        assert_eq!(anchors, vec![(2, 1, 2, 1)]);
    });
}

#[test]
fn builds_unicode_smart_quote_spans_for_unquoted_words() {
    let source = "\
#!/bin/sh
echo “hello”
echo \"hello “world”\"
echo 'hello ‘world’'
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .unicode_smart_quote_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["“", "”"]
        );
    });
}

#[test]
fn builds_unicode_smart_quote_spans_from_source_offsets_for_escaped_literals() {
    let source = "#!/bin/sh\necho “hello\\${”\n";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .unicode_smart_quote_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["“", "”"]
        );
    });
}

#[test]
fn ignores_unicode_smart_quotes_in_heredoc_payloads() {
    let source = "\
#!/bin/sh
cat <<EOF
q { quotes: \"“\" \"”\" \"‘\" \"’\"; }
EOF
";

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

#[test]
fn traces_case_pattern_spans_for_escaped_char_classes() {
    let source = "\
#!/bin/sh
case x in *[!a-zA-Z0-9._/+\\-]*) continue ;; esac
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .pattern_literal_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["*[!a-zA-Z0-9._/+\\-]*"]
        );
        assert!(facts.words().pattern_charclass_spans().is_empty());
    });
}

#[test]
fn marks_suppressed_subscript_references_without_span_scanning() {
    let source = "\
#!/bin/bash
write_target() { map[$assoc_target_id/assoc_target_bare]=value; }
declare -A map
printf '%s\\n' \"${arr[$read_idx]}\" \"${map[$assoc_read_idx]}\" \"$free\"
[[ -v arr[bare_check] ]]
[[ -v arr[$dynamic_check] ]]
arr[bare_target]=value
arr[$dynamic_target]=value
arr+=([bare_key]=value)
arr+=([$dynamic_key]=value)
map+=([assoc_bare_key]=value)
map+=([$assoc_dynamic_key]=value)
write_target
";
    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 reference_span = |name: &str| {
        semantic
            .references()
            .iter()
            .find(|reference| reference.name.as_str() == name)
            .unwrap_or_else(|| panic!("expected {name} reference"))
            .span
    };
    let has_reference = |name: &str| {
        semantic
            .references()
            .iter()
            .any(|reference| reference.name.as_str() == name)
    };

    assert!(
        facts
            .words()
            .is_suppressed_subscript_reference(reference_span("read_idx"))
    );
    assert!(
        facts
            .words()
            .is_suppressed_subscript_reference(reference_span("assoc_read_idx"))
    );
    assert!(
        facts
            .words()
            .is_suppressed_subscript_reference(reference_span("bare_check"))
    );
    assert!(
        facts
            .words()
            .is_suppressed_subscript_reference(reference_span("assoc_bare_key"))
    );
    assert!(!has_reference("assoc_target_bare"));
    assert!(
        !facts
            .words()
            .is_suppressed_subscript_reference(reference_span("dynamic_check"))
    );
    assert!(
        !facts
            .words()
            .is_suppressed_subscript_reference(reference_span("bare_target"))
    );
    assert!(
        !facts
            .words()
            .is_suppressed_subscript_reference(reference_span("dynamic_target"))
    );
    assert!(
        !facts
            .words()
            .is_suppressed_subscript_reference(reference_span("bare_key"))
    );
    assert!(
        !facts
            .words()
            .is_suppressed_subscript_reference(reference_span("dynamic_key"))
    );
    assert!(
        !facts
            .words()
            .is_suppressed_subscript_reference(reference_span("assoc_target_id"))
    );
    assert!(
        !facts
            .words()
            .is_suppressed_subscript_reference(reference_span("assoc_dynamic_key"))
    );
    assert!(
        !facts
            .words()
            .is_suppressed_subscript_reference(reference_span("free"))
    );

    assert!(
        facts
            .words()
            .is_subscript_later_suppression_reference(reference_span("read_idx"))
    );
    assert!(
        facts
            .words()
            .is_subscript_later_suppression_reference(reference_span("assoc_read_idx"))
    );
    assert!(
        !facts
            .words()
            .is_subscript_later_suppression_reference(reference_span("bare_check"))
    );
    assert!(
        !facts
            .words()
            .is_subscript_later_suppression_reference(reference_span("dynamic_check"))
    );
}

#[test]
fn collects_array_index_arithmetic_spans_only_for_indexed_lvalues() {
    let source = "\
#!/bin/bash
arr[$((indexed+1))]=x
declare named[$((declared+1))]=y
declare -A map
map[$((assoc+1))]=z
map[temp_$((mixed+1))]=q
map=([$((compound+1))]=w)
printf '%s\\n' \"${arr[$((read+1))]}\"
[[ -v arr[$((check+1))] ]]
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .array_index_arithmetic_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$((indexed+1))", "$((declared+1))"]
        );
    });
}

#[test]
fn tracks_env_prefix_scope_spans_for_same_command_references() {
    let source = "\
#!/bin/bash
CFLAGS=\"${SLKCFLAGS}\" ./configure --with-optmizer=${CFLAGS}
PATH=/tmp \"$PATH\"/bin/tool
A=1 B=\"$A\" C=\"$B\" cmd
foo=\"$foo\" bar=\"$foo\" cmd
foo=1 export \"$foo\"
foo=1 bar[$foo]=x cmd
FOO=tmp cmd >\"$FOO\"
foo=\"$foo\" cmd
foo=1 cmd \"$(printf %s \"$foo\")\"
foo=1 foo=2 cmd
foo=1 bar=\"$foo\"
COUNTDOWN=$[ $COUNTDOWN - 1 ]
COUNTDOWN=$[ $COUNTDOWN - 1 ] echo \"$COUNTDOWN\"
X=1 A=$[ $X + 1 ] true
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .assignments()
                .env_prefix_assignment_scope_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "CFLAGS",
                "PATH",
                "A",
                "B",
                "foo",
                "foo",
                "foo",
                "FOO",
                "COUNTDOWN",
                "X"
            ]
        );
        assert_eq!(
            facts
                .assignments()
                .env_prefix_expansion_scope_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "${CFLAGS}",
                "$PATH",
                "$A",
                "$B",
                "$foo",
                "$foo",
                "$foo",
                "$FOO",
                "$COUNTDOWN",
                "$X"
            ]
        );
    });
}

#[test]
fn tracks_env_prefix_expansion_spans_for_duplicate_name_self_reference() {
    let source = "\
#!/bin/bash
foo=\"$foo\" foo=2 cmd
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .assignments()
                .env_prefix_expansion_scope_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$foo"]
        );
    });
}

#[test]
fn prompt_expansion_refs_do_not_create_env_prefix_scope_spans() {
    let source = "\
#!/bin/bash
x=1 declare PS1='$x'
x=1 export PS4=\"+ \\${x} \"
";

    with_facts(source, None, |_, facts| {
        assert!(
            facts
                .assignments()
                .env_prefix_assignment_scope_spans()
                .is_empty(),
            "unexpected assignment scope spans: {:?}",
            facts
                .assignments()
                .env_prefix_assignment_scope_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>()
        );
        assert!(
            facts
                .assignments()
                .env_prefix_expansion_scope_spans()
                .is_empty(),
            "unexpected expansion scope spans: {:?}",
            facts
                .assignments()
                .env_prefix_expansion_scope_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>()
        );
    });
}

#[test]
fn builds_word_facts_with_contexts_hosts_and_anchor_spans() {
    let source = "\
#!/bin/bash
case literal in
  @($pat|$(printf '%s' case))) : ;;
esac
trap \"echo $x $(date)\" EXIT
trap - ${signals[@]}
declare declared[$(printf decl-name-subscript)]
declare arr[$(printf decl-subscript)]=\"${name%$suffix}\"
target[$(printf assign-subscript)]=1
declare -A map=([$(printf key-subscript)]=1)
[[ -v assoc[\"$(printf cond-subscript)\"] ]]
printf '%s\\n' prefix${name}suffix ${items[@]}
";

    with_facts(source, None, |_, facts| {
        let case_subject = facts
            .words()
            .case_subject_facts()
            .find(|fact| fact.span().slice(source) == "literal")
            .expect("expected case subject fact");
        assert!(case_subject.is_case_subject());
        assert!(case_subject.classification().is_fixed_literal());

        let trap_action = facts
            .words()
            .expansion_word_facts(ExpansionContext::TrapAction)
            .next()
            .expect("expected trap action fact");
        assert_eq!(
            trap_action
                .double_quoted_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$x", "$(date)"]
        );

        let trap_signal = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .find(|fact| fact.span().slice(source) == "${signals[@]}")
            .expect("expected trap signal argument fact");
        assert_eq!(
            trap_signal
                .unquoted_all_elements_array_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["${signals[@]}"]
        );

        let declaration_name_subscript = facts
            .words()
            .expansion_word_facts(ExpansionContext::DeclarationAssignmentValue)
            .find(|fact| fact.span().slice(source) == "$(printf decl-name-subscript)")
            .expect("expected declaration name subscript fact");
        assert_eq!(
            declaration_name_subscript.host_kind(),
            WordFactHostKind::DeclarationNameSubscript
        );
        assert_eq!(
            declaration_name_subscript
                .command_substitution_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$(printf decl-name-subscript)"]
        );

        let declaration_assignment_subscript = facts
            .words()
            .expansion_word_facts(ExpansionContext::DeclarationAssignmentValue)
            .find(|fact| fact.span().slice(source) == "$(printf decl-subscript)")
            .expect("expected declaration assignment subscript fact");
        assert_eq!(
            declaration_assignment_subscript.host_kind(),
            WordFactHostKind::AssignmentTargetSubscript
        );

        let assignment_subscript = facts
            .words()
            .expansion_word_facts(ExpansionContext::AssignmentValue)
            .find(|fact| fact.span().slice(source) == "$(printf assign-subscript)")
            .expect("expected assignment subscript fact");
        assert_eq!(
            assignment_subscript.host_kind(),
            WordFactHostKind::AssignmentTargetSubscript
        );

        let array_key = facts
            .words()
            .expansion_word_facts(ExpansionContext::DeclarationAssignmentValue)
            .find(|fact| fact.span().slice(source) == "$(printf key-subscript)")
            .expect("expected array key fact");
        assert_eq!(array_key.host_kind(), WordFactHostKind::ArrayKeySubscript);

        let conditional_subscript = facts
            .words()
            .expansion_word_facts(ExpansionContext::ConditionalVarRefSubscript)
            .find(|fact| fact.span().slice(source) == "\"$(printf cond-subscript)\"")
            .expect("expected conditional subscript fact");
        assert_eq!(
            conditional_subscript.host_kind(),
            WordFactHostKind::ConditionalVarRefSubscript
        );

        let parameter_pattern = facts
            .words()
            .expansion_word_facts(ExpansionContext::ParameterPattern)
            .find(|fact| fact.span().slice(source) == "$suffix")
            .expect("expected parameter pattern fact");
        assert!(parameter_pattern.classification().is_expanded());
        assert_eq!(
            facts
                .words()
                .expansion_word_facts(ExpansionContext::ParameterPattern)
                .filter(|fact| fact.span().slice(source) == "$suffix")
                .count(),
            1
        );

        let scalar = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .find(|fact| fact.span().slice(source) == "prefix${name}suffix")
            .expect("expected mixed command argument fact");
        assert!(scalar.has_literal_affixes());
        assert_eq!(
            scalar
                .scalar_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["${name}"]
        );

        let array = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .find(|fact| fact.span().slice(source) == "${items[@]}")
            .expect("expected array argument fact");
        assert_eq!(
            array
                .unquoted_array_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["${items[@]}"]
        );
    });
}

#[test]
fn builds_impossible_case_pattern_spans_from_subject_shape() {
    let source = "\
#!/bin/sh
case \"$words[1]\" in
  install) : ;;
  *\"[1]\") : ;;
esac
case \" $oldobjs \" in
  \" \") : ;;
  \"  \") : ;;
esac
case foo in
  bar) : ;;
esac
case \"x${val}z\" in
  y*) : ;;
  *z) : ;;
esac
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .command_facts()
                .case_pattern_impossible_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["install", "\" \"", "y*"]
        );
    });
}

#[test]
fn word_facts_track_only_literal_command_trailers() {
    let source = "\
#!/bin/bash
\"$PID exists.\"
\"$root/bin/{{\"
\"${loader:?}\"
\"/usr/bin/qemu-${machine}\"
\"$(printf cmd)\"
";

    with_facts(source, None, |_, facts| {
        let trailing_literal_char = |text: &str| {
            facts
                .words()
                .word_facts()
                .find(|fact| fact.span().slice(source) == text)
                .and_then(|fact| fact.trailing_literal_char())
        };

        assert_eq!(trailing_literal_char("\"$PID exists.\""), Some('.'));
        assert_eq!(trailing_literal_char("\"$root/bin/{{\""), Some('{'));
        assert_eq!(trailing_literal_char("\"${loader:?}\""), None);
        assert_eq!(trailing_literal_char("\"/usr/bin/qemu-${machine}\""), None);
        assert_eq!(trailing_literal_char("\"$(printf cmd)\""), None);
    });
}

#[test]
fn word_facts_expose_safe_value_structural_summaries() {
    let source = "\
#!/bin/bash
printf '%s\n' \"$name\" \"${name:-fallback}\" \"${@:1}\" ${@:1} \"$@\" $@
";

    with_facts(source, None, |_, facts| {
        let word_fact = |text: &str| {
            facts
                .words()
                .word_facts()
                .find(|fact| fact.span().slice(source) == text)
                .expect("expected word fact")
        };

        let plain_name = word_fact("\"$name\"");
        assert_eq!(
            plain_name
                .safe_value_plain_scalar_reference_name()
                .map(|name| name.as_str()),
            Some("name")
        );
        assert!(!plain_name.safe_value_special_parameter_access());
        assert!(!plain_name.safe_value_contains_special_parameter_slice());

        let defaulted = word_fact("\"${name:-fallback}\"");
        assert_eq!(defaulted.safe_value_plain_scalar_reference_name(), None);

        let quoted_slice = word_fact("\"${@:1}\"");
        assert!(!quoted_slice.safe_value_contains_special_parameter_slice());

        let unquoted_slice = word_fact("${@:1}");
        assert!(unquoted_slice.safe_value_contains_special_parameter_slice());

        let quoted_special = word_fact("\"$@\"");
        assert!(quoted_special.safe_value_special_parameter_access());
        assert_eq!(
            quoted_special.safe_value_plain_scalar_reference_name(),
            None
        );

        let unquoted_special = word_fact("$@");
        assert!(unquoted_special.safe_value_special_parameter_access());
    });
}

#[test]
fn word_facts_report_arithmetic_expansion_hazards() {
    let source = "\
#!/bin/bash
printf '%s\n' \"prefix $((x + 1)) suffix\" plain
";

    with_facts(source, None, |_, facts| {
        let arithmetic = facts
            .words()
            .word_facts()
            .find(|fact| fact.span().slice(source) == "\"prefix $((x + 1)) suffix\"")
            .expect("expected arithmetic word fact");
        let plain = facts
            .words()
            .word_facts()
            .find(|fact| fact.span().slice(source) == "plain")
            .expect("expected plain word fact");

        assert!(arithmetic.has_arithmetic_expansion());
        assert!(!plain.has_arithmetic_expansion());
    });
}

#[test]
fn builds_case_pattern_expansion_spans_for_simple_dynamic_patterns() {
    let source = "\
#!/bin/sh
case $value in
  $pat) : ;;
  x$pat) : ;;
  $(printf '%s' foo)) : ;;
  \"$left\"$right) : ;;
esac
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .command_facts()
                .case_pattern_expansions()
                .iter()
                .map(|fact| (fact.span().slice(source), fact.replacement().to_owned()))
                .collect::<Vec<_>>(),
            vec![
                ("$pat", "\"${pat}\"".to_owned()),
                ("x$pat", "\"x${pat}\"".to_owned()),
                ("$(printf '%s' foo)", "\"$(printf '%s' foo)\"".to_owned()),
                ("\"$left\"$right", "\"${left}${right}\"".to_owned()),
            ]
        );
    });
}

#[test]
fn ignores_case_pattern_expansions_when_real_globs_are_present() {
    let source = "\
#!/bin/bash
case $value in
  gm$MAMEVER*) : ;;
  *${IDN_ITEM}*) : ;;
  ${pat}*) : ;;
  *${pat}) : ;;
  x${pat}*) : ;;
  [$hex]) : ;;
  @($pat|bar)) : ;;
  x$left@(foo|bar)) : ;;
esac
";

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

#[test]
fn collects_dollar_spans_for_wrapped_substring_offset_arithmetic() {
    let source = "#!/bin/bash\nrest=abcdef\nlen=2\nprintf '%s\\n' \"${rest:$((${#rest}-$len))}\"\n";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();
        let words = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .map(|fact| {
                format!(
                    "{} {:?} {:?}",
                    fact.span().slice(source),
                    fact.host_kind(),
                    fact.word().parts
                )
            })
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["$len"], "command words: {words:?}");
    });
}

#[test]
fn collects_dollar_spans_for_wrapped_substring_length_arithmetic() {
    let source =
        "#!/bin/bash\nstring=abcdef\nwidth=10\nprintf '%s\\n' \"${string:0:$(( $width - 4 ))}\"\n";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();
        let words = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .map(|fact| {
                format!(
                    "{} {:?} {:?}",
                    fact.span().slice(source),
                    fact.host_kind(),
                    fact.word().parts
                )
            })
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["$width"], "command words: {words:?}");
    });
}

#[test]
fn ignores_plain_substring_offset_parameter_expansions_for_dollar_in_arithmetic() {
    let source = "#!/bin/bash\nrest=abcdef\nlen=2\nprintf '%s\\n' \"${rest:${len}:1}\"\n";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn ignores_plain_positional_slice_parameter_expansions_for_dollar_in_arithmetic() {
    let source = "#!/bin/bash\nargs_offset=$#\nprintf '%s\\n' \"${@:1:${args_offset}}\"\n";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn collects_dollar_spans_for_parameter_replacement_arithmetic() {
    let source = "#!/bin/bash\noffset=1\nindex=2\nline=x\necho \"${line/ $index / $(($offset + $index)) }\"\n";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();
        let words = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .map(|fact| {
                format!(
                    "{} {:?} {:?}",
                    fact.span().slice(source),
                    fact.host_kind(),
                    fact.word().parts
                )
            })
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["$offset", "$index"], "command words: {words:?}");
    });
}

#[test]
fn collects_dollar_spans_for_simple_subscripted_parameter_accesses_in_arithmetic() {
    let source = "\
#!/bin/bash
declare -a arr
declare -A assoc
(( ${arr[0]} + ${arr[i]} + ${assoc[key]} ))
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["${arr[0]}", "${arr[i]}", "${assoc[key]}"]);
    });
}

#[test]
fn collects_dollar_spans_for_wrapped_substring_offset_with_simple_subscripted_access() {
    let source = "\
#!/bin/bash
arr=(0 1)
rest=abcdef
printf '%s\\n' \"${rest:$((${arr[0]}))}\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["${arr[0]}"]);
    });
}

#[test]
fn collects_dollar_spans_for_indexed_assignment_subscripts() {
    let source = "\
#!/bin/bash
declare -a arr
i=1
lang=en
arr[$i]=x
arr[$i+1]=y
arr[$i/repo_dir]=z
arr[${lang},27]=q
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["$i", "$i", "$i", "${lang}"]);
    });
}

#[test]
fn ignores_associative_assignment_subscripts_for_dollar_in_arithmetic() {
    let source = "\
#!/bin/bash
declare -A assoc
key=name
lang=en
assoc[$key]=x
assoc[${lang},27]=y
assoc[$key/sfx]=z
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn ignores_quoted_indexed_assignment_subscripts_for_dollar_in_arithmetic() {
    let source = "\
#!/bin/bash
declare -a arr
wash_counter=1
arr[\"${wash_counter}\"]=x
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn ignores_command_substitutions_in_indexed_assignment_subscripts_for_dollar_in_arithmetic() {
    let source = "\
#!/bin/bash
declare -a arr
i=file
arr[$(printf '%s' \"$i\")]=x
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn ignores_multi_declared_associative_append_assignment_subscripts_for_dollar_in_arithmetic() {
    let source = "\
#!/bin/bash
declare -A one=() two=() seen=()
key=name
one[$key]+=x
two[$key]+=y
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn ignores_associative_appends_after_parameter_default_subscripts_for_dollar_in_arithmetic() {
    let source = "\
#!/bin/bash
declare -A map=() other=()
key=name
: \"${map[$key]:=}\"
map[$key]+=$'\\n'
map[$key]+=\"${values[*]}\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn ignores_globally_declared_associative_assignment_subscripts_for_dollar_in_arithmetic() {
    let source = "\
#!/bin/bash
init() {
  declare -gA map
}
helper() {
  map[$key]=1
}
main() {
  key=name
  init
  helper
}
main
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn ignores_globally_declared_associative_assignment_subscripts_with_combined_flags() {
    let source = "\
#!/bin/bash
init() {
  declare -Ag map=()
}
helper() {
  map[$key/field]=1
}
main() {
  key=name
  init
  helper
}
main
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn ignores_dynamic_scope_associative_assignment_subscripts_for_dollar_in_arithmetic() {
    let source = "\
#!/bin/bash
helper() {
  map[${key}]=1
}
wrapper() {
  helper
}
main() {
  local key=name
  declare -A map
  wrapper
}
main
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn reports_shadowing_local_subscripts_even_when_callers_have_assoc_bindings() {
    let source = "\
#!/bin/bash
helper() {
  local map
  map[$key]=1
}
main() {
  local key=name
  declare -A map
  helper
}
main
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["$key"]);
    });
}

#[test]
fn reports_caller_local_shadowing_after_assoc_declaration_for_dollar_in_arithmetic() {
    let source = "\
#!/bin/bash
helper() {
  map[$key]=1
}
main() {
  local key=name
  declare -A map
  unset map
  local map
  helper
}
main
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["$key"]);
    });
}

#[test]
fn ignores_repeated_dynamic_scope_associative_assignment_subscripts_for_dollar_in_arithmetic() {
    let source = "\
#!/bin/bash
helper() {
  map[${key}]=1
  map[${other}]=2
}
main() {
  local key=alpha
  local other=beta
  declare -A map
  helper
}
main
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn reports_wrapper_shadowing_local_subscripts_even_when_outer_callers_have_assoc_bindings() {
    let source = "\
#!/bin/bash
helper() {
  map[$key]=1
}
wrapper() {
  local map
  helper
}
main() {
  local key=name
  declare -A map
  wrapper
}
main
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["$key"]);
    });
}

#[test]
fn ignores_associative_declaration_initializer_subscripts_for_dollar_in_arithmetic() {
    let source = "\
#!/bin/bash
declare -A map=([$key]=1)
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn collects_dollar_spans_for_nested_arithmetic_in_array_access_subscripts() {
    let source = "\
#!/bin/bash
declare -a tools
choice=2
printf '%s\\n' \"${tools[$(($choice-1))]}\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["$choice"]);
    });
}

#[test]
fn collects_dollar_spans_for_nested_arithmetic_in_associative_assignment_subscripts() {
    let source = "\
#!/bin/bash
declare -A assoc
choice=2
assoc[$(($choice-1))]=x
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["$choice"]);
    });
}

#[test]
fn collects_command_substitution_spans_for_wrapped_substring_offset_arithmetic() {
    let source = "#!/bin/bash\nrest=abcdef\nprintf '%s\\n' \"${rest:$((${#rest}-$(printf 1)))}\"\n";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .arithmetic_command_substitution_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["$(printf 1)"]);
    });
}

#[test]
fn word_facts_descend_into_arithmetic_command_substitution_bodies() {
    let source = "\
#!/bin/bash
if (( $(du -c $mask | cut -f 1) == 0 )); then
  :
fi
for (( i=$(next $start); i < 3; i++ )); do
  :
done
";

    with_facts(source, None, |_, facts| {
        let nested_args = facts
            .words()
            .word_facts()
            .filter(|fact| fact.is_nested_word_command())
            .filter(|fact| fact.host_expansion_context() == Some(ExpansionContext::CommandArgument))
            .map(|fact| fact.span().slice(source))
            .collect::<Vec<_>>();

        assert!(nested_args.contains(&"$mask"), "{nested_args:?}");
        assert!(nested_args.contains(&"$start"), "{nested_args:?}");
    });
}

#[test]
fn ignores_quoted_dollar_words_in_arithmetic_command_contexts() {
    let source = "#!/bin/bash\n(( \"$x\" + 1 ))\nfor (( i=\"$y\"; i < 3; i++ )); do :; done\n";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn indexes_pending_arithmetic_word_facts_by_span() {
    let source = "#!/bin/bash\nprintf '%s\\n' $(( $name + 1 ))\n";

    with_facts(source, None, |_, facts| {
        let arithmetic = facts
            .words()
            .arithmetic_command_word_facts()
            .find(|fact| fact.span().slice(source) == "$name")
            .expect("expected arithmetic word fact");

        assert!(arithmetic.is_arithmetic_command());
        assert_eq!(
            facts
                .words()
                .word_fact(arithmetic.span(), arithmetic.context())
                .map(|fact| fact.span().slice(source)),
            Some("$name")
        );
        assert_eq!(
            facts
                .words()
                .any_word_fact(arithmetic.span())
                .map(|fact| fact.span().slice(source)),
            Some("$name")
        );
    });
}

#[test]
fn indexes_parameter_operand_word_facts_by_span_without_exposing_them_in_word_facts() {
    let source = "#!/bin/bash\nprintf '%s\\n' \"${value:-$name}\"\n";

    with_facts(source, None, |_, facts| {
        let operand = facts
            .words()
            .parameter_operand_word_facts()
            .find(|fact| fact.span().slice(source) == "$name")
            .expect("expected parameter operand word fact");

        assert!(operand.is_parameter_operand());
        assert_eq!(
            facts
                .words()
                .word_fact(operand.span(), operand.context())
                .map(|fact| fact.span().slice(source)),
            Some("$name")
        );
        assert_eq!(
            facts
                .words()
                .any_word_fact(operand.span())
                .map(|fact| fact.span().slice(source)),
            Some("$name")
        );
        assert!(
            facts
                .words()
                .word_facts()
                .all(|fact| fact.span().slice(source) != "$name"),
            "parameter operand fact should stay out of the normal word_facts stream"
        );
    });
}

#[test]
fn indexes_arithmetic_word_facts_inside_parameter_replacement_operands() {
    let source = "#!/bin/bash\nprintf '%s\\n' \"${value/foo/$(( $name + 1 ))}\"\n";

    with_facts(source, None, |_, facts| {
        let arithmetic = facts
            .words()
            .arithmetic_command_word_facts()
            .find(|fact| fact.span().slice(source) == "$name")
            .expect("expected arithmetic word fact");

        assert!(arithmetic.is_arithmetic_command());
        assert_eq!(
            arithmetic.host_expansion_context(),
            Some(ExpansionContext::CommandArgument)
        );
        assert_eq!(
            facts
                .words()
                .word_fact(arithmetic.span(), arithmetic.context())
                .map(|fact| fact.span().slice(source)),
            Some("$name")
        );
    });
}

#[test]
fn indexes_arithmetic_word_facts_inside_parameter_default_operands() {
    let source = "\
#!/bin/bash
printf '%s\\n' \"${value:-$(( $default + 1 ))}\" \"${value:=$(( $assign + 1 ))}\" \"${value:+$(( $replace + 1 ))}\" \"${value:?$(( $error + 1 ))}\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .arithmetic_command_word_facts()
            .map(|fact| fact.span().slice(source))
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["$default", "$assign", "$replace", "$error"]);
        assert!(facts.words().arithmetic_command_word_facts().all(|fact| {
            fact.host_expansion_context() == Some(ExpansionContext::CommandArgument)
                && facts
                    .words()
                    .word_fact(fact.span(), fact.context())
                    .is_some()
        }));
    });
}

#[test]
fn ignores_dynamic_and_compound_subscript_parameter_accesses_in_arithmetic() {
    let source = "\
#!/bin/bash
declare -a arr
declare -A assoc
i=0
key=name
(( ${arr[$i]} + ${arr[i+1]} + ${arr[-1]} + ${assoc[$key]} ))
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .dollar_in_arithmetic_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn ignores_escaped_command_substitution_tokens_in_wrapped_substring_offset_arithmetic() {
    let source = "#!/bin/bash\ns=abcdef\ni=1\nprintf '%s\\n' \"${s:$(($i+\\$(printf 1)))}\"\n";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .arithmetic_command_substitution_spans()
            .iter()
            .map(|span| span.slice(source))
            .collect::<Vec<_>>();

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

#[test]
fn builds_word_facts_for_zsh_qualified_globs() {
    let source = "#!/usr/bin/env zsh\nprint -- prefix*(.N)\n";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            let glob = facts
                .words()
                .expansion_word_facts(ExpansionContext::CommandArgument)
                .find(|fact| fact.span().slice(source) == "prefix*(.N)")
                .expect("expected zsh glob fact");

            assert!(glob.classification().is_expanded());
            assert!(glob.analysis().hazards.pathname_matching);
        },
    );
}

#[test]
fn builds_option_sensitive_word_behaviors_for_zsh_words() {
    let source = "\
#!/usr/bin/env zsh
setopt sh_word_split
print $name
noglob rm *
print ${~~name}
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            let split = facts
                .words()
                .expansion_word_facts(ExpansionContext::CommandArgument)
                .find(|fact| fact.span().slice(source) == "$name")
                .expect("expected split-sensitive fact");
            let wrapped_glob = facts
                .words()
                .expansion_word_facts(ExpansionContext::CommandArgument)
                .find(|fact| fact.span().slice(source) == "*")
                .expect("expected wrapped glob fact");
            let double_tilde = facts
                .words()
                .expansion_word_facts(ExpansionContext::CommandArgument)
                .find(|fact| fact.span().slice(source) == "${~~name}")
                .expect("expected double-tilde fact");

            assert_eq!(
                split.analysis().field_splitting_behavior,
                FieldSplittingBehavior::UnquotedOnly
            );
            assert_eq!(
                wrapped_glob.runtime_literal().pathname_expansion_behavior,
                PathnameExpansionBehavior::Disabled
            );
            assert_eq!(
                double_tilde.analysis().pathname_expansion_behavior,
                PathnameExpansionBehavior::LiteralGlobsOnly
            );
        },
    );
}

#[test]
fn builds_ambiguous_pathname_behaviors_for_dynamic_zsh_words() {
    let source = "\
#!/usr/bin/env zsh
opt=glob_subst
setopt \"$opt\"
print $name
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            let ambiguous = facts
                .words()
                .expansion_word_facts(ExpansionContext::CommandArgument)
                .find(|fact| fact.span().slice(source) == "$name")
                .expect("expected ambiguous scalar fact");

            assert_eq!(
                ambiguous.analysis().pathname_expansion_behavior,
                PathnameExpansionBehavior::Ambiguous
            );
            assert!(ambiguous.analysis().hazards.pathname_matching);
        },
    );
}

#[test]
fn builds_glob_subst_for_list_fanout_facts_for_zsh_words() {
    let source = "\
#!/usr/bin/env zsh
setopt glob_subst
for item in $name; do :; done
setopt no_glob
for item in $literal; do :; done
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            let glob_subst = facts
                .words()
                .expansion_word_facts(ExpansionContext::ForList)
                .find(|fact| fact.span().slice(source) == "$name")
                .expect("expected glob_subst for-list fact");
            let no_glob = facts
                .words()
                .expansion_word_facts(ExpansionContext::ForList)
                .find(|fact| fact.span().slice(source) == "$literal")
                .expect("expected no_glob for-list fact");

            assert_eq!(
                glob_subst.analysis().pathname_expansion_behavior,
                PathnameExpansionBehavior::SubstitutionResultsWhenUnquoted
            );
            assert!(glob_subst.analysis().hazards.pathname_matching);
            assert!(glob_subst.analysis().can_expand_to_multiple_fields);
            assert_eq!(
                no_glob.analysis().pathname_expansion_behavior,
                PathnameExpansionBehavior::Disabled
            );
            assert!(!no_glob.analysis().hazards.pathname_matching);
            assert!(!no_glob.analysis().can_expand_to_multiple_fields);
        },
    );
}

#[test]
fn builds_flow_merged_literal_only_pathname_behaviors_for_zsh_words() {
    let source = "\
#!/usr/bin/env zsh
if cond; then
  setopt no_glob
fi
print $name
rm *
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            let scalar = facts
                .words()
                .expansion_word_facts(ExpansionContext::CommandArgument)
                .find(|fact| fact.span().slice(source) == "$name")
                .expect("expected scalar fact");
            let glob = facts
                .words()
                .expansion_word_facts(ExpansionContext::CommandArgument)
                .find(|fact| fact.span().slice(source) == "*")
                .expect("expected glob fact");

            assert_eq!(
                scalar.analysis().pathname_expansion_behavior,
                PathnameExpansionBehavior::LiteralGlobsOnlyOrDisabled
            );
            assert!(!scalar.analysis().hazards.pathname_matching);
            assert_eq!(
                glob.runtime_literal().pathname_expansion_behavior,
                PathnameExpansionBehavior::LiteralGlobsOnlyOrDisabled
            );
            assert!(
                glob.runtime_literal()
                    .pathname_expansion_behavior
                    .literal_globs_can_expand()
            );
        },
    );
}

#[test]
fn builds_glob_failure_behaviors_for_zsh_globs() {
    let source = "\
#!/usr/bin/env zsh
setopt null_glob csh_null_glob
rm *
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            let glob = facts
                .words()
                .expansion_word_facts(ExpansionContext::CommandArgument)
                .find(|fact| fact.span().slice(source) == "*")
                .expect("expected glob fact");

            assert!(
                glob.runtime_literal()
                    .pathname_expansion_behavior
                    .literal_globs_can_expand()
            );
            assert_eq!(
                glob.runtime_literal().glob_failure_behavior,
                GlobFailureBehavior::CshNullGlob
            );
        },
    );
}

#[test]
fn builds_glob_dot_and_pattern_behaviors_for_zsh_globs() {
    let source = "\
#!/usr/bin/env zsh
rm *
setopt glob_dots extended_glob
rm *
setopt ksh_glob sh_glob
rm *
unsetopt extended_glob ksh_glob sh_glob
rm *
opt=glob_dots
unsetopt \"$opt\"
rm *
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            let glob_behaviors = facts
                .words()
                .expansion_word_facts(ExpansionContext::CommandArgument)
                .filter(|fact| fact.span().slice(source) == "*")
                .map(|fact| {
                    let literal = fact.runtime_literal();
                    let pattern = literal.glob_pattern_behavior;
                    (
                        literal.glob_dot_behavior,
                        pattern.extended_glob(),
                        pattern.ksh_glob(),
                        pattern.sh_glob(),
                    )
                })
                .collect::<Vec<_>>();

            assert_eq!(
                glob_behaviors,
                vec![
                    (
                        GlobDotBehavior::ExplicitDotRequired,
                        PatternOperatorBehavior::Disabled,
                        PatternOperatorBehavior::Disabled,
                        PatternOperatorBehavior::Disabled,
                    ),
                    (
                        GlobDotBehavior::DotfilesIncluded,
                        PatternOperatorBehavior::Enabled,
                        PatternOperatorBehavior::Disabled,
                        PatternOperatorBehavior::Disabled,
                    ),
                    (
                        GlobDotBehavior::DotfilesIncluded,
                        PatternOperatorBehavior::Enabled,
                        PatternOperatorBehavior::Enabled,
                        PatternOperatorBehavior::Enabled,
                    ),
                    (
                        GlobDotBehavior::DotfilesIncluded,
                        PatternOperatorBehavior::Disabled,
                        PatternOperatorBehavior::Disabled,
                        PatternOperatorBehavior::Disabled,
                    ),
                    (
                        GlobDotBehavior::Ambiguous,
                        PatternOperatorBehavior::Ambiguous,
                        PatternOperatorBehavior::Ambiguous,
                        PatternOperatorBehavior::Ambiguous,
                    ),
                ]
            );
        },
    );
}

#[test]
fn builds_active_glob_spans_from_option_sensitive_zsh_behavior() {
    let source = "\
#!/usr/bin/env zsh
rm *
noglob rm *
setopt extended_glob
rm foo^bar
rm foo~bar
rm foo*~bar
rm foo~bar*
unsetopt extended_glob
rm foo^bar
setopt ksh_glob
rm ?(*.txt)
unsetopt ksh_glob
rm ?(*.txt)
opt=ksh_glob
setopt \"$opt\"
rm ?(*.txt)
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            let active_spans = facts
                .words()
                .expansion_word_facts(ExpansionContext::CommandArgument)
                .filter(|fact| {
                    matches!(
                        fact.span().slice(source),
                        "*" | "foo^bar" | "foo~bar" | "foo*~bar" | "foo~bar*" | "?(*.txt)"
                    )
                })
                .map(|fact| {
                    (
                        fact.span().slice(source).to_owned(),
                        fact.active_literal_glob_spans(source)
                            .into_iter()
                            .map(|span| span.slice(source).to_owned())
                            .collect::<Vec<_>>(),
                        fact.starts_with_active_glob_operator(source),
                    )
                })
                .collect::<Vec<_>>();

            assert_eq!(
                active_spans,
                vec![
                    ("*".to_owned(), vec!["*".to_owned()], false),
                    ("*".to_owned(), Vec::<String>::new(), false),
                    ("foo^bar".to_owned(), vec!["^".to_owned()], false),
                    ("foo~bar".to_owned(), Vec::<String>::new(), false),
                    (
                        "foo*~bar".to_owned(),
                        vec!["*".to_owned(), "~".to_owned()],
                        false
                    ),
                    (
                        "foo~bar*".to_owned(),
                        vec!["~".to_owned(), "*".to_owned()],
                        false
                    ),
                    ("foo^bar".to_owned(), Vec::<String>::new(), false),
                    ("?(*.txt)".to_owned(), vec!["?(*.txt)".to_owned()], true),
                    (
                        "?(*.txt)".to_owned(),
                        vec!["?".to_owned(), "*".to_owned()],
                        false
                    ),
                    ("?(*.txt)".to_owned(), vec!["?(*.txt)".to_owned()], true),
                ]
            );

            let pathname_hazards = facts
                .words()
                .expansion_word_facts(ExpansionContext::CommandArgument)
                .filter(|fact| {
                    matches!(
                        fact.span().slice(source),
                        "foo~bar" | "foo*~bar" | "foo~bar*"
                    )
                })
                .map(|fact| {
                    (
                        fact.span().slice(source).to_owned(),
                        fact.analysis().hazards.pathname_matching,
                    )
                })
                .collect::<Vec<_>>();

            assert_eq!(
                pathname_hazards,
                vec![
                    ("foo~bar".to_owned(), false),
                    ("foo*~bar".to_owned(), true),
                    ("foo~bar*".to_owned(), true),
                ]
            );
        },
    );
}

#[test]
fn builds_word_facts_for_special_parameter_arguments() {
    let source = "\
#!/bin/bash
printf '%s\\n' $0 $1 $* $@
";

    with_facts(source, None, |_, facts| {
        let argument_words = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .map(|fact| fact.span().slice(source).to_owned())
            .collect::<Vec<_>>();

        assert!(argument_words.contains(&"$0".to_owned()));
        assert!(argument_words.contains(&"$1".to_owned()));
        assert!(argument_words.contains(&"$*".to_owned()));
        assert!(argument_words.contains(&"$@".to_owned()));
    });
}

#[test]
fn builds_word_facts_for_filename_builder_command_substitutions() {
    let source = "\
#!/bin/bash
/sbin/makepkg -l y -c n $OUTPUT/$PRGNAM-$VERSION\\_$(echo ${KERNEL} | tr '-' '_')-$ARCH-$BUILD$TAG.$PKGTYPE
";

    with_facts(source, None, |_, facts| {
        let fact = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .find(|fact| {
                fact.span()
                    .slice(source)
                    .contains("$(echo ${KERNEL} | tr '-' '_')")
            })
            .expect("expected makepkg output argument fact");

        assert_eq!(
            fact.unquoted_command_substitution_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$(echo ${KERNEL} | tr '-' '_')"],
            "parts: {:?}",
            fact.word().parts
        );
        assert_eq!(
            fact.split_sensitive_unquoted_command_substitution_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$(echo ${KERNEL} | tr '-' '_')"],
            "parts: {:?}",
            fact.word().parts
        );
    });
}

#[test]
fn builds_word_facts_for_docker_inspect_command_substitutions() {
    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 fact = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .find(|fact| fact.span().slice(source) == "$(docker ps -q)")
            .expect("expected docker inspect argument fact");

        assert_eq!(
            fact.unquoted_command_substitution_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$(docker ps -q)"],
            "parts: {:?}",
            fact.word().parts
        );
    });
}

#[test]
fn builds_plain_unindexed_array_reference_facts_in_bash() {
    let source = "\
#!/bin/bash
arr=(one two)
printf '%s\\n' $arr \"${arr}\" pre${arr}post \"${arr[0]}\" \"${arr[@]}\" \"${arr%one}\"
cat <<EOF
$arr
${arr}
${arr[0]}
EOF
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .plain_unindexed_array_references()
                .map(|fact| match fact {
                    PlainUnindexedArrayReferenceFact::SelectorRequired(reference) => {
                        ("selector", reference.diagnostic_span().slice(source))
                    }
                    PlainUnindexedArrayReferenceFact::NativeZshScalar(reference) => {
                        (
                            "native-zsh-scalar",
                            reference.expansion_span().slice(source),
                        )
                    }
                    PlainUnindexedArrayReferenceFact::Ambiguous(reference) => {
                        ("ambiguous", reference.diagnostic_span().slice(source))
                    }
                })
                .collect::<Vec<_>>(),
            vec![
                ("selector", "$arr"),
                ("selector", "${arr}"),
                ("selector", "${arr}"),
                ("selector", "$arr"),
                ("selector", "${arr}"),
            ]
        );
    });
}

#[test]
fn plain_unindexed_array_references_classify_native_zsh_scalar_expansions() {
    let source = "\
#!/bin/zsh
arr=(one two)
print -r -- $arr
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            assert_eq!(
                facts
                    .words()
                    .plain_unindexed_array_references()
                    .map(|fact| match fact {
                        PlainUnindexedArrayReferenceFact::SelectorRequired(reference) => {
                            ("selector", reference.diagnostic_span().slice(source))
                        }
                        PlainUnindexedArrayReferenceFact::NativeZshScalar(reference) => {
                            (
                                "native-zsh-scalar",
                                reference.expansion_span().slice(source),
                            )
                        }
                        PlainUnindexedArrayReferenceFact::Ambiguous(reference) => {
                            ("ambiguous", reference.diagnostic_span().slice(source))
                        }
                    })
                    .collect::<Vec<_>>(),
                vec![("native-zsh-scalar", "$arr")]
            );
        },
    );
}

#[test]
fn zsh_array_fanout_marks_only_unquoted_root_references() {
    let source = "\
#!/bin/zsh
arr=(one two)
scalar=value
print -r -- $arr \"$arr\" pre$arr ${scalar:-$arr}
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            assert_eq!(
                facts
                    .words()
                    .expansion_word_facts(ExpansionContext::CommandArgument)
                    .filter_map(|fact| {
                        let text = fact.span().slice(source);
                        matches!(text, "$arr" | "\"$arr\"" | "pre$arr" | "${scalar:-$arr}")
                            .then_some((text, fact.analysis().array_valued))
                    })
                    .collect::<Vec<_>>(),
                vec![
                    ("$arr", true),
                    ("\"$arr\"", false),
                    ("pre$arr", true),
                    ("${scalar:-$arr}", false),
                ]
            );
        },
    );
}

#[test]
fn plain_unindexed_array_references_classify_setopt_ksh_arrays_expansions() {
    let source = "\
#!/bin/zsh
setopt ksh_arrays
arr=(one two)
print -r -- $arr
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            assert_eq!(
                facts
                    .words()
                    .plain_unindexed_array_references()
                    .map(|fact| match fact {
                        PlainUnindexedArrayReferenceFact::SelectorRequired(reference) => {
                            ("selector", reference.diagnostic_span().slice(source))
                        }
                        PlainUnindexedArrayReferenceFact::NativeZshScalar(reference) => {
                            (
                                "native-zsh-scalar",
                                reference.expansion_span().slice(source),
                            )
                        }
                        PlainUnindexedArrayReferenceFact::Ambiguous(reference) => {
                            ("ambiguous", reference.diagnostic_span().slice(source))
                        }
                    })
                    .collect::<Vec<_>>(),
                vec![("selector", "$arr")]
            );
        },
    );
}

#[test]
fn plain_unindexed_array_references_classify_emulate_ksh_expansions() {
    let source = "\
#!/bin/zsh
emulate ksh
arr=(one two)
print -r -- $arr
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            assert_eq!(
                facts
                    .words()
                    .plain_unindexed_array_references()
                    .map(|fact| match fact {
                        PlainUnindexedArrayReferenceFact::SelectorRequired(reference) => {
                            ("selector", reference.diagnostic_span().slice(source))
                        }
                        PlainUnindexedArrayReferenceFact::NativeZshScalar(reference) => {
                            (
                                "native-zsh-scalar",
                                reference.expansion_span().slice(source),
                            )
                        }
                        PlainUnindexedArrayReferenceFact::Ambiguous(reference) => {
                            ("ambiguous", reference.diagnostic_span().slice(source))
                        }
                    })
                    .collect::<Vec<_>>(),
                vec![("selector", "$arr")]
            );
        },
    );
}

#[test]
fn plain_unindexed_array_references_classify_dynamic_option_names_as_ambiguous() {
    let source = "\
#!/bin/zsh
opt=ksh_arrays
setopt \"$opt\"
arr=(one two)
print -r -- $arr
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            assert_eq!(
                facts
                    .words()
                    .plain_unindexed_array_references()
                    .map(|fact| match fact {
                        PlainUnindexedArrayReferenceFact::SelectorRequired(reference) => {
                            ("selector", reference.diagnostic_span().slice(source))
                        }
                        PlainUnindexedArrayReferenceFact::NativeZshScalar(reference) => {
                            (
                                "native-zsh-scalar",
                                reference.expansion_span().slice(source),
                            )
                        }
                        PlainUnindexedArrayReferenceFact::Ambiguous(reference) => {
                            ("ambiguous", reference.diagnostic_span().slice(source))
                        }
                    })
                    .collect::<Vec<_>>(),
                vec![("ambiguous", "$arr")]
            );
        },
    );
}

#[test]
fn plain_unindexed_array_references_classify_dynamic_function_calls() {
    let source = "\
#!/bin/zsh
enable_ksh() {
  emulate ksh
}
dispatcher=enable_ksh
$dispatcher
arr=(one two)
print -r -- $arr
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            assert_eq!(
                facts
                    .words()
                    .plain_unindexed_array_references()
                    .map(|fact| match fact {
                        PlainUnindexedArrayReferenceFact::SelectorRequired(reference) => {
                            ("selector", reference.diagnostic_span().slice(source))
                        }
                        PlainUnindexedArrayReferenceFact::NativeZshScalar(reference) => {
                            (
                                "native-zsh-scalar",
                                reference.expansion_span().slice(source),
                            )
                        }
                        PlainUnindexedArrayReferenceFact::Ambiguous(reference) => {
                            ("ambiguous", reference.diagnostic_span().slice(source))
                        }
                    })
                    .collect::<Vec<_>>(),
                vec![("selector", "$arr")]
            );
        },
    );
}

#[test]
fn plain_unindexed_array_references_classify_ambiguous_function_local_state() {
    let source = "\
#!/bin/zsh
f() {
  if [[ -n $flag ]]; then
    emulate -LR zsh
  fi
  local -a arr=(one two)
  print -r -- $arr
}
fn=f
setopt ksh_arrays
$fn
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Zsh,
        ShellDialect::Zsh,
        |_, facts| {
            assert_eq!(
                facts
                    .words()
                    .plain_unindexed_array_references()
                    .map(|fact| match fact {
                        PlainUnindexedArrayReferenceFact::SelectorRequired(reference) => {
                            ("selector", reference.diagnostic_span().slice(source))
                        }
                        PlainUnindexedArrayReferenceFact::NativeZshScalar(reference) => {
                            (
                                "native-zsh-scalar",
                                reference.expansion_span().slice(source),
                            )
                        }
                        PlainUnindexedArrayReferenceFact::Ambiguous(reference) => {
                            ("ambiguous", reference.diagnostic_span().slice(source))
                        }
                    })
                    .collect::<Vec<_>>(),
                vec![("ambiguous", "$arr")]
            );
        },
    );
}

#[test]
fn builds_word_facts_for_quoted_all_elements_array_expansions() {
    let source = "\
#!/bin/bash
eval \"${shims[@]}\"
";

    with_facts(source, None, |_, facts| {
        let fact = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .find(|fact| fact.span().slice(source) == "\"${shims[@]}\"")
            .expect("expected eval argument fact");

        assert_eq!(
            fact.all_elements_array_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["${shims[@]}"],
            "parts: {:?}",
            fact.word().parts
        );
    });
}

#[test]
fn builds_word_facts_for_conditional_patterns() {
    let source = "\
#!/bin/bash
if [[ x == *${shims[@]}* ]]; then :; fi
";

    with_facts(source, None, |_, facts| {
        let fact = facts
            .words()
            .expansion_word_facts(ExpansionContext::ConditionalPattern)
            .find(|fact| fact.span().slice(source) == "${shims[@]}")
            .expect("expected conditional pattern word fact");

        assert_eq!(
            fact.all_elements_array_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["${shims[@]}"]
        );
        assert_eq!(
            fact.direct_all_elements_array_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["${shims[@]}"],
            "parts: {:?}",
            fact.word().parts
        );
    });
}

#[test]
fn builds_word_facts_for_mixed_quoted_all_elements_array_expansions() {
    let source = "\
#!/bin/bash
shims=(a)
eval \"conda_shim() { case \\\"\\${1##*/}\\\" in ${shims[@]} *) return 1;; esac }\"
";

    with_facts(source, None, |_, facts| {
        let fact = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .find(|fact| fact.span().slice(source).contains("${shims[@]}"))
            .expect("expected eval argument fact");

        assert_eq!(
            fact.all_elements_array_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["${shims[@]}"]
        );
        assert_eq!(
            fact.direct_all_elements_array_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["${shims[@]}"],
            "parts: {:?}",
            fact.word().parts
        );
    });
}

#[test]
fn direct_all_elements_word_facts_ignore_nested_positional_forwarding_idioms() {
    let source = "\
#!/bin/sh
eval shellspec_join SHELLSPEC_EXPECTATION '\" \"' The ${1+'\"$@\"'}
";

    with_facts(source, None, |_, facts| {
        let fact = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .find(|fact| fact.span().slice(source).contains("${1+'\"$@\"'}"))
            .expect("expected eval argument fact");

        assert_eq!(
            fact.all_elements_array_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$@"]
        );
        assert!(
            fact.direct_all_elements_array_expansion_spans().is_empty(),
            "parts: {:?}",
            fact.word().parts
        );
    });
}

#[test]
fn builds_word_facts_for_unquoted_all_elements_array_expansions() {
    let source = "\
#!/bin/bash
printf '%s\\n' $@ ${@:2} ${items[@]} ${items[@]:1} ${!items[@]} ${items[@]/#/#} ${items[@]@Q} ${items[@]:-fallback} ${items[@]:+fallback} \"$@\" \"${items[@]}\" $* ${items[*]} ${1+\"$@\"}
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .flat_map(|fact| {
                fact.unquoted_all_elements_array_expansion_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

        assert_eq!(
            spans,
            vec![
                "$@",
                "${@:2}",
                "${items[@]}",
                "${items[@]:1}",
                "${!items[@]}",
                "${items[@]/#/#}",
                "${items[@]@Q}",
                "${items[@]:-fallback}"
            ]
        );
    });
}

#[test]
fn builds_word_facts_for_unquoted_literals_between_reopened_double_quotes() {
    let source = "\
#!/bin/bash
printf '%s\\n' \"foo\"bar\"baz\" \"foo\"-\"bar\" \"foo\"$(printf '%s' x)\"bar\" \"$left\"-\"$right\" x=\"$(cmd \"a\".\"b\")\" '$('\"foo\"parenmid\"baz\" '${'\"foo\"bracemid\"baz\" $(printf \"(\")\"foo\"quotedparen\"baz\" $(printf \"%s\" \"${x}\")\"foo\"quotedparam\"baz\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .filter(|fact| fact.host_kind() == WordFactHostKind::Direct)
            .flat_map(|fact| {
                fact.unquoted_literal_between_double_quoted_segments_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

        assert_eq!(
            spans,
            vec![
                "bar",
                "-",
                "parenmid",
                "bracemid",
                "quotedparen",
                "quotedparam",
                "."
            ]
        );
    });
}

#[test]
fn builds_word_facts_skip_shellcheck_skipped_mixed_quote_literals() {
    let source = "\
#!/bin/bash
printf '%s\\n' \"foo\"*bar\"baz\" \"foo\"?bar\"baz\" \"foo\"a[b]\"baz\" \"foo\"a{b}\"baz\" \"foo\"a+b\"baz\" \"foo\"a@b\"baz\" \"foo\"user@host\"bar\"
export CARGO_TARGET_\"${env_host}\"_RUSTFLAGS+=\" -C\"
print \"\\
export EASYRSA_REQ_SERIAL=\\\"$EASYRSA_REQ_SERIAL\\\"\\
\" | sed -e s/a/b/
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .filter(|fact| fact.host_kind() == WordFactHostKind::Direct)
            .flat_map(|fact| {
                fact.unquoted_literal_between_double_quoted_segments_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

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

#[test]
fn builds_word_facts_for_each_reopened_quote_line_join() {
    let source = "\
#!/bin/bash
lt_cv_sys_global_symbol_pipe=\"$AWK '\"\\
\"     {last_section=section};\"\\
\"     /^COFF SYMBOL TABLE/{next};\"\\
\"     ' prfx=^$ac_symprfx\"
nested=\"$AWK '\"\\
\"     {value=$(printf \"%s\" x);};\"\\
\"     /^COFF SYMBOL TABLE/{next};\"\\
\"     ' prfx=^$ac_symprfx\"
legacy=\"$AWK '\"\\
\"     {value=`printf \"%s\" x`;};\"\\
\"     /^COFF SYMBOL TABLE/{next};\"\\
\"     ' prfx=^$ac_symprfx\"
grouped=\"$AWK '\"\\
\"     {value=$( (printf x); printf \"%s\" y );};\"\\
\"     /^COFF SYMBOL TABLE/{next};\"\\
\"     ' prfx=^$ac_symprfx\"
param_hash=\"$AWK '\"\\
\"     {value=${value:- # fallback};\"\\
\"     /^COFF SYMBOL TABLE/{next};\"\\
\"     ' prfx=^$ac_symprfx\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .expansion_word_facts(ExpansionContext::AssignmentValue)
            .filter(|fact| fact.host_kind() == WordFactHostKind::Direct)
            .flat_map(|fact| {
                fact.unquoted_literal_between_double_quoted_segments_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

        assert_eq!(
            spans,
            vec![
                "\\\n", "\\\n", "\\\n", "\\\n", "\\\n", "\\\n", "\\\n", "\\\n", "\\\n", "\\\n",
                "\\\n", "\\\n", "\\\n", "\\\n", "\\\n"
            ]
        );
    });
}

#[test]
fn builds_word_facts_for_reopened_quote_line_join_after_word_span() {
    let source = "\
#!/bin/bash
value=\"foo\"\\
\"bar\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .expansion_word_facts(ExpansionContext::AssignmentValue)
            .filter(|fact| fact.host_kind() == WordFactHostKind::Direct)
            .flat_map(|fact| {
                fact.unquoted_literal_between_double_quoted_segments_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["\\\n"]);
    });
}

#[test]
fn builds_word_facts_ignore_comment_text_in_nested_fragment_scan() {
    let source = "\
#!/bin/bash
echo $(echo x # $(
 )\"foo\"bar\"baz\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .flat_map(|fact| {
                fact.unquoted_literal_between_double_quoted_segments_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["bar"]);
    });
}

#[test]
fn builds_word_facts_ignore_comment_text_in_backtick_fragment_scan() {
    let source = "\
#!/bin/bash
echo `echo x # $(
`\"foo\"bar\"baz\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .flat_map(|fact| {
                fact.unquoted_literal_between_double_quoted_segments_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["bar"]);
    });
}

#[test]
fn builds_word_facts_ignore_hashes_inside_nested_double_quotes() {
    let source = "\
#!/bin/bash
echo $(printf \"%s\" \"x # $(printf y)\")\"foo\"bar\"baz\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .flat_map(|fact| {
                fact.unquoted_literal_between_double_quoted_segments_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["bar"]);
    });
}

#[test]
fn builds_word_facts_ignore_comment_text_in_process_substitution_scan() {
    let source = "\
#!/bin/bash
echo <(echo x # ${
 )\"foo\"bar\"baz\"
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .expansion_word_facts(ExpansionContext::CommandArgument)
            .flat_map(|fact| {
                fact.unquoted_literal_between_double_quoted_segments_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

        assert_eq!(spans, vec!["bar"]);
    });
}

#[test]
fn builds_brace_variable_before_bracket_spans_from_direct_words() {
    let source = "\
#!/bin/bash
echo \"$foo[0]\"
echo \"${foo}[0]\"
echo \"$foo\"\"[0]\"
echo \"$foo\\[0]\"
$cmd[0] arg
";

    with_facts(source, None, |_, facts| {
        let spans = facts
            .words()
            .brace_variable_before_bracket_spans()
            .iter()
            .map(|span| (span.start.line(), span.start.column()))
            .collect::<Vec<_>>();

        assert_eq!(spans, vec![(2, 7), (6, 1)]);
    });
}

#[test]
fn builds_function_in_alias_facts_from_static_alias_definitions() {
    let source = "\
#!/bin/sh
alias first='echo $1'
alias rest='printf \"%s\\n\" \"$@\"'
alias conditional='${1+\"$@\"}'
alias escaped_then_pos='echo \\$$1'
alias func='helper() { echo hi; }'
alias literal='echo \\$1'
alias literal_braced='echo \\${1}'
alias quoted='echo '\"'\"'$1'\"'\"''
alias pid='echo $$1'
alias runtime=$BAR
";

    with_facts(source, None, |_, facts| {
        let alias_facts = facts.command_facts().function_in_alias_facts();
        assert_eq!(
            alias_facts
                .iter()
                .map(|fact| fact.span().slice(source))
                .collect::<Vec<_>>(),
            vec![
                "first='echo $1'",
                "rest='printf \"%s\\n\" \"$@\"'",
                "conditional='${1+\"$@\"}'",
                "escaped_then_pos='echo \\$$1'",
            ]
        );
        let (replacement_span, replacement) = alias_facts[0]
            .replacement()
            .expect("expected a function replacement");
        assert_eq!(replacement_span.slice(source), "alias first='echo $1'");
        assert_eq!(replacement, "first() { echo $1; }");
    });
}

#[test]
fn builds_alias_definition_expansion_facts_without_matching_alias_lookups() {
    let source = "\
#!/bin/bash
alias \"${cur%=}\" 2>/dev/null
alias home=$HOME
alias \"$a=$b\"
";

    with_facts(source, None, |_, facts| {
        let alias_facts = facts.command_facts().alias_definition_expansion_facts();
        assert_eq!(
            alias_facts
                .iter()
                .map(|fact| fact.span().slice(source))
                .collect::<Vec<_>>(),
            vec!["$HOME", "$a"]
        );
        let (replacement_span, replacement) = alias_facts[0]
            .replacement()
            .expect("expected a single-quoted alias replacement");
        assert_eq!(replacement_span.slice(source), "home=$HOME");
        assert_eq!(replacement, "home='$HOME'");
        assert!(alias_facts[1].replacement().is_none());
    });
}

#[test]
fn builds_array_assignment_split_word_facts() {
    let source = "\
#!/bin/bash
scalar=$x
arr=($x \"$y\" prefix$z $(cmd) \"${items[@]}\" ${items[@]})
declare declared=($alpha \"$(cmd)\" ${beta})
declare -A map=([k]=$v)
arr+=($tail)
";

    with_facts(source, None, |_, facts| {
        let split_words = facts
            .words()
            .array_assignment_split_word_facts()
            .map(|fact| fact.span().slice(source).to_owned())
            .collect::<Vec<_>>();

        assert_eq!(
            split_words,
            vec![
                "$x",
                "\"$y\"",
                "prefix$z",
                "$(cmd)",
                "\"${items[@]}\"",
                "${items[@]}",
                "$alpha",
                "\"$(cmd)\"",
                "${beta}",
                "$tail",
            ]
        );

        let unquoted_scalar = facts
            .words()
            .array_assignment_split_word_facts()
            .flat_map(|fact| {
                fact.array_assignment_split_scalar_expansion_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();
        assert_eq!(
            unquoted_scalar,
            vec!["$x", "$z", "$alpha", "${beta}", "$tail"]
        );

        let unquoted_array = facts
            .words()
            .array_assignment_split_word_facts()
            .flat_map(|fact| {
                fact.unquoted_array_expansion_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();
        assert_eq!(unquoted_array, vec!["${items[@]}"]);
    });
}

#[test]
fn array_assignment_split_facts_track_command_substitution_boundaries() {
    let source = "\
#!/bin/bash
arr=(\"$(printf '%s\\n' \"$x\")\")
";

    with_facts(source, None, |_, facts| {
        let split_facts = facts
            .words()
            .array_assignment_split_word_facts()
            .collect::<Vec<_>>();
        assert_eq!(split_facts.len(), 1);
        let fact = split_facts[0];

        assert_eq!(
            fact.command_substitution_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$(printf '%s\\n' \"$x\")"]
        );
        assert_eq!(
            fact.array_assignment_split_scalar_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            Vec::<&str>::new()
        );
    });
}

#[test]
fn array_assignment_split_facts_reuse_inner_command_word_facts() {
    let source = "\
#!/bin/bash
arr=($(printf '%s\\n' \"$x\" ${y} prefix$z))
";

    with_facts(source, None, |_, facts| {
        let split_facts = facts
            .words()
            .array_assignment_split_word_facts()
            .collect::<Vec<_>>();
        assert_eq!(split_facts.len(), 1);
        let fact = split_facts[0];

        assert_eq!(
            fact.span().slice(source),
            "$(printf '%s\\n' \"$x\" ${y} prefix$z)"
        );
        assert_eq!(
            fact.command_substitution_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$(printf '%s\\n' \"$x\" ${y} prefix$z)"]
        );
        assert_eq!(
            fact.array_assignment_split_scalar_expansion_spans()
                .iter()
                .map(|span| span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$x", "${y}", "$z"]
        );
    });
}

#[test]
fn array_assignment_split_facts_keep_heredoc_substitutions_as_single_words() {
    let source = "\
#!/bin/bash
arr=(\"$(
  cat <<-EOF
    repository(owner: \\\"${project%/*}\\\", name: \\\"${project##*/}\\\")
EOF
)\")
";

    with_facts(source, None, |_, facts| {
        let split_words = facts
            .words()
            .array_assignment_split_word_facts()
            .map(|fact| fact.span().slice(source).to_owned())
            .collect::<Vec<_>>();

        assert_eq!(
            split_words,
            vec![
                "\"$(\n  cat <<-EOF\n    repository(owner: \\\"${project%/*}\\\", name: \\\"${project##*/}\\\")\nEOF\n)\""
            ]
        );
    });
}

#[test]
fn array_assignment_split_facts_keep_pipelined_heredoc_substitutions_as_single_words() {
    let source = "\
#!/bin/bash
arr=(\"$(
  cat <<-EOF | tr '\\n' ' '
    {
      \\\"query\\\": \\\"query {
        repository(owner: \\\"${project%/*}\\\", name: \\\"${project##*/}\\\") {
          refs(refPrefix: \\\"refs/tags/\\\")
        }
      }\\\"
    }
EOF
)\")
";

    with_facts(source, None, |_, facts| {
        let split_words = facts
            .words()
            .array_assignment_split_word_facts()
            .map(|fact| fact.span().slice(source).to_owned())
            .collect::<Vec<_>>();

        assert_eq!(
            split_words,
            vec![
                "\"$(\n  cat <<-EOF | tr '\\n' ' '\n    {\n      \\\"query\\\": \\\"query {\n        repository(owner: \\\"${project%/*}\\\", name: \\\"${project##*/}\\\") {\n          refs(refPrefix: \\\"refs/tags/\\\")\n        }\n      }\\\"\n    }\nEOF\n)\""
            ]
        );
    });
}

#[test]
fn array_assignment_split_facts_track_realistic_pipelined_heredoc_substitutions() {
    let source = r#"# shellcheck shell=bash
project=owner/repo
graphql_request=(
  -X POST
  -d "$(
    cat <<-EOF | tr '\n' ' '
      {
        "query": "query {
          repository(owner: \"${project%/*}\", name: \"${project##*/}\") {
            refs(refPrefix: \"refs/tags/\")
          }
        }"
      }
EOF
  )"
)
"#;

    with_facts(source, None, |_, facts| {
        let split_facts = facts
            .words()
            .array_assignment_split_word_facts()
            .collect::<Vec<_>>();

        assert_eq!(
            split_facts
                .iter()
                .map(|fact| fact.span().slice(source))
                .collect::<Vec<_>>(),
            vec![
                "-X",
                "POST",
                "-d",
                "\"$(\n    cat <<-EOF | tr '\\n' ' '\n      {\n        \"query\": \"query {\n          repository(owner: \\\"${project%/*}\\\", name: \\\"${project##*/}\\\") {\n            refs(refPrefix: \\\"refs/tags/\\\")\n          }\n        }\"\n      }\nEOF\n  )\"",
            ]
        );
    });
}

#[test]
fn array_assignment_split_facts_ignore_use_replacement_expansions() {
    let source = "\
#!/bin/bash
arr=(${flag:+-f} ${flag:+$fallback} ${name:+\"$name\" \"$regex\"} ${items[@]+\"${items[@]}\"} ${x:-\"$fallback\"})
";

    with_facts(source, None, |_, facts| {
        let split_sensitive = facts
            .words()
            .array_assignment_split_word_facts()
            .flat_map(|fact| {
                fact.array_assignment_split_scalar_expansion_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

        assert_eq!(split_sensitive, vec!["${x:-\"$fallback\"}"]);
    });
}

#[test]
fn array_assignment_split_facts_ignore_expansions_inside_brace_fanout() {
    let source = "\
#!/bin/bash
arr=({$XDG_CONFIG_HOME,$HOME}/{alacritty,}/{.,}alacritty.ym?)
arr=($prefix{a,b} {a,b}$suffix {pre$inside,other})
";

    with_facts(source, None, |_, facts| {
        let split_sensitive = facts
            .words()
            .array_assignment_split_word_facts()
            .flat_map(|fact| {
                fact.array_assignment_split_scalar_expansion_spans()
                    .iter()
                    .map(|span| span.slice(source).to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();

        assert_eq!(split_sensitive, vec!["$prefix", "$suffix"]);
    });
}

#[test]
fn array_assignment_split_facts_keep_brace_literal_expansions_for_sh() {
    let source = "\
# shellcheck shell=sh
arr=({pre$inside,other})
";

    with_facts_dialect(
        source,
        None,
        ParseShellDialect::Bash,
        ShellDialect::Sh,
        |_, facts| {
            let split_sensitive = facts
                .words()
                .array_assignment_split_word_facts()
                .flat_map(|fact| {
                    fact.array_assignment_split_scalar_expansion_spans()
                        .iter()
                        .map(|span| span.slice(source).to_owned())
                        .collect::<Vec<_>>()
                })
                .collect::<Vec<_>>();

            assert_eq!(split_sensitive, vec!["$inside"]);
        },
    );
}

#[test]
fn surface_facts_track_parameter_operations_in_expanding_heredocs() {
    let source = "\
cat <<EOF
${name:2}
${arr[0]//x/y}
${name^^pattern}
EOF
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .substring_expansion_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec!["${name:2}"]
        );
        assert_eq!(
            facts
                .words()
                .replacement_expansion_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec!["${arr[0]//x/y}"]
        );
        assert_eq!(
            facts
                .words()
                .case_modification_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec!["${name^^pattern}"]
        );
    });
}

#[test]
fn surface_facts_cover_replacement_expansions_with_escaped_backslashes() {
    let source = "\
#!/bin/sh
local crypt=$(grep \"^root:\" /etc/shadow | cut -f 2 -d :)
crypt=${crypt//\\\\/\\\\\\\\}
crypt=${crypt//\\//\\\\\\/}
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .replacement_expansion_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec!["${crypt//\\\\/\\\\\\\\}", "${crypt//\\//\\\\\\/}"]
        );
    });
}

#[test]
fn surface_facts_track_zsh_parameter_index_flags_only_for_word_targets() {
    let source = "\
#!/bin/sh
printf '%s\\n' ${\"$foo\"[1]}
printf '%s\\n' ${\"$(printf '%s\\n' \"$PWD\")\"[(w)1]}
printf '%s\\n' ${\"$(printf \"%s\" \")\")\"[(w)1]}
printf '%s\\n' ${map[(I)needle]}
printf '%s\\n' \"${precmd_functions[(r)_z_precmd]}\"
printf '%s\\n' '${\"$bar\"[1]}'
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .zsh_parameter_index_flag_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec![
                "${\"$foo\"",
                "${\"$(printf '%s\\n' \"$PWD\")\"",
                "${\"$(printf \"%s\" \")\")\"",
            ]
        );
    });
}

#[test]
fn shared_command_traversal_collects_word_facts_and_surface_fragments() {
    let source = "\
#!/bin/bash
printf '%s\\n' ${name%$suffix} `printf backtick`
";

    with_facts(source, None, |_, facts| {
        let parameter_pattern = facts
            .words()
            .expansion_word_facts(ExpansionContext::ParameterPattern)
            .find(|fact| fact.span().slice(source) == "$suffix")
            .expect("expected parameter pattern fact");
        assert_eq!(parameter_pattern.host_kind(), WordFactHostKind::Direct);

        assert_eq!(
            facts
                .words()
                .backtick_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec!["`printf backtick`"]
        );
    });
}

#[test]
fn indexed_array_reference_fragments_include_operator_expansions() {
    let source = "\
#!/bin/bash
printf '%s\\n' \"${items[@]#$prefix/}\" \"${items[i]%$suffix}\"
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .indexed_array_reference_fragments()
                .iter()
                .map(|fragment| {
                    let (span, plain, _) = indexed_array_reference_parts(fragment, source);
                    (span, plain)
                })
                .collect::<Vec<_>>(),
            vec![
                ("${items[@]#$prefix/}", false),
                ("${items[i]%$suffix}", false),
            ]
        );
    });
}

#[test]
fn indexed_array_reference_fragments_record_subscript_index_behavior() {
    let source = "\
#!/bin/zsh
printf '%s\\n' ${arr[0]}
setopt ksh_arrays
printf '%s\\n' ${arr[1]}
if cond; then setopt ksh_zero_subscript; fi
printf '%s\\n' ${arr[1]}
unsetopt ksh_arrays
unsetopt ksh_zero_subscript
printf '%s\\n' ${arr[0]}
setopt ksh_zero_subscript
printf '%s\\n' ${arr[0]}
opt=ksh_zero_subscript
unsetopt \"$opt\"
printf '%s\\n' ${arr[0]}
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .indexed_array_reference_fragments()
                .iter()
                .filter_map(|fragment| {
                    let (span, plain, behavior) = indexed_array_reference_parts(fragment, source);
                    plain.then_some((span, behavior))
                })
                .collect::<Vec<_>>(),
            vec![
                ("${arr[0]}", SubscriptIndexBehavior::OneBased),
                ("${arr[1]}", SubscriptIndexBehavior::ZeroBased),
                ("${arr[1]}", SubscriptIndexBehavior::ZeroBased),
                ("${arr[0]}", SubscriptIndexBehavior::OneBased),
                ("${arr[0]}", SubscriptIndexBehavior::OneBasedWithZeroAlias,),
                ("${arr[0]}", SubscriptIndexBehavior::Ambiguous),
            ]
        );
    });
}

#[test]
fn indexed_array_reference_fragments_record_bash_subscript_index_behavior() {
    let source = "\
#!/bin/bash
printf '%s\\n' ${arr[0]}
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .indexed_array_reference_fragments()
                .iter()
                .filter_map(|fragment| {
                    let (span, plain, behavior) = indexed_array_reference_parts(fragment, source);
                    plain.then_some((span, behavior))
                })
                .collect::<Vec<_>>(),
            vec![("${arr[0]}", SubscriptIndexBehavior::ZeroBased)]
        );
    });
}

#[test]
fn parameter_pattern_special_target_fragments_only_mark_direct_pattern_operands() {
    let source = "\
#!/bin/bash
scalar=${name#${items[0]}}
array_trim=\"${items[@]#$prefix/}\"
script_name=${0##*/}
nested=${items[i]%${name%$suffix}}
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .parameter_pattern_special_target_fragments()
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec!["$prefix", "${name%$suffix}"]
        );
    });
}

#[test]
fn positional_parameter_trim_fragments_cover_all_pattern_trim_forms() {
    let source = "\
#!/bin/sh
printf '%s\\n' \"${*%%dBm*}\" \"${*%dBm*}\" \"${*##dBm*}\" \"${*#dBm*}\"
printf '%s\\n' \"${@%%dBm*}\" \"${@%dBm*}\" \"${@##dBm*}\" \"${@#dBm*}\"
printf '%s\\n' \"${name%%dBm*}\"
";

    with_facts(source, None, |_, facts| {
        let trim_fragments = facts.words().positional_parameter_trim_fragments();
        assert_eq!(
            trim_fragments
                .iter()
                .map(|fragment| fragment.span().slice(source))
                .collect::<Vec<_>>(),
            vec![
                "${*%%dBm*}",
                "${*%dBm*}",
                "${*##dBm*}",
                "${*#dBm*}",
                "${@%%dBm*}",
                "${@%dBm*}",
                "${@##dBm*}",
                "${@#dBm*}",
            ]
        );
        let first_fix = trim_fragments[0]
            .fix()
            .expect("expected one grouped fix for the first command");
        assert_eq!(first_fix.insertion(), "_shuck_positional_params=$*\n");
        assert_eq!(first_fix.replacements().len(), 4);
        assert_eq!(
            first_fix.replacements()[0].replacement(),
            "${_shuck_positional_params%%dBm*}"
        );
        assert!(
            trim_fragments[1..4]
                .iter()
                .all(|fragment| fragment.fix().is_none())
        );
        let second_fix = trim_fragments[4]
            .fix()
            .expect("expected one grouped fix for the second command");
        assert_eq!(second_fix.insertion(), "_shuck_positional_params=$@\n");
        assert_eq!(second_fix.replacements().len(), 4);
        assert!(
            trim_fragments[5..]
                .iter()
                .all(|fragment| fragment.fix().is_none())
        );
    });
}

#[test]
fn backtick_fragments_remember_when_the_substitution_body_is_empty() {
    let source = "\
#!/bin/sh
echo \"Resolve the conflict and run ``${PROGRAM} --continue`` plus `date`.\"
";

    with_facts(source, None, |_, facts| {
        assert_eq!(
            facts
                .words()
                .backtick_fragments()
                .iter()
                .map(|fragment| (fragment.span().slice(source), fragment.is_empty()))
                .collect::<Vec<_>>(),
            vec![("``", true), ("``", true), ("`date`", false)]
        );
    });
}

#[test]
fn collects_declaration_assignment_probes_for_process_substitution_subscripts() {
    let source = "\
#!/bin/bash
\\declare -A arr[<(printf \"]\")]=$(date)
\\export out=<(printf hi)
";

    with_facts(source, None, |_, facts| {
        let probes = facts
            .command_facts()
            .structural_commands()
            .flat_map(|fact| fact.declaration_assignment_probes().iter())
            .map(|probe| (probe.target_name(), probe.has_command_substitution()))
            .collect::<Vec<_>>();

        assert_eq!(probes, vec![("arr", true), ("out", false)]);
    });
}

#[test]
fn ignores_readonly_like_tokens_after_escaped_declaration_assignments() {
    let source = "\
#!/bin/bash
demo() {
  \\declare out=$(date) -r
}
";

    with_facts(source, None, |_, facts| {
        let probes = facts
            .command_facts()
            .structural_commands()
            .flat_map(|fact| fact.declaration_assignment_probes().iter())
            .map(|probe| {
                (
                    probe.target_name(),
                    probe.readonly_flag(),
                    probe.has_command_substitution(),
                )
            })
            .collect::<Vec<_>>();

        assert_eq!(probes, vec![("out", false, true)]);
    });
}
